1. Overview

In this tutorial, we’re going to talk about the conventions that Kotlin provides to support operator overloading.

2. The operator Keyword

In Java, operators are tied to specific Java types. For example, String and numeric types in Java can use the + operator for concatenation and addition, respectively. No other Java type can reuse this operator for its own benefit. Kotlin, on the contrary, provides a set of conventions to support limited Operator Overloading.

Let’s start with a simple data class:

data class Point(val x: Int, val y: Int)

We’re going to enhance this data class with a few operators.

In order to turn a Kotlin function with a pre-defined name into an operator, we should mark the function with the operator modifier.  For example, we can overload the “+” operator:

operator fun Point.plus(other: Point) = Point(x + other.x, y + other.y)

This way we can add two Points with “+”:

>> val p1 = Point(0, 1)
>> val p2 = Point(1, 2)
>> println(p1 + p2)
Point(x=1, y=3)

3. Overloading for Unary Operations

Unary operations are those that work on just one operand. For example, -a, a++ or !a are unary operations. Generally, functions that are going to overload unary operators take no parameters.

3.1. Unary Plus

How about constructing a Shape of some kind with a few Points:

val s = shape { 
    +Point(0, 0)
    +Point(1, 1)
    +Point(2, 2)
    +Point(3, 4)
}

In Kotlin, that’s perfectly possible with the unaryPlus operator function.

Since a Shape is just a collection of Points, then we can write a class, wrapping a few Points with the ability to add more:

class Shape {
    private val points = mutableListOf<Point>()

    operator fun Point.unaryPlus() {
        points.add(this)
    }
}

And note that what gave us the shape {…} syntax was to use a Lambda with Receivers:

fun shape(init: Shape.() -> Unit): Shape {
    val shape = Shape()
    shape.init()

    return shape
}

3.2. Unary Minus

Suppose we have a Point named “p” and we’re gonna negate its coordinations using something like “-p”. Then, all we have to do is to define an operator function named unaryMinus on Point:

operator fun Point.unaryMinus() = Point(-x, -y)

Then, every time we add a “-“ prefix before an instance of Point, the compiler translates it to a unaryMinus function call:

>> val p = Point(4, 2)
>> println(-p)
Point(x=-4, y=-2)

3.3. Increment

We can increment each coordinate by one just by implementing an operator function named inc:

operator fun Point.inc() = Point(x + 1, y + 1)

The postfix “++” operator, first returns the current value and then increases the value by one:

>> var p = Point(4, 2)
>> println(p++)
>> println(p)
Point(x=4, y=2)
Point(x=5, y=3)

On the contrary, the prefix “++” operator, first increases the value and then returns the newly incremented value:

>> println(++p)
Point(x=6, y=4)

Also, since the “++” operator re-assigns the applied variable, we can’t use val with them.

3.4. Decrement

Quite similar to increment, we can decrement each coordinate by implementing the dec operator function:

operator fun Point.dec() = Point(x - 1, y - 1)

dec also supports the familiar semantics for pre- and post-decrement operators as for regular numeric types:

>> var p = Point(4, 2)
>> println(p--)
>> println(p)
>> println(--p)
Point(x=4, y=2)
Point(x=3, y=1)
Point(x=2, y=0)

Also, like ++ we can’t use  with vals*.*

3.5. Not

How about flipping the coordinates just by !p? We can do this with not:

operator fun Point.not() = Point(y, x)

Simply put, the compiler translates any “!p” to a function call to the “not” unary operator function:

>> val p = Point(4, 2)
>> println(!p)
Point(x=2, y=4)

4. Overloading for Binary Operations

Binary operators, as their name suggests, are those that work on two operands. So, functions overloading binary operators should accept at least one argument.

Let’s start with the arithmetic operators.

4.1. Plus Arithmetic Operator

As we saw earlier, we can overload basic mathematic operators in Kotlin. We can use “+”  to add two Points together:

operator fun Point.plus(other: Point): Point = Point(x + other.x, y + other.y)

Then we can write:

>> val p1 = Point(1, 2)
>> val p2 = Point(2, 3)
>> println(p1 + p2)
Point(x=3, y=5)

Since plus is a binary operator function, we should declare a parameter for the function.

Now, most of us have experienced the inelegance of adding together two BigIntegers:

BigInteger zero = BigInteger.ZERO;
BigInteger one = BigInteger.ONE;
one = one.add(zero);

As it turns out, there is a better way to add two BigIntegers in Kotlin:

>> val one = BigInteger.ONE
println(one + one)

This is working because the Kotlin standard library itself adds its fair share of extension operators on built-in types like BigInteger.

4.2. Other Arithmetic Operators

Similar to plus,  subtraction, multiplicationdivision, and the remainder are working the same way:

operator fun Point.minus(other: Point): Point = Point(x - other.x, y - other.y)
operator fun Point.times(other: Point): Point = Point(x * other.x, y * other.y)
operator fun Point.div(other: Point): Point = Point(x / other.x, y / other.y)
operator fun Point.rem(other: Point): Point = Point(x % other.x, y % other.y)

Then, Kotlin compiler translates any call to “-“, “*”“/”, or “%” to “minus”, “times”“div”, or “rem” , respectively:

>> val p1 = Point(2, 4)
>> val p2 = Point(1, 4)
>> println(p1 - p2)
>> println(p1 * p2)
>> println(p1 / p2)
Point(x=1, y=0)
Point(x=2, y=16)
Point(x=2, y=1)

Or, how about scaling a Point by a numeric factor:

operator fun Point.times(factor: Int): Point = Point(x * factor, y * factor)

This way we can write something like “p1 * 2”:

>> val p1 = Point(1, 2)
>> println(p1 * 2)
Point(x=2, y=4)

As we can spot from the preceding example, there is no obligation for two operands to be of the same type. The same is true for return types.

4.3. Commutativity

Overloaded operators are not always commutative. That is, we can’t swap the operands and expect things to work as smoothly as possible.

For example, we can scale a Point by an integral factor by multiplying it to an Int, say “p1 * 2”, but not the other way around.

The good news is, we can define operator functions on Kotlin or Java built-in types. In order to make the “2 * p1” work, we can define an operator on Int:

operator fun Int.times(point: Point): Point = Point(point.x * this, point.y * this)

Now we can happily use “2 * p1” as well:

>> val p1 = Point(1, 2)
>> println(2 * p1)
Point(x=2, y=4)

4.4. Compound Assignments

Now that we can add two BigIntegers with the “+” operator, we may be able to use the compound assignment for “+” which is “+=”. Let’s try this idea*:*

var one = BigInteger.ONE
one += one

By default, when we implement one of the arithmetic operators, say “plus”, Kotlin not only supports the familiar “+” operator, it also does the same thing for the corresponding compound assignment, which is “+=”.

This means, without any more work, we can also do:

var point = Point(0, 0)
point += Point(2, 2)
point -= Point(1, 1)
point *= Point(2, 2)
point /= Point(1, 1)
point /= Point(2, 2)
point *= 2

But sometimes this default behavior is not what we’re looking for. Suppose we’re going to use “+=” to add an element to a MutableCollection. 

For these scenarios, we can be explicit about it by implementing an operator function named plusAssign:

operator fun <T> MutableCollection<T>.plusAssign(element: T) {
    add(element)
}

For each arithmetic operator, there is a corresponding compound assignment operator which all have the “Assign” suffix. That is, there are plusAssign, minusAssign, timesAssign, divAssign, and remAssign:

>> val colors = mutableListOf("red", "blue")
>> colors += "green"
>> println(colors)
[red, blue, green]

All compound assignment operator functions must return Unit.

4.5. Equals Convention

If we override the equals method, then we can use the “==” and “!=” operators, too:

class Money(val amount: BigDecimal, val currency: Currency) : Comparable<Money> {

    // omitted
    
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Money) return false

        if (amount != other.amount) return false
        if (currency != other.currency) return false

        return true
    }

    // An equals compatible hashcode implementation
}

Kotlin translates any call to “==” and “!=” operators to an equals function call, obviously in order to make the “!=” work, the result of the function call gets inverted. Note that in this case, we don’t need the operator keyword.

4.6. Comparison Operators

It’s time to bash on BigInteger again!

Suppose we’re gonna run some logic conditionally if one BigInteger is greater than the other. In Java, the solution is not all that clean:

if (BigInteger.ONE.compareTo(BigInteger.ZERO) > 0 ) {
    // some logic
}

When using the very same BigInteger in Kotlin, we can magically write this:

if (BigInteger.ONE > BigInteger.ZERO) {
    // the same logic
}

This magic is possible because Kotlin has a special treatment of Java’s Comparable.

Simply put, we can call the compareTo method in the Comparable interface by a few Kotlin conventions. In fact, any comparisons made by “*<“, “<=”, “>”,* or “>=”  would be translated to a compareTo function call.

In order to use comparison operators on a Kotlin type, we need to implement its Comparable interface:

class Money(val amount: BigDecimal, val currency: Currency) : Comparable<Money> {

    override fun compareTo(other: Money): Int =
      convert(Currency.DOLLARS).compareTo(other.convert(Currency.DOLLARS))

    fun convert(currency: Currency): BigDecimal = // omitted
}

Then we can compare monetary values as simple as:

val oneDollar = Money(BigDecimal.ONE, Currency.DOLLARS)
val tenDollars = Money(BigDecimal.TEN, Currency.DOLLARS)
if (oneDollar < tenDollars) {
    // omitted
}

Since the compareTo function in the Comparable interface is already marked with the operator modifier, we don’t need to add it ourselves.

4.7. In Convention

In order to check if an element belongs to a Page, we can use the “in” convention:

operator fun <T> Page<T>.contains(element: T): Boolean = element in elements()

Again, the compiler would translate “in” and “!in” conventions to a function call to the contains operator function:

>> val page = firstPageOfSomething()
>> "This" in page
>> "That" !in page

The object on the left-hand side of “in” will be passed as an argument to contains and the contains function would be called on the right-side operand.

4.8. Get Indexer

Indexers allow instances of a type to be indexed just like arrays or collections. Suppose we’re gonna model a paginated collection of elements as Page, shamelessly ripping off an idea from Spring Data:

interface Page<T> {
    fun pageNumber(): Int
    fun pageSize(): Int
    fun elements(): MutableList<T>
}

Normally, in order to retrieve an element from a Page, we should first call the elements function:

>> val page = firstPageOfSomething()
>> page.elements()[0]

Since the Page itself is just a fancy wrapper for another collection, we can use the indexer operators to enhance its API:

operator fun <T> Page<T>.get(index: Int): T = elements()[index]

The Kotlin compiler replaces any page[index] on a Page to a get(index) function call:

>> val page = firstPageOfSomething()
>> page[0]

We can go even further by adding as many arguments as we want to the get method declaration.

Suppose we’re gonna retrieve part of the wrapped collection:

operator fun <T> Page<T>.get(start: Int, endExclusive: Int): 
  List<T> = elements().subList(start, endExclusive)

Then we can slice a Page like:

>> val page = firstPageOfSomething()
>> page[0, 3]

Also, we can use any parameter types for the get operator function, not just Int.

4.9. Set Indexer

In addition to using indexers for implementing get-like semantics, we can utilize them to mimic set-like operations, too. All we have to do is to define an operator function named set with at least two arguments:

operator fun <T> Page<T>.set(index: Int, value: T) {
    elements()[index] = value
}

When we declare a set function with just two arguments, the first one should be used inside the bracket and another one after the assignment:

val page: Page<String> = firstPageOfSomething()
page[2] = "Something new"

The set function can have more than just two arguments, too. If so, the last parameter is the value and the rest of the arguments should be passed inside the brackets.

4.10. Invoke

In Kotlin and many other programming languages, it’s possible to invoke a function with functionName(args) syntax*.* It’s also possible to mimic the function call syntax with the invoke operator functions. For example, in order to use page(0) instead of page[0] to access the first element, we can declare an extension:

operator fun <T> Page<T>.invoke(index: Int): T = elements()[index]

Then, we can use the following approach to retrieve a particular page element:

assertEquals(page(1), "Kotlin")

Here, Kotlin translates the parentheses to a call to the invoke method with an appropriate number of arguments. Moreover, we can declare the invoke operator with any number of arguments.

4.11. Iterator Convention

How about iterating a Page like other collections? We just have to declare an operator function named iterator with Iterator as the return type:

operator fun <T> Page<T>.iterator() = elements().iterator()

Then we can iterate through a Page:

val page = firstPageOfSomething()
for (e in page) {
    // Do something with each element
}

4.12. Range Convention

In Kotlin, we can create a range using the “..” operator. For example, “1..42” creates a range with numbers between 1 and 42.

Sometimes it’s sensible to use the range operator on other non-numeric types. The Kotlin standard library provides a rangeTo convention on all Comparables:

operator fun <T : Comparable<T>> T.rangeTo(that: T): ClosedRange<T> = ComparableRange(this, that)

We can use this to get a few consecutive days as a range:

val now = LocalDate.now()
val days = now..now.plusDays(42)

As with other operators, the Kotlin compiler replaces any “..” with a rangeTo function call.

5. Use Operators Judiciously

Operator overloading is a powerful feature in Kotlin which enables us to write more concise and sometimes more readable codes. However, with great power comes great responsibility.

Operator overloading can make our code confusing or even hard to read when it’s too frequently used or occasionally misused.

Thus, before adding a new operator to a particular type, first, ask whether the operator is semantically a good fit for what we’re trying to achieve. Or ask if we can achieve the same effect with normal and less magical abstractions.

6. Conclusion

In this article, we learned more about the mechanics of operator overloading in Kotlin and how it uses a set of conventions to achieve it.

The implementation of all these examples and code snippets can be found in the GitHub project.


« 上一篇: Kotlin中的内联函数
» 下一篇: Kotlin契约