Kotlin Scope Functions: A Developer Manual

Andrea
Level Up Coding
Published in
11 min readAug 1, 2020

--

Photo by David Pisnoy on Unsplash

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 after ApplicationWindow has been created.
  • Prefixing this is redundant.
  • For a nicer syntax, we could move the show() function in an also 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

(Beware, I removed some non-interesting code, to focus on the content of let)
  1. The generic type declaration, <T, R>, implies that the type which let extends, T, can vary from the returned type, R.
  2. let is an extension function, which applies to any type T. Indeed, T.let means “let can be called on any type, and we’ll call your whatever type T”.
  3. When let is invoked, a lambda is passed to let, named block. Indeed, let(block: (T) -> R) .
  4. block is a lambda, and as such, accepts one parameter, of type T, and returns a value of type R. (Tstands for parameter-Type, and Rstands for Return-type). Indeed, let(block: (T) -> R) .
  5. block is then executed inside let, and the R-Type result is returned by let. 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

(Beware, I removed some non-interesting code, to focus on the content of apply)
  1. The generic type declaration, <T>, implies that the type which apply extends, T, must match itself upon returning.
  2. apply is an extension function, which applies to any T. Indeed, T.apply means “apply can be called on any type, and we’ll call your whatever type T”.
  3. When apply is invoked, a lambda is passed to apply, named block. Indeed, apply(block: T.() -> Unit).
  4. block is a parameterless lambda, extends T and returns Unit. (While within let we had (T), which meant that block was a lambda accepting one parameter of type T, with apply, block’s signature is T.(). This means that block’s scope is T, therefore we won’t have anymore it as the T-Type object. This time it’s going to be this. The smart thing about the following approach is that we don’t need to directly refer to a variable in our block function, since our code is executing on top of T).
  5. block is then executed inside apply. Indeed we see block() on a line on its own.
  6. The T-Type result is returned by apply. 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 with T.
  • 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.

(Beware, I removed some non-interesting code, to focus on the content of 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 type R.
  • (T) -> R: Takes 1 argument of type T, returns an object of type R.
  • (T, U, V) -> R: Takes 3 arguments, returns an object of type R.
  • T.() -> R: Takes no argument, scoped inside T, returns R.
  • T.(U, V) -> X<Y>: Takes 2 arguments of type U and V, scoped inside T, returning an object of type X<Y>.

More details in the official documentation here.

--

--