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 asit
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 asthis
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 asit
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 AbstractSuperclass
’ data
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 asthis
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 isnull
, 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 asthis
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!