Introduction to Kotlin Context-Oriented Programming

This is a translation of An introduction to context-oriented programming in Kotlin

In this article I will try to describe a new phenomenon that arose as a by-product of the rapid development of the Kotlin language. This is a new approach to designing the architecture of applications and libraries, which I will call context-oriented programming.

A few words about function permissions

As is well known, there are three main programming paradigms (pedant note: there are other paradigms):

  • Procedural Programming
  • Object oriented programming
  • Functional programming

All these approaches work with functions one way or another. Let's look at this from the point of view of the resolution of functions, or the scheduling of their calls (meaning the choice of the function that should be used in this place). Procedural programming is characterized by the use of global functions and their static resolution based on the function name and argument types. Of course, types can only be used with statically typed languages. For example, in Python, functions are called by name, and if the arguments are incorrect, an exception is thrown in runtime during program execution. The resolution of functions in languages ​​with a procedural approach is based only on the name of the procedure / function and its parameters, and in most cases is done statically.

An object-oriented programming style limits the scope of functions. Functions are not global, instead they are part of classes, and can only be called on an instance of the corresponding class (pedant note: some classical procedural languages ​​have a modular system and, therefore, scope; procedural language! = C).

Of course, we can always replace a member function of a class with a global function with an additional argument of the type of the called object, but from a syntactic point of view, the difference is quite significant. For example, in this case, the methods are grouped in the class to which they refer, and therefore it is more clearly visible what kind of behavior the objects of this type provide.

Of course, encapsulation is most important here, due to which some fields of a class or its behavior can be private and accessible only to members of this class (you cannot provide this in a purely procedural approach), and polymorphism, thanks to which the method actually used is determined not only based on the name method, but also based on the type of object from which it is called. Dispatching a method call in an object-oriented approach depends on the type of object defined in runtime, the name of the method, and the type of arguments at the compilation stage.

A functional approach does not bring anything fundamentally new in terms of function resolution. Functionally-oriented languages ​​usually have better rules for delimiting scopes (pedant note: once again, C is not all procedural languages, there are those in which the scope is well delimited) that allow more detailed control over the visibility of functions based on a system of modules, but in addition, resolution is performed at compile time based on the type of arguments .

What is this?

In the case of the object approach, when invoking a method on an object, we have its arguments, but in addition we have an explicit (in the case of Python) or an implicit parameter representing an instance of the called class (hereinafter all examples are written in Kotlin):

class A {
    fun doSomething () {
        println ("This method is called on $ this")
    }
}

Nested classes and closures complicate things a bit:

interface B {
    fun doBSomething ()
}
 
class A {
    fun doASomething () {
        val b = object: B {
            override fun doBSomething () {
                println ("This method is called on $ this inside $ {this @ A}")
            }
        }
        b.doBSomething ()
    }
}

In this case, there are two implicit this for function doBSomething – one corresponds to an instance of the class Band the other comes from instance closure A. The same thing happens in the much more common case of lambda closure. It is important to note that this in this case works not only as an implicit parameter, but also as a scope or context for all functions and objects called in the lexical scope. So the doBSomething method actually has access to any members of the class, public or private A, as well as to members of the B.

And here is Kotlin

Kotlin gives us a completely new “toy” – extension functions. (Pedant Note: actually not so new, in C # they are also there). You can define a function like A.doASomething() anywhere in the program, not just inside A. Inside this function, we have an implicit this-parameter called the receiver (receiver), indicating the instance A on which the method is called:

class A
 
fun A.doASomthing () {
    println ("This extension method is called on $ this")
}
 
fun main () {
    val a = A ()
    a.doASomthing ()
}

Extension functions do not have access to the private members of their recipient, so encapsulation is not violated.

The next important thing that Kotlin has is code blocks with receivers. You can run an arbitrary block of code using something as a recipient:

class A {
    fun doInternalSomething () {}
}
 
fun A.doASomthing () {}
 
fun main () {
    val a = A ()
    with (a) {
        doInternalSomething ()
        doASomthing ()
    }
}

In this example, both functions could be called without the additional "a."at the beginning, because the with function puts all the code of the next block inside the context of a. This means that all the functions in this block are called as if they were called on an (explicitly passed) object a.

The final step to context-oriented programming at this point is the ability to declare extensions as members of a class. In this case, the extension function is defined inside another class, like this:

class B
 
class A {
    fun B.doBSomething () {}
}
 
fun main () {
    val a = A ()
    val b = B ()
    with (a) {
        b.doBSomething () // will work
    }
    b.doBSomething () // will not compile
}

Important here B gets some new behavior, but only when it is in a specific lexical context. The extension function is a regular member of the class. A. This means that the resolution of the function is done statically based on the context in which it is called, but the real implementation is determined by the instance Apassed as context. The function can even interact with the state of the object. a.

Context-Oriented Dispatch

At the beginning of the article, we discussed different approaches to dispatching function calls, and this was done for a reason. The fact is that the extension functions in Kotlin allow you to work with dispatching in a new way. Now the decision about which particular function should be used is based not only on the type of its parameters, but also on the lexical context of its call. That is, the same expression in different contexts can have different meanings. Of course, nothing changes from the implementation point of view, and we still have an explicit receiver object that defines dispatching for its methods and extensions described in the body of the class itself (member extensions) – but from the syntax point of view, this is a different approach .

Let's look at how the context-oriented approach differs from the classical object-oriented approach, using the classic problem of arithmetic operations on numbers in Java as an example. Class Number in Java and Kotlin is the parent for all numbers, but unlike specialized numbers like Double, it does not define its mathematical operations. So you can’t write, for example, like this:



val n: Number = 1.0
 
n + 1.0 // the operation `plus` is not defined in the class` Number`

The reason here is that it is not possible to consistently define arithmetic operations for all numeric types. For example, integer division is different from floating point division. In some special cases, the user knows what type of operation is needed, but usually it makes no sense to define such things globally. An object-oriented (and, in fact, functional) solution would be to define a new class inheritor type Number, the necessary operations in it, and use it where necessary (in Kotlin 1.3, you can use inline classes). Instead, let's define a context with these operations and apply it locally:



interface NumberOperations {
    operator fun Number.plus (other: Number): Number
    operator fun Number.minus (other: Number): Number
    operator fun Number.times (other: Number): Number
    operator fun Number.div (other: Number): Number
}
 
object DoubleOperations: NumberOperations {
    override fun Number.plus (other: Number) = this.toDouble () + other.toDouble ()
    override fun Number.minus (other: Number) = this.toDouble () - other.toDouble ()
    override fun Number.times (other: Number) = this.toDouble () * other.toDouble ()
    override fun Number.div (other: Number) = this.toDouble () / other.toDouble ()
}
 
fun main () {
    val n1: Number = 1.0
    val n2: Number = 2
    val res = with (DoubleOperations) {
        (n1 + n2) / 2
    }
     
    println (res)
}

In this example, the calculation res done inside a context that defines additional operations. A context does not have to be defined locally; instead, it can be passed implicitly as the receiver of a function. For example, you can do this:



fun NumberOperations.calculate (n1: Number, n2: Number) = (n1 + n2) / 2
 
val res = DoubleOperations.calculate (n1, n2)

This means that the logic of operations within the context is completely separate from the implementation of this context, and can be written in another part of the program or even in another module. In this simple example, a context is a stateless singleton, but state contexts can also be used.

It is also worth remembering that contexts can be nested:

with (a) {
    with (b) {
        doSomething ()
    }
}

This gives the effect of combining the behavior of both classes, however, this feature is difficult to control today due to the lack of extensions with multiple recipients (KT-10468).

The Power of Explicit Coroutines

One of the best examples of a context-oriented approach is used in the Kotlinx-coroutines library. An explanation of the idea can be found in an article by Roman Elizarov. Here I just want to emphasize that Coroutinescope Is a case of a context-oriented design with a context having a state. CoroutineScope plays two roles:

  • He contains Coroutinecontext, which is needed to run corutin and is inherited when a new coroutine is launched.
  • It contains the state of the parent coroutine, which allows you to cancel it if the generated coroutine throws an error.

Also, structured concurrency provides a great example of a context-oriented architecture:

suspend fun CoroutineScope.doSomeWork () {}
 
GlobalScope.launch {
    launch {
        delay (100)
        doSomeWork ()
    }
}

Here doSomeWork Is a context function, but defined outside its context. Methods launch create two nested contexts that are equivalent to the lexical areas of the corresponding functions (in this case, both contexts are of the same type, so the inner context obscures the outer one). A good starting point for learning Kotlin coroutines is the official guide.

DSL

There is a wide class of tasks for Kotlin, which are usually referred to as tasks of building DSL (Domain Specific Language). In this case, DSL is understood as some code providing a user-friendly builder of some kind of complex structure. In fact, the use of the term DSL is not entirely correct here, as in such cases, the basic Kotlin syntax is simply used without any special tricks – but let's still use this common term.

DSL builders are context-oriented in most cases. For example, if you want to create an HTML element, you must first check whether you can add this particular element to this place. The kotlinx.html library does this by providing context-based class extensions that represent a specific tag. In fact, the entire library consists of context extensions for existing DOM elements.

Another example is the TornadoFX GUI builder. The entire builder of the scene graph is arranged as a sequence of nested context builders, where the inner blocks are responsible for building children for external blocks or adjusting the parameters of parents. Here is an example from the official documentation:



override val root = gridPane {
    tabpane {
        gridpaneConstraints {
            vhGrow = Priority.ALWAYS
        }
        tab ("Report", HBox ()) {
            label ("Report goes here")
        }
        tab ("Data", GridPane ()) {
            tableview {
                items = persons
                column ("ID", Person :: idProperty)
                column ("Name", Person :: nameProperty)
                column ("Birthday", Person :: birthdayProperty)
                column ("Age", Person :: ageProperty) .cellFormat {
                    if (it <18) {
                        style = "-fx-background-color: # 8b0000; -fx-text-fill: white"
                        text = it.toString ()
                    } else {
                        text = it.toString ()
                    }
                }
            }
        }
    }
}

In this example, the lexical domain defines its context (which is logical, since it represents the GUI section and its internal structure), and has access to the parent contexts.

What's next: multiple recipients

Context-oriented programming gives Kotlin developers many tools and opens up a new way of designing application architecture. Do we need anything else? Probably yes.

At the moment, development in the contextual approach is limited by the fact that you need to define extensions in order to get some kind of context-limited class behavior. This is fine when it comes to a custom class, but what if we want the same thing for a class from a library? Or if we want to create an extension for an already restricted behavior (for example, add some kind of extension inside CoroutineScope)? Kotlin currently does not allow extension functions to have more than one recipient. But multiple recipients could be added to the language without breaking backward compatibility. The possibility of using multiple recipients is currently being discussed (KT-10468) and will be issued as a KEEP request (UPD: already issued). The problem (or maybe a chip) of nested contexts is that they allow you to cover most, if not all, options for using type classes (type-classes), another very desirable of the proposed features. It is rather unlikely that both of these features will be implemented in the language at the same time.

Addition

We want to thank our full-time Pedant and Haskell lover Alexei Khudyakov for his comments on the text of the article and amendments to my rather free use of terms. I also thank Ilya Ryzhenkov for valuable comments and proofreading the English version of the article.

Author of the original article: Alexander Nozik, Deputy Head of the Laboratory of Nuclear Physics Experimental Methods at JetBrains Research.

Translated by: Peter Klimay, Researcher at the Laboratory of Nuclear Physics Experiment Methods at JetBrains Research

Similar Posts

Leave a Reply Cancel reply