An In-Depth Guide to Kotlin's Scoped Functions: let, run, with, also, apply

An In-Depth Guide to Kotlin's Scoped Functions: let, run, with, also, apply

Demystifying Kotlin's Scoped Functions: let, run, with, also, apply

ยท

5 min read

Play this article

Kotlin, a modern programming language is well known due to its simplicity, conciseness, and safety. One of the features that make Kotlin stand out is its set of scoped functions: let, run, with, also, and apply. These functions enable developers to manipulate objects and handle nullable values concisely and expressively. In this article, we will explore each of these functions and understand their unique purposes with detailed examples.


Let

The let function is particularly useful for working with nullable objects. It allows us to perform operations on an object if it's not null and returns the result of the operations. If the object is null, let does nothing and returns null. The syntax for let is as follows:

fun <T, R> T?.let(block: (T) -> R?): R?

Where:

  • T: The type of the object to be processed.

  • R: The type of the result of the operations.

  • block: The lambda function that takes the non-null object as an argument and returns the result.

Let's illustrate this with an example. Imagine we have a nullable String representing a user's name, and we want to print its length only if it's not null:

val userName: String? = "John Doe"
userName?.let { name ->
    println("User's name is $name, and its length is ${name.length}")
}

In this example, the lambda function inside let will be executed only if userName is not null. If userName is null, nothing happens, and the code inside let it skip.


Run

The run function is useful when we want to perform multiple operations on an object. It simplifies the code by providing a temporary scope where we can access the properties and functions of the object directly without the need to qualify them. The syntax for run is as follows:

fun <T, R> T.run(block: T.() -> R): R

Where:

  • T: The type of the object to be processed.

  • R: The type of the result of the operations.

  • block: The lambda function with the receiver (denoted by T.()) that operates on the object and returns the result.

Let's consider an example where we have a data class Person and we want to manipulate its properties within a temporary scope:

data class Person(val name: String, var age: Int)

val person = Person("Alice", 30)
val modifiedPerson = person.run {
    age += 5
    this // Return the modified person object
}
println("Modified person: $modifiedPerson")

In this example, we use run to access and modify the age property of the person object within a temporary scope. The modified person object is then stored in modifiedPerson, and we print the result.


With

The with function similar to run, but it is a standard library function rather than an extension function. It allows us to access the properties and functions of an object directly, just like run. The syntax for with is as follows:

fun <T, R> with(receiver: T, block: T.() -> R): R

Where:

  • receiver: The object on which the operations will be performed.

  • R: The type of the result of the operations.

  • block: The lambda function with the receiver (denoted by T.()) that operates on the object and returns the result.

Here's an example using the same Person data class as before:

val person = Person("Bob", 25)
val result = with(person) {
    age += 3
    name.toUpperCase()
}
println("Modified person: $person")
println("Result: $result")

In this example, we use with to modify the age property of the person object and return the uppercase version of the name property.


Also

The also function is handy when we want to perform additional operations on an object and retain the original object. It returns the original object after performing the specified operations. The syntax for also is as follows:

fun <T> T.also(block: (T) -> Unit): T

Where:

  • T: The type of the object to be processed.

  • block: The lambda function that takes the object as an argument and performs the additional operations.

Let's see an example where we have a list of integers, and we want to filter out the odd numbers and print the modified list along with the original list:

val numbers = listOf(1, 2, 3, 4, 5)
val filteredNumbers = numbers.filter { it % 2 == 0 }
    .also { modifiedList ->
        println("Modified List: $modifiedList")
    }
println("Original List: $numbers")

In this example, we use also to print the modified list after filtering out odd numbers while still retaining the original list.


Apply

The apply function is useful for configuring the properties of an object during its initialization. It returns the object itself after applying the configurations. The syntax for apply is as follows:

fun <T> T.apply(block: T.() -> Unit): T

Where:

  • T: The type of the object to be processed.

  • block: The lambda function with the receiver (denoted by T.()) that configures the properties of the object.

Here's an example where we create a Person object and configure its properties using apply:

val newPerson = Person("", 0).apply {
    name = "Eve"
    age = 28
}
println("New person: $newPerson")

In this example, we use apply to create a Person object with default values and then configure its name and age properties within the same scope.


Conclusion

Kotlin's scoped functions (let, run, with, also, and apply) are powerful tools that contribute to writing concise and readable code. By leveraging these functions, developers can handle nullable objects, perform operations on objects with a temporary scope, configure object properties during initialization, and perform additional operations while retaining the original object. Understanding and effectively using these functions can lead to cleaner and more expressive Kotlin code.


That's it for today. Happy Coding...


If get to know something new by reading my articles, don't forget to endorse me on LinkedIn

Read about SOLID Principles

Read about Android Development

ย