Understanding Kotlin scope functions

Subscribe to my newsletter and never miss my upcoming articles

Plenty of Android Developers have by now moved to Kotlin from Java and are benefiting from the awesome power that the language offers. Kotlin has a bunch of features that make our code more concise and less error-prone. One such feature is scope functions. If you have been developing in Kotlin, chances are you have used them already. However, have you used all of them and how can you leverage their power effectively?

What are Kotlin scope functions?

According to the official documentation, scope functions execute a block of code within the context of an object

The resulting block of code is run within a lambda, which in turn provides a temporary scope that allows you to access your receiver (the object) without using its name. Depending on the scope function you use, the object can be accessed using it or this. There are five scope functions in Kotlin namely let, run, with, also and apply. To get a good grasp of what each does, it is better to see examples.

For the sake of the demonstration, we shall be using the following Animal class.

class Animal() {
    var name = "Panda"
    var origin = "Wherever pandas come from"
    var colour = "Black and White"
}

let

The let function allows you to perform a certain operation on an object and return a value if you want. By default, the last statement in a let block is a return statement if it is not an assignment. If you do not return a value in the let block, it is the same as calling a function that contains no return value. See the following code examples.

private fun letExample() {
    val animal = Animal().let {
        //The it is the object, which is Animal in this case
        "The animal is from: ${it.origin}"
    }
    print(animal)
}
//Will print the following
The animal is from Wherever pandas come from

If you take a look at the code within the let block, the last statement is not an assignment so it is returned. If we changed it a bit to the following

private fun letExample() {
    val animal = Animal().let {
        //The it is the object, which is Animal in this case
       it.origin = "Nigeria"
    }
    print(animal)
}

//Will print the following
Kotlin.Unit

The last statement in the block is an assignment. That is, we are assigning the origin to a new value and are not returning anything. In the case of let, the object context is obtained using it. If you are familiar with lambdas, you can rename the it to whatever you like for readability, especially if you are dealing with nested blocks. Thus, looking at the previous example, we can have:

private fun letExample() {
    val animal = Animal().let {animal ->
        //it has been renamed to animal for readability 
        "The animal is from: ${animal.origin}"
    }
    print(animal)
}
//Will print the following
The animal is from Wherever pandas come from

The let function is also particularly useful in ensuring null safety. For example, if we make the attribute origin as nullable and we wanted to access it, then we can simply do the following thus saving us from lengthy null checks

private fun letExample() {
    val animal = Animal().origin?.let {origin ->
        //The it is has now become the attribute origin
        "The animal is from: $origin"
    }
    print(animal)
}
//Will print the following
The animal is from Wherever pandas come from

Finally, let is extremely useful if you want to perform an operation on a result you get from chaining calls. Consider the following where other list contains objects of type Person, which has an age attribute. The following code filters the Person objects and gets a list of only those of age 18 or higher.

val list = otherList.filter{it.age > 18}
print(list)
With let, you can simplify this to the following
//The it in the let block is the result of the chaining call
otherList.filter{it.age > 18}.let{print(it)}

apply

Unlike let, apply uses this to refer to the context of the object.

private fun applyExample() {
    val animal = Animal().apply {
        //this is the object, which is Animal in this case
        name = "Tiger" //Similar to this.name
        origin = "India" //Similar to this.origin
    }
    print("The new animal is a ${animal.name} and is from ${animal.origin}")
}
//Output
The new animal is a Tiger and is from India

Note that we do not have to specifically use this.name or this.origin. You can do it if you want to or, you can rename this to another name if you have multiple nested lambdas.

run

Run is quite similar to let in the sense that you can return a value of a different type from the receiver. However, the contextual object is this instead of it. Run is especially useful if you want to initialize an object and then compute a return value like the following

private fun runExample() {
    print(
        Animal().run {
            //this is the object, which is Animal in this case
            name = "Great Dane"
            origin = "Denmark"
            "The animal is called $name and is from $origin"
        }
    )
}
//Will print
The animal is called Great Dane and is from Denmark

with

With and run are similar down to their use of the this keyword. Using the previous example above, we can rewrite it to get the following

private fun withExample() {
    val animal = with(Animal()) {
       "The name of the animal is $name and the origin is $origin"
    }

    println(animal)
}
//output
The name of the animal is Panda and the origin is Wherever pandas come from

also

The also scope function has similarities to let in that the context of the receiver is it while providing null checks as well. Additionally, the return value is the object. This means the following code will not actually print the value in the also block

private fun alsoExample() {
    val animal = Animal().also {animal ->
        "The name of the animal is ${animal.name}"
    }

    println(animal)
}
//Will not print the string but the address of the object

To print the line, then you do the following ```private fun alsoExample() { Animal().also { animal -> println("The name of the animal is ${animal.name}") } } //output The name of the animal is Panda

If you wish to change the return value from the receiver type, you can chain those calls like below
```private fun alsoExample() {
   val animal =  Animal().also { animal ->
        println("The name of the animal is ${animal.name}")
       animal.name = "Tiger new name"
    }.run {
       "The new name of the animal is now $name"
   }

    println(animal)
}
//output
The name of the animal is Panda
The new name of the animal is now Tiger new name

By chaining the call with run, the type of animal now changes to a string and thus can be printed.

Final thoughts

Admittedly, they are quite a lot to take in at first. As such, you may be confused as to how you choose the right function to use. Most of the time, this will be up to you. However, if you wish to get a more in-depth understanding of the same, then you can look at the official documentation and this chart . Happy coding!

PS: Let us connect on GitHub , Twitter , and LinkedIn .

No Comments Yet