Skip to content
academia.sh

Lesson 08 / 23

Scope Functions

Introduces the scope functions `let`, `run`, `with`, `apply`, and `also`; measures whether the receiver is `this` or `it` and what each returns, and shows that the result does not change the equality criterion.

Published Updated

In this lesson

  • Distinguishes `let`, `run`, `with`, `apply`, and `also` by receiver and return value
  • Reads which object an expression works on (`this` or `it`)
  • Measures that the object a scope function returns does not change its equality criterion
Contents

In the previous lesson we carried lambdas as values. Scope functions are five ready-made higher-order functions that run these lambdas in the context of an object: let, run, with, apply, and also. The difference between them comes from two questions: how is the object reached inside the lambda, and what does the function return? At the end of the lesson a third question arrives: does the equality criterion of an object change when it passes through a scope function?

Receiver and Return Value

A scope function gives the object it is called on to the lambda in one of two ways. let and also give the object as the lambda parameter it. run, with, and apply make the object the receiver: inside the lambda, the members of the object are reached with this, and most often with no prefix at all. This distinction is the first place to look when reading which object an expression works on.

The return value has two forms. let, run, and with return the result of the last expression of the lambda. apply and also return the object itself. That is, apply is used while building an object, and also is used to do a side job without touching the object.

In this lesson too, the measurements are made with the helper file introduced in the first lesson. The Criterion class takes a value, its twin, and a different value, and asks three questions; the file is given below exactly as it was.

// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
    val sameObject = first === twin
    val equalTwin = first == twin
    val equalOther = first == other

    val name: String = when {
        sameObject -> "singleton"
        !equalTwin -> "identity"
        !equalOther -> "content"
        else -> "partial-content"
    }

    fun report() {
        println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
    }
}

fun summarize(checks: List<Criterion>) {
    for (name in listOf("singleton", "identity", "content", "partial-content")) {
        println("$name: ${checks.count { it.name == name }}")
    }
}

This choice is a readability decision. If you want to use the members of the object inside the lambda without a prefix, one of the three functions with the receiver form is suitable; if you will pass the object under a name as an argument to something else, the two with the it form are suitable. The return value decides what you are left with after the block: the object itself, or a computed result.

The program below runs the five functions in turn. Point is a data class with two mutable fields.

// Main.kt — let, run, with, apply, and also
data class Point(var x: Int, var y: Int)

fun main() {
    val letResult = Point(1, 2).let { it.x + it.y }
    val runResult = Point(3, 4).run { x * y }
    val withResult = with(Point(5, 6)) { "$x,$y" }
    val applied = Point(0, 0).apply {
        x = 7
        y = 8
    }
    val alsoResult = Point(9, 9).also { println("observed: ${it.x}") }
    println(letResult)
    println(runResult)
    println(withResult)
    println(applied)
    println(alsoResult)
}
observed: 9
3
12
5,6
Point(x=7, y=8)
Point(x=9, y=9)

The first line came from the println call inside also and wrote observed: 9; the also block observed the object, and the object was returned as it was. In the let block the two fields were added through it, and 3 came out. In the run block x * y was written with the receiver, without a prefix, and 12 came out. with used the same receiver syntax and gave the text "5,6", which is the result of the lambda. In the apply block the fields were assigned, and the function returned the object itself: Point(x=7, y=8). The last line shows the object returned by also: Point(x=9, y=9).

LB52. The five functions were measured only on Point and only with integer fields; uses of ?.let, ?.apply, and takeIf on a receiver that can be null are measured later in this lesson, only with String?.

LB53. with is not an extension but an ordinary function that takes the object as an argument; this distinction was only stated here, not measured.

A Scope Function Does Not Change the Criterion

The measurement asks this: does passing an object through a scope function change whether its twin is counted as equal? Here Point is a data class with fields that do not change. We write three observations. let-copy: Point(1, 2).let { it.copy() }. run-copy: Point(1, 2).run { copy() }; the same copy, this time with the receiver syntax. also-twin: the same source variable is passed through also { } and compared with itself.

Since let and run return the result of the lambda, what is returned is a separate object built with copy(). also, on the other hand, returns its own object.

// Main.kt — what a scope function returns
data class Point(val x: Int, val y: Int)

fun main() {
    val source = Point(1, 2)
    val checks = listOf(
        Criterion("let-copy", Point(1, 2), Point(1, 2).let { it.copy() }, Point(1, 3)),
        Criterion("run-copy", Point(1, 2), Point(1, 2).run { copy() }, Point(1, 3)),
        Criterion("also-twin", source, source.also { }, Point(1, 3)),
    )
    for (check in checks) {
        check.report()
    }
    summarize(checks)
}
let-copy: same-object=false, twin=true, other=false -> content
run-copy: same-object=false, twin=true, other=false -> content
also-twin: same-object=true, twin=true, other=false -> singleton
singleton: 1
identity: 0
content: 2
partial-content: 0

let-copy and run-copy are content: same-object=false, twin=true. The copy that comes out of the scope function is not the same object as the source, but since it carries the same values, the equals of the data class counts them as equal. Which scope function was used did not determine the criterion; what determines the criterion is that Point is a data class.

also-twin is a singleton: same-object=true. Since also returns the object itself, the twin and the first value came out as the same object. The summary lines give one singleton, zero identity, two content, and zero partial content.

LB54. In the also-twin observation no twin was built; since the scope function returns the object itself, the comparison was made between an object and itself. This observation breaks the measurement condition, as explained below.

LB55. The copy() call produces a shallow copy; how the copy behaves in a data class that carries a nested object was not measured in this lesson.

What the Five Functions Return

The real difference between scope functions comes down to two things: is the object reached with it or with this, and what does the expression return? The block below runs all five on the same Point object and tests the returned object against the source with ===.

// Main.kt — what the scope functions return
data class Point(var x: Int, var y: Int)

fun main() {
    val p = Point(1, 2)
    println(p.let { it } === p)
    println(p.run { this } === p)
    println(with(p) { this } === p)
    println(p.apply { x = 10 } === p)
    println(p.also { it.y = 20 } === p)
    println(p.let { 5 })
    println(p.apply { x = 11 }.run { x + y })
    println(p)
}
true
true
true
true
true
5
31
Point(x=11, y=20)

All five lines gave true, but not for the same reason. p.let { it } === p and p.run { this } === p are true because the last expression of the lambda is the object itself. with(p) { this } === p is true for the same reason. apply and also, on the other hand, do not look at the value of the lambda; they always return the receiver object.

The sixth line gave 5: the value of the expression let { 5 } is the last expression of the lambda, not the object. The seventh line is 31: apply { x = 11 } returned the object, and run { x + y } computed the sum, the sum of 11 and 20, which is 31. The last line gave Point(x=11, y=20), that is, the assignments in the previous five calls changed the object itself. These assignments were not made on a local copy but on p.

The rule: let, run, and with give the result of the lambda; apply and also give the object. The frequent mistake is to write a value on the last line inside apply and assume that it will be returned. Java has no equivalent construct; for chaining, the methods have to return this (the builder pattern).

LB56. The return values were measured with Point and integer results; with is not an extension but an ordinary function, and this distinction was not measured.

Scope Functions with null, and takeIf

The most frequent use is to process a value that may be null safely with ?.let. The block below tries this with a filled and an empty String?, looks at the difference between ?.let and .let { it?.... }, and tests the functions takeIf and takeUnless.

// Main.kt — scope functions with null, and takeIf
fun main() {
    val present: String? = "kotlin"
    val missing: String? = null
    println(present?.let { it.length })
    println(missing?.let { it.length })
    println(missing.let { it?.length })
    println(missing?.apply { length })
    println(missing?.let { it.length } ?: -1)
    println(present.takeIf { it?.startsWith("k") == true })
    println(present.takeUnless { it?.startsWith("k") == true })
    println("abc".takeIf { it.length > 5 })
}
6
null
null
null
-1
kotlin
null
null

present?.let { it.length } gave 6; missing?.let { it.length } gave null. The safe call ?. did not run the lambda at all when the receiver was null. The third line also gave null, but the reason is different: in the expression missing.let { it?.length }, let is also called on the null value, the lambda runs, and it?.length inside produces a null result. Even if the results of these two forms are the same, the lambda did not run in the first and did run in the second. The fourth line is null: missing?.apply { length } also ran nothing because the receiver was null, and the result is the null value. The fifth line is -1: the elvis operator turned the null result into a default.

takeIf returns the object if the condition is true and null if it is false. present.takeIf { ... } gave kotlin, because the string starts with k. takeUnless works in reverse and gave null with the same condition. "abc".takeIf { it.length > 5 } gave null, because the condition was not met. The frequent mistake is to use the result of takeIf directly, forgetting that it is of a nullable type; the compiler reports a compile error in this case, but this error was not measured.

LB57. The null measurement was made with String?; side effects in the takeIf condition and chains of takeIf were not measured in this lesson.

Nested Scope Functions and this

When two scope functions are written nested, which object do this and it look at? The block below runs a second run inside a run, reaches the outer receiver with a label, then uses a run without an object, and reads size in nested apply calls.

// Main.kt — nested scope functions and this
data class Box(var size: Int)

fun main() {
    val box = Box(1)
    val result = box.run outer@{
        size = 5
        Box(size * 2).run { "inner: $size, outer: ${[email protected]}" }
    }
    println(result)
    val note = run {
        val temp = 21
        temp * 2
    }
    println(note)
    Box(3).apply {
        Box(4).apply {
            println(size)
        }
        println(size)
    }
}
inner: 10, outer: 5
42
4
3

The first line gave inner: 10, outer: 5. In the outer run, size = 5 was assigned, and in the inner run a new Box(10) was built with size * 2. In the inner block, size looks at the nearest receiver, that is, the Box(10) object. To reach the outer receiver, [email protected] is written, and 5 is read. For this reason, nested receiver use produces code that is hard to read; giving the name it with also and let is clearer. The frequent mistake is to write size in the inner block and assume that the field of the outer object is being read.

The second line gave 42: a run { } without an object only opens a block and returns the last expression. The third and fourth lines gave 4 and 3: inside the inner apply, size is the value 4 of the inner object, and inside the outer apply it is the value 3 of the outer object. When the inner block ends, the scope returns to the outer one. In Java, the same effect in inner classes comes from the syntax Outer.this.field; in Kotlin, the label is its counterpart.

LB58. The nested receiver measurement was made with two levels; the resolution of an unlabeled this in use nested three or more levels deep was not measured in this lesson.

Habits That Come from the Measurements

Two questions are asked when choosing a scope function: is the object reached with it or this, and will the result be the object or the value of the lambda? The measurement confirmed the table: apply and also return the object, and let, run, and with return the value of the lambda. In practice, apply is used while building an object, also when adding a side effect (such as writing a log), let when transforming a value that may be null, and run and with when doing a calculation on an object and taking the result. This pairing is not a rule but a common habit; the same job can often be done with another function as well.

Overuse is a real risk. Three nested scope functions make it hard to follow which object the names this and it go to. In the measurement, size inside the inner run went to the inner object, and access to the outer value was made with [email protected]. In use that goes beyond two nested levels, named local variables are clearer. As a rule, if one scope function is written inside another, either the outer one should be named with it or the code should be moved to a separate function.

With null values, ?.let is the common pattern, and the measurement showed the difference between the two forms: missing?.let { ... } does not run the lambda at all, while missing.let { it?.... } runs the lambda with the null value. Even if the results look the same, the difference appears in a lambda that carries a side effect. takeIf and takeUnless put a condition into the chain: if the condition is not met, null returns and the following ?. steps are skipped.

Another frequent mistake is to write a value on the last line of an apply block and assume that it will be returned. apply always returns the object, so the expression at the end is ignored. If you want to turn the result into another value, let or run should be chosen. In Java, the counterpart of these jobs is the builder pattern, which depends on every method returning this; Kotlin provides this at the language level, without changing the existing class.

Finally, with is not an extension but an ordinary function that takes the object as an argument. For this reason it cannot be attached with ?. to an object that may be null; for a nullable receiver, ?.run or ?.let should be chosen.

The Bounding Measurement

The also-twin observation breaks the measurement condition: Criterion expects a separately built twin of a value, whereas also gives the same object back. The singleton result that came out is not a finding about the criterion but a reflection of the return value rule of the function. For this reason, to show that a scope function does not change the criterion, the let and run observations are meaningful, while the also observation shows only the limit. If you see a scope function in an expression, ask these two questions: which object is this expression working on, and does the function return this object or the result of the lambda? The answer to the equality question always stays in the own type of the object.

Summary

  • let and also give the object as it; run, with, and apply give it as the receiver (this).
  • let, run, and with return the result of the lambda; apply and also return the object itself.
  • Point(1, 2).let { it.copy() } and run { copy() } gave separate but equal objects: content.
  • Since also gives the same object back, also-twin came out as a singleton and broke the measurement condition.
  • This lesson’s criterion: content; bounding measurement: the also twin is a singleton and breaks the measurement condition because it builds no twin.

Next Step

The language basics close here. In the next topic, null enters the equality question: when a value can be absent, is null the same as null, and how does the compiler follow this case inside the type? The topic starts with the lesson Null Safety.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close