The Scope of Kotlin: How to use the Kotlin scope operators correctly

The Scope of Kotlin: How to use the Kotlin scope operators correctly

ยท

11 min read

The Kotlin standard library comes with some of the inbuilt scope functions which can be used to execute a block of code within the context of a receiving object. But often times it's a little confusing to select a specific one since they all have a lambda function associated with an object and all of them work almost the same. In the below sections, I will try to explain each and every one of them in detail and also its use cases.

By the end of this article, you will understand clearly how we can use the scope functions to minimize our code and improve readability while avoiding some common pitfalls that can happen if you don't know the intent of a scope function.

In this article, we will try to touch upon the following points:

  • What are scoped functions in Kotlin?
  • How do we differentiate between different types of scoped functions
  • When to use a specific function and what does the name of the function indicate about its usage?

So let's get started.

Scope

Before moving ahead let's try to understand scope first. The standard definition of a scope is a policy that manages the availability of a variable. In simpler terms what this means is that a scope is a block of code where there is an associated variable with a definite value.

A simple example of a scope will be this

fun iAmScoped(data: SomeData) {
    //data object is a constant here,
    //and its value cannot change inside of this function.
}

Yes, you read that right, I have defined a function in the name of a scope. What I really want to point out here is that inside the function the variable data can never change. You may be able to change the contents of this object but never the actual object. So any operations performed inside this function can only be applied against a constant value of an object, and this is exactly the definition of scope.

A scope is a block of code that hosts a variable and the value of this variable can only be accessible inside the scope and not outside the scope. A realistic example of scope is a lambda function in Kotlin.

Now that we have defined scopes let's move on to the Kotlin scope functions.

1. Let

The let scope function allows you to take a variable and perform some operations upon it. I know this sounds silly but bear with me there is more to it. Since Kotlin was written in a fashion where everything was like reading English sentences, so they also made a function similar to the English word let. Almost all scope functions are based on English words. So the let scope function means in English that using the given variable let the following happen.

Here is a simple example of how the let scope function is used.

val name = "Bawender Yandra"

name.let {
    print(it.toUpperCase())
}

Inside the lambda of the scope function, the object is available by a short reference it instead of its actual name. You can also provide a custom name for the variable. Here is a bit of complex usage of the let scope function.

val users = listOf(
    Person(name = "Mary", age = 29),
    Person(name = "Adam", age = 26),
    Person(name = "Ash", age = 32),
)

users.map { it.name }
     .filter { it.startsWith("A") }
     .let { names ->
         print("users whose name starts with A")

         names.forEach {  print(it) }
     }

As you can see the let scope allows us to perform operations without actually creating a variable to store the result of the map and filter operations.

One of the most common use cases of a let scope function is null assertions. As we know a nullable variable type in Kotlin can only be evaluated using the null safe operator ( ?. ), which can lead to a clumsy-looking code for cases where you only wanted to handle the nonnull case. But here is an alternative using the let scope function:

val name : String? = "Bawender Yandra"

//primitive way of handling null
print(name?.toUpperCase() ?: "")

//optimized solution using let operator
name?.let { userName ->
    print(userName)
}

Use the let scope function only when you want to perform non-null operations or if you want to perform some extra work out of the result of a function without creating a separate variable.

2. Also

The also scope function just like the let scope function has an English meaning to it. It means that given a variable or object perform this also. As it might be already evident this should be used in situations where you have created an object but want to do something more with it. The most common example of this is when you have created an object but you also have to call certain functions on top of it in order to complete the initialization process.

The below code demonstrates this use case.

class FacebookManager {
    private lateinit var callbackManager : CallbackManager;

    fun init() {
        callbackManager = CallbackManager.Factory.create();
    }


    fun initiateLogin(onLoginSuccess: (LoginResult) -> Unit) {
        LoginManager.getInstance().registerCallback(callbackManager, object: FacebookCallback<LoginResult>() {

            onSuccess(LoginResult loginResult) {
                onLoginSuccess(loginResult)
            }
        }
    }
}

class Activity: AppCompatActivity() {

    fun onCreate(savedInstanceState: SavedInstanceState) {
        val fbManager = FacebookManager().also {
            it.init() // initialize the manager class
            it.initiateLogin {
                //code...
            }
        }

    }
}

In the above code FacebookManager class needs to be initialized and then there are two other functions that need to be called in order to get the login result from this class. The also scope function lets us create the FacebookManager object and then at the same time use the newly created instance to perform the init and initiateLogin functions. This keeps the code nice and simple as the one reading it would automatically know that something else is also happening after creating the FacebookManager class instance.

Another example of the also scope function


class Singleton private constructor() {

    companion object {
        private val instance :Singleton? = null

        fun getInstance(): Singleton {
             return if(instance == null) {
                 Singleton().also { instance = it }
             } else {
                 instance
             }
        }
    }    
}

The above is a code for a Singleton object creation in Kotlin. Notice how I use the also scope function to perform the additional job of assigning the value to the instance variable. Just like the let scope function the variable created is accessible inside the scope as a shorthand property called it.

Use the also scope function when you want to initialize an object but also perform certain actions on top of it as soon as the object is created.

3. Run

The run scope function is meant to represent a function that may or may not have a receiver object. To understand this let's look at some usage examples of run scope function.


data class User(val name: String, val working: Boolean = true) {

    fun sendStatusWorkingSignal() {
        print("Hi I am $name") 
        print("I am signing off for the day!") 
    }

    fun stopWorking(): User = this.copy(working = false)
}

main() {

    val workingUser = User("Bawender")

    val stoppedUser = workingUser.run {
        sendStatusWorkingSignal()
        stopWorking()
    }

    print(stoppedUser) //prints -> User(name="Bawender", working="false")
}

The first thing to notice here is that there is no it in the run-scope function. Instead of the short reference it the object is accessible as the this keyword. When a this keyword is available in a scope then it acts as if you are inside the User class itself allowing you to access everything inside the object that is being passed as the receiver to the run scope function.

The next thing to observe is how we invoked multiple functions in one run scope function and also were able to return the result back. Whenever you read a run scope function in a code, it means to tell you that some operation or function is being performed on the receiving object.

Let's look at another example for the run scope function.


val fullName = run {
    val name = "bawender yandra"
    val words = name.split(" ") // returns ["bawender", "yandra"]
    words.map { it.capitalize() }.joinToString(" ")
}

print(fullName) // prints -> Bawender Yandra

Here in this example, I have used the run scope function to encapsulate a group of functions that work together to generate a final result. Notice how there is no receiver object being passed to the run scope function and it is referred to as a non-extension run function.

Use the run scope function when you want to run multiple functions based on the receiver object or when you want to encapsulate a group of functions to promote more readability for the code. You can also use the run scope function as a block that runs during null evaluations as shown below.

var user: String? = null

//When the user is null the run block is executed
print(
    user ?: run {
    user = "Bawender Yandra"
    user
}
)

4. With

Unlike all the other scope functions, the with scope function is the only one that is by default a non-extension function. This means that we cannot pass a receiver to the with scope function to be used in the scope. Instead, it requires us to invoke with scope function as a normal function and pass the object that you want to apply this upon as a functional parameter. The parameter then becomes available using the this keyword inside the block scope. The with scope function in simpler terms means that with this object perform the following operations.

The generally recommended way for using the with scope function is for performing operations on the receiver object without providing the lambda result. Even though the with scope function can return a result like all other scope functions but using it to return values will seem ambiguous and can lead to confusion when reading. Since all scope functions can return values and have very few differences in terms of the receiver object usage, it often becomes unclear as to when to use which. If we follow the usage method mentioned in this blog we can solve this problem.

A typical usage example for the with scope function:

val binding = ActivityMainBinding.inflate(layoutInflater)

with(binding) {
    tvTitle.text = "With block usage:"
    tvSubtitle.text = "Use with to access the receiver and perform multiple operations"

    btnOk.setOnClickListener {

    }
}

The above is the most common use case of the with-scope function in Android. The with-scope function was created to help you avoid situations where you would have to invoke multiple operations using the object name. Below is the same usage code without the with-scope function.

val binding = ActivityMainBinding.inflate(layoutInflater)

binding.tvTitle.text = "With block usage:"
binding.tvSubtitle.text = "Use with to access the receiver and perform multiple operations"
binding.btnOk.setOnClickListener {
    //do something
}

Notice how we had to write the binding variable every time when we wanted to perform some operation.

Use the with scope function when you have an object using which you want to perform a lot of operations.

5. Apply

Apply in simpler terms means apply the following assignments to the object. The apply scope function is similar to the with scope function but it receives the object as an extension property like other scope functions (let, also, run). The most typical usage of the apply scope function is for object configuration.

Here is typical usage for the apply scope function:

val admin = Person("Bawender").apply {
    age = 32
    city = "London"        
}

Even though the apply scope function can also return a value but I usually advise against this notion as it breaks the readability of the code.

Use apply scope function for the object configuration process after the initialization of the object.

Bonus Tip

Avoid nesting and chaining scope functions in multiple levels as they become too hard to interpret the flow of the code. At maximum, the chaining should be used at most 2 levels deep only without any nesting involved. The below code tries to show how things go bad when we go for chaining and nesting wrongly.

val data: Data? = null

//needs more focus to understand, who the hell wrote this? (-_-)
let user = User().apply {
    name = "Bawender"
    age = (
        data.actualAge ?: run {
        val result = fetchUserData()
        result
    }.actualAge
    ).also {
        it + 10
    }
}

Conclusions


The Kotlin scope functions have a lot of overlapping in terms of usage and functioning which makes it hard to select them in practice, but if we follow this guide then we will understand what the scope function means and then we can surely figure out which one to use when. Below is a table that summarizes the usage of the scope functions.

Always remember that with great power comes great responsibility.

Scope functionReceiver shorthandUsage
letitperform non-null operations on nullable variables or perform some extra work based on the result of a function
alsoitperform extra operations after object initialization
runthisencapsulate a group of functions that work together to generate a common result run some operation after the Elvis operator ( ?: )
withthisperform multiple functions or access the receiver object multiple times in a consecutive fashion
applythisobject configuration process after the initialization of the object

If you think this article helped you with understanding the scope functions better, consider sharing this with your friends.

ย