Kotlin Scoping Functions apply vs. with, let, also, and run

Functional-style programming is highly advocated and supported by Kotlin’s syntax as well as a range of functions in Kotlin’s standard library. In this post we will examine five such higher-order functions: apply, with, let, also, and run.

When learning these five functions, you will need to memorize 2 things: how to use them, and when to use them. Because of their similar nature, they can seem a bit redundant at first.

In this post we will first see what these five scoping functions have in common, followed by exploring their differences. At the end, we will learn about the conventions for when to use them.

What do they do?

Let’s first see how this works with one of those functions. The with function is basically defined as follows:

inline fun <T, R> with(receiver: T, block: T.() -> R): R {
return
receiver.block()
}

By using it, we can make the code more concise. Let’s see some ordinary code that does not use scoping functions, first:

class Person {
var name: String? = null
var age
: Int? = null
}

val person: Person = getPerson()
print(person.name)
print(person.age)

The following code snippet is equivalent to the one above, except that it uses with() scoping function to remove repetition of the person variable:

val person: Person = getPerson()
with(person) {
print(name)
print(age)
}

Nice! But, why do we need five functions, then? Let’s see below!

Differences between apply, with, let, also, and run

Let’s compare the with() function to the signature and implementation of one of the other functions, the also() function, which is basically defined as follows:

inline fun <T, R> with(receiver: T, block: T.() -> R): R {
return
receiver.block()
}
inline fun <T> T.also(block: (T) -> Unit): T {
block(this)
return this
}

The with() and the also() functions differ in 3 things.

  1. The receiver argument is provided as an explicit parameter T in the case of with(), whereas it is provided as an implicit receiver T in the case of also().
  2. The block argument is defined as a function that has an implicit receiver T in the case of with(), whereas it has an explicit argument T in the case of also().
  3. The with() function returns what is returned by executing its block argument, whereas the also() function returns the same object that was provided as its receiver.

Because of these 3 differences, the also() function needs to be used in a different way:

val person: Person = getPerson().also {
print(it.name)
print(it.age)
}

This code snippet will retrieve a person using the getPerson() function, and assign it to the person variable. Before doing so, the also() function will print the retrieved person’s name and age.

What about the other functions, apply, let, and run? They all differ in 1 of the 3 differences shown above:

  • explicit receiver parameter vs. implicit receiver
  • provided to the block argument as an explicit parameter vs. an implicit receiver
  • returning the receiver vs. returning what the block returns

Here is the definition of all 5 functions:

inline fun <T, R> with(receiver: T, block: T.() -> R): R {
return
receiver.block()
}
inline fun <T> T.also(block: (T) -> Unit): T {
block(this)
return this
}
inline fun <T> T.apply(block: T.() -> Unit): T {
block()
return this
}
inline fun <T, R> T.let(block: (T) -> R): R {
return block(this)
}
inline fun <T, R> T.run(block: T.() -> R): R {
return
block()
}

When learning these functions, it can be hard to memorize how they are defined. The following spreadsheet shows their differences in a matrix. I recommend printing it and referring to it whenever needed:

When to use apply, with, let, also, or run

There are several best practices and conventions for these five functions defined in the official Kotlin documentation. By learning these conventions, you will write more idiomatic code, and it will help you to faster understand the intend of other developer’s code.

Conventions for using apply

val peter = Person().apply {
// only access properties in apply block!
name = "Peter"
age
= 18
}

The equivalent code without apply() would look like this:

val clark = Person()
clark.name = "Clark"
clark.age = 18

Conventions for using also

class Book(author: Person) {
val author = author.also {
requireNotNull(it.age)
print(it.name)
}
}

The equivalent code without also() would look like this:

class Book(val author: Person) {
init {
requireNotNull(author.age)
print(author.name)
}
}

Conventions for using let

  • execute code if a given value is not null
  • convert a nullable object to another nullable object
  • limit the scope of a single local variable
getNullablePerson()?.let {
// only executed when not-null
promote(it)
}
val driversLicence: Licence? = getNullablePerson()?.let {
// convert nullable person to nullable driversLicence
licenceService.getDriversLicence(it)
}
val person: Person = getPerson()
getPersonDao().let { dao ->
// scope of dao variable is limited to this block
dao.insert(person)
}

The equivalent code without let() would look like this:

val person: Person? = getPromotablePerson()
if (person != null) {
promote(person)
}
val driver: Person? = getDriver()
val driversLicence: Licence? = if (driver == null) null else
licenceService.getDriversLicence(it)
val person: Person = getPerson()
val personDao: PersonDao = getPersonDao()
personDao.insert(person)

Conventions for using with

val person: Person = getPerson()
with(person) {
print(name)
print(age)
}

The equivalent code without with() looks like this:

val person: Person = getPerson()
print(person.name)
print(person.age)

Conventions for using run

val inserted: Boolean = run {
val person: Person = getPerson()
val personDao: PersonDao = getPersonDao()
personDao.insert(person)
}
fun printAge(person: Person) = person.run {
print(age)
}

The equivalent code without run() would look like:

val person: Person = getPerson()
val personDao: PersonDao = getPersonDao()
val inserted: Boolean = personDao.insert(person)
fun printAge(person: Person) = {
print(person.age)
}

Combining Multiple Scoping Functions

When scoping functions are nested, the code can get confusing fast. As a rule, try not to nest the scoping functions that bind their receiver argument to the receiver of the lambda block (apply, run, with). When nesting the other scoping functions (let, also) provide an explicit name for the lambda block’s parameter, i.e. don’t use the implicit parameter it when nesting those scoping functions.

Besides nesting, scoping functions can also be combined in a call chain. Unlike nesting there is no readability penalty when combining scoping functions in this way. Quite the contrary, the improvements in readability will be even bigger.

As a conclusion to this post, we will see some examples of combining scoping functions in call chains.

private fun insert(user: User) = SqlBuilder().apply {
append("INSERT INTO user (email, name, age) VALUES ")
append("(?", user.email)
append(",?", user.name)
append(",?)", user.age)
}.also {
print("Executing SQL update: $it.")
}.run {
jdbc
.update(this) > 0
}

The snippet above shows a dao function for inserting a User into the database. It uses Kotlin’s expression body syntax while still separating concerns within its implementation: preparing the SQL, logging the SQL, and executing the SQL. At the end, this function returns a Boolean indicating the success of the insert.