Surcharge des opérateurs à Kotlin

1. Vue d'ensemble

Dans ce tutoriel, nous allons parler des conventions que Kotlin fournit pour prendre en charge la surcharge des opérateurs.

2. Le mot-clé de l' opérateur

En Java, les opérateurs sont liés à des types Java spécifiques. Par exemple, les types String et numérique en Java peuvent utiliser l'opérateur + pour la concaténation et l'addition, respectivement. Aucun autre type Java ne peut réutiliser cet opérateur pour son propre bénéfice. Kotlin, au contraire, fournit un ensemble de conventions pour prendre en charge une surcharge d'opérateur limitée .

Commençons par une classe de données simple :

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

Nous allons améliorer cette classe de données avec quelques opérateurs.

Afin de transformer une fonction Kotlin avec un nom prédéfini en opérateur, nous devons marquer la fonction avec le modificateur d' opérateur . Par exemple, nous pouvons surcharger l' opérateur «+» :

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

De cette façon, nous pouvons ajouter deux points avec «+» :

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

3. Surcharge pour les opérations unaires

Les opérations unaires sont celles qui fonctionnent sur un seul opérande . Par exemple, -a, a ++ ou ! A sont des opérations unaires. Généralement, les fonctions qui surchargeront les opérateurs unaires n'acceptent aucun paramètre.

3.1. Unaire Plus

Que diriez-vous de construire une forme quelconque avec quelques points :

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

Dans Kotlin, c'est parfaitement possible avec la fonction opérateur unaryPlus .

Puisqu'une forme n'est qu'une collection de points , nous pouvons alors écrire une classe, en enveloppant quelques points avec la possibilité d'en ajouter plus:

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

Et notez que ce qui nous a donné la forme de la syntaxe {…} était d'utiliser un Lambda avec des récepteurs :

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

3.2. Moins unaire

Supposons que nous ayons un point nommé «p» et que nous annulions ses coordinations en utilisant quelque chose comme «-p» . Ensuite, il ne nous reste plus qu'à définir une fonction opérateur nommée unaryMinus on Point:

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

Ensuite, chaque fois que nous ajoutons un préfixe «-» avant une instance de Point , le compilateur le traduit en un appel de fonction unaryMinus :

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

3.3. Incrément

Nous pouvons incrémenter chaque coordonnée d'une unité simplement en implémentant une fonction opérateur nommée inc :

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

L' opérateur de suffixe «++» retourne d'abord la valeur actuelle, puis augmente la valeur de un:

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

Au contraire, l' opérateur préfixe «++» augmente d'abord la valeur puis renvoie la valeur nouvellement incrémentée:

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

De plus, puisque l' opérateur «++» réassigne la variable appliquée, nous ne pouvons pas utiliser val avec eux.

3.4. Décrémenter

Tout à fait similaire à l'incrémentation, nous pouvons décrémenter chaque coordonnée en implémentant la fonction d'opérateur dec :

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

dec prend également en charge la sémantique familière pour les opérateurs pré et post décrémentation comme pour les types numériques réguliers:

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

De plus, comme ++, nous ne pouvons pas utiliser - avec val s .

3.5. ne pas

Que diriez-vous de retourner les coordonnées juste par ! P ? Nous pouvons le faire sans :

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

En termes simples, le compilateur traduit tout «! P» en un appel de fonction à la fonction opérateur unaire «non» :

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

4. Surcharge pour les opérations binaires

Les opérateurs binaires, comme leur nom l'indique, sont ceux qui fonctionnent sur deux opérandes . Ainsi, les fonctions surchargeant les opérateurs binaires doivent accepter au moins un argument.

Commençons par les opérateurs arithmétiques.

4.1. Plus opérateur arithmétique

Comme nous l'avons vu précédemment, nous pouvons surcharger les opérateurs mathématiques de base dans Kotlin. Nous pouvons utiliser «+» pour ajouter deux points ensemble:

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

Ensuite, nous pouvons écrire:

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

Puisque plus est une fonction d'opérateur binaire, nous devons déclarer un paramètre pour la fonction.

Maintenant, la plupart d'entre nous ont connu l'inélégance d'ajouter deux BigInteger :

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

En fin de compte, il existe un meilleur moyen d'ajouter deux BigIntegers dans Kotlin:

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

Cela fonctionne car la bibliothèque standard Kotlin elle-même ajoute sa juste part d'opérateurs d'extension sur des types intégrés tels que BigInteger .

4.2. Autres opérateurs arithmétiques

Semblable à plus , la soustraction , la multiplication , la division et le reste fonctionnent de la même manière:

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)

Ensuite, le compilateur Kotlin traduit tout appel en «-» , «*» , «/» ou «%» en «moins» , «times» , «div» ou «rem» , respectivement:

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

Ou, que diriez-vous de mettre à l'échelle un point par un facteur numérique:

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

De cette façon, nous pouvons écrire quelque chose comme «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.

Ainsi, avant d'ajouter un nouvel opérateur à un type particulier, demandez d'abord si l'opérateur est sémantiquement un bon ajustement pour ce que nous essayons de réaliser. Ou demandez si nous pouvons obtenir le même effet avec des abstractions normales et moins magiques.

6. Conclusion

Dans cet article, nous en avons appris davantage sur les mécanismes de la surcharge des opérateurs dans Kotlin et sur la façon dont il utilise un ensemble de conventions pour y parvenir.

L'implémentation de tous ces exemples et extraits de code se trouve dans le projet GitHub.