Переопределение в Kotlin

Наследование в Kotlin начинается не с объявления программистом какого-то класса родительским. Уже самый первый родительский класс является наследником общего для всех суперкласса Any. Это наследование происходит по-умолчанию, поэтому его можно не указывать. В случае явного указания заголовок класса выглядит примерно так:

class NumInc(n: Int, gap: Int): Any() {
...

Поскольку дочерние классы наследуют свойства и функции родительского, то все, что есть у Any, есть у всех остальных классов. Не важно, непосредственно это класс-наследник или наследник наследника.

Класс Any включает три функции-члена – equals(), toString(), hashСode(). Если мы создадим пустой класс, у его объектов все-равно будут методы, унаследованные от Any:

fun main() {
    val a = Days()
    val b = Days()
 
    println(a.equals(b)) // или a == b
    println(a.hashCode())
    println(b.hashCode())
    println(a.toString())
    println(a)
}
 
class Days {}

Пример выполнения программы:

false
1018547642
1456208737
Days@3cb5cdba
Days@3cb5cdba

Метод toString() в данном случае можно не вызывать, так как функция println() делает это сама. Этот метод отвечает за преобразование объекта, каких-то данных о нем, к строке. Он должен возвращать строку. В классе Any запрограммирован возврат строки, содержащей имя класса и через "собаку" по всей видимости адрес объекта в памяти. Нас может не устраивать такое положение дел, лучше бы, чтобы toString() выводит что-нибудь более полезное.

Для этого нам потребуется переопределить в нашем классе функцию-член родительского класса. Это значит, что мы создаем в своем классе метод с таким же именем, что и в родительском, а тело метода пишем свое. И когда объект будет вызывать этот метод, будет использована функция-член своего класса, а не родительского.

Если в классе начать писать имя метода родительского класса, то IntelliJ IDEA сама предложит его для переопределения.

После подтверждения код класса будет выглядеть так:

open class NumInc(n: Int, gap: Int) {
    var number = n
    var step = gap
 
    fun inc() {number += step}
    fun dec() {number -= step}
 
    override fun toString(): String {
        return super.toString()
    }
}

Слово override говорит, что данная функция переопределяет родительскую. String после двоеточия – тип возвращаемых функцией данных, должна возвращаться строка.

В теле автоматически сгенерированной функции происходит обращение к одноименной функции-члену родительского класса. Происходит это через ключевое слово super, которое обозначает родительский класс. Из него мы получаем строку, строка возвращается в функцию toString() класса NumInc, а уже отсюда с помощью return она передается в место, откуда вызывался данный метод.

В таком варианте, несмотря на то, что мы переопределили функцию в дочернем классе, ничего не изменилось. Как по большому счету выполнялось тело функции родительского класса, так оно и будет выполняться. Просто вызов идет не напрямую, а опосредовано через дочерний класс.

Пусть мы хотим, чтобы метод toString() возвращал информацию о текущих значениях свойств объектов. Для этого в теле метода удалим вызов аналогичного метода родительского класса и напишем свое тело:

open class NumInc(n: Int, gap: Int) {
    var number = n
    var step = gap
 
    fun inc() {number += step}
    fun dec() {number -= step}
 
    override fun toString(): String {
        val n = "number = $number"
        val s = "step = $step"
        return "$n \n $s"
    }
}

Если main будет таким,

fun main() {
    val a = NumInc(0, 1)
    val b = NumInc(12, 2)
    println(a.toString())
    println(b)
}

результат выполнения таким.

number = 0 
 step = 1
number = 12 
 step = 2

Мы можем переопределять не только методы класса Any, но и своего родительского в его дочернем. Пусть нам нужен класс подобный NumInc, но функции inc() и dec() которого уменьшают и увеличивают number на двойной шаг. В этом случае, если этот класс будет дочерним от NumInc, нам придется переопределить функции inc() и dec():

class NumDouble(n: Int, gap: Int): NumInc(n, gap) {
 
    override fun inc() {
        number += step * 2
    }
 
    override fun dec() {
        number -= step * 2
    }
}

Однако мы не сможем этого сделать до тех пор, пока в родительском классе не разрешим переопределять методы с этими именами. Для этого следует объявить как открытые с помощью слова open:

open class NumInc(n: Int, gap: Int) {
    var number = n
    var step = gap
 
    open fun inc() {number += step}
    open fun dec() {number -= step}
    ...
}

Другими словами, в Kotlin, чтобы иметь возможность переопределять методы, недостаточно, чтобы их класс был открытым. Необходимо дополнительно указывать такую возможность для каждого метода. По-умолчанию методы вместо модификатора open имеют модификатор final. Мы его не пишем.

Однако, если дочерний класс переопределяет метод родительского, то в нем open не пишется. Вместо этого стоит override. При этом метод остается открытым. Если у дочернего будет свой дочерний, то он сможет переопределить такой метод. Если требуется исключить возможность дальнейшего переопределения, то перед override пишут final.

В примере на скрине мы объявили класс NumDouble открытым и создали от него дочерний NumD2. В нем мы можем переопределить метод inc() и не можем сделать это с dec(), потому что в NumDouble объявили метод финальным.

Бывает так, что код переопределяемого метода не сильно отличается от того, что используется в родительском классе. Нам нужно его лишь дополнить. В этом случае из тела метода дочернего класса, вызывается родительский метод через слово super. После того, как он возвращает поток выполнения в метод дочернего класса, здесь выполняется дополнительный код. Подобное мы уже видели, переопределяя toString().

Давайте сделаем это в нашем классе NumDouble, хотя в данном случае большого смысла в этом нет. Тело класса очень простое.

open class NumDouble(n: Int, gap: Int): NumInc(n, gap) {
 
    override fun inc() {
        super.inc()
        number += step
    }
 
    override fun dec() {
        super.dec()
        number -= step
    }
}

Здесь мы изменяем number на один шаг через родительский метод, потом делаем это еще раз уже в дочернем.

Следует отметить, что переопределять можно не только функции-члены класса, но и свойства. Однако нельзя изменить их тип. Поэтому такое переопределение обычно имеет место, когда используются инициирующие значения для полей, и в дочернем классе эти значения должны быть не такими, каковы они в родительском.

open class NumInc {
    open var number = 0
    open var step = 1
 
    open fun inc() {number += step}
    open fun dec() {number -= step}
}
 
open class NumDouble: NumInc() {
    override var number = 1
    override var step = 2
}

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

Пусть свойство step родительского класса NumInc имеет сеттер, проверяющий число на неравенство нулю (нулевой шаг недопустим). В дочернем классе NumDouble такая проверка не нужна. Наследуется ли сеттер, следует ли его переопределять?

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


Введение в объектно-ориентированное программирование на Kotlin




Все разделы сайта