article banner

Scope functions

This is a chapter from the book Functional Kotlin. You can find it on LeanPub or Amazon. It is also available as a course.

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` implementation without contract inline fun <T, R> T.let(block: (T) -> R): R = block(this)

let is a very 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.

fun main() { println(listOf("a", "b", "c").map { it.uppercase() }) // [A, B, C] println("a".let { it.uppercase() }) // A }

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.

val fis = FileInputStream("someFile.gz") val bis = BufferedInputStream(fis) val gis = ZipInputStream(bis) val ois = ObjectInputStream(gis) val someObject = ois.readObject()

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:

val someObject = FileInputStream("someFile.gz") .let { BufferedInputStream(it) } .let { ZipInputStream(it) } .let { ObjectInputStream(it) } .readObject()

If you prefer, you can also use constructor references0:

val someObject = FileInputStream("someFile.gz") .let(::BufferedInputStream) .let(::ZipInputStream) .let(::ObjectInputStream) .readObject()

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.

class CoursesService( private val userRepository: UserRepository, private val coursesRepository: CoursesRepository, private val userCoursesFactory: UserCoursesFactory, ) { // Imperative approach, without let fun getActiveCourses(token: String): UserCourses? { val user = userRepository.getUser(token) ?: return null val activeCourses = coursesRepository .getActiveCourses(user.id) ?: return null return userCoursesFactory.produce(activeCourses) } // Functional approach, using let fun getActiveCourses(token: String): UserCourses? = userRepository.getUser(token) ?.let {coursesRepository.getActiveCourses(it.id)} ?.let(userCoursesFactory::produce) }

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.

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).

class UserCreationRequest( val id: String, val name: String, val surname: String, ) class UserDto( val userId: String, val firstName: String, val lastName: String, ) fun UserCreationRequest.toUserDto() = UserDto( userId = this.id, firstName = this.name, lastName = this.surname, )

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.

class UserCreationRequest( val name: String, val surname: String, ) class UserDto( val userId: String, val firstName: String, val lastName: String, ) class UserCreationService( private val userRepository: UserRepository, private val idGenerator: IdGenerator, ) { fun addUser(request: UserCreationRequest): User = request.toUserDto() .also { userRepository.addUser(it) } .toUser() // Anti-pattern! Avoid using member extensions private fun UserCreationRequest.toUserDto() = UserDto( userId = idGenerator.generate(), firstName = this.name, lastName = this.surname, ) }

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.

class UserCreationRequest( val name: String, val surname: String, ) class UserDto( val userId: String, val firstName: String, val lastName: String, ) class UserCreationService( private val userRepository: UserRepository, private val idGenerator: IdGenerator, ) { fun addUser(request: UserCreationRequest): User = request.let { createUserDto(it) } // or request.let(::createUserDto) .also { userRepository.addUser(it) } .toUser() private fun createUserDto(request: UserCreationRequest) = UserDto( userId = idGenerator.generate(), firstName = request.name, lastName = request.surname, ) }

This approach works just as well when object creation is extracted into a class, like UserDtoFactory.

class UserCreationService( private val userRepository: UserRepository, private val userDtoFactory: UserDtoFactory, ) { fun addUser(request: UserCreationRequest): User = request.let { userDtoFactory.produce(it) } .also { userRepository.addUser(it) } .toUser() // or // fun addUser(request: UserCreationRequest): User = // request.let(userDtoFactory::produce) // .also(userRepository::addUser) // .toUser() }

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.

// Not good, not terrible val someObject = FileInputStream("/someFile.gz") .let(::BufferedInputStream) .let(::ZipInputStream) .let(::ObjectInputStream) .readObject() println(someObject) // Terrible print( FileInputStream("/someFile.gz") .let(::BufferedInputStream) .let(::ZipInputStream) .let(::ObjectInputStream) .readObject() )

The solution to this problem is to use let (or another scope function) to invoke print "on the result".

FileInputStream("/someFile.gz") .let(::BufferedInputStream) .let(::ZipInputStream) .let(::ObjectInputStream) .readObject() .let(::print)

Some developers will argue that in such cases one should use also instead of let. The reasoning is that let is a transformation function and should therefore have no side effects, while also is dedicated to use for side effects. On the other hand, using let in such cases is popular.

This approach allows us to use safe-calls and call operations only on non-null objects.

FileInputStream("/someFile.gz") .let(::BufferedInputStream) .let(::ZipInputStream) .let(::ObjectInputStream) .readObject() ?.let(::print)

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.

class User(val name: String) var user: User? = null fun showUserNameIfPresent() { // will not work, because cannot smart-cast a property // if (user != null) { // println(user.name) // } // works // val u = user // if (u != null) { // println(u.name) // } // perfect user?.let { println(it.name) } }

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.

Some developers will again argue that in such cases one should use also instead of let; again, using let 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

// `also` implementation without contract inline fun <T> T.also(block: (T) -> Unit): T { block(this) return this }

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.

fun addUser(request: UserCreationRequest): User = request.toUserDto() .also { userRepository.addUser(it) } .toUser()

It can be also used for all kinds of additional operations, like printing logs or storing a value in a cache.

fun addUser(request: UserCreationRequest): User = request.toUserDto() .also { userRepository.addUser(it) } .also { log("User created: $it") } .toUser() class CachingDatabaseFactory( private val databaseFactory: DatabaseFactory, ) : DatabaseFactory { private var cache: Database? = null override fun createDatabase(): Database = cache ?: databaseFactory.createDatabase() .also { cache = it } }

As mentioned already, also can also be used instead of let to unpack a nullable object or move an operation to the end.

class User(val name: String) var user: User? = null fun showUserNameIfPresent() { user?.also { println(it.name) } } fun readAndPrint() { FileInputStream("/someFile.gz") .let(::BufferedInputStream) .let(::ZipInputStream) .let(::ObjectInputStream) .readObject() ?.also(::print) }

takeIf and takeUnless

// `takeIf` implementation without contract inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? { return if (predicate(this)) this else null } // `takeUnless` implementation without contract inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? { return if (!predicate(this)) this else null }

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.

val lines = File("SomeFile") .takeIf { it.exists() } ?.readLines()

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.

class UserCreationService( private val userRepository: UserRepository, ) { fun readUser(token: String): User? = userRepository.findUser(token) .takeIf { it.isValid() } ?.toUser() }

apply

// `apply` implementation without contract inline fun <T> T.apply(block: T.() -> Unit): T { block() return this }

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.

fun createDialog() = Dialog().apply { title = "Some dialog" message = "Just accept it, ok?" // ... } fun showUsers(users: List<User>) { listView.apply { adapter = UsersListAdapter(users) layoutManager = LinearLayoutManager(context) } }

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:

class Node(val name: String) { fun makeChild(childName: String) = create("$name.$childName") .apply { print("Created $name") } fun create(name: String): Node? = Node(name) } fun main() { val node = Node("parent") node.makeChild("child") }

The intuitive answer is "Created 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.

class Node(val name: String) { fun makeChild(childName: String) = create("$name.$childName") .also { print("Created ${it?.name}") } fun create(name: String): Node? = Node(name) } fun main() { val node = Node("parent") node.makeChild("child") // Created child }

with

// `with` implementation without contract inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()

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.

// explicit scope changing in Kotlin Coroutines val scope = CoroutineScope(SupervisorJob()) with(scope) { launch { // ... } launch { // ... } } // unit-test assertions with(user) { assertEquals(aName, name) assertEquals(aSurname, surname) assertEquals(aWebsite, socialMedia?.websiteUrl) assertEquals(aTwitter, socialMedia?.twitter) assertEquals(aLinkedIn, socialMedia?.linkedin) assertEquals(aGithub, socialMedia?.github) }

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

// `run` implementation without contract inline fun <R> run(block: () -> R): R = block() // `run` implementation without contract inline fun <T, R> T.run(block: T.() -> R): R = block()

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.

val locationWatcher = run { val positionListener = createPositionListener() val streetListener = createStreetListener() LocationWatcher(positionListener, streetListener) }

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:

0:

Constructor references were explained in the chapter Function references.

1:

Except for with, which is not an extension function.

2:

For details, see Effective Kotlin, Item 46: Avoid member extensions.

3:

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.