All Articles

Let, also, apply, run, with - Scope functions in Kotlin

Box representing scope
Have clearly defined boundaries for the scope of your code

Scope functions are, as the name says, functions that run some code in a separated context. Anything declared in the given scope will be accessible only inside this code block. The keywords for these scope functions are let, also, apply, run and with. This article will cover the differences between them, and how and when to use them.

Scope functions

As stated in the introduction, scope functions execute code in a very limited context. In that context, the variables that serves as the context can be referenced as it. This makes the code very simple to read.

Scope functions do not necessarily provide a new functionality. Readability is a major topic, however, in development. This is the main goal of the scope functions. They clearly define where the scope starts, as well as ends, and the actions that are performed upon the context.

Any object instance can serve as such a context. An extremely simple example would be the following snippet:

"Hello".let { println(it) }

The snippet above may be useless, but it shows how anything can become the subject of the scope!

The biggest difference between the scope functions are the following:

  • The object reference (it or this)
  • Return value (context object or rescoped object)

Let

Let is probably the most widely used scoped function, especially after a null check. As shown in the example above, however, it can also simply scope a non-nullable value as well.

Here is some notable information about let:

  • The scoped context can be referenced using it
  • When used in null checks, smart casting makes the object non-nullable
  • Returns the value from the last line

A sample usage of let would be:

data class Person(val username: String, val nickname: String? = null, val birthdate: LocalDate = LocalDate.now())
@Test
fun let(){
    val voidReturn = Person("Joe").let {
        println("The person's birthdate is ${it.birthdate}")
        println(it)
    }
    println("voidReturn = $voidReturn")
    val stringReturn = Person("Maria").let {
        println("The person's birthdate is ${it.birthdate}")
        it.username
    }
    println("stringReturn = $stringReturn")
}

The code snippet above would return the following:

Let evaluation
The evaluation of the let scope

As you can see, let returns the value from the last line.

Now, a more useful example would be to perform some code snippet that only runs on a condition. An example would be something like:

enum class Channel{EMAIL, NOTIFICATION, SMS}

data class Evaluation(val distribution: Distribution? = null, val outcome: String)
data class Distribution(val channel: Channel = Channel.EMAIL, val message: String = "Welcome")

class NotificationService(){
    fun notify(distribution: Distribution){
        // doSomething
    }
}
@Test
fun let_conditional(){
    val notificationService = NotificationService()
    Evaluation(outcome = "Accepted").distribution?.let { notificationService.notify(it) }
}

Now, here we have some code that would conditionally apply some additional operations on the object if that object is not null, and otherwise simply continue. This is very frequently used.

Run

Run is very similar to let and can be used almost interchangeably. The biggest difference is that it works with the keyword this instead of it.

In fact, it is so similar that you could simply replace the code above like so:

@Test
fun run_conditional(){
    val notificationService = NotificationService()
    Evaluation(outcome = "Accepted").distribution?.run { notificationService.notify(this) }
}

run also returns the last line of the block, same as let. If we create the exact same test as before, we’d have the following:

@Test
fun run(){
    val voidReturn = Person("Joe").run {
        println("The person's birthdate is ${this.birthdate}")
        println(this)
    }
    println("voidReturn = $voidReturn")
    val stringReturn = Person("Maria").run {
        println("The person's birthdate is ${this.birthdate}")
        this.username
    }
    println("stringReturn = $stringReturn")
}

which would produce the exact same result:

Run evaluation
The evaluation of the run scope

Now, what is the difference exactly? Well, there really isn’t one. However, run can also be used as a standalone block without an object as an extension.

@Test
fun run_standalone(){
    run{
        val myString = "Hi"
        println(myString) // prints "Hi"
    }
    println(myString) //wouldn't compile
}

This is where the main difference is, in my opinion. If I ever need a conditional block, I always use let. If I ever need to quickly declare some values, perform some operations, and then throw away those variables, then I’d use run.

Also

Now, when looking at also, the main difference is that it returns the context object. Here, we could chain multiple lambdas on the same object if we wanted to, since every also returns the scoped object itself (albeit with changes, if those are performed inside the lamdba). also uses the it keyword again in its scope.

data class Person(val username: String, var nickname: String? = null, val birthdate: LocalDate = LocalDate.now())
@Test
fun also() {
    val person = Person("Joe").also {
        println("The person's birthdate is ${it.birthdate}")
    }.also {
        println(it)
    }.also {
        it.nickname = "Now it's got a nickname"
    }
    println("Also return = $person") // The nickname from the last also will be in here
}
Also evaluation
The evaluation of the also scope

Now, an actual usecase in my code is from the following snippet:

val coinList: MutableList<CryptoAsset>? = coinGeckoGateway.loadGreatestCoins(page).collectList().block()
coinList?.map {
    cryptoAssetRepository.findById(it.id).toNullable()?.also { coin -> coin.price = it.price } ?: it
}
    ?.map { it.addNewSnapshot() }
    ?.let { cryptoAssetRepository.saveAll(it) }
  1. A service call is made to retrieve coin values
  2. Each coin is looked up

    • If present, the price value is set (but other values, such as the ID, are form the DB)
    • If not present, the coin is integrally taken from the service call
    • Either way, in the end, the coin contains the updated price
  3. Mapping of an additional snapshot takes place the same for all coins, no matter whether they went through the also block
  4. The coins are saved. New coins are now assigned an id, the others are simply updated

Apply

The difference between apply and also is the same as it was earlier between let and run. The test from before could be rewritten as such:

 @Test
 fun apply() {
     val person = Person("Joe").apply {
         println("The person's birthdate is ${this.birthdate}")
     }.apply {
         println(this)
     }.apply {
         this.nickname = "Now it's got a nickname"
     }
     println("Apply return = $person") // The nickname from the last also will be in here
 }

It would have the exact same output.

Again, the difference is mostly a user preference. For example, apply would be used in cases where additional instantiation is required.

@Test
fun actual_apply() {
    val person = Person("Joe").apply {
        this.nickname = this.nickname?: this.username
    }
    println("The first person is = $person") // nickname is set for him
    
    val person2 = Person("Joe", nickname = "The baws").apply {
        this.nickname = this.nickname?: this.username
    }
    println("The second person is = $person2") // no nickname override
}

The output would be the following:

Apply evaluation
The evaluation of the apply scope

Again, this would have worked just fine with also. It just applies some additional operation during the construction, and may be just a little more readable.

With

Finally, there’s the last scope function, with. The usage is a little bit different here, as it is actually not an extension function on an object. with takes an object as a variable for a small scope. With returns the rescoped object, meaning the last item of the list. Here’s a small snippet and its evaluation:

@Test
 fun with() {
     val people = listOf(Person("Joe"), Person("Maria", nickname = "The baws"))
     val whatAmI = with(people){
         println("There are ${this.size} people in the list")
         println("The last person is ${this.last().username}")
     }
     println("And I am... drumroll... $whatAmI") // what do you think?
 }

Well, I said before that it would return the last operation from inside the scope, so it should be Unit. Let’s see…

With evaluation
The evaluation of the with scope

So, yes, it indeed returns Unit. Kind of anti-climactic, I’m sorry about that… :)

Sooo, there you have it. When I started out with learning Kotlin, I thought that scope functions were nice, but also kind of intimidating, as it was hard to see which one to use at what point. However, as you can see, there really aren’t many differences!! It mostly is a personal preference, and yeah, there may be some small guidelines, but really, it’s up to you.