Operator Overloading di Kotlin

1. Ikhtisar

Dalam tutorial ini, kita akan membahas tentang konvensi yang disediakan Kotlin untuk mendukung overloading operator.

2. Kata Kunci Operator

Di Java, operator terikat pada tipe Java tertentu. Misalnya, tipe String dan numerik di Java dapat menggunakan operator + untuk penggabungan dan penjumlahan. Tidak ada tipe Java lain yang dapat menggunakan kembali operator ini untuk keuntungannya sendiri. Kotlin, sebaliknya, menyediakan sekumpulan konvensi untuk mendukung Operator Overloading terbatas .

Mari kita mulai dengan kelas data sederhana :

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

Kami akan meningkatkan kelas data ini dengan beberapa operator.

Untuk mengubah fungsi Kotlin dengan nama yang telah ditentukan sebelumnya menjadi operator, kita harus menandai fungsi tersebut dengan operator modifier. Misalnya, kita dapat membebani operator "+" :

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

Dengan cara ini kita dapat menambahkan dua Poin dengan “+” :

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

3. Overloading untuk Operasi Unary

Operasi unary adalah operasi yang bekerja hanya pada satu operan . Misalnya, -a, a ++ atau ! A adalah operasi unary. Umumnya, fungsi yang akan membebani operator unary tidak mengambil parameter.

3.1. Unary Plus

Bagaimana membangun suatu Bentuk dengan beberapa Poin :

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

Di Kotlin, hal itu sangat mungkin dilakukan dengan fungsi operator unaryPlus .

Karena Shape hanyalah kumpulan Poin , maka kita dapat menulis kelas, membungkus beberapa Poin dengan kemampuan untuk menambahkan lebih banyak:

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

Dan perhatikan bahwa yang memberi kami sintaks bentuk {…} adalah menggunakan Lambda dengan Penerima :

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

3.2. Unary Minus

Misalkan kita memiliki Titik bernama "p" dan kita akan meniadakan koordinatnya menggunakan sesuatu seperti "-p" . Kemudian, yang harus kita lakukan adalah mendefinisikan fungsi operator bernama unaryMinus di Point:

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

Kemudian, setiap kali kita menambahkan awalan “-“ sebelum instance Point , kompilator menerjemahkannya menjadi pemanggilan fungsi unaryMinus :

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

3.3. Kenaikan

Kita dapat menambah setiap koordinat dengan satu hanya dengan mengimplementasikan fungsi operator bernama inc :

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

Operator “++” postfix , pertama-tama mengembalikan nilai saat ini dan kemudian meningkatkan nilainya satu kali:

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

Sebaliknya, operator awalan "++" , pertama-tama meningkatkan nilainya, lalu mengembalikan nilai yang baru ditambahkan:

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

Juga, karena operator "++" menetapkan kembali variabel yang diterapkan, kita tidak dapat menggunakan val dengan mereka.

3.4. Pengurangan

Mirip dengan increment, kita dapat mengurangi setiap koordinat dengan mengimplementasikan fungsi operator dec :

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

dec juga mendukung semantik yang sudah dikenal untuk operator pra dan pasca penurunan seperti untuk tipe numerik biasa:

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

Juga, seperti ++ kita tidak dapat menggunakan - dengan val s .

3.5. Tidak

Bagaimana kalau membalik koordinat hanya dengan ! P ? Kita bisa melakukan ini dengan tidak :

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

Sederhananya, kompilator menerjemahkan "! P" apa pun menjadi panggilan fungsi ke fungsi operator unary "bukan" :

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

4. Overloading untuk Operasi Biner

Operator biner, seperti namanya, adalah operator yang bekerja pada dua operan . Jadi, fungsi yang membebani operator biner harus menerima setidaknya satu argumen.

Mari kita mulai dengan operator aritmatika.

4.1. Ditambah Operator Aritmatika

Seperti yang kita lihat sebelumnya, kita dapat membebani operator matematika dasar di Kotlin. Kita dapat menggunakan "+" untuk menambahkan dua Poin bersama-sama:

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

Lalu kita bisa menulis:

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

Karena plus adalah fungsi operator biner, kita harus mendeklarasikan parameter untuk fungsi tersebut.

Sekarang, sebagian besar dari kita pernah mengalami inelegance untuk menambahkan dua BigInteger :

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

Ternyata, ada cara yang lebih baik untuk menambahkan dua BigInteger di Kotlin:

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

Ini berfungsi karena pustaka standar Kotlin sendiri menambahkan operator ekstensi yang adil pada tipe bawaan seperti BigInteger .

4.2. Operator Aritmatika Lainnya

Mirip dengan plus , pengurangan , perkalian , pembagian, dan sisanya bekerja dengan cara yang sama:

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)

Kemudian, compiler Kotlin menerjemahkan setiap panggilan menjadi "-" , "*" , "/", atau "%" menjadi "minus" , "times" , "div", atau "rem" , masing-masing:

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

Atau, bagaimana jika menskalakan Poin dengan faktor numerik:

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

Dengan cara ini kita dapat menulis sesuatu seperti "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.

Jadi, sebelum menambahkan operator baru ke jenis tertentu, pertama-tama, tanyakan apakah operator secara semantik cocok untuk apa yang ingin kita capai. Atau tanyakan apakah kita dapat mencapai efek yang sama dengan abstraksi normal dan kurang magis.

6. Kesimpulan

Dalam artikel ini, kita mempelajari lebih lanjut tentang mekanisme operator overloading di Kotlin dan bagaimana ia menggunakan sekumpulan konvensi untuk mencapainya.

Penerapan semua contoh dan cuplikan kode ini dapat ditemukan di proyek GitHub.