Files
obsidian/Kotlin/ООП/Наследование.md
T

14 KiB
Raw Blame History

title, description, tags
title description tags
Наследование open, вызов конструктора базового класса, наследование свойств и выбор в пользу композиции
kotlin
oop

Класс закрыт для наследования, пока не объявлен open.

open class Animal(val name: String) {
    open fun sound() = "..."
    fun describe() = "$name говорит ${sound()}"      // final: переопределить нельзя
}

class Dog(name: String) : Animal(name) {
    override fun sound() = "Гав"
}

fun main() {
    println(Dog("Рекс").describe())      // Рекс говорит Гав
}

describe() не open, но вызывает sound(), который переопределён - это и есть полиморфизм: шаблон поведения в базовом классе, детали в подклассе.

Вызов конструктора базового класса

Указывается в заголовке:

open class Base12(val id: Int)

class Derived12(id: Int, val name: String) : Base12(id)

fun main() {
    val d = Derived12(1, "Иван")
    println("${d.id} ${d.name}")      // 1 Иван
}

Аргументы можно вычислять:

open class Shape2(val area: Double)

class Circle2(val radius: Double) : Shape2(Math.PI * radius * radius)

fun main() {
    println("%.2f".format(java.util.Locale.US, Circle2(1.0).area))   // 3.14
}

Если у подкласса нет основного конструктора, базовый вызывается из каждого вторичного через super:

open class Base13(val source: String)

class Derived13 : Base13 {
    constructor(file: java.io.File) : super("файл ${file.name}")
    constructor(url: String, port: Int) : super("$url:$port")
}

fun main() {
    println(Derived13("http://x.ru", 80).source)      // http://x.ru:80
}

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

open class Base14
class Derived14 : Base14()       // скобки = вызов конструктора

interface Marker2
class Impl3 : Marker2            // у интерфейса скобок нет

fun main() { println(Derived14()::class.simpleName) }   // Derived14

Наличие скобок различает класс и интерфейс в списке супертипов.

Что можно наследовать

open class Base15 {
    open val version: Int = 1
    open var state: String = "новый"
    open fun run2() = "база"
    val fixed = "не переопределить"          // не open
}

class Derived15 : Base15() {
    override val version = 2
    override var state: String = "изменённый"
    override fun run2() = "подкласс"
}

fun main() {
    val d: Base15 = Derived15()
    println("${d.version} ${d.state} ${d.run2()} ${d.fixed}")
    // 2 изменённый подкласс не переопределить
}

open нужен каждому члену отдельно: открытый класс не делает своих членов открытыми.

val можно переопределить как var

open class Base16 {
    open val value: Int = 1
}

class Derived16 : Base16() {
    override var value: Int = 2       // добавили сеттер
}

fun main() {
    val d = Derived16()
    d.value = 5
    println(d.value)      // 5
}

Обратное запрещено: var нельзя сузить до val - это сломало бы код, который присваивает значение.

Переопределение свойства геттером

open class Base17 {
    open val size: Int = 0
}

class Derived17(private val items: List<Int>) : Base17() {
    override val size: Int get() = items.size     // вычисляемое вместо хранимого
}

fun main() {
    println(Derived17(listOf(1, 2, 3)).size)      // 3
}

Свойство в конструкторе подкласса

open class Base18 {
    open val name: String = "базовое"
}

class Derived18(override val name: String) : Base18()

fun main() {
    println(Derived18("своё").name)      // своё
}

override прямо в параметре конструктора - короткая и частая форма.

Абстрактный член не требует open

abstract class Shape3 {
    abstract val area: Double             // open подразумевается
    abstract fun describe(): String
    open fun show() = "фигура: ${describe()}"
}

class Square2(val side: Double) : Shape3() {
    override val area get() = side * side
    override fun describe() = "квадрат со стороной $side"
}

fun main() {
    println(Square2(2.0).show())      // фигура: квадрат со стороной 2.0
}

Подробнее: Абстрактные классы

Одиночное наследование, множественные интерфейсы

open class Entity(val id: Int)
interface Serializable2 { fun serialize(): String }
interface Validatable { fun validate(): Boolean }

class User4(id: Int, val name: String) : Entity(id), Serializable2, Validatable {
    override fun serialize() = """{"id":$id,"name":"$name"}"""
    override fun validate() = name.isNotBlank()
}

fun main() {
    val u = User4(1, "Иван")
    println(u.serialize())      // {"id":1,"name":"Иван"}
    println(u.validate())       // true
}

Класс в списке супертипов идёт первым - это соглашение, а не требование компилятора.

Что наследовать нельзя

class Final2                                   // закрыт по умолчанию
data class Point3(val x: Int)                  // data class не может быть open
@JvmInline value class Id2(val value: Int)     // value class тоже
object Singleton2                              // object
enum class Status2 { OK }                       // enum
sealed class Limited                            // только в своём модуле и пакете

fun main() { println("перечислены закрытые формы") }

Для enum есть исключение: отдельная константа может переопределить метод, образуя анонимный подкласс. Подробнее: Enum class

Композиция вместо наследования

Наследование связывает подкласс с реализацией базового класса. Изменение базового класса может сломать подкласс, даже если публичный контракт не менялся - это проблема хрупкого базового класса.

Классический пример - подсчёт добавленных элементов:

// плохо: наследование от реализации
class CountingList<T> : ArrayList<T>() {
    var added = 0
    override fun add(element: T): Boolean {
        added++
        return super.add(element)
    }
    override fun addAll(elements: Collection<T>): Boolean {
        added += elements.size
        return super.addAll(elements)      // внутри может вызвать add()!
    }
}

fun main() {
    val list = CountingList<Int>()
    list.addAll(listOf(1, 2, 3))
    println("добавлено: ${list.added}, реально: ${list.size}")
}

На текущем JDK выведется добавлено: 3, реально: 3 - потому что ArrayList.addAll копирует массив напрямую и add не вызывает. Но это не гарантия: реализация, которая реализует addAll через цикл с add, удвоит счётчик. Корректность класса зависит от внутренностей базового, а они не входят в его контракт и могут измениться между версиями JDK.

Композиция такой зависимости не создаёт:

class CountingList2<T> {
    private val items = mutableListOf<T>()
    var added = 0
        private set

    fun add(element: T) { added++; items.add(element) }
    fun addAll(elements: Collection<T>) { elements.forEach { add(it) } }
    val size: Int get() = items.size
}

fun main() {
    val list = CountingList2<Int>()
    list.addAll(listOf(1, 2, 3))
    println("добавлено: ${list.added}, реально: ${list.size}")   // 3, 3
}

Kotlin делает композицию такой же короткой, как наследование, - через делегирование by. Подробнее: Делегирование

[!info] Отличия от Java

Java Kotlin
class A extends B class A : B()
implements I та же запись через запятую
класс открыт, final закрывает закрыт, open открывает
метод виртуален по умолчанию финален по умолчанию
super(args) первой строкой тела : Base(args) в заголовке
@Override опциональна override обязательно
нельзя переопределить поле свойство переопределяется

Закрытость по умолчанию - осознанный выбор. Проектировать класс под наследование дорого: нужно документировать, какие методы вызывают какие, и поддерживать это между версиями. Java по умолчанию делает всё наследуемым, из-за чего библиотечные классы либо получают final, либо навсегда остаются связанными своими внутренностями.

Переопределение свойств в Java невозможно - поля скрываются, а не переопределяются:

class Base { int value = 1; }
class Derived extends Base { int value = 2; }   // сокрытие, не переопределение
// ((Base) new Derived()).value == 1

Обращение по ссылке базового типа даст поле базового класса. В Kotlin свойство - это методы доступа, поэтому override работает полиморфно и такой ловушки нет.

Сравнить: Наследование в Java, Сокрытие

[!warning] Подводные камни open у класса не открывает его члены - каждый метод и свойство нужно помечать отдельно.

Переопределённый метод остаётся open. Чтобы закрыть ветку иерархии, нужен final override:

open class A4 { open fun f() = "A" }
open class B4 : A4() { final override fun f() = "B" }
// class C4 : B4() { override fun f() = "C" }    // ошибка: f закрыт

fun main() { println(B4().f()) }      // B

Вызов open-метода из конструктора видит неинициализированный подкласс:

open class Base19 {
    init { println("из init: ${describe()}") }
    open fun describe() = "база"
}

class Derived19 : Base19() {
    private val name: String = "Иван"
    override fun describe() = "подкласс, name=$name"
}

fun main() { Derived19() }    // из init: подкласс, name=null

Подробнее: Инициализация

Скобки обязательны при наследовании класса: : Base(), а не : Base. Без скобок компилятор ищет интерфейс.

protected в Kotlin не даёт доступа из пакета - только из подклассов. Код, перенесённый из Java, может перестать компилироваться.

var нельзя переопределить как val - только наоборот.

Наследование от классов стандартной библиотеки хрупко. ArrayList, HashMap и подобные не документируют, какие методы вызывают друг друга внутри. Используйте делегирование.

Глубокие иерархии трудно читать. Чтобы понять поведение класса в иерархии из пяти уровней, нужно просмотреть все пять. Практическое правило: наследование - для «является» и когда базовый класс спроектирован под него; во всех остальных случаях - композиция.

Нельзя наследоваться от data class, поэтому иерархии носителей данных строят на sealed-интерфейсах с data class-реализациями.

Дополнительно: Переопределение Далее: Переопределение