Choosing the right scope function in Kotlin


For newcomers to Kotlin, the scope functions can be hard to wrap your head around. With similar sounding names (let, run, apply, also, with), choosing the right one can be difficult. What are the differences between them? When should we use them?

The scope functions all serve a similar purpose: to execute code on an object. They're mostly different in two ways:

  1. The return value
  2. Lambda receiver vs. lambda argument

What is a lambda receiver? How is it different from a lambda argument?

Lambda Receiver vs. Lambda Argument

Most likely, you're already familiar with lambda arguments. They're simply the argument of a lambda function.

val list = listOf(1, 2, 3)
list.map { number -> number * number }

// or the implicit argument `it`
list.map { it * it }

But what is a lambda receiver? It's an object available in a lambda function, as if the code were executing in a normal class. So the code we write can have a very clean API:

buildString {
    append("foo")    // same as `this.append("foo")`
    append("bar")
}

// foobar

But how would you write a function like buildString?

// `action` is type of extension function
fun buildString(action: StringBuilder.() -> Unit): String {
    val builder = StringBuilder()

    builder.action() // can be called directly on type `StringBuilder`

    return builder.toString()
}

In the above example, action is a lambda function, with the type of an extension function.

A lambda with a receiver is a lambda function with the same type as an extension function.

The type being extended, which is available in the lambda as the context object this, is called the lambda receiver.

The Scope Functions

As we mentioned earlier, scope functions differ in two ways—the return type and how they access the object they're running code on.

  1. The return type can be either the object itself, or the result of the lambda function.
  2. The object they're accessing can be available as a lambda receiver (this) or a lambda argument (it).
From this great article

What makes it hard is knowing which one to choose in a certain situation.

I need to...

Use a lambda on a non-null object:  let

When dealing with a nullable type, we have a few options.

fun purchase(buyer: Buyer?) {
    // TODO: Call `sendEmail` to the buyer's email
}

How would we go about that?

// 1. Smart casting
if (buyer != null) sendEmail(buyer.email)

// 2. Using let
buyer?.let { sendEmail(it.email) }

// 3. Null-check
sendEmail(buyer!!.email)

Smart casting and using let are solid options that take good advantage of Kotlin's type system.

Doing a hard null-check, however, can result in a null pointer exception. It doesn't handle the nullable type well—it just gives it an ultimatum.

Avoid polluting the outer scope: let

val raffle = listOf("Jane Wilson", "Tammy Davis", "Mark Vestburg")

raffle.first().let { winner ->
    val firstName = winner.split(" ").first()
    "The winner is $firstName!"
}
// The winner is Jane!


// Replaces the much less debuggable
"The winner is ${raffle.first().split(" ").first()}!"

Configure an object: apply

val db = Database().apply { this.connect() }

// replaces
val db = Database()
db.connect()

Configure an object and compute the result: run

val password: Password = PasswordGenerator().run {
       seed = "ZG68&!xZo4l5"
       hash = HashFunctions.SHA
       hashRepetitions = 1000

       generate()
}

A lot of the times we can get away with putting all of those fields in a constructor, but the run function is still a good option.

Use statements where an expression is required: run (as a non-extension)

val hexNumberRegex = run {
    val digits = "0-9"
    val hexDigits = "A-Fa-f"
    val sign = "+-"

    Regex("[$sign]?[$digits$hexDigits]+")
}

for (match in hexNumberRegex.findAll("+1234 -FFFF not-a-number")) {
    println(match.value)
}

This is similar to our raffle example, where the goal is keeping a minimalist outer scope, using the let function. These two scope functions are very similar, the difference being that run takes a lambda receiver (this), and let takes a lambda argument (it).

Add an additional effect: also

also is the best-named scope function. Think "also, please log this variable".

listOf(1, 2, 3, 4)
    .map { /* crazy function */ }
    .also(::println)   // check your work
    .filter { /* ... */ }

Group function calls on an object: with

// using `with`
with(configuration) {
    println("$host:$port")
}

// instead of
println("${configuration.host}:${configuration.port}")

When should you not use scope functions?

Frankly, the scope functions require time to understand, especially for people who are tackling Kotlin for the first time.

Therefore, the main downside is making your code less approachable.

Don't use them just for the sake of using them, only do so in cases where it actually adds value and makes your code more readable.◾