Overview
Kotlin's standard library provides several utility functions designed to execute a block of code within a specific object's context. When invoking these functions on an object with a lambda expression, a temporary scope is established where the object becomes accessible without explicit naming. These utilities are collectively known as scope functions.
Kotlin offers five scope functions: let, run, with, apply, and also.
These functions perform essentially the same operation—executing a block of code on an object—but differ in two key aspects: how the object is referenced within the block and what the function returns.
Basic Usage
Here's a typical example demonstrating scope function usage:
User("Alice", 20, "Amsterdam").let {
println(it)
it.relocate("London")
it.birthday()
println(it)
}
Without scope functions, you'd need to introduce a temporary variable and repeat it throughout your code:
val alice = User("Alice", 20, "Amsterdam")
println(alice)
alice.relocate("London")
alice.birthday()
println(alice)
Scope functions don't introduce new capabilities but can significantly improve code brevity and readability.
Key Differences
Understanding the distinctions between scope functions is crucial since they share similar structures. The primary differences lie in:
- Context object access method
- Return value behavior
Context Object: this vs it
Inside a lambda expression, the context object can be accessed using shorthand references instead of its actual name. Each scope function uses one of two approaches: as a receiver via this or as a parameter via it.
fun demonstrate() {
val greeting = "Hello"
greeting.run {
println("String length: $length")
// Equivalent to: println("String length: ${this.length}")
}
greeting.let {
println("String length: ${it.length}")
}
}
Using this:
Functions run, with, and apply reference the context object via this. Within their lambdas, you access members just like in regular member functions. In most cases, you can omit this for brevity, but doing so makes it difficult to distinguish between the receiver's members and external scope members. Therefore, prefer this when the lambda primarily operates on the object's members.
val kate = User("Kate").apply {
age = 25
city = "Paris"
}
println(kate)
Using it:
Functions let and also pass the context object as a lambda parameter. Without a custom name, the object is accessible via the implicit default name it, which is shorter than this and often produces more readable expressions. However, unlike this, you can't implicitly access the object when invoking its functions or properties. Prefer it when the context object serves primarily as an argument in function calls, especially when multiple variables appear in the block.
fun generateId(): Int {
return Random.nextInt(1000).also {
Logger.record("Generated ID: $it")
}
}
val id = generateId()
You can also provide a custom parameter name for more explicit context:
fun generateId(): Int {
return Random.nextInt(1000).also { generatedValue ->
Logger.record("Generated ID: $generatedValue")
}
}
Return Values
Scope functions fall into two categories based on their return behavior:
applyandalsoreturn the context object itselflet,run, andwithreturn the lambda expression result
Chaining with context object returns:
Since apply and also return the original object, they fit naturally into method chains:
val scores = mutableListOf<Double>()
scores.also { println("Initializing collection") }
.apply {
add(2.71)
add(3.14)
add(1.0)
}
.also { println("Sorting collection") }
.sort()
They also work well in return statements:
fun generateId(): Int {
return Random.nextInt(1000).also {
Logger.record("Generated ID: $it")
}
}
Chaining with lambda results:
Functions let, run, and with return the lambda's computed result, making them suitable for assignments or further chaining:
val items = mutableListOf("alpha", "beta", "gamma")
val matchingCount = items.run {
add("delta")
add("epsilon")
count { it.endsWith("a") }
}
println("Found $matchingCount items matching criteria")
You can also use these functions solely for creating a temporary scope, ignoring the return value:
val items = mutableListOf("alpha", "beta", "gamma")
with(items) {
val first = first()
val last = last()
println("Range: [$first, $last]")
}
Function Reference
let
Accesses the context object as a lambda parameter (it). Returns the lambda result.
let excels at executing operations on chain results:
val data = mutableListOf("apple", "banana", "cherry", "date")
val processed = data.map { it.length }.filter { it > 4 }
println(processed)
With let, this becomes:
val data = mutableListOf("apple", "banana", "cherry", "date")
data.map { it.length }.filter { it > 4 }.let {
println(it)
}
For single-function lambdas, method references work well:
val data = mutableListOf("apple", "banana", "cherry", "date")
data.map { it.length }.filter { it > 4 }.let(::println)
let is commonly used for null-safety operations. Combine the safe-call operator ?. with let to execute code only on non-null objects:
val input: String? = "Hello"
val charCount = input?.let {
println("Processing: $it")
validateInput(it)
it.length
}
Another use case is introducing scoped local variables for improved readability. Provide a custom parameter name instead of the default it:
val words = listOf("one", "two", "three", "four")
val transformed = words.first().let { firstItem ->
println("Processing: '$firstItem'")
if (firstItem.length >= 4) firstItem else "[$firstItem]"
}.uppercase()
println("Result: '$transformed'")
with
A non-extension function that receives the context object as a parameter but accesses it as a receiver (this) inside the lambda. Returns the lambda result.
Use with when calling multiple functions on an object without needing the lambda result. Think of it as "given this object, perform the following operatinos":
val items = mutableListOf("alpha", "beta", "gamma")
with(items) {
println("Collection contains $this")
println("Total elements: $size")
}
Another application is introducing a helper object for computed values:
val items = mutableListOf("alpha", "beta", "gamma")
val summary = with(items) {
"First: ${first()}, Last: ${last()}"
}
println(summary)
run
Accesses the context object as a receiver (this). Returns the lambda result.
run provides the same functionality as with but called as an extension function on the context object:
val service = HttpService("https://api.example.com", 443)
val response = service.run {
port = 8443
query(buildRequest() + " to port $port")
}
Compare with the equivalent using let:
val letResponse = service.let {
it.port = 8443
it.query(it.buildRequest() + " to port ${it.port}")
}
run also works as a non-extension function for executing multi-statement blocks in expression contexts:
val pattern = run {
val digits = "0-9"
val hexChars = "A-Fa-f"
val sign = "+-"
Regex("[$sign]?[$digits$hexChars]+")
}
pattern.findAll("+1234 -FFFF abc").forEach { println(it.value) }
apply
Accesses the context object as a receiver (this). Returns the context object itself.
Use apply for code blocks that configure an object without producing a return value. The invocation means "apply these assignments to the object":
val user = User("John").apply {
age = 30
city = "Berlin"
}
println(user)
Since apply returns the receiver, you can chain it for more complex processign:
val config = Config().apply {
host = "localhost"
port = 8080
}.also {
Logger.info("Config created: $it")
}
also
Accesses the context object as a lambda parameter (it). Returns the context object itself.
also is useful for operations that need the object itself as a parameter, particularly when you need the reference rather than its properties, or when you want to avoid shadowing outer scope references:
val items = mutableListOf("x", "y", "z")
items
.also { println("Before modification: $it") }
.add("w")
.also { println("After modification: $it") }
Think of also as "also perform this action on the object".
Selecting the Appropriate Function
| Function | Object Reference | Return Value | Extension Function |
|---|---|---|---|
let |
it |
Lambda result | Yes |
run |
this |
Lambda result | Yes |
run (non-extension) |
— | Lambda result | No |
with |
this |
Lambda result | No (object as parameter) |
apply |
this |
Context object | Yes |
also |
it |
Context object | Yes |
Quick Selection Guide:
- Execute code on a non-null object:
let - Introduce an expression as a local variable:
let - Configure an object:
apply - Configure an object and compute a result:
run - Execute statements in an expression context: non-extension
run - Add side effects:
also - Call multiple functions on an object:
with
Use project or team conventions when multiple functions could serve equally well.
Performance Considerations
While scope functions can make code more concise, avoid overuse—it degrades readability and introduces subtle bugs. Don't nest scope functions, and be cautious when chaining them as the current context and the meaning of this or it can become confusing.
takeIf and takeUnless
Beyond scope functions, Kotlin's standard library includes takeIf and takeUnless for embedding state checks directly into call chains.
When called with a predicate, takeIf returns the object if it matches the predicate, otherwise null. Conversely, takeUnless returns the object if it doesn't match, and null if it does. The object is accessed via it:
val num = Random.nextInt(50)
val divisibleByFive = num.takeIf { it % 5 == 0 }
val notDivisibleByFive = num.takeUnless { it % 5 == 0 }
println("Value: $num, divisible by 5: $divisibleByFive")
After takeIf or takeUnless, remember to use null checks or safe calls (?.) since these functions return nullable types:
val text = "Sample"
val upperCased = text.takeIf { it.isNotBlank() }?.uppercase()
println(upperCased)
Combining takeIf with let creates powerful patterns. Call takeIf on an object, then use safe calls to invoke let on matching objects:
fun locateSubstring(input: String, target: String) {
input.indexOf(target).takeIf { it >= 0 }?.let {
println("Found '$target' at position $it in '$input'")
}
}
locateSubstring("Kotlin", "otlin")
locateSubstring("Kotlin", "xyz")
Without these utilities, the equivalent would require explicit conditional logic:
fun locateSubstring(input: String, target: String) {
val position = input.indexOf(target)
if (position >= 0) {
println("Found '$target' at position $position in '$input'")
}
}