Перегрузка операторов

В языках программирования есть множество различных операторов. Например, есть операторы для математических операций (+, - и др.), сравнения (==, > и др.), извлечения элемента по индексу с помощью квадратный скобок.

В ряде случаев эти операторы используются для разных типов данных. Так в случае оператора "+" операндами могут быть как числа, так и строки (3 + 4, "hello " + "world"). Также оператор индекса используется как для строки, так массива и списка.

В Kotlin операторы – это своего рода сокращенный вариант записи методов своих классов. Причем на уровне языка имеется соглашение, как этот метод должен называться. Так, если класс имеет метод plus(), то для его вызова можно использовать знак "+".

fun main() {
    val a = 10
    println(a.plus(12))
    println(a + 12)
 
    val b = "Hello"
    println(b.plus(" world"))
    println(b + " world")
}

Результат выполнения программы:

22
22
Hello world
Hello world

В данном примере видим, что вызов метода plus() и результат операции сложения дают один и тот же результат для одинаковых исходных данных и разный для разных типов данных. Причем разный в первую очередь по смыслу. Сложение строк – это вовсе не сложение, а банальное присоединение второй к первой. Все это значит, что "+" – это то же самое, что plus(), а то, что делает plus(), то есть код тела этого метода, зависит от класса.

Наличие в разных классах методов с одинаковыми именами, но разным кодом, – проявление так называемого полиморфизма – еще одной ключевой концепции объектно-ориентированного программирования, наряду с наследованием и инкапсуляцией. Внешне полиморфизм выглядит так, как будто одна и та же функция способна работать с разными типами данных.

Соответственно, и для своего класса мы можем определить метод plus() со своим телом.

class NumInc(var number: Int, var step: Int) {
    fun inc() {number += step}
    fun dec() {number -= step}
 
    operator fun plus(numInc: NumInc): NumInc {
        val n = number + numInc.number
        val s = step + numInc.step
        return NumInc(n, s)
    }
}
fun main() {
    val a = NumInc(10, 2)
    val b = NumInc(6, 4)
 
    val c = a + b
    val d = a.plus(b)
    val e = b.plus(NumInc(20, -5))
    val f = NumInc(1,2) + b
 
    println("${c.number}, ${c.step}") // 16, 6
}

В функции main() показаны разные способы вызова метода plus(): через оператор +, обращением по имени метода, когда в функцию объект передается через переменную и напрямую.

Рассмотрим метод plus() нашего класса:

...
operator fun plus(numInc: NumInc): NumInc {
    val n = number + numInc.number
    val s = step + numInc.step
    return NumInc(n, s)
}
...

В каком-то смысле мы переопределяем оператор "+" по аналогии с тем, как переопределяли родительские функции-члены в дочерних классах. Только здесь вместо ключевого слова override используется operator.

На самом деле это не переопределение, потому что в родительском классе (в данном случае Any) такого метода нет. Мы просто следуем соглашению, что если у класса есть методы, определение которых начинается со слова operator, то они переопределяют какие-то существующие в языке операции. Поэтому обычно используют термин "перегрузка операторов", а не "переопределение операторов".

Метод-оператор plus() языка Kotlin должен обязательно иметь один параметр. При этом не обязательно, чтобы он был того же класса (в нашем случае он того же). Не обязателен и возврат из функции значения. Вот пример другой реализации plus():

operator fun plus(n: Int) {
    number += n
}

Такой метод будет вызываться, когда к объекту класса NumInс будут прибавлять обычное целое число. Компилятор языка присвоит это число параметру n.

В классе могут быть два метода с одинаковыми именами. (Это касается не только методов, перегружающих операторы языка.) В данном контексте класс может выглядеть так:

class NumInc(var number: Int, var step: Int) {
    fun inc() {number += step}
    fun dec() {number -= step}
 
    operator fun plus(numInc: NumInc): NumInc {
        val n = number + numInc.number
        val s = step + numInc.step
        return NumInc(n, s)
    }
 
    operator fun plus(n: Int) {
        number += n
    }
}

Какая именно функция plus() будет вызвана, зависит от передаваемого аргумента. Если это объект NumInc, будет вызываться первая, если целое число – вторая.

fun main() {
    val a = NumInc(10, 2)
    val b = NumInc(6, 4)
    val c = a + b
    a + 3
    println("${c.number}, ${c.step}") // 16, 6
    println("${a.number}, ${a.step}") // 13, 2
}

Обратите внимание, вторая функция plus() не создает нового объекта, она меняет имеющийся.

В программировании подобное явление, когда функция с одним именем имеет несколько реализаций в одном классе, называется перегрузкой функций. Это явление также можно отнести к концепции полиморфизма. Еще раз отметим, что перегружать можно любую функцию, а не только функцию-оператор. Также подобное мы уже видели у конструкторов. У класса может быть несколько конструкторов, но вызывается только один, в зависимости от того, какие переданы аргументы.

Если родительский класс имеет метод, перегружающий оператор, то в дочернем этот метод уже не перегружается, а переопределяется (если возникает такая необходимость), то есть его надо объявлять через слово override.

open class NumInc(var number: Int, var step: Int) {
    fun inc() {number += step}
    fun dec() {number -= step}
 
    open operator fun plus(n: Int) {
        number += n
    }
}
class NumDouble (n: Int, s: Int): NumInc(n, s) {
 
    override fun plus(n: Int) {
        number += 2 * n
    }
}
fun main() {
    val a = NumDouble(3, 1)
    a + 5
    println("${a.number}") // 13
}

Подобное происходит и с методом equals(), который перегружает оператор проверки на равенство (==). Он уже определен в классе Any, поэтому, если мы хотим переопределить его, делать это надо через override.

class NumInc(var number: Int, var step: Int) {
    fun inc() {number += step}
    fun dec() {number -= step}
 
    operator fun plus(n: Int) {
        number += n
    }
 
    override fun equals(other: Any?): Boolean {
        if (other is NumInc) {
            return number == other.number
        }
        else
            return false
    }
}
fun main() {
    val a = NumInc(3, 1)
    val b = NumInc(3, 2)
    val c: Any? = 3
 
    println(a == b) // true
    println(a == c) // false
    println(a == NumInc(4,1)) // false
}

Рассмотрим перегрузку извлечения элемента по индексу и присвоение значения по индексу с помощью квадратных скобок, то есть перегрузку оператора индекса. Пусть к полям нашего класса можно будет обращаться не только по их именам, но и по индексам. Полю number будет соответствовать индекс 0, а step – индекс 1.

Имя функции-оператора для извлечения по индексу – get(). Она должна иметь один параметр – в нашем контексте индекс элемента. У функции присвоения по индексу – set() – должно быть как минимум два параметра. Первый – в данном случае индекс элемента, второй – присваиваемое значение.

class NumInc(var number: Int, var step: Int) {
    fun inc() {number += step}
    fun dec() {number -= step}
 
    operator fun get(i: Int): Int? {
        when (i) {
            0 -> return number
            1 -> return step
            else -> return null
        }
    }
 
    operator fun set(i: Int, v: Int) {
        when (i) {
            0 -> number = v
            1 -> step = v
        }
    }
}
fun main() {
    val a = NumInc(3, 1)
    println("${a.number}, ${a.step}") // 3, 1
    println("${a[0]}, ${a[1]}") // 3, 1
 
    a[0] = 10
    a[1] = 2
    println("${a[0]}, ${a[1]}") // 10, 2
}

Обратите внимание, что перегружающие оператор индекса методы get() и set() не имеют отношения к геттерам и сеттерам полей свойств.

Перечень всех операторов, которые можно перегрузить в Kotlin, и имена соответствующих им методов можно посмотреть в официальной документации языка: https://kotlinlang.org/docs/reference/operator-overloading.html

Практическая работа:

В нашем классе NumInc функции inc() и dec() не переопределяют никаких операторов. Это обычные функции-члены класса. Однако в Kotlin есть операции инкремента (++) и декремента (--). По отношению к целым числам инкремент увеличивает значение переменной на единицу, декремент – уменьшает. Например, если i равна пяти, то выражение i++ сделает i равной шести.

Операциям инкремента и декремента в Kotlin соответствуют функции с именами inc() и dec(). Измените функции-члены inc() и dec() нашего класса так, чтобы они перестали быть "обычными", а перегружали соответствующие им операторы. При этом инкремент экземпляра класса NumInc должен также выполнять увеличение number на step, декремент – уменьшение на step.

Могут ли в классе быть одноименные методы, один из которых перегружает оператор, а другой – нет?

PDF-версия курса с ответами к практическим работам

Приложение для Android "Kotlin. Курс"