Sovraccarico dell'operatore in Kotlin

1. Panoramica

In questo tutorial, parleremo delle convenzioni fornite da Kotlin per supportare il sovraccarico degli operatori.

2. La parola chiave dell'operatore

In Java, gli operatori sono legati a specifici tipi di Java. Ad esempio, i tipi String e numerici in Java possono utilizzare l'operatore + rispettivamente per la concatenazione e l'addizione. Nessun altro tipo Java può riutilizzare questo operatore a proprio vantaggio. Kotlin, al contrario, fornisce una serie di convenzioni per supportare il sovraccarico dell'operatore limitato .

Cominciamo con una semplice classe di dati :

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

Miglioreremo questa classe di dati con alcuni operatori.

Per trasformare una funzione Kotlin con un nome predefinito in un operatore, dovremmo contrassegnare la funzione con il modificatore dell'operatore . Ad esempio, possiamo sovraccaricare l' operatore "+" :

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

In questo modo possiamo aggiungere due punti con "+" :

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

3. Sovraccarico per operazioni unarie

Le operazioni unarie sono quelle che funzionano su un solo operando . Ad esempio, -a, a ++ o ! A sono operazioni unarie. In genere, le funzioni che sovraccaricano gli operatori unari non accettano parametri.

3.1. Unario Plus

Che ne dici di costruire una forma di qualche tipo con alcuni punti :

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

In Kotlin, questo è perfettamente possibile con la funzione operatore unaryPlus .

Poiché una forma è solo una raccolta di punti , possiamo scrivere una classe, racchiudendo alcuni punti con la possibilità di aggiungerne altri:

class Shape { private val points = mutableListOf() operator fun Point.unaryPlus() { points.add(this) } }

E nota che ciò che ci ha dato la sintassi della forma {...} è stato utilizzare un Lambda con ricevitori :

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

3.2. Unario meno

Supponiamo di avere un punto chiamato "p" e negheremo le sue coordinate usando qualcosa come "-p" . Quindi, tutto ciò che dobbiamo fare è definire una funzione operatore denominata unaryMinus on Point:

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

Quindi, ogni volta che aggiungiamo un prefisso "-" prima di un'istanza di Point , il compilatore lo traduce in una chiamata di funzione unaryMinus :

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

3.3. Incremento

Possiamo incrementare ogni coordinata di una semplicemente implementando una funzione operatore chiamata inc :

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

L' operatore suffisso "++" restituisce prima il valore corrente e poi aumenta il valore di uno:

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

Al contrario, l' operatore prefisso “++” , prima aumenta il valore e poi restituisce il valore appena incrementato:

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

Inoltre, poiché l' operatore "++" riassegna la variabile applicata, non possiamo usare val con loro.

3.4. Decremento

Abbastanza simile all'incremento, possiamo decrementare ogni coordinata implementando la funzione dell'operatore dec :

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

dec supporta anche la semantica familiare per gli operatori di pre e post-decremento come per i normali tipi numerici:

>> 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)

Inoltre, come ++ non possiamo usare - con val s .

3.5. Non

Che ne dici di capovolgere le coordinate semplicemente di ! P ? Possiamo farlo senza :

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

In poche parole, il compilatore traduce qualsiasi "! P" in una chiamata di funzione alla funzione operatore unario "non" :

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

4. Sovraccarico per operazioni binarie

Gli operatori binari, come suggerisce il nome, sono quelli che lavorano su due operandi . Quindi, le funzioni che sovraccaricano gli operatori binari dovrebbero accettare almeno un argomento.

Cominciamo con gli operatori aritmetici.

4.1. Operatore aritmetico Plus

Come abbiamo visto prima, possiamo sovraccaricare gli operatori matematici di base in Kotlin. Possiamo usare "+" per aggiungere due punti insieme:

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

Quindi possiamo scrivere:

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

Poiché plus è una funzione operatore binario, dovremmo dichiarare un parametro per la funzione.

Ora, la maggior parte di noi ha sperimentato l'ineleganza di sommare due BigInteger :

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

A quanto pare, c'è un modo migliore per aggiungere due BigInteger in Kotlin:

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

Questo funziona perché la stessa libreria standard di Kotlin aggiunge la sua giusta quota di operatori di estensione su tipi integrati come BigInteger .

4.2. Altri operatori aritmetici

Simile a più , sottrazione , moltiplicazione , divisione e il resto funzionano allo stesso modo:

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)

Quindi, il compilatore Kotlin traduce qualsiasi chiamata in "-" , "*" , "/" o "%" in "meno" , "volte" , "div" o "rem" , rispettivamente:

>> 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)

Oppure, che ne dici di ridimensionare un punto in base a un fattore numerico:

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

In questo modo possiamo scrivere qualcosa come "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 smooth 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  MutableCollection.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 { // 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 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 { 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  Page.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 { fun pageNumber(): Int fun pageSize(): Int fun elements(): MutableList }

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  Page.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  Page.get(start: Int, endExclusive: Int): List = 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  Page.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 = 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  Page.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  Page.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.rangeTo(that: T): ClosedRange = 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 its too frequently used or occasionally misused.

Quindi, prima di aggiungere un nuovo operatore a un tipo particolare, chiedi innanzitutto se l'operatore è semanticamente adatto a ciò che stiamo cercando di ottenere. Oppure chiedi se possiamo ottenere lo stesso effetto con astrazioni normali e meno magiche.

6. Conclusione

In questo articolo, abbiamo appreso di più sulla meccanica del sovraccarico degli operatori in Kotlin e su come utilizza una serie di convenzioni per ottenerlo.

L'implementazione di tutti questi esempi e frammenti di codice può essere trovata nel progetto GitHub.