# Scope Functions

Scope functions create a temporary scope wherein you can access an object without using its name.

Scope functions exist only to make your code more concise and readable. They do not provide additional abilities.

There are five scope functions: let(), run(), with(), apply(), and also(). They are designed to work with a lambda and do not require an import. They differ in the way you access the context object, using either it or this, and in what they return. with() uses a different calling syntax than the others. Here you can see the differences:

// ScopeFunctions/Differences.kt
package scopefunctions
import atomictest.eq

data class Tag(var n: Int = 0) {
  var s: String = ""
  fun increment() = ++n
}

fun main() {
  // let(): Access object with 'it'
  // Returns last expression in lambda
  Tag(1).let {
    it.s = "let: ${it.n}"
    it.increment()
  } eq 2

  // let() with named lambda argument:
  Tag(2).let { tag ->
    tag.s = "let: ${tag.n}"
    tag.increment()
  } eq 3

  // run(): Access object with 'this'
  // Returns last expression in lambda
  Tag(3).run {
    s = "run: $n" // Implicit 'this'
    increment()   // Implicit 'this'
  } eq 4

  // with(): Access object with 'this'
  // Returns last expression in lambda
  with(Tag(4)) {
    s = "with: $n"
    increment()
  } eq 5

  // apply(): Access object with 'this'
  // Returns modified object
  Tag(5).apply {
    s = "apply: $n"
    increment()
  } eq "Tag(n=6)"

  // also(): Access object with 'it'
  // Returns modified object
  Tag(6).also {
    it.s = "also: ${it.n}"
    it.increment()
  } eq "Tag(n=7)"

  // also() with named lambda argument:
  Tag(7).also { tag ->
    tag.s = "also: ${tag.n}"
    tag.increment()
  } eq "Tag(n=8)"
}

There are multiple scope functions because they satisfy different combinations of needs:

  • Scope functions that access the context object using this (run(), with() and apply()) produce the cleanest syntax within their scope block.
  • Scope functions that access the context object using it (let() and also()) allow you to provide a named lambda argument.
  • Scope functions that produce the last expression in their lambda (let(), run() and with()) are for creating results.
  • Scope functions that return the modified context object (apply() and also()) are for chaining expressions together.

run() is a regular function and with() is an extension function; otherwise they are identical. Prefer run() for call chains and when the receiver is nullable.

Here’s a summary of scope function characteristics:

this Context it Context
Produces last expression with, run let
Produces receiver apply also

You can apply a scope function to a nullable receiver using the [safe access operator](javascript:void(0)) ?., which only calls the scope function if the receiver is not null:

// ScopeFunctions/AndNullability.kt
package scopefunctions
import atomictest.eq
import kotlin.random.Random

fun gets(): String? =
  if (Random.nextBoolean()) "str!" else null

fun main() {
  gets()?.let {
    it.removeSuffix("!") + it.length
  }?.eq("str4")
}

In main(), if gets() produces a non-null result then let is invoked. The non-nullable receiver of let becomes the non-nullable it inside the lambda.

Applying the safe access operator to the context object null-checks the entire scope, as seen in [1]-[4] in the following. Otherwise, each call within the scope must be individually null-checked:

// ScopeFunctions/Gnome.kt
package scopefunctions

class Gnome(val name: String) {
  fun who() = "Gnome: $name"
}

fun whatGnome(gnome: Gnome?) {
  gnome?.let { it.who() }     // [1]
  gnome.let { it?.who() }
  gnome?.run { who() }        // [2]
  gnome.run { this?.who() }
  gnome?.apply { who() }      // [3]
  gnome.apply { this?.who() }
  gnome?.also { it.who() }    // [4]
  gnome.also { it?.who() }
  // No help for nullability:
  with(gnome) { this?.who() }
}

When you use the safe access operator on let(), run(), apply() or also(), the entire scope is ignored for a null context object:

// ScopeFunctions/NullGnome.kt
package scopefunctions
import atomictest.*

fun whichGnome(gnome: Gnome?) {
  trace(gnome?.name)
  gnome?.let { trace(it.who()) }
  gnome?.run { trace(who()) }
  gnome?.apply { trace(who()) }
  gnome?.also { trace(it.who()) }
}

fun main() {
  whichGnome(Gnome("Bob"))
  whichGnome(null)
  trace eq """
    Bob
    Gnome: Bob
    Gnome: Bob
    Gnome: Bob
    Gnome: Bob
    null
  """
}

The trace shows that when whichGnome() receives a null argument, no scope functions execute.

Attempting to retrieve an object from a Map has a nullable result because there’s no guarantee it will find an entry for that key. Here we show the different scope functions applied to the result of a Map lookup:

// ScopeFunctions/MapLookup.kt
package scopefunctions
import atomictest.*

data class Plumbus(var id: Int)

fun display(map: Map<String, Plumbus>) {
  trace("displaying $map")
  val pb1: Plumbus = map["main"]?.let {
    it.id += 10
    it
  } ?: return
  trace(pb1)

  val pb2: Plumbus? = map["main"]?.run {
    id += 9
    this
  }
  trace(pb2)

  val pb3: Plumbus? = map["main"]?.apply {
    id += 8
  }
  trace(pb3)

  val pb4: Plumbus? = map["main"]?.also {
    it.id += 7
  }
  trace(pb4)
}

fun main() {
  display(mapOf("main" to Plumbus(1)))
  display(mapOf("none" to Plumbus(2)))
  trace eq """
    displaying {main=Plumbus(id=1)}
    Plumbus(id=11)
    Plumbus(id=20)
    Plumbus(id=28)
    Plumbus(id=35)
    displaying {none=Plumbus(id=2)}
  """
}

Although with() can be forced into this example, the results are too ugly to consider.

In the trace you see that each Plumbus object is created during the first call to display() in main(), but none are created during the second call. Look at the definition of pb1 and recall [the Elvis operator](javascript:void(0)). If the expression to the left of ?: is not null, it becomes the result and is assigned to pb1. But if that expression is null, the right side of ?: becomes the result, which is return so display() returns before completing the initialization of pb1, and thus none of the values pb1-pb4 are created.

Scope functions work with nullable types in chained calls:

// ScopeFunctions/NameTag.kt
package scopefunctions
import atomictest.trace

val functions = listOf(
  fun(name: String?) {
    name
      ?.takeUnless { it.isBlank() }
      ?.let { trace("$it in let") }
  },
  fun(name: String?) {
    name
      ?.takeUnless { it.isBlank() }
      ?.run { trace("$this in run") }
  },
  fun(name: String?) {
    name
      ?.takeUnless { it.isBlank() }
      ?.apply { trace("$this in apply") }
  },
  fun(name: String?) {
    name
      ?.takeUnless { it.isBlank() }
      ?.also { trace("$it in also") }
  },
)

fun main() {
  functions.forEach { it(null) }
  functions.forEach { it("  ") }
  functions.forEach { it("Yumyulack") }
  trace eq """
    Yumyulack in let
    Yumyulack in run
    Yumyulack in apply
    Yumyulack in also
  """
}

functions is a List of function references that are applied by the forEach calls in main(), using it together with function-call syntax. Each function in functions uses a different scope function. The forEach calls to it(null) and it(" ") are effectively ignored, so we only display non-null, non-blank input.

When nesting scope functions, multiple this or it objects can be available in a given context. Sometimes it’s difficult to know which object is selected:

// ScopeFunctions/Nesting.kt
package scopefunctions
import atomictest.eq

fun nesting(s: String, i: Int): String =
  with(s) {
    with(i) {
      toString()
    }
  } +
  s.let {
    i.let {
      it.toString()
    }
  } +
  s.run {
    i.run {
      toString()
    }
  } +
  s.apply {
    i.apply {
      toString()
    }
  } +
  s.also {
    i.also {
      it.toString()
    }
  }

fun main() {
  nesting("X", 7) eq "777XX"
}

In all cases, the call to toString() is applied to Int because the “closest” this or it is the Int implicit receiver. apply() and also() return the modified object s instead of the result of the calculation. As scope functions are intended to improve readability, nesting scope functions is a questionable practice.

None of the scope functions provide [resource cleanup](javascript:void(0)) the way that use() does:

// ScopeFunctions/Blob.kt
package scopefunctions
import atomictest.*

data class Blob(val id: Int) : AutoCloseable {
  override fun toString() = "Blob($id)"
  fun show() { trace("$this")}
  override fun close() = trace("Close $this")
}

fun main() {
  Blob(1).let { it.show() }
  Blob(2).run { show() }
  with(Blob(3)) { show() }
  Blob(4).apply { show() }
  Blob(5).also { it.show() }
  Blob(6).use { it.show() }
  Blob(7).use { it.run { show() } }
  Blob(8).apply { show() }.also { it.close() }
  Blob(9).also { it.show() }.apply { close() }
  Blob(10).apply { show() }.use {  }
  trace eq """
    Blob(1)
    Blob(2)
    Blob(3)
    Blob(4)
    Blob(5)
    Blob(6)
    Close Blob(6)
    Blob(7)
    Close Blob(7)
    Blob(8)
    Close Blob(8)
    Blob(9)
    Close Blob(9)
    Blob(10)
    Close Blob(10)
  """
}

Although use() looks similar to let() and also(), use() does not allow anything to be returned from its lambda. This prevents expression chaining or producing results.

Without use(), close() is not called for any of the scope functions. To use a scope function and guarantee cleanup, place the scope function inside the use() lambda as in Blob(7). Blob(8) and Blob(9) show how to explicitly call close(), and how to use apply() and also() interchangeably.

Blob(10) uses apply() and the result is passed into use(), which calls close() at the end of its lambda.

# Scope Functions are Inlined

Normally, passing a lambda as an argument stores the lambda code in an auxiliary object, adding a small bit of runtime overhead compared to a regular function call. This overhead is usually not a concern, considering the benefits of lambdas (readability and code structure). In addition, the JVM contains numerous optimizations that often compensate for the overhead.

Any performance cost, no matter how small, produces recommendations to “use a feature with care.” All runtime overhead is eliminated by defining the scope functions as inline. This way, scope functions can be used without hesitation.

When the compiler sees an inline function call, it substitutes the function body for the function call, replacing all parameters with actual arguments.

Inlining works well for small functions, where function-call overhead can be a significant portion of the entire call. As functions get larger, the cost of the call shrinks in comparison to the time required by the entire call, diminishing the value of inlining. At the same time, the resulting bytecode increases because the entire function body is inserted at each call site.

When an inlined function takes a lambda argument, the compiler inlines the lambda body together with the function body. Thus, no additional classes or objects are created to pass the lambda to the function. (This only works when the lambda is called directly, or passed to another inline function).

Although you can apply it to any function, inline is intended for either inlining lambda bodies or creating [reified generics](javascript:void(0)). You can find more information about inline functions here (opens new window).

Exercises and solutions can be found at www.AtomicKotlin.com.