Increasing readability using operator conventions in Kotlin
Updated: Apr 1, 2019
During his keynote at KotlinConf 2018, lead language designer Andrey Breslav emphasized what the language development team considers the most important principles of Kotlin. As a quick recap, these principles are readability, reusability, interoperability, and safety and tool support. The purpose of this article is to show how Kotlin’s operator conventions support these principles, particularly emphasizing readability.
What are operator conventions? Let’s have a quick Java retro moment. Recall that Java enables us to iterate over objects that implement the Iterable interface using a for-loop. Therefore, Java relies on a class having a specific type to allow us to use it with a particular language construct. In contrast, Kotlin simply requires that our class use a specific convention — a way of marking and naming a function — to enable us to use it with particular language constructs.
While this approach may seem strange at first, it is important to remember that Kotlin does not only consider newly written code, but also existing code. While we often can’t change which interfaces an existing class implements, in Kotlin, we can define an extension function following an operator convention, thereby allowing us also to use specific language constructs with existing classes. Regarding interoperability, we are able to use the operator convention functions from Java directly, but this is somewhat limited in usefulness.
Operator conventions require the implementing function to have a specific name, and also the operator modifier to ensure that the function was not accidentally given the same name as a convention. While the invoke and arithmetic operator conventions are flexible in their parameter lists and return types, most other conventions are fixed in this respect.
The invoke operator
The invoke operator has been quite misunderstood in the Kotlin community, mostly because of the traditional way of thinking about function invocation. In short, the invoke operator allows us to call an object as though it were a function. Consider the following example:
The latter is difficult to understand because we tend to think of function invocations as actions. Therefore, it is best to confine the use of the invoke operator to classes that represent actions and naming objects of those classes appropriately to indicate that they represent an action.
This may remind you of the Command design pattern, which encapsulates an action as an object. One of the best-known use cases of this design pattern is that of the undo operation. When combined with the Memento design pattern, it can be a very readable way of using Kotlin to support a typical operation in user interfaces (UIs), as illustrated below.
Arithmetic operators allow us to use a limited set of mathematical operators on objects, including binary (+, -, *, /, %) and unary (+, -, !, ++, — ) operators. Consider the following example:
The above example makes sense because subtracting one Cartesian point from another to determine the distance between them is a well-known mathematical operation. In the past, arithmetic operators were often abused for the sake of brevity. When deciding whether to implement an arithmetic operator, it is essential to consider whether such an operator truly makes sense, especially to someone maintaining your code many years from now. Be kind — always favor readability over conciseness.
Comparison operators allow us to use a set of comparison operators on objects, including equality (== and !=) and ordering (<, <=, >, >=) operators. Consider the following example:
The above example makes sense in a rather narrow context of a space shuttle being launched from Earth (represented by the point (0,0)), but generally, it will not make sense. Always consider the context when considering whether following a convention is readable, favoring a broader context over a narrower one.
Destructuring declarations allows us to unpack composite objects into separate variables concisely. Consider the following example:
This example illustrates an excellent use case of destructuring declarations because the composed values and their ordering are well-known. Consider also the following example:
The above shows a good use case of destructuring declarations because the composed values and their ordering are obvious from the function name. This example is a trick for when you want to return multiple values from a function without defining another class simply to return the values. This technique should be sparingly used because a function returning multiple values is often an indication of a function that is unfocused in its intent.
Note that Kotlin creates the functions required to enable destructuring declarations for data classes automatically. However, this does not mean that every data class is the ideal use case of destructuring declarations. Only consider using destructuring declarations when there are just a few values, and their ordering is well-known.
Collection and range-related operators
The index operator can be used to get or set values of an object using a concise syntax. Consider the following example:
Again, this notation only makes sense because of the well-known ordering of the Cartesian coordinate system.
The in operator can be used to determine whether a value is contained in a collection or range. It can also be used to iterate over elements in a collection or range, depending on its syntactic context. Consider the following example:
In summary, operator conventions are a powerful means to improve the readability of your code. Always be kind- carefully consider whether someone else will understand the intent of the operator, rather than merely appreciating the conciseness of the usage.
Kotlin in Action by Dmitry Jemerov and Svetlana Isakova.