Coping with Kotlin's Scope Functions
Functions in Kotlin are very important and it's much fun()
to use them. One special collection of relevant functions can be described as "scope functions" and they are part of the Kotlin standard library: let
, run
, also
, apply
and with
.
You probably already heard about them and it's also likely that you even used some of them yet. Most people tend to have problems distinguishing all those functions, which is not very remarkable in view of the fact that their names may be a bit confusing. This post intends to demonstrate the differences between the available scope functions and also wants to discuss relevant use cases. Finally, an example will show how to apply scope functions and how they help to structure Kotlin code in a more idiomatic way.
Disclaimer: The topic of scope functions is under consideration in various StackOverflow posts very often, which I will occasionally refer to throughout this article.
The Importance of Functions
In Kotlin, functions are as important as integers or strings. Functions can exist on the same level as classes, may be assigned to variables and can also be passed to/returned from other functions. Kotlin makes functions "first-class citizens" of the language, which Wikipedia describes as follows:
A first-class citizen [...] is an entity which supports all the operations generally available to other entities. These operations typically include being passed as an argument, returned from a function, modified, and assigned to a variable.
As already said, functions are as powerful and significant as any other type, e.g. Int
. In addition to that, functions may appear as "higher-order functions", which in turn is described as the following on Wikipedia:
In mathematics and computer science, a higher-order function (also functional, functional form or functor) is a function that does at least one of the following:
- takes one or more functions as arguments (i.e., procedural parameters),
- returns a function as its result.
The boldly printed bullet point is the more important one for the present article since scope functions also act as higher-order functions that take other functions as their argument. Before we dive into this further, let's observe a simple example of higher-order functions.
Higher-Order Function in Action
A simple higher-order function that's commonly known in Kotlin is called repeat
and it's defined in the standard library:
inline fun repeat(times: Int, action: (Int) -> Unit)
As you can see, repeat
takes two arguments: An ordinary integer times
and also another function of type (Int) -> Unit
. According to the previously depicted definition, repeat
is a higher-order function since it "takes one or more functions as arguments". In its implementation, the function simply invokes action
as often as times
indicates. Let's see how repeat
can be called from a client's point of view:
repeat(3) { rep ->
println("current repetition: $rep")
}
In Kotlin, lambdas can be lifted out of the parentheses of a function call if they act as the last argument to the function.
Note that if a function takes another function as the last parameter, the lambda expression argument can be passed outside the parenthesized argument list.
The official documentation is very clear about all lambda features and I highly recommend to study it.
In the shown snippet, a regular lambda, which only prints the current repetition to the console, is passed to repeat
. That's how higher-order function calls look like.
Function Literal with Receiver
Kotlin promotes yet another very important concept that makes functions even more powerful. If you've ever seen internal domain specific languages (DSL) in action, you might have wondered how they are implemented. The most relevant concept to understand is called function literal with receiver (also lambda with receiver). Since this feature is also vital for scope functions, it will be discussed next.
Function literals with receiver are often used in combination with higher-order functions. As shown earlier, functions can be made parameters of other functions, which happens by defining parameters with the function type syntax (In) -> Out
. Now imagine that these function types can even be boosted by adding a receiver: Receiver.(In) -> Out
. Such types are called function literal with receiver and are best understood if visualized as "temporary extension functions". Take the following example:
inline fun createString(block: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.block()
return sb.toString()
}
The function createString
can be called a higher-order function as it takes another function block
as its argument. This argument is defined as a function literal with receiver type. Now, let's think of it as an extension function defined for StringBuilder
that will be passed to the createString
function. Clients will hand on arbitrary functions with the signature () -> Unit
, which will be callable on instances of StringBuilder
. That's also shown in the implementation: An instance of StringBuilder
is being created and block
gets invoked on it. Eventually, the method transforms StringBuilder to an ordinary
String` and to the caller.
What does that mean for the client of such a method? How can you create and pass function literals with receiver to other functions? Since the receiver is defined as StringBuilder
, a client will be able to pass lambdas to createString
that make use of that receiver. The receiver is exposed as this
inside the lambda, which means that clients can access visible members (properties, functions etc.) without additional qualifiers:
val s = createString { //here we're in the context of a StringBuilder
append(4)
append("hello")
}
The example shows that append
, a function defined for the receiver StringBuilder
, is being invoked without any qualifiers (e.g. it
). The same is possible in the definition of extension functions, which is why I used it as an analogy earlier. The client defines a temporary extension function which gets invoked on the corresponding receiver within createString
afterward. For another description of the concept, please consult the associated documentation. I also tried to answer a related StackOverflow question a while ago.
Scope Functions
Scope functions make use of the concepts described above. They are defined as higher-order functions, i.e. they take another function as their argument. These arguments may even appear as function literals with receiver in certain cases. Scope functions take an arbitrary object, the context object, and bring it to another scope. In that scope, the context object is either accessible as it
(or custom name) or this
, depending on the type of function. In the following, the functions let
, run
, also
, apply
and with
will be introduced and explained.
[let
]
Documentation: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/let.html
public inline fun <T, R> T.let(block: (T) -> R): R
One of the most famous scope functions is certainly let
. It's inspired by functional programming languages like Haskell and is used quite often in the Kotlin language, too. Let's inspect its signature:
- Defined as an extension on
T
, the receiver/context object - Generic type
R
defines the function's return value - Result
R
ofblock
will be the result oflet
itself, i.e. it can be an arbitrary value block
argument with regular function type(T) -> R
- Receiver
T
is passed as argument toblock
Use Cases
a. Idiomatic replacement for if (object != null)
blocks
As you can read in the Kotlin Idioms section, let
is supposed to be used to execute blocks if a certain object is not null
.
val len = text?.let {
println("get length of $it")
it.length
} ?: 0
The nullable text
variable is brought into a new scope by let
if it isn't null
. Its value then gets mapped to its length
. Otherwise, the null
value is mapped to a default length 0
with the help of the Elvis operator. As you can see, the context object text
gets exposed as it
inside let
, which is the default implicit name for single parameters of a lambda.
b. Map nullable value if not null
The let
function is also often used for transformations, especially in combination with nullable types again, which is also defined as an idiom.
val mapped = value?.let { transform(it) } ?: defaultValue
c. Confine scope of variable/computation
If a certain variable or computation is supposed to be available only in a confined scope and should not pollute the outer scope, let
again can be helpful:
val transform = "stringConfinedToLetScope".let {
println("variable can be accessed in let: $it")
"${it.length}$it"
}
//cannot access original string from here
}
The shown string "stringConfinedToLetScope"
is made the context object of let
, which uses the value for some simple transformation that is returned as the result of let
. The outer scope only uses the transformed value and does not access the temporarily needed string. There's no variable polluting the outer scope due to confining it to the relevant scope.
[run
]
Documentation: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/run.html
inline fun <T, R> T.run(block: T.() -> R): R
As an alternative to let
, the run
function makes use of a function literal with receiver as used for the block
parameter. Let's inspect its signature:
- Defined as an extension on
T
, the receiver/context object - Generic type
R
defines the function's return value - Result
R
ofblock
will be the result ofrun
itself, i.e. it can be an arbitrary value block
argument defined as function literal with receiverT.() -> R
The run
function is like let
except how block
is defined.
Use Cases
run
can basically serve the same use cases as let
, whereas the receiver T
is exposed as this
inside the lambda argument:
a. Idiomatic replacement for if (object != null)
blocks
val len = text?.run {
println("get length of $this")
length //this
can be omitted
} ?: 0
b. Transformation
It's also good to use run
for transformations. The following shows an example that is even more readable than with let
since it accesses the context object's functions without qualifiers:
import java.util.Calendar
val date: Int = Calendar.getInstance().run {
set(Calendar.YEAR, 2030)
get(Calendar.DAY_OF_YEAR) //return value of run
}
[also
]
Documentation: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/also.html
inline fun T.also(block: (T) -> Unit): T
The also
function is the scope function that got lastly added to the Kotlin language, which happened in version 1.1. Let's inspect its signature:
- Defined as an extension on
T
, the receiver/context object - Returns the receiver object
T
block
argument with regular function type(T) -> Unit
- Receiver
T
is passed as argument toblock
also
looks like let
, except that it returns the receiver T
as its result.
Use Cases
a. Receiver not used inside the block
It might be desired to do some tasks related to the context object but without actually using it inside the lambda argument. An example can be logging. As described in the official Kotlin coding conventions, using also
is the recommended way to solve scenarios like the one shown next:
val num = 1234.also {
log.debug("the function did its job!")
}
In this case, the code almost reads like a normal sentence: Assign something to the variable and also log to the console.
b. Initializing an object
Another very common scenario that can be solved with also
is the initialization of objects. As opposed to the two previously introduced scope functions, let
and run
, also
returns the receiver object after the block
execution. This fact can be very handy:
val bar: Bar = Bar().also {
it.foo = "another value"
}
As shown, a Bar
instance is created and also
is utilized in order to directly initialize one of the instance's properties. Since also
returns the receiver object itself, the expression can directly be assigned to a variable of type Bar
.
c. Assignment of calculated values to fields
The fact that also
returns the receiver object after its execution can also be useful to assign calculated values to fields, as shown here:
fun getThatBaz() = calculateBaz().also { baz = it }
A value is being calculated, which is assigned to a field with the help of also
. Since also
returns that calculated value, it can even be made the direct inline result of the surrounding function.
[apply
]
Documentation: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/apply.html
inline fun T.apply(block: T.() -> Unit): T
The apply
function is another scope function that was added because the community asked for it. Its main use case is the initialization of objects, similar to what also
does. The difference will be shown next. Let's inspect its signature first:
- Defined as an extension on
T
, the receiver/context object - Returns the receiver object
T
block
argument defined as function literal with receiverT.() -> R
The relation between apply
and also
is the same as between let
and run
: Regular lambda vs. Function literal with receiver parameter:
Relation (apply
,also
) == Relation (run
,let
)
Use Cases
a. Initializing an object
The ultimate use case for apply
is object initialization. The community actually asked for this function in relatively late stage of the language. You can find the corresponding feature request here.
val bar: Bar = Bar().apply {
foo1 = Color.RED
foo2 = "Foo"
}
Although also
was already shown as a tool for solving these scenarios, it's obvious that apply
has a big advantage: There's no need to use "it
" as a qualifier since the context object, the Bar
instance in this case, is exposed as this
. The difference got answered in this StackOverflow post.
b. Builder-style usage of methods that return Unit
As described in the Kotlin Idioms section, apply
can be used for wrapping methods that would normally result in Unit
responses.
data class FooBar(var a: Int = 0, var b: String? = null) {
fun first(aArg: Int): FooBar = apply { a = aArg }
fun second(bArg: String): FooBar = apply { b = bArg }
}
fun main(args: Array<String>) {
val bar = FooBar().first(10).second("foobarValue")
println(bar)
}
In the example, apply
is used to wrap simple property assignments that would usually simply result in Unit
. Since the class wants to expose a builder-style API to the client, this approach is very useful as the setter-like methods return the surrounding object itself.
[with
]
Documentation: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/with.html
inline fun <T, R> with(receiver: T, block: T.() -> R): R
The with
function is the last scope function that will be discussed here. It's a very common feature in many even older languages like Visual Basic and Delphi.
It varies from the other four functions in that it is not defined as an extension function. Let's inspect its signature:
- Defined as an independent function that takes a receiver/context object
T
as its first argument - Result
R
ofblock
will be the result ofwith
itself, i.e. it can be an arbitrary value block
argument defined as function literal with receiverT.() -> R
This function aligns with let
and run
in regards to its return value R
. It's often said to be similar to apply
; the difference got described here. Another simple description of with
can be found here (both StackOverflow).
Use Cases
a. Working with an object in a confined scope
Also defined as an idiom, with
is supposed to be used when an object is only needed in a certain confined scope.
val s: String = with(StringBuilder("init")) {
append("some").append("thing")
println("current value: $this")
toString()
}
The StringBuilder
passed to with
is only acting as an intermediate instance that helps in creating the more relevant String
that gets created in with
. It's obvious that with
is utilized for wrapping the calls to StringBuilder
without exposing the instance itself to the outer scope.
b. Using member extensions of a class
Extension functions are usually defined on package level so that they can be imported and accessed from anywhere else with ease. It's also possible to define these on class or object level, which is then called a "member extension function". These kinds of extension functions can easily be used inside that class but not from outside. In order to make them accessible from anywhere outside the enclosing class, that class has to be brought "into scope". The with
function is very useful here:
object Foo {
fun ClosedRange<Int>.random() =
Random().nextInt(endInclusive - start) + start
}
// random() can only be used in context of Foo
with(Foo) {
val rnd = (0..10).random()
println(rnd)
}
The shown object Foo
defines a sweet member extension function random()
, which can be used only in the scope of that object. With the help of with
, this can easily be achieved. Note that this strategy is especially recommendable if particular extension functions are to be grouped meaningfully.
Comparison and Overview
After the five different scope functions have been discussed, it's necessary to see them all next to each other:
//return receiver T
fun T.also(block: (T) -> Unit): T //T exposed as it
fun T.apply(block: T.() -> Unit): T //T exposed as this
//return arbitrary value R
fun <T, R> T.let(block: (T) -> R): R //T exposed as it
fun <T, R> T.run(block: T.() -> R): R //T exposed as this
//return arbitrary value R, not an extension function
fun <T, R> with(receiver: T, block: T.() -> R): R //T exposed as this
The scope functions also
and apply
both return the receiver object after their execution. In apply
, the block parameter is defined as a function literal with receiver and T
gets exposed as this
, whereas in also
, it's a regular function type and T
gets exposed as it
.
The scope functions let
and run
on the other hand both return an arbitrary result R
, i.e. the result of the block itself. Again, run
works with a function literal with receiver, whereas let
uses the simple function type.
Last but not least, with
is kind of a misfit amongst the scope functions since it's not defined as an extension on T
. It defines two parameters, one of which represents the receiver object of this scope function. Same as apply
and run
, with
works with function literal with receiver.
returns receiver object | returns arbitrary result | |
---|---|---|
exposed as it | also | let |
exposed as this | apply | run & with 1 |
1Not an extension.
IDE Support
As of version 1.2.30, the IntelliJ IDEA Kotlin plugin offers intentions that can convert between let
and run
and also between also
and apply
calls. Read more about it here.
Example: Requesting a REST API
In this section, I'm going to show an example that applies the previously discussed scope functions on a pretty basic use case: Calling an HTTP REST endpoint. The goal is to provide functionality for requesting information about contributors of the jetbrains/kotlin GitHub project. Therefore we define the appropriate GitHub endpoint and a simplified representation of a Contributor
that is annotated for Jackson:
const val ENDPOINT = "https://api.github.com/repos/jetbrains/kotlin/contributors"
@JsonIgnoreProperties(ignoreUnknown = true)
data class Contributor(
val login: String,
val contributions: Int
)
The following shows the code that provides the desired functionality in its initial form:
object GitHubApiCaller {
private val client = OkHttpClient()
private var cachedLeadResults =
mutableMapOf<String, Contributor>()
private val mapper = jacksonObjectMapper()
@Synchronized
fun getKotlinContributor(name: String): Contributor {
val cachedLeadResult = cachedLeadResults[name]
if (cachedLeadResult != null) {
LOG.debug("return cached: $cachedLeadResult")
return cachedLeadResult
}
val request = Request.Builder().url(ENDPOINT).build()
val response = client.newCall(request).execute()
val responseAsString = response.use {
val responseBytes = it.body()?.source()?.readByteArray()
if (responseBytes != null) {
String(responseBytes)
} else throw IllegalStateException("No response from server!")
}
LOG.debug("response from git api: $responseAsString\n")
val contributors =
mapper.readValue(responseAsString)
val match = contributors.first { it.login == name }
this.cachedLeadResults[name] = match
LOG.debug("found kotlin contributor: $match")
return match
}
}
The depicted snippet shows a singleton object GitHubApiCaller
with an OkHttpClient
(OkHttp), a Jackson mapper and a simple Map
that's used for caching results. The code of getKotlinContributor
can be decomposed into the following sub-tasks:
- When the result is already cached, return it immediately and skip the rest
- Create a request object using the
ENDPOINT
- Get the response by executing the request on the
client
- Extract the JSON data from the response object (Error handling omitted)
- De-serialize the JSON to an
Array
- Filter for the contributor that is searched for
- Cache the result and return it to the client
In my opinion, this code is very comprehensive and everybody is able to make use of it. Nevertheless, it can be taken as a good basis for a little refactoring.
Reviewing the Code
Let's now try to find some appropriate use cases for scope functions in the previously shown function.
Refactoring No. 1
The first thing that we can improve is the if
block in the very beginning:
val cachedLeadResult = cachedLeadResults[name]
if (cachedLeadResult != null) {
println("return cached: $cachedLeadResult")
return cachedLeadResult
}
As shown earlier, the let
function is normally used for resolving these kinds of if
blocks. Applied to the concrete example, we get the following:
return cachedLeadResults[name]?.let {
LOG.debug("return cached: $it")
it
}
The problem here is that let
is defined with a generic return type R
so that the it
needs to be written at the end in order to make it the return value of the expression. Another obvious insufficiency is the missing else
statement. The first problem can be addressed pretty easily. We just need to use a scope function that returns its receiver, i.e. the cached result, directly from the block. Additionally, it should still expose the receiver as it
, which makes also
the best suitable candidate:
@Synchronized
fun getKotlinContributor(name: String): Contributor {
return cachedLeadResults[name]?.also {
LOG.debug("return cached: $it")
} ?: requestContributor(name)
}
The Elvis operator, shown before, is very often used for handling the else
case, i.e. when the receiver is null
. In order to make the code more readable, a private function requestContributor
now handles the cache miss.
That's it, the if
block was replaced with an easy also
invocation. A more idiomatic solution.
Refactoring No. 2
The next portion that is worth reconsidering contains an unnecessary local variable that represents the request
object:
val request = Request.Builder().url(ENDPOINT).build()
val response = client.newCall(request).execute()
It's literally only used for getting a response object from the client
and could therefore simply be inlined. Alternatively, the shown actions can be thought of as a basic transformation, which we learned to express with the let
function:
val response =
Request.Builder().url(ENDPOINT).build().let { client.newCall(it).execute() }
The request has been made the context object of let
and directly gets executed in a straightforward transformation. It makes the local request
variable obsolete without affecting readability negatively.
Refactoring No. 3
The following snippet points at another if (obj != null)
block, which in this case can actually be solved with let
:
// Before Refactoring
val responseAsString = response.use {
val responseBytes = it.body()?.source()?.readByteArray()
if (responseBytes != null) {
String(responseBytes)
} else throw IllegalStateException("No response from server!")
}
// With Scope Function
val responseAsString = response.use {
it.body()?.source()?.readByteArray()?.let { String(it) }
?: throw IllegalStateException("No response from server!")
}
Again, the Elvis operator handles the null
scenario very nicely.
Refactoring No. 4
Moving on to the next two statements of the code, we observe that the responseAsString
variable is being logged and finally used for the Jackson de-serialization. Let
's group them together:
// Before Refactoring
LOG.debug("response from git api: $responseAsString\n")
val contributors =
mapper.readValue(responseAsString)
// With Scope Function
val contributors = responseAsString.let {
LOG.debug("response from git api: $it\n")
mapper.readValue(it)
}
Refactoring No. 5
After the first few refactorings, the situation looks as follows: We have a response
, a responseAsString
and a contributors
variable and still need to filter the Contributor
s for the desired entry. Basically, the whole requesting and response handling is not relevant for the last step of filtering and caching. We can smoothly group these calls and confine them to their own scope. Since these actions happen with the help of the OkHttpClient
, it makes sense to make the client
the context object of that scope:
private fun requestContributor(name: String): Contributor {
val contributors =
with(client) {
val response =
Request.Builder().url(ENDPOINT).build().let { newCall(it).execute() }
val responseAsString = response.use {
it.body()?.source()?.readByteArray()?.let { String(it) }
?: throw IllegalStateException("No response from server!")
}
responseAsString.let {
LOG.debug("response from git api: $it\n")
mapper.readValue(it)
}
}
//...
}
There isn't any new code here, the previous edits have simply be wrapped in a call of with
and are therefore not visible to the surrounding scope (function requestContributors
) anymore. It made sense to use with
in this case since it exposes client
as this
and the newCall
invocation can therefore omit its qualifier. As described earlier, with
can have an arbitrary result R
. In this case, the last statement inside the lambda, the result of the last let
call, becomes that R
.
Refactoring No. 6
Now a single variable contributors
is available in the outer scope and we can apply the filtering:
return contributors.first { it.login == name }.also {
cachedLeadResults[name] = it
LOG.debug("found kotlin contributor: $it")
}
The previous version of the above code consisted of four independent statements that are now grouped in a simple also
call with the filtered Contributor
as its receiver. That receiver is put in the cache and also logged for debugging purposes. Of course, since also
returns its receiver directly, the whole statement can be made the return of the function.
The entire function looks like this:
private fun requestContributor(name: String): Contributor {
val contributors =
with(client) {
val response =
Request.Builder().url(ENDPOINT).build().let { newCall(it).execute() }
val responseAsString = response.use {
it.body()?.source()?.readByteArray()?.let { String(it) }
?: throw IllegalStateException("No response from server!")
}
responseAsString.let {
LOG.debug("response from git api: $it\n")
mapper.readValue(it)
}
}
return contributors.first { it.login == name }.also {
cachedLeadResults[name] = it
LOG.debug("found kotlin contributor: $it")
}
}
In my opinion, the code looks very well structured and still readable. Yet, I don't want to encourage the readers to apply scope functions in every situation after reading this article. It's very important to know that this set of functions is so powerful that they could even be used to chain an unlimited amount of expressions and make them a single expression. You don't want to do that because it messes up the code very quickly. Try to find a balance here and don't apply scope functions everywhere.
Conclusion
In this article, I discussed the powerful set of scope functions of the Kotlin standard library. Many situations can be solved in a very idiomatic way with the help of these functions and it's vital to have a rough idea of the differences between them. Try to memorize that there are scope functions that can return arbitrary values (let
, run
, with
) and those that return the receiver itself (apply
, also
). Then there are functions, which expose their receiver as it
(let
, also
) and others, which expose their receiver as this
(run
, apply
, with
). The concluding example demonstrated how easily scope functions may be used for refactoring appropriate code sections according to the earlier learned concepts. You shouldn't get the impression that every single opportunity should actually be embraced; it's still necessary to reason about the application of scope functions. Also, you shouldn't try to use all of the shown functions at any price since most of them can be used interchangeably sometimes. Try to find your own favorites 🙂
You can find the shown code examples in this GitHub Repo.
Feel free to contact me and follow on Twitter. Also, check out my Getting Started With Kotlin cheat sheet here.
If you want to read more about Kotlin's beautiful features I highly recommend the book Kotlin in Action and my other articles to you.
Simon is a software engineer with 9+ years of experience developing software on multiple platforms including the JVM and Serverless environments. He currently builds scalable distributed services for a decision automation SaaS platform. Simon is a self-appointed Kotlin enthusiast.
Thank you for this article. Here is minor correction in the “with” section:
Thanks, I fixed this one
[…] a method that makes use of Function Literals with Receiver. It’s one of Kotlin’s famous scope functions, which creates a scope on an arbitrary context object, in which we access members of that context […]
[…] I already mentioned in the beginning, Kotlin’s standard library contains methods using this concept, one of which is apply. This one is defined as […]
[…] KotlinExpertise.com – scope-functions MediumAndroidDev – Standard Functions cheat-sheet GitHub-Kotlin-std-fun Medium – Standard Functions […]
[…] too. One example of this is the very helpful set of scope functions, which you can learn about here. Of course, lambdas are a vital ingredient to the collections API, i.e., stuff defined in […]
[…] It still uses an explicit for loop but got simplified by the use of apply which allows us to initialize the map in a single statement. Learn more about it here. […]