Context receivers
This is a chapter from the book Functional Kotlin. You can find it on LeanPub or Amazon.
Context parameters were added in Kotlin
1.6.20
and do not work in earlier versions. What is more, to enable this experimental feature in that version, one needs to add the "-Xcontext-receivers" compiler argument.
There are two kinds of problems that extension functions help us solve. The first one is quite intuitive: extending types with additional methods. This is basically what extension functions are designed for. So, for instance, if you need the capitalize
method on String
or the product
method on Iterable<Int>
, nothing is lost as you can always add these methods using an extension function.
The second kind of use case is less obvious but also quite common. We turn functions into extensions to explicitly pass a context of their use. Let's take a look at a few examples.
Consider a situation in which you use Kotlin HTML DSL, and you want to extract some structures into a function. We might use the DSL we defined in the Type Safe DSL Builders chapter and define a standardHead
function that sets up a standard head. Such a function needs a reference to HtmlBuilder
, which we might provide as an extension receiver.
Defining an extension function in a case like this is very popular because it is very convenient. However, this is not what extension functions were initially designed for: we do not intend to call standardHead
on an object of type HtmlBuilder
. Instead, we want it to be used where there is a receiver of type HtmlBuilder
. An extension in such a use case is used to receive a context. We should prefer a dedicated feature for just receiving a context. Why? Let's consider the essential extension function problems with this use case.
Extension function problems
Extension functions were designed to define new methods to call on objects, so they do not work well when used to receive a context. Here are the most important problems:
- extension functions are limited to a single receiver,
- using an extension receiver to pass a context gives a false impression of this function’s meaning and how it should be called,
- an extension function can only be called on a receiver object.
Let's discuss these problems in detail.
Extension functions are limited to a single receiver. This makes a lot of sense when we define extension functions as methods to call on objects, but not when we want to use them to pass a receiver.
For example, when we use Kotlin Coroutines, we often want to launch a flow on a coroutine scope[12_1]. A scope is often used as a receiver, but the function used to launch it is already an extension on Flow<T>
, so it cannot also be an extension on CoroutineScope
. As a result we have the launchIn
function, which expects CoroutineScope
as a regular argument and is often called as launchIn(this)
.
Using an extension receiver to pass a context gives a false impression of this function’s meaning and how it should be called. To understand this, consider the sendNotification
function, which sends a notification to a user. Its additional functionality is displaying info using a logger. Let's say that in our application we make our classes implement LoggerContext
to be able to use a logger implicitly. When we call sendNotification
, we need to pass this LoggingContext
somehow, and the most convenient way is as a receiver. So, we define sendNotification
as an extension function on LoggerContext
. However, this is a very poor design choice because it suggests that sendNotification
is a method on LoggingContext
, which is not true.
An extension function can only be called on objects, which is precisely why extension functions were invented, but this is not great when we want to use extension functions to pass a receiver implicitly. Consider standardHead
from the example above. We want to use it as a part of HTML DSL, but we do not want to allow it to be called on an object of type HtmlBuilder
To address all these problems, Kotlin introduced a feature called context parameters.
Introducing context parameters
Kotlin 1.6.20
introduced a new feature that is dedicated to passing implicit receivers into functions. This feature is called context parameters and it addresses all the aforementioned issues. How do we use it? For any function, we can specify the context parameter types inside brackets after the context
keyword. Such functions have receivers of specified types, and these functions need to be called in the scope where all the specified receivers are.
Importantly, a context parameter function call expects an implicit receiver, so such functions cannot be called on an object of receiver type.
When you want to use an explicit context parameter, you need to specify a label after this
with a type that specifies which receiver you want to use.
context(Foo)
fun callFoo() {
this@Foo.foo() // OK
this.foo() // ERROR, this is not defined
}
Context parameters can specify multiple receiver types. For example, in the code below, the callFooBoo
function expects both Foo
and Boo
receiver types.
A receiver is anything that this
represents. It might be an extension function receiver, a lambda expression receiver, or a dispatch receiver (the enclosing class for methods and properties). One receiver can be used for multiple expected types. For example, in the code below, inside the method call
in FooBoo
, we use a dispatch receiver for both Foo
and Boo
types.
Use cases
Now, let's see how context parameters address the aforementioned issues. We could use a context parameter to define that standardHead
needs to be called on HtmlBuilder
. This way should be preferred over using an extension function.
Context parameters are a better choice for most functions that should be used on a DSL and DSL definitions.
The function that is used to launch a flow can also benefit from context parameters functionality. We could define a launchFlow
extension function on Flow<T>
with the CoroutineScope
context parameter. Such a function needs to be called on a flow in a scope where CoroutineScope
is a receiver.
Now, consider the sendNotification
function, which needed LoggingContext
, but we did not want it to be defined as an extension function. We could provide LoggingContext
using a context parameter.
Now let's see some other examples. Consider an external DSL builder where you can add items using the addItem
method.
Let's say that you want to extend this builder to make it possible to define items using the unary plus operator on String
instead:
To do that, we need to define a unaryPlus
operator function which is an extension on String
. However, we also need a receiver that will let us add elements using the addItem
function. To do that, we can use a context parameter.
A popular Android example of using a context receiver is defining dp size (density-independent pixels) in code. This is a standard way of describing width or height. The problem is that dp size depends on a view because it depends on display density. The solution is that the dp
extension property might have a context parameter of View
type. Then, such a property can quickly and conveniently be used as a part of view builders.
As you can see, there are many cases in which context receiver functionality is useful, but remember that most of them are related to DSL builders.
Concerns
Like every good feature, context parameters can also be used poorly, which could lead to code that is more complicated or less safe than it needs to be. We should use this feature only where it makes sense, and using too many receivers in our code is not good for readability. Implicit function calls are not as clear as explicit ones. There are also risks of name collisions. Receivers are not as visible as arguments. Using implicit receivers too often can make code confusing for other developers. I suggest not using context receivers if they often need wrapping function calls using scope functions, like with
.
In general, my suggestions are:
- When there is no good reason to use a context parameter, prefer using a regular argument.
- When it is unclear from which receiver a method comes, consider using an argument instead of the receiver or use this receiver explicitly[12_2].
Named context parameters
There are plans for context parameters to allow naming received values, so we can use them explicitly by their names.
It will also be possible to ignore the receiver name by using an underscore.
Summary
Kotlin introduced a new prototype feature called context parameters to address situations in which we want to pass receivers into functions or classes implicitly. Until now, we used extension functions for this, but their biggest issues were:
- extension functions are limited to a single receiver,
- using an extension receiver to pass a context gives a false impression of this function’s meaning and how it should be called,
- an extension function can only be called on a receiver object.
Context parameters solve all these problems and are very convenient. I’m looking forward to them becoming a stable feature that I can use in my projects.
[12_1]: More about this in the Kotlin Coroutines: Deep Dive book. [12_2]: See Effective Kotlin, Item 14§: Consider referencing receivers explicitly.