Blog Post

Kotlin Context Receivers — A Preview

Michael Kellner
Illustration: Kotlin Context Receivers — A Preview

If you’ve ever used our PSPDFKit for Android library, you’ve probably noticed that it’s still implemented in Java at the SDK level. However, underneath, all the new internal implementations are being done in Kotlin, and we’re replacing existing Java code with Kotlin code little by little.

As I’m a big fan of the Kotlin language itself, I’m always keen on what fancy stuff the Kotlin developers will provide us with in the future. And just recently, I found a somewhat lengthy video about a new feature called context receivers. This blog post will go into a bit of detail about what they are and how they’ll work.

Introduction

Context receivers are an upcoming language feature that will be available with Kotlin 1.6.20. At the time of writing, they’re still in the prototype stage, so don’t use them for production code. Additionally, they’re only available on the Java virtual machine (JVM).

To make them work in your project, adjust your Gradle file accordingly:

plugins {
	kotlin("jvm") version "1.6.20"
}

Tasks.withType<KotlinCompile> {
	kotlinOptions.jvmTarget="1.8"
	kotlinOptions.freeCompilerOptionArgs = listOf("-Xcontext-receivers")
}

With that set up, it’s time to look at what’s required to make context receivers work.

Required Tools

There are a couple of tools necessary for using context receivers: receivers, and the with function. The next sections will go into detail about them, explaining what they are and what they do.

Receivers

Anyone who’s done some coding in Kotlin has most likely encountered extension functions, but for those who haven’t, here’s a short recap.

Extension functions are an elegant way to add functionality to an existing class without the need to subclass it. For demonstration purposes, let’s implement a function that returns the sum of the ASCII codes of all characters of a string:

fun String.sumCharCodes(): Int = this.sumBy{ it.code }

The class type that’s extended by the function — in our case, String — is called the receiver type. You can call sumCharCodes() on any string object (or even a string literal). Within the function code, you have full access to the members and methods of the String class, except for private ones.

Another popular use case for receivers is that of lambda functions with receivers. Let’s assume we provide a function that queries and encrypts some data and allows the developer who uses this function to provide their own anonymous function for the encryption as a lambda. In the following example, we expect a lambda parameter with a receiver type string that returns an Int.

For the lambda, the same rules for the extension function apply; its code will run in the context of the String:

fun queryAndTransform(transform: String.() -> Int) : Int {
	val data: String = queryData()
	return data.transform()
}

We could execute this method using the extension function from our previous example:

queryAndTransform{
	sumCharCodes()
}

Within the lambda, we’re within the String object, and that allows us to call sumCharCodes() directly.

The with Function

with takes its argument and turns it into a receiver. Usually, you’d use it to avoid unnecessary repetition in your code.

Assume we have a simple calculation class, and we use it to add a couple of numbers:

class Adder{
	fun add (x: Int, y: Int) = x + y
}

val adder = Adder()

adder.add(1,3)
adder.add(192,28)
adder.add(17,4)

Using with, we could put all the add() calls under the scope of adder and write:

with (adder){
	add(1,3)
	add(192,82)
	add(17,4)
}

Both of the code samples above do the same thing, but they express it in a different way.

So, What Are Context Receivers?

Assume you want constrain a function to only be callable if an object of a specific type is available — for example, a validator, a storage interface, or a reporting service. And without the presence of this object — our context — the function wouldn’t be able to work properly.

Let’s create a Processor class, which does some arbitrary data processing, but should additionally contact a reporting service, so that all executions are recorded somewhere. With the means that we currently have at hand, we’d provide this service as a parameter:

class ReportingService{
	fun report(reportEntry: String){
        //  Do some reporting stuff here.
    }
}

class Processor{
	fun process(data: String, service: ReportingService){
		processData(data)
		service.notify("$data processed")
	}
}

val service = ReportingService()
val processor = Processor()
with (processor){
	process("Can't this", service)
	process("be done", service)
	process("more elegantly?", service)
}

Finally, it’s time to introduce our first context receiver. To do so, we modify our processData function like this:

class Processor{
    context(ReportingService)
    fun process(data: String){
        processData(data)
        notify("$data processed")   // Since we have a `ReportingService` in scope, we can call `notify`.
    }
}

In the code above, I removed the service parameter from the function, but I added the line context(ReportingService) above the function. This is the actual context receiver. It enforces a context of type ReportingService for the execution of the process function.

So we need to bring a context of type ReportingService in scope with the already discussed with function. But hey, you might think we already have the processor in scope.

Worry not, it’s perfectly legit to nest with statements:

val service = ReportingService()
val processor = Processor()
with (processor){   // From here, the processor is in scope.
    with (service){ // From here, both the processor and the service are in scope.
        process("Yes")
        process("it")
        process("can")
    }
}

Just don’t overdo it, as things could otherwise get messy pretty quickly.

Multiple Contexts

Context receivers can request not only single contexts, but as many as you like, as the next snippet shows. As always, be conscientious, and don’t just wildly inject contexts and confuse everyone with your code.

class Calculator{
    fun calculate(data: Int): Int
}
class Formatter{
    fun format(data: Object): String
}

context(Calculator, Formatter)
fun handleData(input: Int): String{
    val result = calculate(input)
    return format(result)
}

A potential problem with nested scopes is that name clashes could arise — especially when you’re not only dealing with your own classes, but external ones, where you don’t have control over the naming.

Let’s play through this and change the names of the methods of both classes to cause some ambiguity:

class Calculator{
    fun process(data: Int): Int
}
class Formatter{
    fun process(data: Object): String
}

Just calling process wouldn’t suffice, as the compiler doesn’t know which one of the two you mean. To resolve this, you have two options to choose from, outlined below.

  • You can use labeled this statements:

context(Calculator, Formatter)
fun handleData(input: Int){
    val result = this@Calculator.process(input)
    return this@Formatter.process(result)
}

This works, but the legibility has some room for improvement.

  • Or, you can wrap the required contexts into an interface.

First, define an interface containing everything you need context-wise:

interface ProcessingContext{
    val calc: Calculator
    val formatter: Formatter
}

Adjust the handleData function accordingly:

context(ProcessingContext)
fun handleData(input: Int){
    val calculated = calc.process(input)
    return formatter.process(calculated)
}

And now call process like this:

val context = object: ProcessingContext {
    val calc = Calculator()
    val formatter = Formatter()
}

with (context){
    handleData(17)
}

The latter method is actually the one recommended by the Kotlin team.

Crunch Question: When to Use Context Receivers?

As already mentioned, don’t sprinkle contexts around like fairy dust.

The official statement from the Kotlin developers is:

“Context receivers shall be used to provide additional, ubiquitous context for actions. As a litmus test, ask yourself if that information might have been provided via a global top-level scope in a similar application with a smaller architecture. If the answer is yes, then it might be a good idea to provide it with a context receiver.”

As with all features, if you overdo it, you’ll end up with a mess. However, when properly used, context receivers can help improve your code.

Author
Michael Kellner Native Engineer

Michael has a thing for architecture (slick software and unconventional buildings). If he’s not sitting in front of a computer or watching a series, you’ll find him in a CrossFit gym, in the kitchen, or at some Burning Man event.

Related products
Share post
Free trial Ready to get started?
Free trial