A.I, Data and Software Engineering

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

K

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 Kotlin scoping functions (aka 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. In the end, we will learn about the conventions for when to use them.

What do they do?

All of these five functions basically do very similar things. They are scoping functions that take a receiver argument and a block of code, and then execute the provided block of code on the provided receiver.

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

Scoping Functions

Although these functions do very similar things, there are important differences in their signature and implementation. These differences dictate how they must be used.

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 Person() 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:Kotlin Standard Scoping FunctionsSheet1 Kotlin Standard Scoping Functions input, receiver, parameter binding in lambda receiver, apply, run, with…docs.google.com

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

We know how these five functions differ, now. But we still don’t know when to use which scoping function. They are very similar in nature, and often interchangeable.

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 intent of other developers’ code.

Conventions for using apply

Use the apply() function if you are not accessing any functions of the receiver within your block, and also want to return the same receiver. This is most often the case when initializing a new object. The following snippet shows an example:

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

Use the also() function, if your block does not access its receiver parameter at all, or if it does not mutate its receiver parameter. Don’t use also() if your block needs to return a different value. For example, this is very handy when executing some side effects on an object or validating its data before assigning it to a property:

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

Use the let() function in either of the following cases:

  • 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(driver)

val person: Person = getPerson()
val personDao: PersonDao = getPersonDao()
personDao.insert(person)

Conventions for using with

Use with() only on non-nullable receivers, and when you don’t need its result. For example:

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

Use run() function if you need to compute some value or want to limit the scope of multiple local variables. Use run() also if you want to convert explicit parameters to an implicit receiver.

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

The previous sections have shown how scoping functions can be used in isolation in order to improve code readability. It is often tempting to combine multiple scoping functions within the same block of code.

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. In the end, this function returns a Boolean indicating the success of the insert.

For more Kotlin related articles, go here.

Add comment

💬

A.I, Data and Software Engineering

PetaMinds focuses on developing the coolest topics in data science, A.I, and programming, and make them so digestible for everyone to learn and create amazing applications in a short time.

Categories