Scoping functions in Kotlin

Using let

The let function is a useful tool for variable scoping and to work with nullable types. It has three main use cases.

  • Scoping variables so that they are only visible inside a lambda
  • Executing some code only if a nullable variable is not null
  • Converting one nullable object into another

First, variable scoping can be used whenever a variable or object should only be used in a small region of the code, for instance, a buffered reader reading from a file, as shown in following listing.

val lines = File("rawdata.csv").bufferedReader().let {
    val result = it.readLines()
    it.close()
    result
}

This way, the buffered reader object and all variables declared inside the let block (the lambda expression) are only visible inside that block. This avoids unwanted access to the reader from outside this block. Notice that, similar to if and when expressions, let returns the value defined in the last line of the lambda. This example returns a list of all lines in a file and must store that into a temporary variable in order to call close in between and close the file - we will improve this later with use.

Second, let is useful to work with nullable types because, when called on a nullable object using the safe call operator, the let block will only be executed if that object is not null. Otherwise, the let block will simply be ignored. Imagine trying to fetch weather data with a network call that may return null, as illustrated in following listing.

val weather: Weather? = fetchWeatherOrNull()
weather?.let {
    updateUi(weather.temperature)
}

When using let with a safe­call operator like this, then inside the let block, the Kotlin compiler helps you by automatically casting the weather variable to a non­-nullable type so that it can be used without further addressing nullability. If the weather data could not be fetched, the lambda passed to let is not run. Thus, the UI will not be updated.

Lastly, you can convert one nullable object into another with the same construct as in above listing by calling let on the original nullable object and defining the desired converted value in the last line inside the let block. This last line defines the return value of let.

Using apply

The higher-­order function apply has two primary use cases.

  • Encapsulating multiple calls on the same object
  • Initializing objects

Following listing shows how to encapsulate multiple calls on the same object into an own code block. In the following subsections, let’s assume countryToCapital was a mutable map, not a read­only one, so that you can add new elements to it.

countryToCapital.apply {
    putIfAbsent("India", "Delhi")
    putIfAbsent("France", "Paris")
}

With apply, you do not refer to it because the lambda you pass in is executed like an extension function on the object that you call apply on, here countryToCapital. Thus, you can write the code inside the apply block as if it were part of the MutableMap class. You could write this.putIfAbsent, but using this as a prefix is optional.

Most commonly, apply is used for object initialization as in following listing.

val container = Container().apply {
    size = Dimension(1024, 800)
    font = Font.decode("Arial­-bold-­22")
    isVisible = true
}

This approach uses the fact that apply first runs any code in its lambda and then returns the object that it was called on. Thus, the container variable includes any changes made to the object inside the lambda. This also enables chaining apply with other calls on the object, as shown in following listing.

countryToCapital.apply { ... }
    .filter { ... }
    .map { ... }

Using with

The with function behaves almost like apply and has two major use cases.

  • To encapsulate multiple calls on the same object
  • To limit the scope of a temporary object

In contrast to apply, it returns the result of the lambda, not the object on which it was called. This is shown in following listing.

val countryToCapital = mutableMapOf("Germany" to "Berlin")
val countries = with(countryToCapital) {
    putIfAbsent("England", "London")
    putIfAbsent("Spain", "Madrid")
    keys
}

This shows that the return value of with is the return value of the lambda you pass in - defined by the last line inside the lambda. Here, the expression in the last line is the keys of the map, so the with block returns all countries stored as keys in the map.

Similar to let, you can limit the scope of an object to only the lambda expression if you create that object ad­hoc when passing it into with. This is demonstrated in following listing.

val essay = with(StringBuilder()) {
    appendln("Intro")
    appendln("Content")
    appendln("Conclusion")
    toString()
}

Note that with is superior to apply in this case because you don’t want to get the string builder back as a result but rather the lambda’s result. Builders like this are the most common example, where with is preferable to apply.

Using run

The run function is helpful to:

  • Work with nullables as with let but then use this inside the lambda instead of it.
  • Allow immediate function application.
  • Scope variables into a lambda.
  • Turn an explicit parameter into a receiver object.

First, run can be seen as a combination of with and let. Imagine that countryToCapital was nullable, and that you want to call multiple methods on it, as shown in following listing.

val countryToCapital: MutableMap<String, String>?
    = mutableMapOf("Germany" to "Berlin")

val countries = countryToCapital?.run {
    putIfAbsent("Mexico", "Mexico City")
    putIfAbsent("Germany", "Berlin")
    keys
}

It fulfills the same purpose as with listing but skips all operations in the lambda if the map is null.

Next, you can use run for immediate function application, which means plainly running a given lambda. This is shown in following listing.

val success = run {
    // Only visible inside this lambda
    val username = getUsername()
    val password = getPassword()
    validate(username, password)
}

Here, username and password are only accessible where they really have to be. Reducing scopes like this is a good practice to avoid unwanted accesses.

Lastly, you can use run to transform an explicit parameter into a receiver object if you prefer working with it that way. This is demonstrated in following listing, which assumes a User class that has a username.

fun renderUsername(user: User) = user.run {
    val premium = if (paid) " (Premium)" else ""
    val displayName = "$username$premium"
    println(displayName)
}

Inside the lambda expression, you can access all members of the User class directly (or via this). This is especially useful if the parameter name is long and you have to refer to it often inside the function.

Using also

This last higher-­order function from Standard.kt has two main use cases:

  • Performing ancillary operations like validation or logging
  • Intercepting function chains

First, consider following listing for an example that performs validation using also.

val user = fetchUser().also {
    requireNotNull(it)
    require(it!!.monthlyFee > 0)
}

First, the fetchUser function is called and may return null. After that, the lambda is executed, and only if it runs through is the variable assigned to the user. The usefulness of this function for logging and other operations "on the side" only becomes apparent in a chain of function calls, as in following listing.

users.filter { it.age > 21 }
.also { println("${it.size} adult users found.") }
.map { it.monthlyFee }

Outside of such chains, there’s no direct need to use also because you could do logging and other side operations on the next line. In call chains, however, also lets you intercept the intermediate results without breaking the chain.

Using use

The use function is not part of Standard.kt but has a similar structure and benefits. It ensures that the resource it is called on is closed after the given operations are performed. For this reason, it is only defined for subtypes of Closeable like Reader, Writer, or Socket. Using this, you can improve the code from first listing as shown below

val lines = File("rawdata.csv").bufferedReader().use { it.readLines() }

Since the buffered reader is now closed automatically at the end of the use block, there is no need to store the result in a temporary variable, and you can reduce the lambda to a single line. Apart from ensuring close is called, use is just like let. Internally, it uses a finally block to ensure the Closeable instance is ultimately closed.