Skip to content

Kotlin Primer

Scope Functions

There are many programming patterns that would be nice to capture into a function, and thanks to Kotlin's function definition capabilities, there are many that we can!

let

Remember our "capture then check" pattern for avoiding race conditions?

class Person(
    var name: String?,
) {
    fun doSomething() {
        println(name)
        val capturedName = name
        if (capturedName != null) {
            println(capturedName.length)
        }
    }
}

fun main() {
    Person("Scott").doSomething()
    Person(null).doSomething()
}

Because name is a var property, it can change (via concurrently-running code) between the time we check if it's null and the time we use it. We capture a snapshot of it as a val, then test and use that snapshot.

This is a very common pattern. So common, that we have a nice little function that helps us out.

class Person(
    var name: String?,
) {
    fun doSomething() {
        println(name)
        name?.let {
            println(it.length)
        }
    }
}

fun main() {
    Person("Scott").doSomething()
    Person(null).doSomething()
}

let is a "scope function". It runs its block using its receiver expression as a parameter. That parameter is acting as the "capture" that we did before, but we don't have to explicitly specify it.

Let's look at a simplified version of its definition:

public inline fun <T, R> T.let(block: (T) -> R): R {
    return block(this)
}

There are a few things we haven't seen yet.

inline functions replace their call with the code in their body. This is crucial for this function, because it allows us to place a return inside the block passed to let, and that return will return from doSomething().

<T, R> indicates this is a generic function. I won't be going into much detail, but what it's saying is that there are two "type parameters" for the function. The first, T, represents the type of the receiver to let. You can see this by the T.let. This allows let to be used on any type.

The R is the return type of the let, as seen in the : R part of the declaration. This will be driven by the block that's passed in.

The block is a lambda that takes the receiver (as seen in block(this)) and returns some value of type R, which then becomes the return type of the let call.

The net result is that the receiver to let is passed as an argument to block and the result of the block call is returned.

Capture, then process.

We normally call let after a safe-access operator

name?.let { ... }

This ensures that the value passed in is not null, and the type inside the block is non-nullable.

In Kotlin, we often use x?.let {...} in place of if (x != null) {...} for its "capture and process" power.

We'll use let quite a bit in this course.

with

Removing redundancies is a great goal in Kotlin. The with function takes an argument and passes it in as this to reduce some code or provide access to functions.

Suppose we were initializing a class that didn't have a constructor:

class Person {
    var name: String = ""
    var age: Int = 0
}

fun main() {
    val person = Person()
    person.name = "Scott"
    person.age = 59 // how did THAT happen???
    println(person.name)
    println(person.age)
}

We can reduce the redundant person. by using with:

class Person {
    var name: String = ""
    var age: Int = 0
}

fun main() {
    val person = Person()
    with(person) {
        name = "Scott"
        age = 59
        println(name)
        println(age)
    }
}

A little cleaner, but not terribly necessary. We'll be using it to bring certain functions into scope when we're creating our user interfaces in Jetpack Compose.

A big challenge in writing your code, especially libraries, is managing the proliferation of names in a namespace. It's very easy to accidentally overload functions, especially when you use names like add, which could mean many different things depending on context.

And sometimes, those functions cannot exist without some data to provide context.

Let's look at a function we'll be using in Compose: toPx(). This function acts upon a density-independent pixel (dip or dp) specification to scale your user interface. A dp is equal to a single pixel width on a 160dpi (dots-per-inch) screen.

You specify sizes using dp, and scale according to the density of the current device. To create a Dp, you use the Int.dp extension function, which creates a Dp instance, containing a float copy of the value (graphic functions in Android use Floats).

data class Dp(val value: Float)

val Int.dp
    get() = Dp(this.toFloat())

fun main() {
    val widthDp = 48.dp
    println(widthDp.value)
}

To determine the actual pixel width to draw, we need to multiply the dp width against a scaling factor. For example, if we wanted to draw on a 320dpi screen, we'd need to multiply by 2.

We ask Android for that scaling factor by referencing the LocalDensity.current property, which hosts the density info of the current device. It also contains a Dp.toPx() extension function that we can use to convert our Dp for use on the device screen.

Think of it as

data class Density(val density: Float) {
    fun Dp.toPx() = value * density
}

data object LocalDensity {
    val current = Density(2f)
}

There's no way to run toPx() outside of the context of a Density instance! So we use with to make the current Density the surrounding this:

data class Dp(val value: Float)

val Int.dp
    get() = Dp(this.toFloat())

data class Density(val density: Float) {
    fun Dp.toPx() = value * density
}

data object LocalDensity {
    val current = Density(2f)
}

//sampleStart
fun main() {
    val widthDp = 48.dp
    with(LocalDensity.current) {
        val widthPx = widthDp.toPx()
        println(widthPx)
    }
}
//sampleEnd

Note

Aside from cases like this where you really need the context of a class like Density, I almost never use with. I generally prefer other scope functions, especially apply.

apply

Let's take another look at that initialization example where we used with:

class Person {
    var name: String
    var age: Int
}

fun main() {
    val person = Person()
    with(person) {
        name = "Scott"
        age = 59
    }
}

But what if we wanted to initialize the person as part of a property in a class? First attempt: set up an instance initializer:

class Company {
    val manager = Person()

    init {
        with(manager) {
            name = "Scott"
            age = 59
        }
    }
}

Think about how this would look with several properties that you need to initialize. The initialization for many properties wouldn't be close to the property definition. I like to keep things together when possible...

It turns out that with returns the last expression in its block. So we could remove the initializer and use the with by returning this as its last expression:

class Company {
    val manager = 
        with(Person()) {
            name = "Scott"
            age = 59
            this
        }
}

Cool! This keeps the initialization at the property declaration site. But I really don't like having to type this at the end every time.

That's where apply comes in. It acts the same as with but it always returns this.

class Company {
    val manager = 
        Person().apply {
            name = "Scott"
            age = 59
        }
}

apply is most-often used for actions to take when initializing properties, whether it's setting properties as we've seen, or additional actions. For example, in our Graphics module, we'll write something like

lines += Line().apply {
    lineInProgress = this
}

which adds a Line to a list of lines and sets the lineInProgress property.

run

The run function doesn't take a receiver. It just has a block that returns its last value. This is useful to start new call chains, especially after our friend Elvis:

val result =
    foo?.let {
        // do something if foo isn't null
    } ?: run {
        // do something if foo is null    
    }

Warning

In the above example, be particularly careful not to omit the run!

val result =
    foo?.let {
        // do something if foo isn't null
    } ?: {
        // do something if foo is null    
    }

Rather than setting result to the value of the last expression in that lambda after Elvis, result will be set to the lambda itself. This usually isn't what you had intended, unless the let was also returning a lambda... And there are cleaner ways to conditionally return different functions/lambdas...

also

The also function is intended to be used in the middle of a call chain, as a "side-effect" of processing. It passes in its receiver as a parameter to the block (it or an explicit name), and then returns the receiver.

We could use it in that Line example

lines += Line().also {
    lineInProgress = it
}

The main advantage to also is that it doesn't hide the surrounding this. For example:

data class Person(val name: String) {
    val friends = mutableListOf<Person>()

    fun makeFriend(friendName: String) {
        friends += Person(friendName).also {
            println("$name just met ${it.name}")
                // NOTE: name is from THIS PERSON, not the 
                //   new friend. If we had used apply() instead
                //   of also(), name would refer to the new
                //   friend's name!
        }
    }
}

fun main() {
    val person = Person("Scott")
    person.makeFriend("Pam")
}

If you used apply instead of also, this would refer to the new friend inside the block.

Note

If you need to reach past the closest this, you can qualify the this. For example:

data class Person(val name: String) {
    val friends = mutableListOf<Person>()

    fun makeFriend(friendName: String) {
        friends += Person(friendName).apply {
            println("${this@Person.name} just met ${this@apply.name}")
            // NOTE: name is from THIS PERSON, not the
            //   new friend. If we had used apply() instead
            //   of also(), name would refer to the new
            //   friend's name!
        }
    }
}

fun main() {
    val person = Person("Scott")
    person.makeFriend("Pam")
}

Here the this@Person says to use the this for the Person class, and this@apply is the this provided by the apply function. (You don't need to qualify the this@apply as it's the currently-active this in the block.