Skip to Main Content

Mastering all 5 Kotlin Scope Functions

It should come as no surprise that our Android team here at Punch Through loves Kotlin, and that we strive to write idiomatic Kotlin, taking advantage of its unique language features whenever possible. Some common questions we get from newer team members who are learning Kotlin revolve around its scope functions, namely: let, run, with, apply, and also. The scope functions are very similar in the way that they work and behave, or so it seems at first.

What are scope functions?

As the name suggests, scope functions re-scope the context of your executing code — we use a scope function to re-scope an object as either this or it in the enclosing lambda.

The main purpose of scope functions is to help us write more concise and readable code. For us, the number of lines of code is not a metric for the code’s conciseness or readability. And though using scope functions will almost always increase the number of lines of code, it can help reduce repetition and unnecessary variable initialization.

A very popular use of scope functions is to unwrap nullable references and access their underlying values. We know that chaining of safe calls on a nullable reference is one of the key features of Kotlin and even other popular modern programming languages like Swift, but long, chained safe calls can sometimes appear inelegant when called repeatedly.

Another popular example of scope function use is to perform post-initialization configuration on an object. Other uses include returning or bailing early during function execution, performing side effects, and the list goes on. 

Without further ado, let’s dive into the various scope functions Kotlin has available for us.

‘let’

At a glance:

  • The let scope function re-scopes an object as it in its lambda unless a parameter name is explicitly declared.
  • let returns the computation result of the lambda expression.
  • Main usage by the Punch Through team: safely unwrap nullable values.

Most developers’ first encounter with scope functions would probably be with let. If we’re in an Activity class, this typically refers to said Activity class itself. However, if we have a nullable variable that we intend to unwrap, we could do something like someVariable?.let { … }. In that lambda enclosed by curly brackets, someVariable would’ve been unwrapped and subsequently re-scoped into it.

Some readers might ask, “Can’t we just do a null check instead, and have Kotlin’s smart casting or type inference automatically infer that the variable is not null?” 

Well, that would work if the nullable reference in question is a val, but for cases where the reference is a var, Kotlin’s smart cast would complain that it cannot guarantee this var would remain the same throughout the execution of the if statement. Scope functions like let allow us to essentially capture a snapshot of the underlying reference and operate on it without being worried about it becoming null from underneath us.

var someVariable: String? = null

if (someVariable != null) {
    // Do something with ‘someVariable’, which is still nullable
}

someVariable?.let {
    // Do something with ‘it’, which is now guaranteed to be non-null
}

To be fair, let is far from being a silver bullet, and manual null checks do have their places in our code occasionally. We’ll talk more about this in one of the later sections.

‘apply’

At a glance:

  • The apply scope function re-scopes an object as this in its lambda.
  • apply returns the object on which it’s called on.
  • Main usage by the Punch Through team: configure an object.

Our team absolutely loves the apply scope function for its elegance in performing configuration operations on an object. Case in point, if we are configuring an AppCompatActivity’s action bar, and if we had no knowledge of scope functions, we’d do something like the following:

supportActionBar?.setDisplayShowTitleEnabled(true)
supportActionBar?.setTitle(R.string.title)
supportActionBar?.setDisplayHomeAsUpEnabled(true)

And while there’s obviously nothing wrong with the code above, apply allows us to write more elegant code while also reducing the chances of programmer error (e.g., calling functions on the wrong class instance).

supportActionBar?.apply {
    setDisplayShowTitleEnabled(true)
    setTitle(R.string.title)
    setDisplayHomeAsUpEnabled(true)
}

Note how we didn’t have to prefix the function calls inside the apply lambda expression with it. This is because apply re-scopes the AppCompatActivity’s ActionBar as this inside the lambda expression, and since Kotlin expressions are implicitly called on this, there is no need to prefix the Intent function calls with anything.

It’s also possible to perform configuration on a freshly-instantiated object using apply.

val intentFilter = IntentFilter().apply {
    addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
    addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
}

A word of caution: the code inside the apply lambda expression will execute first before apply returns the object on which it was called on. In the example above, the IntentFilter is first created before it gets configured to our specifications in the apply lambda expression. After the lambda expression finishes, the configured IntentFilter gets returned by the apply call and is then assigned to the intentFilter variable.

Depending on the specific use case, it may be worth considering performing configuration tasks separately or after the variable assignment, especially if there are code side effects during the post-initialization phase that other code are relying on.

‘also’

At a glance:

  • The also scope function re-scopes an object as it in its lambda unless a parameter name is explicitly declared.
  • also returns the object on which it’s called on.
  • Main usage by the Punch Through team: perform mass configuration on an object without shadowing this.

also is an interesting scope function. Functionally, it’s almost identical to apply except for how it re-scopes the object. apply re-scopes the object on which it’s called on as this, while also re-scopes the object as it, much like what let does.

So then when do we use one over the other?

We generally use also when we don’t want to shadow the current this in the wider context. For example, if we’re in some class that either has a function performAddition() — or worse, if its superclass implements it, which hides it away — and the object we’re thinking of configuring by calling apply or also on also has a function performAddition() on it, it can be confusing to the implementing developer and others reviewing or reading the code exactly which performAddition() is being called. In cases like this, it’s often preferable to use also over apply.

abstract class AbstractSuperclass {
    var data: Uri? = null
}

class SomeClass : AbstractSuperclass() {
    fun someFunction(): Intent {
        return Intent(Intent.ACTION_SENDTO).also {
            it.data = Uri.parse(CONTACT_EMAIL_URI)
            it.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.support_email_subject))
            it.putExtra(Intent.EXTRA_TEXT, getString(R.string.email_template))
        }
    }
}

In the example above, AbstractSuperclass has a data property, and Intent also has a data property that was automatically generated from Intent’s Java setData and getData methods. When attempting to set Intent’s data, using also disambiguates which data we’re setting, and the code is more readable as a result.

Of course, apply can be used in situations like the above, but when wanting to access AbstractSuperclassdata property from within the apply lambda expression, one would have to explicitly prefix the data property with this@SomeClass. It’s our opinion that using also would yield a cleaner piece of code in this case, but there’s absolutely nothing stopping anyone from using apply here.

‘run’

At a glance:

  • The generic extension function version of the run scope function re-scopes an object as this in its lambda.
  • run returns the computation result of the lambda expression.
  • Main usage by the Punch Through team: perform multiline side effects after an elvis (?:) operator in the case when something is null, and return early.

The run scope function is available both as a standalone global function (kotlin.run {...}) and as a generic extension function on any class instance. We’ll focus on the latter in this post because the former is identical to running a simple function.

Our favorite usage of the run scope function is to perform side effects (for instance, logging) on the right-hand side of a Kotlin elvis (?:) operator if a nullable value being assigned to a variable is in fact null. If the nullable value is non-null, then the run is not called and the variable assignment works as expected.

var mostRecentLogFilename: String? = null

private fun shareLogFile() {
    val fileName = mostRecentLogFilename ?: run {
        log("Nothing to share")
        return
    }

    // Perform other operations with fileName being a non-nullable String
    val uri = FileProvider.getUriForFile(
        this,
        "com.punchthrough.testapp.provider",
        getFileStreamPath(fileName)
    )
    val intent = Intent(Intent.ACTION_SEND)
        .setType("text/*")
        .putExtra(Intent.EXTRA_STREAM, uri)
    startActivity(Intent.createChooser(intent, "Share log using:"))
}

Note that we can call return from within run to return early from a function if some prerequisites aren’t met, and this is what makes our run usage so powerful. This feature is similar to Swift’s vastly popular guard statement, and it allows us to perform sanity checks earlier on in our function rather than potentially doing a bunch of throwaway work. It also allows us to reason about our code in a way such that there is very little room for error, because all the ambiguities (for instance, nullable values) were clarified at the start of the function.

This way of combining the usages of the elvis operator and run also comes with an added bonus — it allows the variable on which the assignment is performed on to be a non-nullable value and saves us the trouble of having to unwrap it yet again in the ensuing code.

Can this run usage be replaced with a let

Absolutely. We’re starting to see a common theme now where scope functions are essentially interchangeable, but bear with me for a bit more, dear reader, as we talk about the last Kotlin scope function, with.

‘with’

At a glance:

  • The with scope function re-scopes an object as this in its lambda.
  • with returns the computation result of the lambda expression.
  • Main usage by the Punch Through team: perform operations which mainly revolve around a central object.

The main difference between with and the other scope functions is that with isn’t called on a generic object. Instead, the object is passed into with as a function argument. Aside from that, with is actually very similar to run. Our usages between with and run then boil down to selecting which one of them makes sense grammatically for a task, and so we find ourselves using with to perform operations that revolve around a central object (“perform these operations with this object”).

private val broadcastReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        with(intent) {
            if (action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
                val device = getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
                val previousBondState = getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1)
                val bondState = getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
                val bondTransition = "${previousBondState.toBondStateDescription()} to " +
                    bondState.toBondStateDescription()
                Timber.w("${device?.address} bond state changed | $bondTransition")
            }
        }
    }

    private fun Int.toBondStateDescription() = when (this) {
        BluetoothDevice.BOND_BONDED -> "BONDED"
        BluetoothDevice.BOND_BONDING -> "BONDING"
        BluetoothDevice.BOND_NONE -> "NOT BONDED"
        else -> "ERROR: $this"
    }
}

In the example above, we’re having to obtain a lot of information from the Intent parameter on the onReceive function, and by re-scoping the execution context to the Intent in question, we could simply use getParcelableExtra, getIntExtra, action, etc. without having to prefix them with the Intent instance.

Caveats of scope functions

With all the advantages that scope functions afford us, it’s hard to imagine them having any downsides. However, there are a couple of pitfalls that developers may encounter if they’re overzealous with using scope functions.

Too much of a good thing isn’t necessarily ideal

There’s a fine balance to strike between using something for the sake of using it, and using it properly. Scope functions aren’t exceptions to that.

Excessive or unnecessary usage of scope functions can in turn cloud readability of our code, especially in the cases of apply, with and run, where it can sometimes be confusing who the receiver (this) is for a given function call. 

Excessive nesting of business logic in a plethora of scope functions is also eerily reminiscent of the infamous JavaScript callback hell. For instance, consider the following code snippet which appears very unwieldy.

var variableOne: String? = null
var variableTwo: String? = null
var variableThree: String? = null

variableOne?.let { variableOneUnwrapped ->
	variableTwo?.let { variableTwoUnwrapped ->
		variableThree?.let { variableThreeUnwrapped ->
			val result = variableOneUnwrapped + variableTwoUnwrapped + variableThreeUnwrapped
		}
	}
}

In cases like the above, it’s much more practical and readable to capture those variables as val instances and do combined manual null checks. In the enclosing if statement, all three constants would be non-null due to Kotlin’s smart casting type inference system.

val constantOne = variableOne
val constantTwo = variableTwo
val constantThree = variableThree

if (constantOne != null && constantTwo != null && constantThree != null) {
	val result = constantOne + constantTwo + constantThree
}

Scope functions are too similar to one another

When you have a bunch of screwdrivers that are roughly the same size in your toolbox, it can be difficult to know which one would be the best for the job — they can all arguably solve the same problem, and that’s a problem in itself.

As we alluded to in our examples throughout the post, all five of the scoping functions are technically interchangeable, and this adds to the confusion surrounding them especially in a team environment. 

Depending on your team culture, your team members may already have a preference for doing things a certain way (e.g., using let to unwrap nullable variables vs. using run). Make sure you check with them to avoid bikeshedding, and to ensure code consistency!

Our advice for developers who are either working alone, or working in a team environment but are having to make decisions on which scope functions do what for their teams, is to be consistent. If you decide to use let to unwrap nullable variables, and to use apply to perform object configurations, then stick to those uses throughout the course of your project. When in doubt, how the Punch Through team uses each scope function is a good place to start.

Scope functions are fun!

In this post, we covered what the five Kotlin scope functions are, what they do, and how we here at Punch Through use them. We think that scope functions are one of the best features of Kotlin, and that they make the development experience that much more fun. We hope this post has been helpful to those of you trying to understand the differences between these scope functions, and stay tuned for more Kotlin tips and tricks!

Interested in Learning More?

All of our blog posts are written by the engineers at Punch Through—the same engineers who build amazing products for our clients. Need help on your project? Learn more about some of the ways we can help!