Kotlin scope and extension functions. How to not harm your code.

Dmitry Si
ProAndroidDev
Published in
6 min readOct 10, 2020

--

Scope functions in Kotlin are powerful tools that can make the code more concise but, like any powerful tool, can also ruin it if not used properly. This article covers the typical scope function issues and gotchas and offers mitigation practices.

(The article is based on my 25 min Droidcon Online presentation, but provides more examples and details. Scroll down for the best practices list.)

0. Scope functions overview

First, let's do a recap.

Kotlin standard library offers five scope functions, four of which are defined as extensions

Scope function is the function executes arbitrary code (lambda that you pass) in the scope of the context-object.

Extension function is the function that takes a receiver, which becomes this inside the function and serves as the context.

1. Problem definition

So what the problem with them, and how can they harm the code?

  • Each extension function switches the context of execution and the context of the reader
  • Each scope function adds context to the one that already exists (context of our class or outer function). The more we need to memorize, the harder it’s to read the code. We also need to remember which scope functions return the result and which return the scope-object itself.

That may seem easy but try to recall which two functions on this image take lambda with a receiver (T.() -> R) and which two return the result of the lambda?

Answer: with and apply take lambda with a receiver, with and let return the result of lambda (same does extension function run, not listed on the picture)

In some extreme cases, accidental use of the wrong scope or extension function can even lead to error, during compilation or in runtime.

The “same” extension function may have different behavior, in this case in compile time

Enough theory, let’s check some production code!

3. Examples from real life

a) That one I found in the medium article about the “best” practices, which suggests several improvements, like the following

as you can see the “improved” approach indeed is much shorter, but it’s not clear at all whether it returns the file, or the path, or nothing at all. What’s not obvious either is that path.let part is not needed at all, the function can be reduced to fun makeDir(path: String) = File(path).also{ it.mkdirs() }

b) and here is the one from the app I’m working on

it’s kinda OK, but only because of IDE hint, during code-review, it looks like that (now it’s hard to tell what’s first and second)

that can be slightly improved if we use lambda with parameter instead of a lambda with receiver

and even further with explicit name and restructuring of a pair

but let’s compare it to the version without any scope functions. It’s actually shorter and reads equally well.

c) that’s not to say no one should use clever tricks with scope or extension function. Here is the parameterized JUnit4 test, that doesn’t look great

but with customand and shouldReturn functions it’s now 100% clear how to read test data.

4. Recommendations

Kotlin’s own documentation offers the guide for scope functions, but it’s quite vague and examples demonstrate mostly the capabilities rather than the good style, and there is no guide for extension functions whatsoever. So here is my proposed guide, which consists of mnemonics (for each function) and rules (for general cases).

Scope functions unofficial guide

Mnemonics

  • [If I need to] Let it work with a nullable value → let.
    The key is that it lets us use the object that may not be available sometimes. nullable?.let { log(it) }
  • [If I need to] Apply configuration → apply.
    The key is that we change some properties we can’t set via the constructor. ServerConfig(host).apply { port = 8888 }
  • [If I need to]Return an object, and also do something → also.
    The key is that we do all the main job in the function and ready to return an object, but also need to do something auxiliary, like logging.
    return Something(withParameters).also { log("Created $it") }
  • [If I need to]Do multiple similar things with an object → with.
    The key is that we are working with one and only one object doing multiple related operations with it.
    with(service) { connect(); sendData(data); disconnect() }
  • Don’t use run! Run away from it!
    Really, there is no reason to, other functions cover everything.

Rules

  • If it returns the resultkeep it one line.
    That rule helps a lot with the readability of long lambdas where the last statement happens to be a return value, when it’s one line it’s much easier to see that something is returned without explicit “return”.
  • If it works with an object — use a single object.
    Multiple receivers are hard to track, and two separate this in one scope is enough.
  • Establish patterns.
    Even if some clever trick may not be obvious, it becomes clear and mundane when used often enough. So find use-cases and use the same functions for them every time: e.g. let for nullable variables, apply for builders, also for logging, etc.
  • Don’t try to make the code shorter.
    That’s never the goal, even Kotlin’s developer and advocate Roman Elizarov in his review of functions with receiver emphasizes that the brevity should not come at the expense of clarity.

Extension function unofficial guide

  • apply action to the receiver.
    Non-infix extension function should not treat left (receiver) and right (arguments) sides equally, it applies an action to the left with parameters from the right. In the example below the second function has the signature fun String.connect(vararg params: String) that doesn’t make much sense.
  • nest under an object or an extra package.
    Extension almost always requires an extra nesting level that matches the file name they’re defined at so that the imports make sense for the reader. In the example below function names are very generic (and, or) but the extra package testutils clarifies the first case.
  • avoid using existing names (e.g. toString, to, run).
    Failing on that can cause severe real-time issues. Build-in extensions do not require any imports, so if the code contains any custom function with a name like run, plus, let it will work till the moment it’s copied somewhere or IDE reorganizes the imports, at which point it would likely still remain compilable, but will produce an unexpected result.

5. Summary

It’s easy to prevent Kotlin features abuse with little discipline a couple of rules. Focus on the business task and readability rather than on cleverness and brevity, and the can solve that problem entirely!

Safe coding!

Here is the original #droidcon presentation, and full code from this video can be found here.

--

--

Software developer. Most recently Android Java/Kotlin engineer. Former manager, desktop and embedded software creator. https://github.com/fo2rist/