# 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()
andapply()
) produce the cleanest syntax within their scope block. - Scope functions that access the context object using
it
(let()
andalso()
) allow you to provide a named lambda argument. - Scope functions that produce the last expression in their lambda (
let()
,run()
andwith()
) are for creating results. - Scope functions that return the modified context object (
apply()
andalso()
) 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.