Author profile picture

Understanding Kotlin's let(), also(), run(), and apply()

Kotlin’s standard library includes some often-used scope functions that are so abstract that even those who have been programming in Kotlin for a while can have a hard time keeping them straight. In this guide, we’re going to clarify four of these scope functions in particular - let(), also(), run(), and apply(). By the end of this guide, you’ll have a framework for understanding them and should have a good idea of which one is most applicable in different scenarios.

Key Characteristics

As a starting point for examination, let’s look at some code. Here’s an example of apply():

val date = Calendar.getInstance().apply {
    set(Calendar.YEAR, 2016)
    set(Calendar.MONTH, Calendar.FEBRUARY)
    set(Calendar.DAY_OF_MONTH, 15)
}

In this code listing, you’ll notice a few things:

  1. apply() is an extension function - Calendar doesn’t have an apply() method. Kotlin extends it with this function in Standard.kt.
  2. apply() takes a block of code. Don’t be fooled by the lack of parentheses. When the only argument is a function, you can skip the parentheses and just use the curly braces.
  3. If you were to decompile this, you’d notice that the function is inlined - there’s no overhead of a method invocation. Nice!

These three characteristics actually apply to all four functions, not just apply().

Distinguishing Characteristics

So how do they differ? They differ on two dimensions:

  1. The context of the code block
  2. The value that is returned by the function

Context of the Code Block

The first dimension is the context of the code block.

Extension functions have a receiver - that is, an object upon which that extension function is invoked. In our example above, the receiver is an instance of Calendar - the instance that was returned by getInstance().

Inside the code block, there are two options for the context:

  1. The context can be preserved. In this case, the value of this does not change, but the receiver object is accessible as the first lambda argument, which by default is it.
  2. The context can become the receiver object. In other words, inside the code block, this can refer to the receiver.

In our code listing above, you’ll notice the three calls to set() are setting values on the Calendar instance. So apply() falls under the second case - this refers to the Calendar instance.

Let’s see the same example, but this time using also():

val date = Calendar.getInstance().also {
    it.set(Calendar.YEAR, 2016)
    it.set(Calendar.MONTH, Calendar.FEBRUARY)
    it.set(Calendar.DAY_OF_MONTH, 15)
}

This time, since we’re using also() instead of apply(), we had to prefix the set() calls with it. This is because this still refers to the same thing as it did outside of our code block.

Return Value

The second dimension is the return value.

The result returned by the function can be one of two things:

  1. The receiver object itself.
  2. Whatever the code block returns.

Here’s the apply() example again, but this time with the type of date indicated explicitly:

val date: Calendar = Calendar.getInstance().apply {
    set(Calendar.YEAR, 2016)
    set(Calendar.MONTH, Calendar.FEBRUARY)
    set(Calendar.DAY_OF_MONTH, 15)
}

As you can see, the result of calling apply() is a Calendar - it’s the same instance as the receiver object that we called apply() on. So apply() fits in the first category - it returns the receiver object.

Now instead of needing the full date, let’s say we just need to know the day of the year. Here’s how we can update that last code listing to do that.

val date: Int = Calendar.getInstance().apply {
    set(Calendar.YEAR, 2016)
    set(Calendar.MONTH, Calendar.FEBRUARY)
    set(Calendar.DAY_OF_MONTH, 15)
}.get(Calendar.DAY_OF_YEAR)

But with run(), the result returned is the result of the code block, so we’d shuffle that call to get():

val date: Int = Calendar.getInstance().run {
    set(Calendar.YEAR, 2016)
    set(Calendar.MONTH, Calendar.FEBRUARY)
    set(Calendar.DAY_OF_MONTH, 15)
    get(Calendar.DAY_OF_YEAR)
}

Keeping them Straight

These two dimensions - context and result - can form a nice little grid that can make it easy to remember what’s what:

  Return Block Result Return Receiver
Context Unchanged - it let() also()
Context Changed - this run() apply()

Following the first letter of each, you get the acronym “LARA”.

Code Comparison

The following demonstrates the four functions, accomplishing the same thing in each case - printing Hello and then returning the length of the string.

val size = "Hello".let {
    println(it)
    it.length
}
val size = "Hello".also {
    println(it)
}.length

val size = "Hello".run {
    println(this)
    this.length
}
val size = "Hello".apply { 
    println(this)
}.length

Choosing from among the LARA functions

Do you want to preserve the this scope?

Your code block might need to reference the existing this scope. In this case, you would probably prefer the top row - either let() or also(). Of course, you can reference the this of outer scopes, but it’s a bit more verbose.

On the other hand, if your code block is calling lots of functions on the receiver, you get better signal-to-noise ratio by using one of the two functions from the bottom row - either run() or apply(), because you won’t need to specify the object at all.

Compare the two EditText objects being created in this code listing.

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_example)

        // The calls to the setters are a tad more verbose
        // The call to `getString()` is concise
        val view = EditText(this).also {
            it.setText(getString(R.string.app_name))
            it.visibility = View.VISIBLE
            it.setSelection(0, 4)
        }

        // The calls to the setters are more concise
        // The call to `getString()` is more verbose
        val view2 = EditText(this).apply {
            setText(this@ExampleActivity.getString(R.string.app_name))
            visibility = View.VISIBLE
            setSelection(0, 4)
        }
    }
}

Are you just initializing an object?

If so, you’ll want one of the functions on the right-hand column, because they return the receiver object when you’re done.

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_example)

        // Concise - receiver object is returned
        val view = EditText(this).also {
            it.setText(getString(R.string.app_name))
            it.visibility = View.VISIBLE
            it.setSelection(0, 4)
        }

        // Verbose - gotta add `it` at the end to return it
        val view2 = EditText(this).let {
            it.setText(getString(R.string.app_name))
            it.visibility = View.VISIBLE
            it.setSelection(0, 4)
            it // <-- verbosity here
        }

        container.addView(view)
    }
}

Common use cases for each

Here are some of the more common use cases:

If you’re looking for more information about each of these four functions, check out their respective pages in the Concepts section.

Share this article: