Why Kotlin needs Context Receivers and how to use them
Over a year ago, in February 2022, when Kotlin 1.6.20 was released, the language introduced context receivers for the first time. The language makers decided to come up with a prototype to demonstrate the design proposal solving the highly popular use case of adding context to Kotlin functions. Adding context to Kotlin functions was possible even before context receivers entered the game. We already know extension functions that are highly relevant in this article’s setting. With an extension function, you specify a receiver object that this function can be called on. Making this a member function of a different class adds even more context to that function as it can only be called from the context of the enclosing class. But is this enough?
Some time ago, even before Kotlin was released in version 1.0, individuals noticed that extension functions are great but could become even more powerful by allowing not only a single but multiple receivers. You can read the initial proposal and discussion in this YouTrack issue from 2015 (by Damian Wieczorek). Don’t worry if the above does not seem to make sense to you yet. We are going to start with the basics, i.e. understanding extension functions, and move on to the more advanced stuff after that. Let’s dive right in!
Extension Functions and what are “Receivers” anyway?
To start things off, we want to recap what extension functions are and how we can use them in Kotlin. Basically, an extension function allows adding functionality to existing classes without having to modify those classes directly. This isn’t some kind of magic but boils down to being syntactic sugar provided by Kotlin. When we write an extension function for example to add functionality to the much-loved Map
class, this does not change the original Map
source code in any way. Instead, the compiler generates a static function that takes the extension’s receiver as its argument. Let’s look at an example and make sure we understand the concepts presented here since we’re going to build on top of these fundamentals in the remainder of this article 😊.
fun <K, V> Map<K, V>.customPrint() { | |
forEach { (k, v) -> | |
println("K: $k") | |
println("V: $v") | |
println("________________") | |
} | |
} | |
// usage | |
val mapping = mapOf("a" to 1, "b" to 2, "c" to 3) | |
mapping.customPrint() |
Okay, so we imagine there’s a project out there that requires maps to be printed in a custom way which the team implements with the help of a simple extension function. Since we want a generic solution, we don’t specify the generics K and V defined by Map
and just keep them in our extension definition. This way, just any map can be used. There’s one important detail in this function definition that is different from regular functions: the receiver. The receiver in this example is defined as Map<K, V>
which syntactically is the portion in front of the .customPrint()
. You can notice that in the case of an extension function, the “receiver” is the same thing as the type that is being extended, Map
in this case. One thing to understand with regard to the implementation above is that we can access all public members of the receiver type without an additional qualifier. You may just use forEach {}
but can also be explicit using the this
qualifier as in this.forEach {}
.
Extension functions are powerful and, most of the time, defined on the project module’s top level. There are specific use cases, on the other hand, that require extension functions to be members of other classes. We are going to cover this in the following section.
Another important feature that is not directly relevant to this article is called function literals with receivers. This also works based on the concept of extensions and receivers and is similar to extension functions in general. I explained it in a past article you can feel free to refer to as well. Function literals with receivers are going in a slightly different direction though, so we’re not going to cover them in more detail in this article.
Member Extension Functions and the use of Scopes/Contexts
Taking it one little step further, we need to consider the fact that an extension function does not need to live on the top level of your project but can also be a member of some class. A construct like this entails not a single but two receivers: a dispatch receiver from the class and an extension receiver from the method’s extension. Let’s consider the example from earlier, now embedded in a separate class PrintingCustomizer
:
class PrintingCustomizer { // PrintingCustomizer is a dispatch receiver | |
private val separator = "________________" | |
fun <K, V> Map<K, V>.customPrint() { // Map<T, V> is an extension receiver for customPrint | |
forEach { (k, v) -> | |
println("K: $k") | |
println("V: $v") | |
println(separator) | |
} | |
} | |
} |
Job Offers
So what do we have here? Our customPrint
now is a member of PrintingCustomizer
and with that, we have access to two receivers’ members within the function body. We can still access Map<K, V>
‘s members, as it represents our extension receiver. In addition, we can access our dispatch receiver’s members; see how separator
is accessed.
Let’s talk about scope and context…
So far we’ve learned about regular extension functions and also member extension functions. Using an extension function is straightforward, but how do we invoke a member extension function that is defined as part of a different class, its dispatch receiver? That’s where we start talking about scopes and contexts, so let’s check those boxes now 👊
I’m sure many of you have come across a very important set of functions in Kotlin that we call scope functions. These include let
, run
, apply
, also
and with
. All of these allow us to bring a certain context object into a different scope where the object is able to access things it wouldn’t in the original context. I am not going to describe scope functions in much more detail but defer to my in-depth article covering the topic.
For the purpose of this article, let’s focus on how we can access a member extension function in our code which in fact will involve a subset of scope functions. So let’s recap first: An extension function defines an extension on a certain extension receiver type, Map<K, V>
in our case. As a result, we can call this extension on any object that has exactly this type. With a member extension function, this is slightly more complicated. We can’t access the extension on just any Map
object but need this map to be in the context of the dispatch receiver. For us, this means we need a map to be in the context of PrintingCustomizer
in order to access PrintingCustomizer::customPrint
. To do so, we will normally use a scope function, the most common one for this use case being with
. Let’s consider the following snippet:
val mapping = mapOf("a" to 1, "b" to 2, "c" to 3) | |
val customizer = PrintingCustomizer() | |
// usage | |
with(customizer){ | |
mapping.customPrint() | |
} |
Here, we bring mapping: Map<String, Int>
, our context object, into the scope of a PrintingCustomizer
object by using the with
scope function and call customPrint
on mapping
.
Amember extension function can be called a context-dependent function, and a dispatch receiver represents the context of an action (PrintingCustomizer
).
To practically illustrate the purpose of this whole idea, we can say that the PrintingCustomizer
offers an extension for Maps. It does not take the PrintingCustomizer
as its argument as you can see in howcustomPrint
is called. However, it requires the caller to be in the context of a PrintingCustomizer
to be able to offer the functionality in that way. For a very practical example of scoping and context-dependent constructs take a look at how Kotlin coroutines are implemented because they are already making heavy use of the constructs described.
The limitations of Member Extension Functions
We have learned that member extension functions are a way to define context-dependent declarations and in fact, these are the only way in Kotlin. What are the limitations of this approach though?
👎 First of all, this kind of scoping is only available in combination with extension functions. We can’t have context-dependent functions, that is functions depending on the presence of a particular context, without also having an extension receiver involved. There are use cases that don’t require an extension receiver, though.
👎👎 More importantly, the whole approach does not work on third-party libraries because we can’t add members to classes we cannot modify which drastically reduces opportunities.
👎👎 Last but not least, and that’s where the original idea was born, we cannot have more than one receiver representing a function’s context. Imagine a case where we want to have functions being callable only if the context is made up of a TransactionalScope and a LoggingScope without requiring us to pass those scopes around in our application to those functions directly.
🚀 And this is where context receivers become reality.
The idea of Context Receivers
So far, we’ve learned that Kotlin needs ways to define context-dependent constructs and that the existing way of using member extension functions is quite limited. We need some way to allow multiple receivers to represent a function’s context. With Kotlin 1.6.20, the language introduced a prototype for context receivers which is a feature that allows exactly this: Adding a required context to actions that has to be provided at the call site, not by passing the context into the function explicitly but by using scoping. Let’s look at this feature in the following.
Since context receivers are still experimental as of the current release 1.8.10, it’s necessary to opt-in to using the feature. You can do so by setting the corresponding compiler option
-Xcontext-receivers
.
class PrintingScope( | |
val separator: String = "________________" | |
) | |
context(PrintingScope) | |
fun <K, V> Map<K, V>.customPrint() { | |
forEach { (k, v) -> | |
println("K: $k") | |
println("V: $v") | |
println(separator) | |
} | |
} | |
// usage | |
val mapping = mapOf("a" to 1, "b" to 2, "c" to 3) | |
val scope = PrintingScope() | |
with(scope) { | |
mapping.customPrint() | |
} |
By applying context receiver syntax to the example from above, we can extract the extension function from the PrintingCustomizer
class to not have it defined as a member extension function any longer. We also renamed the class to PrintingScope
so we clarify its purpose. The call site hasn’t changed as we’re still bringing our map into the scope of PrintingScope
so we can access the customPrint
extension. With Kotlin’s approach of introducing a new context
keyword that takes a list of scope objects, all limitations of member extension functions are being solved. How would we now add a second context receiver type to the function’s context?
class PrintingScope( | |
val separator: String = "________________" | |
) | |
class TimeScope { | |
fun getCurrentTime(): LocalTime = | |
LocalDateTime.now().toLocalTime().truncatedTo(ChronoUnit.SECONDS) | |
} | |
context(PrintingScope, TimeScope) | |
fun <K, V> Map<K, V>.customPrint() { | |
println("Current time when printing: ${getCurrentTime()}") | |
forEach { (k, v) -> | |
println("K: $k") | |
println("V: $v") | |
println(separator) | |
} | |
} |
To demonstrate a non-standard context with more than one context receiver type, we’re adding a TimeScope
that provides a way to access a custom-formatted current time object which we use for an additional log written by customPrint
.
On the call site, we’re now nesting two separate with
expressions to have both scopes available when calling mapping.customPrint()
as shown below.
val mapping = mapOf("a" to 1, "b" to 2, "c" to 3) | |
with(PrintingScope()) { | |
with(TimeScope()) { | |
mapping.customPrint() | |
} | |
} |
Some things to note:
- Context receivers listed in one
context
expression can’t repeat (duplicates are not allowed) and no pair can share a subtyping relationship. - Contexts can be added to functions (including top-level, member and extension functions) and also to property getters and setters
- Within a contextual function, individual context receivers can be accessed with a qualified this expression, e.g.
this@LoggerScope
- Nesting multiple
with
scope functions isn’t ideal and there will likely come a more convenient way in the final version to solve this problem. - ⚠️ This is still a prototype and details may change in future releases.
Interoperability with Java
I quickly want to look at the way Kotlin’s compiler is handling this particular syntax and the newly added context
keyword. Just as a side note, the new keyword may be introducing some minor backward compatibility issues as existing proprietary function names may be experiencing name clashes. In a past article, I explained how particular Kotlin features are made possible by looking at generated bytecode. As I know many people enjoyed this kind of work, I decided to take a look at the contextual function bytecode here as well. Unfortunately, the Kotlin IDEA plugin crashed in my case while trying to look at the bytecode so I reckon that’s due to the fact that the new syntax is not being handled appropriately yet. As a result, I’ll do the explanation just theoretically for now.
As we’ve learned earlier, Kotlin introduced a new context
keyword to support contextual functions since release 1.6.20 which introduced this feature as a prototype. To answer the question of whether these functions will be callable from Java code, we should understand what kind of bytecode construct the Kotlin compiler is generating based on the new syntax. As outlined in the KEEP for this feature, contextual functions are just regular functions in the bytecode where every context receiver type becomes a regular parameter of the function. This means that the new feature is mainly syntactic sugar. To demonstrate what this means, we should look at the type of such function. We take the customPrint
example from earlier again:
context(PrintingScope, TimeScope) | |
fun <K, V> Map<K, V>.customPrint() { | |
// ... | |
} | |
// get type in two different ways | |
val funType1: KFunction3<PrintingScope, TimeScope, Map<String, String>, Unit> = | |
Map<String, String>::customPrint | |
val funType2: context(PrintingScope, TimeScope) Map<String, String>.() -> Unit = | |
Map<String, String>::customPrint |
This snippet shows two different ways of declaring the contextual function’s type. Kotlin allows the context
syntax also in this case (see second type definition) while the first type definition shows the regular way of describing a function type (using KFunction*
syntax). In the first case, we do see that the two context receivers may simply be described as parameters which also means that there is absolutely no reason those functions couldn’t be callable from Java code 👍.
Closing words
I decided to make this article an educational one that only aims to explain the ideas and make clear how the feature can be used in general. I tried to leave out any kind of suggestions and How-To’s because I think that the feature is too unclear at this point in time and may change in final releases. However, it should still be helpful to get a good first impression and maybe some ideas already came up in your head as to how it may benefit your source code. If you’re looking for specific advice and code style suggestions, I want to refer to the official KEEP discussion which contains a lot of these things already.
Thanks so much for reading and let me know how this feature feels to you!
This article was previously published on proandroiddev.com