Kotlin Scope Functions: A Developer Manual
TL;DR; Cheat sheet’s at the end of the page.
“I suppose it is tempting, if the only tool you have is a hammer, to treat everything as if it were a nail.”
Formally known as the Golden Hammer rule, American psychologist Abraham Maslow created this simple, yet truthful principle, which devalues over-reliance on a single tool in the process of solving a problem, in favor of a wider array of instruments at someone’s disposal, to tackle an issue with elegance and precision.
JetBrains worked at its finest while developing Kotlin, furnishing developers with dozens of precision Swiss knives, to handle every kind of scenario properly and rigorously.
A bright example of this is the broad set of scope functions offered by the language standard library. Very few languages can show off this whole arsenal of tooling and control over dynamic scoping and object manipulation.
But, in particular, the developer is offered a bunch of different sharp tools for often extremely similar use cases. Whilst being a powerful ally, you need to be responsible and make sure to understand scope functions correctly and use them properly.
There is a lot of documentation and code online regarding this topic. There are however, no official clear-cut guidelines about which function is best to pick, depending on the context you’re in. Indeed, the usage of one scope function over another is a little arbitrary, within the range of syntax & semantic correctness. Thus, you have to understand how to make the most out of them and to decide, your coding style for a project, based on your/your team’s preferences.
What‘s a Scope Function to begin with?
We’ll temporarily fly the rigorous definition, and I’ll instead say this: Using scope functions is really just a powerful, clean and compact way of coding. Their ability is to turn repetitive, redundant code into poetry.
Those functions in Kotlin are run
, let
, apply
& also
.
They allow you to do magic with objects. You can call a scope function on any object you’d like, because they are both extension functions and generic functions. And since in Kotlin everything is an object, they’re a perfect match.
Another key concept is that sometimes you can do the same thing in more than one way. We could of course argue that run, let
, also
& apply
will all *technically* do the job for you, since they all solve a similar problem, but in subtly different ways.
Code Example.
Consider the following syntax, which is supposed to create a window and do some trivial operations with it.
What bumps to our eyes in a particular disturbing way is that we have a lot of code which is repeated. Count how many times window
appears. We even had to write out window.header.buttons
three times.
That’s unbearably redundant and repetitive. Let’s spice this up with scope functions.
That’s a big improvement. We can now start to see the impact of scope functions: thanks to our apply
block, this
points to a different scope. We now have a main editing block to apply changes inside the window
object. We also used run
in order to cycle the add
function three times (We used run
instead of apply
because we didn’t care about the result).
There are still a few improvements to be made:
- The
apply
call can be inlined, right afterApplicationWindow
has been created. - Prefixing
this
is redundant. - For a nicer syntax, we could move the
show()
function in analso
block.
Voilà. Same exact result with hugely different code structure.
This is an example of the substantial change you can bring to your code by adding those tools to your belt.
Under The Hood: let & apply.
We are now going to break down the behaviour of let
and apply
(which are the most different extension functions, just like run
is to also
) directly by looking at their source code, to fully comprehend the mechanism and the parts which make them work. Then we can integrate run
and also
to differentiate scope functions basing on those changing parts and figure out which one should be used for what.
let
- The generic type declaration,
<T, R>
, implies that the type whichlet
extends,T
, can vary from the returned type,R
. let
is an extension function, which applies to any typeT
. Indeed,T.let
means “let
can be called on any type, and we’ll call your whatever typeT
”.- When
let
is invoked, a lambda is passed tolet
, namedblock
. Indeed,let(block: (T) -> R)
. block
is a lambda, and as such, accepts one parameter, of typeT
, and returns a value of typeR
. (T
stands for parameter-Type, andR
stands for Return-type). Indeed,let(block: (T) -> R)
.block
is then executed insidelet
, and theR
-Type result is returned bylet
. Indeed,return block(this)
.
Super important:block
accepts a T
-Type as its argument. Indeed, this
is fed into block()
, because this
, inside let
, refers to the objectlet
is called on (since let
is an extension function). And that object must be a T
-Type, by let
’s definition. Plus, since block() -> R
and let
returns block()
, it makes sense that let -> R
.
📌: let
’s lambda signature is (T) -> R
, which means it accepts one argument of type T
, it.
📌: let
returns whatever its block()
function returns, as an R
-Type, which may be different from theT
-Type T.let
was called on.
T
is Person
, and block
is the function between the curly brackets which takes it
and returns whatever the last statement was; Precisely, R
, which is Unit
since block
‘s last line doesn’t return anything (println(it)
)
apply
- The generic type declaration,
<T>
, implies that the type whichapply
extends,T
, must match itself upon returning. apply
is an extension function, which applies to anyT
. Indeed,T.apply
means “apply
can be called on any type, and we’ll call your whatever typeT
”.- When
apply
is invoked, a lambda is passed toapply
, namedblock
. Indeed,apply(block: T.() -> Unit)
. block
is a parameterless lambda, extendsT
and returnsUnit
. (While withinlet
we had(T)
, which meant thatblock
was a lambda accepting one parameter of type T, withapply
,block
’s signature isT.()
. This means thatblock
’s scope isT
, therefore we won’t have anymoreit
as theT
-Type object. This time it’s going to bethis
. The smart thing about the following approach is that we don’t need to directly refer to a variable in ourblock
function, since our code is executing on top ofT
).block
is then executed insideapply
. Indeed we seeblock()
on a line on its own.- The
T
-Type result is returned byapply
. Indeed,return this
.
Super important: after block()
ran, its return value is ignored. this
is returned. If block
altered in some way this
, then those changes will reflect the function output.
📌: apply
’s lambda signature is T.() -> Unit
, whose block
function is scoped into T
, referred by this.
📌: apply
returns the object it was called on. No matter what the block()
function returns, that is going to be thrown away. apply
doesn’t care what the lambda returns.
T
is Person
, and block
is the function between the curly brackets which executes on top of T
and returns the now updated base object (alice
, which is Person
).
Differential Analysis.
Perhaps the most frustrating bit about scope function is that there is no magic table for how to choose one of them. Sure, you can plot out their properties, but the choice boils down to the context, and what you believe fits nicely in your code. You have to slowly develop an intuition and a gut feeling to confidently pick one over the others, and this comes with practise and creativity. Mix them up, play, test and mess with them. This will help you getting comfortable. Read code to pick up patterns and write sample test code to learn. It’ll be worth it
.
🟢 let
You’re in shit? let me take care of it.
Really handy when joined with the safe call ?.
operator to deal with nullability. More about it here.
Returns the last statement of the lambda block.
🔴 run
We’r gonna run this bunch of functions together, I got u.
Useful either to execute one specific action from inside an object (which may involve accessing deep inside the object) and to return its result, or to run a generic group of functions on top of that base object, especially when in large number and repetitive otherwise, without caring about the result. Its main purpose is to cut redundancy.
Returns the last statement of the lambda block.
🟠 apply
I’mma apply this change to this object. yw.
That’s arguably the most useful one. Overall it’s a pretty idiomatic function to use when you have to change properties and invoke functions on some object. It’s cool because it lets you do whatever you want directly in the scope and then it returns the updated version of the object, thus making it work very well also in a chain of calls. It is widely used in object initialization.
Returns the base object after executing the lambda block.
🔵 also
Oh, I was about to forget, also let me add it to the list.
Deeply neglected, but precious and unique, surely my favourite scope function. It’s used to do things you would normally write out unequivocally badly, because, as the name suggests, it is perfect to add final touches and finish up an operation. It is the perfect place for doing unrelated actions, which are far from the current context: It speaks for itself in places where you would otherwise need a comment.
Returns the base object after executing the lambda block.
Drawing the sand line.
There are thin boundaries between some functions and it is important to make them as clear as possible, in order to properly choose.
🔴 run vs 🟠 apply
apply
is used extensively in object initialization & configuration, because we care about the result of the function (The updated object itself), while run
is mainly used to (for the lack of a better word) run either one very specific task and to evaluate its result as the last statement, or a bunch of functions on the same object, to cut redundancy, because we don’t care what we get back.
🟢 let vs 🔵 also
let
and also
are not used in object initialization because it would be redundant to type it
every time you want to refer to the current object, whereas you can use it as the current context object with this
.
Instead, they are useful in other scenarios. Notoriously for null-checks and additional operations respectively, but you can use them wherever they fit nicely in your code and the syntax is clear and readable.
Props Cheatsheet.
The leftmost symbols row represent the return value:
- The circle symbolizes the base object being returned back to the caller. This means that functions with such property will have a lambda returning
Unit
, since their return value will be ignored. - The λ symbolizes the last statement of the block lambda being returned back to the caller. Thus, functions with such property’s return type will be what the last statement of the lambda they have been passed was, and may differ from their base call type.
The top row represents the context object:
it
means the lambda is parameterized withT
.this
means the lambda is parameterless and is scoped withinT
.
📌: also
and apply
both start with a
, and both return the base object. let
and run
don’t start with the same character, and they return the last statement of the lambda.
Don’ts.
While using scope functions there are a few, very precise patterns that are a clear sign of something going wrong. You have to be able to pick them up.
context object returns.
That’s a big red don’t. If you choose to use either run
or let
, then your return value will always be the last statement of the lambda you passed in, and forcing it to be the scope object so that it returns itself is plain wrong, because for this sole purpose there are, respectively, apply
and also
.
context redundancy.
Using let
(or also
) was the wrong call. You keep repeating it
. Compare to how it would have been if it had used run
(or apply
) instead.
If you are sure you will be invoking functions only on a target object, it makes sense to go all the way inside that object scope to apply changes, and not via a parameter. It would have looked better without scope functions at all.
shadowing.
Inside the paint.run
block, this
is pointing at wall.paint
(Which is referenced from the outer block wall.run
)
println(color)
will print out paint.color
, (Because we’re running within the scope of paint
), and not the color
variable inside the outer lambda. This is because of variable shadowing.
You may be doing this unawarely or on purpose. Whichever it is, correct this issue, because that’s sneaky and error-prone code. This will surely fly off your radar, especially after months or years you don’t actively read that piece of code. Be explicit and leave no doubt about what you are doing.
The Double Run.
There actually are 2 versions of run: run
and T.run
The first one isn’t an extension function and can instead be used to create a scope on-the-fly, without using the context of any object. Comes handy in a few scenarios, especially since just like T.run
only the last statement is evaluated as the block result.
The second one is the actual run
scope & extension function which can be invoked on any T
-Type object.
With.
I completely ditched with
because it’s the least interesting one. It does the same thing as run
, but takes the base object as a parameter instead of being an extension function.
Though I will say, with
is more appealing than run
, at least syntactically-speaking.
You should probably avoid mixing them up in your code.
Take If & Take Unless.
Alongside what we have seen inside Standard.kt, we can also find takeIf
and takeUnless
, which both act as a nice glue inside call chains, and work well with scope functions and the safe call operator.
Formal Properties of Scope Functions.
A scope function behaviour is set by 2 properties: return value and context object. The first defines whether a scope function f
returns the object it was invoked upon (<T> T.f(): T
) or the result the block lambda yields (<T, R> T.f(): R
). The latter defines whether a scope function f
‘s lambda block is parameterized (block: (T)
) and thereby uses it
to point at f
’s invocation object, or dynamically scoped (block: T.()
) and thereby executed within this
.
Side Note about lambda notation in Kotlin.
() -> R
: Takes no argument, returns an object of typeR
.(T) -> R
: Takes 1 argument of type T, returns an object of typeR
.(T, U, V) -> R
: Takes 3 arguments, returns an object of typeR
.T.() -> R
: Takes no argument, scoped insideT
, returnsR
.T.(U, V) -> X<Y>
: Takes 2 arguments of typeU
andV
, scoped insideT
, returning an object of typeX<Y>
.
More details in the official documentation here.