Mastering Kotlin's Scope Functions: A Comprehensive Guide to let, apply, run, and with.

Mastering Kotlin's Scope Functions: A Comprehensive Guide to let, apply, run, and with.

Hello Devs,

Today we are going to learn how to write more readable and concise code by using scope functions in Kotlin. If you have vast experience with Kotlin or have developed a number of Android applications with Kotlin then I bet you have used scope functions somewhere in your code but do you really know the differences between the 5 types of scope functions let, apply, run, with and also or in which use cases one is supposed to be used. By the end of this article, you will have a clear understanding of where to use each one of them.

Let's jump in...

What are scope functions?

Scope functions are functions that execute a block of code within the context of an object and return either a result or an object depending on the scope function used.

A result can be returned from a computation performed and an object can be returned from a modification or configuration of an object again this will depend on the scope function used, by now you're starting to see the difference and application between each one of the scope functions.

To get a proper hang of it let's jump more into the details and look at each one of the scope functions with a few lines of code.

Types of Scope Functions

There are 5 types of scope functions in Kotlin.

  • let

  • apply

  • run

  • with

  • also

Scope Functions: Practical Examples and Use Cases

Let's look at practical examples of the usage of scope functions using a few lines of code to get a good understanding of the functions and be able to distinguish between each one of them.

let

let operator takes an object and returns the result of the lambda expression and refers to the context of an on object as it.

let can also be used as a null safety operator since it only executes on in null objects and skips execution if an object is null. Let's look at an example


private fun performRunOperation() {
    val car: Car? = null
    car?.let {
        it.model = "Toyota"
        it.registrationNumber = "0987654321"

        print("The details of the Person of null object is:" + it.displayCarDetails())
    }

    Car().let {
        it.model = "Toyota"
        it.registrationNumber = "0987654321"

        print("The car dtails of non null object is :" + it.displayInfo())
        return@let "The car details of non null object is :" + it.displayInfo()
    }
}


//Output : 
// The car details of non null object is :Model: Toyota, registrationNumber: 0987654321, OwnerID : 01123456

The first execution is skipped since the car is null and let's ensure its execution is only performed on nonnull values.

From this, we have learned the following about let the operator

  • Executes nonnull values only and skips execution if the value is null.

  • Uses it for object reference.

  • Returns result of lambda expression.

  • Accepts return statements.

also

also operator as the name suggests is used to perform additional operations on already initialised objects within a chain of operations which can be used to perform side effects and also uses it is for object reference.

It's easy to confuse let operator and also operator but the only similarity they have is the way they use it as a reference to the context and the difference is that let's accept a return statement while also does not accept a return statement.

Let's look at an example:

data class Car(
    var model: String,
)

private fun performRunOperation() {

    val carList = listOf(
        Car("Subaru"),
        Car("Mercedes"),
        Car("Volvo"),
        Car("Toyota"),
        Car("Nissan")
    )

    var cars: List<Car> = carList

    val result = cars
        .map { it.model.toUpperCase() }
        .also { println("After Upppercase conversion: $it") }
        .filter { it.length > 4 }
        .also { println("After filtering: $it") }
        .joinToString(separator = ", ")

    println("Final result: $result")

}

fun main(args: Array<String>) {
    performRunOperation()
}


// After converting to uppercase: [SUBARU, MERCEDES, VOLVO, TOYOTA, NISSAN]
// After filtering: [SUBARU, MERCEDES, VOLVO, TOYOTA, NISSAN]
// Final result: VOLVO

To understand this, let's analyze the performRunOperationFunction we have a list of Car objects in which we are going to perform a chain of operations and this is where also, the operator comes in handy and here we use also as a logger to print the results after each step which would have been harder without it first, we map our object to uppercase then print them filter the length and print the one whose length is below 4 and print and the chaining can go on and on.

apply

apply is used to reference objects or runs on the receiver and this is to access the objects as opposed to also and let which use it and does not accept a return statement and requires the object reference to be run in an expression.

Let's look at an example

data class Car(
    var model: String,
    var owner:String,
    var registration:String
)

private fun performRunOperation() {

    val car = Car("Subaru","Peter","KBJ 456Y")

       car.apply {
        this.model = "Toyota"
        this.owner = "Mike"
    }
    print(car)
}

fun main(args: Array<String>) {
    performRunOperation()
}

//Car(model=Toyota, owner=Mike, registration=KBJ 456Y)

We have returned the same car object but modified it within the car scope and can be useful and come in handy when you want to make multiple changes or modifications to an object during its creation. It does this in a clean and concise way since it provides a convenient and easy way to initialize, modify or configure objects.

The use of this is totally optional, the code can do without.

run

run operator can be used to execute non-null blocks of code and hence can perform null safety calls. It's like a combination of let and with.

Uses this to refer to its objects.

Let's look at an example

data class Car(
    var model: String? =  "Toyota",
    var owner:String? = "Mike",
    var registration:String  = "KBGL5674W"
)

private fun performRunOperation() {

//    Since car is initialised to null the run operator
    //    block wont be executed and a non exception wont occur
    var car:Car? = null
     car?.run {
         print(model)
     }

//    This will be executed since car is not null
    val cars:Car =  Car()
    cars.run {
        print(model)
    }
}

fun main(args: Array<String>) {
    performRunOperation()
}

//Toyota

Here we have tested therun operator on a null object to perform a null safety call, but since the car object is null, the block of code won't be executed, but when we call a non-null object, it will be safely executed. Also, we are usingthis to reference the context object as a lambda receiver, so we did not use this to access the member receivers.

with

with is similar to using run and both use this to refer to their objects and also return lambda result and so what's the difference?

We can perform null checks using the with the operator however, there will be a slight difference in syntax since we cannot use the safe call operator (?) on it we will have to use a longer route which might not be convenient so you can use with non-null receivers and run for null receivers.

Summary

  • Object reference : this or it

  • Return values: Either lambda or context value itself.

  • apply and also return the context object.

  • let, run and with return the lambda result

  • let and also uses it is for object reference

  • run,with and apply use this is for object reference.

Happy coding :)