Files
obsidian/Kotlin/ООП/ООП.md
T

13 KiB
Raw Blame History


title: ООП description: Обзор объектной модели Kotlin: что закрыто по умолчанию, чего нет совсем и почему tags:

  • kotlin
  • oop

Объектная модель Kotlin устроена так же, как в Java - один суперкласс, множество интерфейсов, виртуальные вызовы. Отличия в значениях по умолчанию и в том, что из Java убрано.

Главное отличие: всё закрыто по умолчанию

class User(val name: String) {
    fun greet() = "Привет, $name"
}

// class Admin : User("админ")    // ошибка: User закрыт для наследования

Класс нельзя унаследовать, пока он не объявлен open. Метод нельзя переопределить, пока он не open. Это противоположность Java, где final нужно писать явно.

open class User(val name: String) {
    open fun greet() = "Привет, $name"
    fun id() = name.hashCode()          // переопределить нельзя
}

class Admin(name: String) : User(name) {
    override fun greet() = "Админ $name"
    // override fun id() = 0            // ошибка: id не open
}

fun main() {
    val u: User = Admin("Иван")
    println(u.greet())      // Админ Иван — виртуальный вызов
}

Причина - проблема хрупкого базового класса. Автор класса, который не проектировал его под наследование, не может гарантировать, что подкласс не нарушит инварианты. open - это явное обещание поддерживать наследование как часть контракта.

Карта раздела

graph TD
    A["Классы и конструкторы"] --> B["Свойства"]
    B --> C["Инициализация"]
    C --> D["Модификаторы видимости"]
    D --> E["Наследование"]
    E --> F["Переопределение"]
    F --> G["Абстрактные классы"]
    G --> H["Интерфейсы"]
    H --> I["Делегирование"]
    I --> J["Вложенные классы"]
    J --> K["Полиморфизм"]
Заметка О чём
Классы и конструкторы основной и вторичные конструкторы, параметры-свойства
Свойства геттеры, сеттеры, backing field, lateinit, делегаты
Инициализация порядок выполнения init, ловушка вызова open-метода
Модификаторы видимости public, internal, protected, private
Наследование open, вызов конструктора базового класса
Переопределение override, final, super, ковариантность результата
Абстрактные классы abstract против open, выбор с интерфейсом
Интерфейсы реализация по умолчанию, свойства, ромбовидное наследование
Делегирование by для интерфейсов и свойств
Вложенные и внутренние классы nested по умолчанию, inner для ссылки на внешний
Полиморфизм подтипы, перегрузка, дженерики

Чего в Kotlin нет

Из Java Что вместо
static companion object, функции верхнего уровня
поля (field) в публичном API свойства с геттером и сеттером
checked exceptions все исключения непроверяемые, см. Система исключений
package-private (без модификатора) internal - видимость в пределах модуля
анонимные классы объект-выражение
примитивы и обёртки как разные типы один Int, упаковка по необходимости
wait/notify в Object корутины и примитивы JDK

Классы на любой вкус

Kotlin предлагает специализированные формы класса вместо одного универсального:

class Service                                    // обычный
data class Point(val x: Int, val y: Int)         // носитель данных
enum class Status { OK, FAIL }                   // фиксированный набор значений
sealed interface Result3                         // фиксированный набор типов
object Registry                                  // синглтон
@JvmInline value class UserId(val value: Int)    // обёртка без аллокации
abstract class Shape                             // частичная реализация
interface Named                                  // контракт
annotation class Marker                          // аннотация
fun interface Handler { fun handle(x: Int) }      // SAM-интерфейс

Правильный выбор формы снимает большую часть ручной работы: data class избавляет от написания equals, sealed - от else в when, object - от реализации синглтона.

Подробнее о первых пяти: Базовые типы ООП

Свойства вместо полей

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

class Rect(val width: Int, val height: Int) {
    val area: Int get() = width * height      // вычисляется при каждом чтении

    var scale: Double = 1.0
        set(value) {
            require(value > 0) { "масштаб должен быть положительным" }
            field = value
        }
}

fun main() {
    val r = Rect(2, 3)
    println(r.area)        // 6 — похоже на поле, но это вызов геттера
    r.scale = 2.0
    println(r.scale)       // 2.0
}

Практическое следствие: заменить хранимое свойство на вычисляемое можно без изменения кода клиентов. В Java для этого пришлось бы менять obj.field на obj.getField() везде.

Подробнее: Свойства

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

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

interface Logger2 { fun log(msg: String) }

class ConsoleLogger : Logger2 {
    override fun log(msg: String) = println("[LOG] $msg")
}

class Service2(logger: Logger2) : Logger2 by logger    // делегирует всё

fun main() {
    Service2(ConsoleLogger()).log("работает")   // [LOG] работает
}

Service2 реализует Logger2, не наследуя реализацию: методы просто перенаправляются. Подробнее: Делегирование

Что осталось от Java без изменений

  • одиночное наследование классов, множественная реализация интерфейсов
  • динамическая диспетчеризация: вызов идёт по фактическому типу объекта
  • Any в корне иерархии (аналог Object)
  • стирание типов в дженериках
  • модель памяти и сборка мусора JVM

То есть байт-код, который порождает Kotlin, - обычные JVM-классы. Любая Java-библиотека работает с Kotlin-классами, и наоборот. Подробнее: Взаимодействие с Java

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

Java Kotlin
класс открыт, final закрывает класс закрыт, open открывает
метод виртуален по умолчанию метод финален по умолчанию
@Override опциональна override обязательно
поля + геттеры/сеттеры свойства
static companion object
нет модификатора = package-private нет модификатора = public
нет аналога internal (видимость в модуле)
checked exceptions их нет
делегирование вручную by

Обязательный override закрывает два класса ошибок Java: опечатка в имени метода создаёт новый метод вместо переопределения, а добавление метода в базовый класс может случайно «переопределиться» существующим методом подкласса. В Kotlin и то и другое - ошибка компиляции.

Отсутствие модификатора означает public, а не package-private. Это ловушка при переходе: Java-разработчик по привычке не пишет модификатор, рассчитывая на видимость в пакете, и получает публичный API.

Сравнить: ООП в Java, Инкапсуляция

[!warning] Подводные камни open не наследуется транзитивно для метода. Переопределённый метод остаётся открытым, пока его явно не закроют:

open class A3 { open fun f() = "A" }
open class B3 : A3() { override fun f() = "B" }          // всё ещё open!
class C3 : B3() { override fun f() = "C" }               // можно

open class D3 : A3() { final override fun f() = "D" }    // так закрывают

fun main() {
    println(listOf(A3(), B3(), C3(), D3()).joinToString { it.f() })   // A, B, C, D
}

Публичный val - часть API навсегда. Превратить его в var можно, а убрать или переименовать - ломающее изменение, потому что клиенты обращаются к нему как к полю.

internal не защищает от Java. В байт-коде такой член становится public с искажённым именем, поэтому Java-код из другого модуля может его вызвать:

class Service3 {
    internal fun secret() = "виден из Java"
}

fun main() { println(Service3().secret()) }

Отсутствие checked exceptions означает, что сигнатура не говорит об ошибках. Функция может бросить что угодно, и компилятор об этом не предупредит. Способы это компенсировать - в Result и runCatching.

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

Далее: Классы и конструкторы