Surcharge des opérateurs à Kotlin

Surcharge des opérateurs à Kotlin

1. Vue d'ensemble

Dans ce didacticiel, nous allons parler des conventions fournies par Kotlin pour prendre en charge la surcharge des opérateurs.

2. Le mot-cléoperator

En Java, les opérateurs sont liés à des types Java spécifiques. Par exemple,String et les types numériques 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 à son avantage. Kotlin, au contraire, fournit un ensemble de conventions pour prendre en charge desOperator Overloading limités.

Commençons par un simpledata class:

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

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

Pour transformer une fonction Kotlin avec un nom prédéfini en un opérateur,we should mark the function with the operator modifier. 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 deuxPoints 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

Unary operations are those that work on just one operand. Par exemple,-a, a++ ou!a sont des opérations unaires. Généralement, les fonctions qui vont surcharger les opérateurs unaires ne prennent aucun paramètre.

3.1. Unary Plus

Que diriez-vous de construire unShape d'une sorte avec quelquesPoints:

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érateurunaryPlus.

Comme unShape n'est qu'une collection dePoints, alors nous pouvons écrire une classe, en enveloppant quelquesPoint 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 syntaxeshape \{…} était d'utiliser unLambda avecReceivers:

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

    return shape
}

3.2. Unary Minus

Supposons que nous ayons unPoint nommé“p” et que nous annulions ses coordonnées en utilisant quelque chose comme“-p”. Ensuite, il suffit de définir une fonction opérateur nomméeunaryMinus surPoint:

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

Ensuite, chaque fois que nous ajoutons un préfixe“-“ avant une instance dePoint, le compilateur le traduit en un appel de fonctionunaryMinus:

>> 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éeinc:

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 du préfixe“++”, augmente d'abord la valeur puis renvoie la valeur nouvellement incrémentée:

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

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

3.4. Décrémenter

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

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++ we ne peut pas utiliser avecvals _._

3.5. Not

Que diriez-vous de retourner les coordonnées juste par!p? We can do this with not:

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“not”:

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

4. Surcharge pour les opérations binaires

Binary operators, as their name suggests, are those that work on two operands. 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 des opérateurs mathématiques de base en Kotlin. Nous pouvons utiliser“+” pour ajouter deuxPoints 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)

Puisqueplus 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'additionner deuxBigIntegers:

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

As it turns out, il existe une meilleure façon d'ajouter deuxBigIntegers dans Kotlin:

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

Cela fonctionne car lesKotlin standard library itself adds its fair share of extension operators on built-in types like BigInteger.

4.2. Autres opérateurs arithmétiques

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

Ensuite, le compilateur Kotlin traduit tout appel en“-“,“*”,“/”, or “%” en“minus”,“times”,“div”, or “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 unPoint 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)

Comme on peut le voir dans l'exemple précédent, il n'y a aucune obligation pour que deux opérandes soient du même type. The same is true for return types.

4.3. Commutativité

Les opérateurs surchargés ne sont pas toujourscommutative. C'est-à-direwe can’t swap the operands and expect things to work as smooth as possible.

Par exemple, nous pouvons mettre à l'échelle unPoint par un facteur entier en le multipliant par unInt, disons“p1 * 2”, mais pas l'inverse.

La bonne nouvelle est que nous pouvons définir des fonctions d'opérateur sur les types intégrés Kotlin ou Java. Afin de faire fonctionner les“2 * p1”, nous pouvons définir un opérateur surInt:

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

Maintenant, nous pouvons également utiliser avec plaisir“2 * p1”:

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

4.4. Assignations composées

Maintenant que nous pouvons ajouter deuxBigIntegers avec le“”_ operator, we may be able to use the compound assignment for _“” qui est“+=”. Essayons cette idée:

var one = BigInteger.ONE
one += one

Par défaut, lorsque nous implémentons l'un des opérateurs arithmétiques, disons“plus”, Kotlin prend non seulement en charge l'opérateur _ "" _ familier, * il fait également la même chose pour l '_ affectation composée_ correspondante, qui est "=". *

Cela signifie que, sans plus de travail, nous pouvons également faire:

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

Mais parfois, ce comportement par défaut n'est pas ce que nous recherchons. Supposons que nous allons utiliser“+=” pour ajouter un élément à unMutableCollection. 

Pour ces scénarios, nous pouvons être explicites à ce sujet en implémentant une fonction opérateur nomméeplusAssign:

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. Autrement dit, il y aplusAssign, minusAssign, timesAssign, divAssign, etremAssign:

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

Toutes les fonctions d'opérateur d'affectation composée doivent renvoyerUnit.

4.5. Equals Convention

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

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 traduit tout appel aux opérateurs“==” et“!=” en un appel de fonctionequals, évidemment afin de faire fonctionner le“!=”, le résultat de l'appel de fonction est inversé. Note that in this case, we don’t need the operator keyword.

4.6. Opérateurs de comparaison

Il est temps de revenir surBigInteger!

Supposons que nous allons exécuter une logique conditionnelle si unBigInteger est supérieur à l'autre. En Java, la solution n’est pas tout à fait propre:

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

Lorsque vous utilisez les mêmesBigInteger dans Kotlin, nous pouvons par magie écrire ceci:

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

Cette magie est possible carKotlin has a special treatment of Java’s Comparable.

En termes simples, nous pouvons appeler la méthodecompareTo dans l'interfaceComparable par quelques conventions Kotlin. En fait, toute comparaison faite par «<“, “⇐”, “>”, ou“>=” serait traduite en un appel de fonctioncompareTo.

Afin d'utiliser des opérateurs de comparaison sur un type Kotlin, nous devons implémenter son interfaceComparable:

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
}

Ensuite, nous pouvons comparer des valeurs monétaires aussi simples que:

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

Puisque la fonctioncompareTo dans l'interfaceComparable est déjà marquée avec le modificateuroperator, nous n'avons pas besoin de l'ajouter nous-mêmes.

4.7. En convention

Afin de vérifier si un élément appartient à unPage, nous pouvons utiliser la convention“in”:

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

Encore une fois,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

L'objet sur le côté gauche de“in” sera passé comme argument àcontains et la fonctioncontains sera appelée sur l'opérande du côté droit.

4.8. Obtenir l'indexeur

Indexers allow instances of a type to be indexed just like arrays or collections. Supposons que nous allons modéliser une collection paginée d'éléments en tant quePage<T>, en arrachant sans vergogne une idée àSpring Data:

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

Normalement, pour récupérer un élément d'unPage, we, il faut d'abord appeler la fonctionelements:

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

Puisque lePage lui-même n'est qu'un wrapper sophistiqué pour une autre collection, nous pouvons utiliser les opérateurs d'indexeur pour améliorer son API:

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

Le compilateur Kotlin remplace toutpage[index] sur unPage par un appel de fonctionget(index):

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

On peut aller encore plus loin en ajoutant autant d'arguments que l'on veut à la déclaration de la méthodeget.

Supposons que nous récupérions une partie de la collection encapsulée:

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

Ensuite, nous pouvons découper unPage comme:

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

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

4.9. Définir l'indexeur

En plus d'utiliser des indexeurs pour implémenterget-like semantics,we can utilize them to mimic set-like operations également. Tout ce que nous avons à faire est de définir une fonction opérateur nomméeset avec au moins deux arguments:

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

Lorsque nous déclarons une fonctionset avec seulement deux arguments, le premier doit être utilisé à l'intérieur du crochet et un autre après lesassignment:

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

La fonctionset peut également avoir plus de deux arguments. Si tel est le cas, le dernier paramètre est la valeur et le reste des arguments doit être passé entre crochets.

4.10. Iterator Convention

Que diriez-vous d'itérer unPage comme d'autres collections? Il suffit de déclarer une fonction opérateur nomméeiterator avecIterator<T> comme type de retour:

operator fun  Page.iterator() = elements().iterator()

Ensuite, nous pouvons parcourir unPage:

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

4.11. Convention de gamme

Dans Kotlin,we can create a range using the “..” operator. Par exemple,“1..42” crée une plage avec des nombres compris entre 1 et 42.

Il est parfois judicieux d’utiliser l’opérateur de plage sur d’autres types non numériques. The Kotlin standard library provides a rangeTo convention on all Comparables:

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

Nous pouvons utiliser ceci pour obtenir une plage de quelques jours consécutifs:

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

Comme avec les autres opérateurs, le compilateur Kotlin remplace tout“..” par un appel de fonctionrangeTo .

5. Utiliser judicieusement les opérateurs

Operator overloading is a powerful feature in Kotlin qui nous permet d'écrire des codes plus concis et parfois plus lisibles. Cependant, un grand pouvoir entraîne de grandes responsabilités.

Operator overloading can make our code confusing or even hard to read lorsqu'il est trop fréquemment utilisé ou parfois mal utilisé.

Ainsi, avant d’ajouter un nouvel opérateur à un type particulier, demandez d’abord si l’opérateur convient sémantiquement à ce que nous essayons d’obtenir. 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 d'opérateurs dans Kotlin et sur l'utilisation d'un ensemble de conventions pour y parvenir.

L'implémentation de tous ces exemples et extraits de code se trouve dans leGitHub project - c'est un projet Maven, il devrait donc être facile à importer et à exécuter tel quel.