Kotlin Demystified: What are 'scope functions' and why are they special?
Names are really helpful, both in programming and in the real world. Being able to talk about someone is much easier this way, rather than having to remember some arbitrary ID. Naming variables is so useful, in fact, that even the Harvard Mark I, which was presented to the school in early August of 1944, had the ability to name variables.
While names are useful, the same individual can be referred to differently depending on the group and situation: me, you, Nikki, Nicole, Nicole from Google, etc… This is similar to how scope works in computer science.
.let
’s talk about scope functions
Kotlin “scope functions” are functions that allow for changing the scope, or the range, of a variable. There are five such functions included in the Kotlin standard library: apply
, run
, with
, let
, and also
.
Here’s a really contrived example:
fun myFun() {
val outside = 6.2831853071
run {
val inside = 1.61803398875
// Both outside and inside are usable and in scope
}
// inside is out of scope, and only outside is available
}
In that example, we used run
to create a smaller scope for inside
.
this
is the receiver
For the scope functions apply
, run
, and with
, one of the most useful features is that the object referred to by this
inside the block is the variable that’s used in the call.
class Foo {
//...
myView.run {
// this refers to myView rather than Foo inside the block.
alpha = 0.5f
background = ContextCompat.getDrawable(context, R.drawable.my_drawable)
}
}
This works because the scope of this
has changed to myView
inside the run
block. Aside from that, if we wanted to get access to the this
object from before, we can do it just like we could from an inner class or anonymous object by using this@Foo
.
ThreeTwo values
Since scope functions are functions, they should return a value, and after thinking about it, one might consider three such candidates for those values:
- The object itself
- The last value of the block
Nothing
Actually, there’s no reason for it to ever have no value (AKA ‘Unit’) because we can always just ignore it, which leaves us with two possibilities.
The first option of the value for the block is for the value to be the object, AKA the receiver, itself. Kind of like a builder. This is how apply
works.
val paint = Paint().apply {
color = Color.MAGENTA
style = Paint.Style.STROKE
textSize = textHeadlinePx
}
Rather convenient! We could create and configure our Paint
in one statement.
The second type is the function type, where the value of the block is the value of the last statement in the block. This is actually how both run
and with
work.
val line = PoetryGenerator.obtain().run {
style = "Emily Dickinson"
style += "Lucille Clifton"
lines = 1
generate()
}
Here we get a reference to a PoetryGenerator
instance and perform our configuration on it. But we’re not interested in the PoetryGenerator
itself, we’re interested in the line of poetry it creates. Since run
will set the value of line
to the value of the last statement, all we have to do is call generate()
at the end. line
is then set to the return value of generate()
.
with
works exactly the same way, but while it’s possible to write nullableVar?.run {...}
, it would be a bit different with with
:
val hash = with(nullableGenerator) {
this?.configuration = config
this?.generate()
}
Even though with
returns a value, it reminds me of the with
keyword in Pascal and VB, which means I’ll usually just end up using it like this:
with (myConfig) {
data = value
autoRefresh = false
// ...etc...
}
I’d rather be it
There are times when shifting the scope of this
to another object temporarily makes things easier, but there are other times where that’s not the case:
myIntent?.run {
data = this@MainActivity.data
startActivity(this)
}
Yuck! Not only do we have to use a qualified this, just to reference a class property, but since myIntent
is referenced by this
, the call to startActivity
looks a bit odd.
Fortunately this is where also
and let
come in. In this case, we essentially want to check if myIntent
is null and proceed only when it’s not. The idiomatic way to do this in Kotlin is with the let
scope function:
myIntent?.let {
it.data = data
startActivity(it)
}
let
works exactly like run
except that instead of the object being referenced by this
, it’s referenced with it
.
At least if you’d like it to be. This also works as expected:
myIntent?.let { intent ->
intent.data = data
startActivity(intent)
}
Now myIntent
is referenced by intent
inside the block, which can be helpful when you’d want to provide more context than it
can provide.
The last scope function, also
, which works like apply
, but, again, where the object is referenced with it
instead of this
.
This is useful for two main reasons. First, it can be thought of as its name: create an object and also do this with it:
val myListener = Listener().also {
addListener(it)
}
But it’s also tremendously helpful when doing something completely unrelated to the object or statement, but should be done along with it. A great example of this is logging:
val key: String get() = keystore.getKey(KEY_ID).also {
Log.v(TAG, "Read key at ${System.currentTimeMillis()}")
}
The log doesn’t even use the object. Using also
allows us to add log a message without having to change the rest of the code, and then, when the log is no longer needed, it’s simple to pull out again.
What’s so special then?
“But wait!”, you might be saying, “All functions and lambdas create new scopes. What’s special about these?” And yes, actually we create new scopes all the time when we’re writing Kotlin. For example:
parentViewGroup.forEach { favoriteChild ->
// Do something with favoriteChild…
}
Here, the scope of favoriteChild
is limited to the inside of the forEach
lambda, but forEach
isn’t a scope function. What makes them different?
In truth, it’s actually how ordinary they are that makes them special. forEach
, map
, filter
, and many others create new scopes, but they also iterate over an Iterable
, or perform a mapping, filter out values, etc…
Scope functions, in contrast, don’t do anything other than create a new scope.
The functions themselves are extremely simple as well, but the fact that they’re included in the standard library is also potentially what makes them noteworthy.
How do I choose?
There’s been a lot of discussion about the topic of scope functions, including a flowchart to help select what function to use.
The choice comes down to this: if you want to return the object you’re starting with, then your choice is between apply
and also
. If you want to return the result of a method, then you’ll want to look at let
, run
, and with
.
Scope function | Object Referenced as… | Returns |
---|---|---|
apply | this |
the object |
also | it |
the object |
let | it |
last statement |
run | this |
last statement |
with | this |
last statement |
Then it’s just about which method of referencing the object is easier to read and maintain.
Wrapping it
up
We talked about how Kotlin includes five scope functions in the standard library. Three of them, apply
, run
, and with
, use a receiver to change the scope of this
to the object so its public properties and methods can be accessed without being qualified by the variable name.
We talked about how the remaining two, let
and also
, take the object and use it like a parameter, allowing it to be referenced with it
or another name.
Finally, we talked about how the scope functions are special because of how they’re unique in that they allow creating a local scope without overhead.