Vincent Tsen
Android Kotlin Weekly

Android Kotlin Weekly

Simple Way to Understand Kotlin Scope Functions - let, run, with, apply, also

You have no idea when to use them because they are confusing. This article explains the common practical usages of these Scope functions.

Vincent Tsen's photo
Vincent Tsen
·Jan 7, 2022·

5 min read

Simple Way to Understand Kotlin Scope Functions - let, run, with, apply, also

Subscribe to my newsletter and never miss my upcoming articles

Table of contents

  • Syntax Differences
  • It is confusing...
  • When to Use let?
  • When to Use run?
  • When to Use run Non-extension Function?
  • When to Use with?
  • When to Use apply?
  • When to Use also?
  • Summary

If you look at the scope function examples in my previous post here, you can clearly tell that all those Kotlin Scope Functions (i.e. let, run, with, apply, also) can accomplish the same thing. They're all interchangeable, with slightly different in syntax.

Syntax Differences

Let's understand the syntax differences between them first.

simple_way_to_understand_kotlin_scope_functions_01.JPG Source: kotlinlang.org

Note 1: All these scope functions are extension functions except for run and with. There are 2 runscope functions. One is with extension and another one without the extension function.

val str = "test"
// let, run, also, apply - are "extension functions"
str.run {
    println(this)
}

// with - is NOT an "extension function"
with(str) {
    println(this)
}

// run - could be NOT an "extension function"
run {
    println(str)
}

Note 2: Return value is lambda result means return the last line returned value in the lambda expression. All scope functions return value is lambda result except for apply and also which context object is returned.

val str = "test"

/* let, run, with -  return last returned value in the lambda expression */
val tmp1 = str.run {
    // return Kotlin.Unit (equivalent to void in Java)
    println(this)
}

// this will print out "Kotlin.Unit"
println(tmp1)

// apply, also -  always return the object context (i.e. str)
val tmp2 = str.apply {
    println(this)
}

// tmp2 is str, this will print out "test"
println(tmp2)

Note 3: Within the scope, we can reference the object either through this or it.

  • run, with and apply use this.
  • let and also use it
val str = "test"

/* run, with, apply - object reference is "this" */
str.run {
    println(this)
}

// let, also - object reference is "it"
str.let {
    println(it)
}

It is confusing...

I must admit, trying to understand all these differences make me even more confused! This is because the syntax itself doesn't explain when and how exactly we should use them?

The crucial part is not the syntax but their usages but how and when developer usually use them? Let's look at more practical usages of them in the following!

Let's assume we have the following class.

class Dog(val name: String) {
    var age: Int = 0
    var owner: String? = null

    fun bark() = println("$name is barking")
    fun eat() = println("$name is eating")
}

When to Use let?

let is typically used for nullable object to avoid NullPointerException

Replace null check

var nullableDog: Dog? = null
if (nullableDog != null) {
    nullableDog.age++
    nullableDog.owner = "Vincent"
    nullableDog.bark()
    nullableDog.eat()
}
val age = nullableDog?.age

with let

var nullableDog: Dog? = null
val age = nullableDog?.let {
    it.owner = "Vincent"
    it.bark()
    it.eat()
    it.age++
}

it can be replaced by any name for readability. This is especially useful if you have chained / nested scope functions, where the name can be used to differentiate between inner vs outer scope.

val age = nullableDog?.let { myDog ->
    myDog.owner = "Vincent"
    myDog.bark()
    myDog.eat()
    myDog.age++
}

Note: This example above is NOT a nested scope function. I personally hate it and will avoid it

If you don't need this readability, let is pretty useless in my opinion because it can be completely replaced by run and you don't need to specify the it to reference the context object.

When to Use run?

run usage is similar to let which makes your code readable to perform the null check. Since this can be omitted in the lambda expression, I feel that the code is cleaner. Thus, I personally would prefer run over let.

var nullableDog: Dog? = null
val age = nullableDog?.run {
    owner = "Vincent"
    bark()
    eat()
    age++
}

When to Use run Non-extension Function?

run with non-extension function is very similar to standard scope function in other languages. dog variable in the inner scope refers to Jimmy and dog variable in the outer scope refers to Lily

val dog = Dog("Lily")

run {
    val dog = Dog("Jimmy")
    println("Dog name is ${dog.name}")
}

println("Dog age is ${dog.name}")

Note: This is probably rarely used. I haven't seen any code that uses this. Separating out to a different function is probably a better idea than using the run non-extension function, scope function.

When to Use with?

with is similar to run except it is used when you want to operate on a non-null object. If your object is nullable, do NOT use with.

val dog = Dog("Lily")

val age = with(dog) {
    owner = "Vincent"
    bark()
    eat()
    age++
}

When to Use apply?

apply is usually used to initialize the properties in the class during the object creation.

You replace this

val dog = Dog("Lily")
dog.owner = "Vincent"
dog.age = 10

with

val dog = Dog("Lily").apply {
    owner = "Vincent"
    age = 10
}

When to Use also?

also is pretty much the same as apply and we rarely need it. Similar to let, it is probably needed for readability issue in nested scope functions, where you can replace it with a more meaningful name.

val dog = Dog("Lily").also {
    it.owner = "Vincent"
    it.age = 10
}

Summary

This is how I'm going to use these scope functions:

  • let - do NOT use this for null check unless for readability issue in nested / chained scope functions
  • run - use this for null check instead. However, if you need to use this for object reference, consider to use let instead for readability
  • run (non-extension function) - probably NOT required, replace this scope with a function call instead for better readability
  • with - use this for non-nullable object
  • apply - use this for object creation and initialization
  • also - do NOT use this, unless for readability issue in nested scope function. Use apply instead.

The purpose of scope function is to reduce code and make your code readable. So, it is really up to you how you would like to use them to achieve it. Sometimes this could be just a personal thing.

For example, what I suggested here (i.e. do NOT use let for null check) probably goes against the majority, but I find run is more straight forward. It makes my options simple. I just need to choose between run and with

  • use run for nullable variable
  • use with for non-nullable variable.

Isn't this simple?

Scope function is a nice-to-have feature. Even if you don't use them, it is fine too as long as your code is still readable.

Did you find this article valuable?

Support Vincent Tsen by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this