Scope functions
This is a chapter from the book Functional Kotlin. You can find it on LeanPub or Amazon.
There is a group of minimalistic but useful inline functions from the standard library called scope functions. This group typically includes let
, apply
, also
, run
and with
. Some developers also include takeIf
and takeUnless
in this group. They are all extensions on any generic type1. All scope functions are just a few lines long. Let's discuss their usages and how they work, starting with the functions I find most useful.
let
let
is a simple function, yet it is used in many Kotlin idioms. It can be compared to the map
function but for a single object: it transforms an object using a lambda expression.
Let's see its common use cases.
Mapping a single object
To understand how let
is used, let's imagine that you need to read a zip file with buffering, unpack it, and read an object from the result. On JVM, we use input streams for such operations. We first create a FileInputStream
to read a file, and then we decorate it with classes that add the capabilities we need.
This pattern is not very readable because we create plenty of variables that are used only once. We can easily make a mistake, for instance by using an incorrect variable at any step. How can we improve it? By using the let
function! We can first create FileInputStream
, and then decorate it using let
:
If you prefer, you can also use constructor references0:
Using let
, we can form a nice flow of how an element is transformed. What is more, if a nullability is introduced at any step, we can use let
conditionally with a safe call. To see this in practice, let's imagine that we are implementing a service that, based on a user token, responds with this user's active courses.
In these cases, let
is not necessary, but it’s very convenient. I see similar usage quite often, especially on backend applications. It makes our functions form a nice flow of data, and it lets us easily control the scope of each variable. It also has downsides, such as the fact that debugging is harder, so you need to decide yourself whether to use this approach in your applications.
Here is another practical example, coming from AnkiMarkdown library, that is using let to update notes, and get the result of this operation:
Here is an example from the same project, where let
is used to decorate a string with a prefix and a postfix:
The problem with member extension functions
At this point, it is worth mentioning that there is an ongoing discussion about transforming objects from one class to another. Let's say that we need to transform from UserCreationRequest
to UserDto
. The typical Kotlin way is to define a toUserDto
or toDomain
method (either a member function or an extension function).
The problem arises when the transformation function needs to use some external services. It needs to be defined in a class, and defining member extension functions is an anti-pattern2.
also
function will be explained next.
A good solution to this problem is defining transformation functions as regular functions in such cases, and if we want to call them "on an object", just use let
.
This approach works just as well when object creation is extracted into a class, like UserDtoFactory
.
Moving an operation to the end of processing
The second typical use case for let
is when we want to move an operation to the end of processing. Let's get back to our example, where we were reading an object from a zip file, but this time we will assume that we need to do something with that object in the end. For simplification, we might be printing it. Again, we face the same problem: we either need to introduce a variable or wrap the processing with a misplaced print
call.
The solution to this problem is to use let
(or another scope function) to invoke print
"on" the result.
This approach allows us to use safe-calls and call operations only on non-null objects.
In real-life applications it is typically calling some other function than print
. For instance, it can be sending a message:
Some developers will argue that in such cases one should use
also
instead oflet
. The reasoning is thatlet
is a transformation function and should therefore have no side effects, whilealso
is dedicated to use for side effects. On the other hand, usinglet
in such cases is popular.
Dealing with nullability
The let
function (and nearly all other scope functions) is called on an object, so it can be called with a safe call. We’ve already seen a few examples of how this capability helped us in the previous use cases. But it goes even further: let
is often called just to help with nullability. To see this, let's consider the following example, where we want to print the user name if the user
is not null
. Smart casting does not work for variables because they can be modified by another thread. The easiest solution uses let
.
In this solution, if user
is null, let
is not called (due to the safe call used), and nothing happens. If user
is not-null, let
is called, so it calls println
with the user name. This solution is fully thread-safe even in extreme cases: if user
is not null during the safe call, and it then changes to null
straight after that, printing the name will work fine because it
is the reference to the user that was used at the time of the nullability check.
Here is a practical example, coming from my script for generating solutions from this book as a website:
Some developers will again argue that in such cases one should use
also
instead oflet
; again, usinglet
for null checks is popular.
These are the key cases where let
is used. As you can see, it is pretty useful but there are other scope functions with similar characteristics. Let's see these, starting from the one mentioned a few times already: also
.
also
We have mentioned the use of also
already, so let's discuss it. It is pretty similar to let
, but instead of returning the result of its lambda expression, it returns the object it is invoked on. So, if let
is like map
for a single object, then also
can be considered an onEach
for a single object, as also
returns the object as it is.
also
is used to invoke an operation on an object. Such operations typically include some side effects. We've used it already to add a user to our database.
It can be also used for all kinds of additional operations, like printing logs or storing a value in a cache.
As mentioned already, also
can also be used instead of let
to unpack a nullable object or move an operation to the end.
Here is a function that after referencing a directory, deletes it and then creates is again:
Here is a function from an Android project, that uses also
to cache MoviesDatabase
instance in a variable (if we do not need to change instance
in any other way, lazy
can be used instead).
takeIf
and takeUnless
We already know that let
is like a map
for a single object. We know that also
is like an onEach
for a single object. So, now it’s time to learn about takeIf
and takeUnless
, which are like filter
and filterNot
for a single object.
Depending on what their predicates return, these functions either return the object they were invoked on, or null
. takeIf
returns an untouched object if the predicate returned true
, and it returns null
if the predicate returned false
. takeUnless
is like takeIf
with a reversed predicate result (so takeUnless(pred)
is like takeIf { !pred(it) }
).
We use these functions to filter out incorrect objects. For instance, if you want to read a file only if it exists.
We use such checks for safety. For example, if a file does not exist, readLines
throws an exception. Replacing incorrect objects with null
helps us handle them safely. It also helps us drop incorrect results, or just replace some values with null
.
Here is a simple example of extracting an article title from the first line:
takeUnless
usage in AnkiMarkdown library. It transforms field value to null
if it is empty.takeUnless
usage in AnkiMarkdown library. Mapping uses takeUnless
to ignore items whose field is empty.apply
Moving into a slightly different kind of scope function, it’s time to present apply
, which we already used in the DSL chapter. It works like also
in that it is called on an object and it returns it, but it introduces an essential change: its parameter is not a regular function type but a function type with a receiver.
This means that if you take also
and replace it with apply
, and you replace the argument (typically it
) with a receiver (this
) inside the lambda, the resulting code will be the same as before. However, this small change is actually really important. As we learned in the DSL chapter, changing receivers can be both a big convenience and a big danger. This is why we should not change receivers thoughtlessly, and we should restrict apply
to concrete use cases. These use cases mainly include setting up an object after its creation and defining DSL function definitions.
The dangers of careless receiver overloading
The this
receiver can be used implicitly, which is both convenient and potentially dangerous. It is not a good situation when we don’t know which receiver is being used. In some languages, like JavaScript, this is a common source of mistakes. In Kotlin, we have more control over the receiver, but we can still easily fool ourselves. To see an example, try to guess what the result of the following snippet will be:
The intuitive answer is "Created parent.child", but the actual answer is "Created parent". Why? Notice that the create
function declares a nullable result type, so the receiver inside apply
is Node?
. Can you call name
on Node?
type? No, you need to unpack it first. However, Kotlin will automatically (without any warning) use the outer scope, and that is why "Created parent" will be printed. We fooled ourselves. The solution is to avoid unnecessary receivers (for name resolution). This is not a case in which we should use apply
: it is a clear case for also
, for which Kotlin would force us to use the argument value safely if we used it.
with
As you can see, changing a receiver is not a small deal, so it is good to make it visible. apply
is perfect for object initialization; for most other cases, a very popular option is with
. We use with
to explicitly turn an argument into a receiver.
In contrast to other scope functions, with
is a top-level function whose first argument is used as its lambda expression receiver. This makes the new receiver definition really visible.
Typical use cases for with
include explicit scope changing in Kotlin Coroutines, or specifying multiple assertions on a single object in tests.
with
returns the result of its block
argument, so it can be used as a transformation function; however, this fact is rarely used, and I would suggest using with
as if it is returning Unit
.
run
We have already encountered a top-level run
function in the Lambda expressions chapter. It just invokes a lambda expression. Its only advantage over an immediately invoked lambda expression ({ /*...*/ }()
) is that it is inline. A plain run
function is used to form a scope. This is not a common need, but it can be useful from time to time.
Another variant of the run
function is invoked on an object. Such an object becomes a receiver inside the run
lambda expression. However, I do not know any good use cases for this function. Some developers use run
for certain use cases, but nowadays, I rarely see run
used in commercial projects. Personally, I avoid using it3.
Using scope functions
In this chapter, we have learned about many small but useful functions, called scope functions. Most of them have clear use cases. Some compete with each other for use cases (especially let
and apply
, or apply
and with
). Nevertheless, knowing all these functions well and using them in suitable situations is a recipe for nicer and cleaner code. Just please use them only where they make sense; don’t use them just to use them.
A simplified comparison between key scope functions is presented in the following table:
Constructor references were explained in the chapter Function references.
Except for with
, which is not an extension function.
For details, see Effective Kotlin, Item 46: Avoid member extensions.
Email me if you have some good use cases where you think that run
clearly fits better than the other scope functions. My email is marcinmoskala@gmail.com.