Files
obsidian/Kotlin/Синтаксис и типы/Базовые типы ООП/Sealed class и interface.md
T

13 KiB
Raw Blame History

title, description, tags
title description tags
Sealed class и interface Закрытые иерархии, исчерпывающий when, выбор между sealed class и interface
kotlin
oop

sealed означает: все наследники известны компилятору. Новый наследник может появиться только в том же модуле и пакете.

sealed interface Result2
data class Success(val data: String) : Result2
data class Failure(val error: Throwable) : Result2
data object Loading : Result2

fun render(r: Result2): String = when (r) {
    is Success -> "данные: ${r.data}"
    is Failure -> "ошибка: ${r.error.message}"
    Loading -> "загрузка"
}

fun main() {
    println(render(Success("ok")))                        // данные: ok
    println(render(Failure(RuntimeException("сбой"))))    // ошибка: сбой
    println(render(Loading))                              // загрузка
}

else не нужен: компилятор видит все три варианта. Добавление четвёртого сломает компиляцию во всех when - это и есть цель.

Зачем это нужно

sealed - способ выразить «одно из» в системе типов. Без него альтернативой был бы класс со всеми полями сразу и флагом:

// плохо: половина полей всегда null
class BadResult(val data: String?, val error: Throwable?, val loading: Boolean)

Такой класс позволяет создать бессмысленные состояния: данные и ошибка одновременно. sealed их запрещает на уровне типов.

sealed class или sealed interface

sealed class Shape {
    abstract val area: Double              // общее состояние и поведение
}
class Circle(val r: Double) : Shape() {
    override val area get() = Math.PI * r * r
}
class Square(val side: Double) : Shape() {
    override val area get() = side * side
}

fun main() {
    val shapes: List<Shape> = listOf(Circle(1.0), Square(2.0))
    println(shapes.sumOf { it.area })      // 7.141592653589793
}
sealed class sealed interface
состояние (свойства с полем) можно нет
конструктор есть нет
наследник может входить в другую иерархию нет (один супертип-класс) да
data class как наследник да да

Правило: берите sealed interface, если общего состояния нет. Он гибче - один тип может реализовать несколько sealed-интерфейсов:

sealed interface Error2
sealed interface Retryable

data class Timeout(val seconds: Int) : Error2, Retryable
data class NotFound(val id: Int) : Error2

fun describe(e: Error2) = when (e) {
    is Timeout -> "таймаут, можно повторить: ${e is Retryable}"
    is NotFound -> "не найдено ${e.id}"
}

fun main() {
    println(describe(Timeout(30)))     // таймаут, можно повторить: true
    println(describe(NotFound(1)))     // не найдено 1
}

Где можно объявлять наследников

С Kotlin 1.5 наследники должны быть в том же модуле и пакете, но не обязательно в том же файле:

// файл Result.kt
sealed interface Result2
// файл Success.kt — тот же пакет, тот же модуль
// data class Success(val data: String) : Result2

Раньше требовался один файл. Сейчас обычно всё равно держат в одном - иерархию видно целиком.

Вложенное объявление - распространённый стиль:

sealed interface NetworkResult {
    data class Ok(val body: String) : NetworkResult
    data class HttpError(val code: Int) : NetworkResult
    data object Offline : NetworkResult
}

fun handle(r: NetworkResult) = when (r) {
    is NetworkResult.Ok -> r.body
    is NetworkResult.HttpError -> "HTTP ${r.code}"
    NetworkResult.Offline -> "нет сети"
}

fun main() {
    println(handle(NetworkResult.HttpError(404)))    // HTTP 404
}

Плюс: имена не загрязняют пакет и читаются как NetworkResult.Offline.

Исчерпывающий when

Проверка работает и для when-инструкции (без результата) - с Kotlin 2.0:

sealed interface Event
data object Start : Event
data object Stop : Event

fun log(e: Event) {
    when (e) {               // инструкция, не выражение
        Start -> println("старт")
        Stop -> println("стоп")
    }                        // else не нужен, полнота проверяется
}

fun main() { log(Start) }    // старт

Для nullable-типа нужна ветка null:

sealed interface Event
data object Start : Event

fun log(e: Event?) = when (e) {
    Start -> "старт"
    null -> "ничего"
}

fun main() { println(log(null)) }   // ничего

Иерархия глубже одного уровня

Промежуточный узел тоже может быть sealed:

sealed interface Expr
data class Num(val value: Int) : Expr
sealed interface BinOp : Expr {
    val left: Expr
    val right: Expr
}
data class Add(override val left: Expr, override val right: Expr) : BinOp
data class Mul(override val left: Expr, override val right: Expr) : BinOp

fun eval(e: Expr): Int = when (e) {
    is Num -> e.value
    is Add -> eval(e.left) + eval(e.right)
    is Mul -> eval(e.left) * eval(e.right)
}

fun main() {
    println(eval(Add(Num(2), Mul(Num(3), Num(4)))))   // 14
}

Можно сопоставлять и на промежуточном уровне:

sealed interface Expr
data class Num(val value: Int) : Expr
sealed interface BinOp : Expr
data class Add(val l: Expr, val r: Expr) : BinOp
data class Mul(val l: Expr, val r: Expr) : BinOp

fun kind(e: Expr) = when (e) {
    is Num -> "литерал"
    is BinOp -> "операция"       // покрывает Add и Mul
}

fun main() { println(kind(Add(Num(1), Num(2)))) }   // операция

Перебор наследников

sealed interface Status {
    data object Active : Status
    data object Blocked : Status
}

fun main() {
    println(Status::class.sealedSubclasses.map { it.simpleName })
    // [Active, Blocked]
}

Работает через рефлексию и требует kotlin-reflect. Для фиксированных объектов проще держать список руками. Подробнее: Рефлексия

Типичные применения

Результат операции вместо исключений:

sealed interface Parsed<out T> {
    data class Ok<T>(val value: T) : Parsed<T>
    data class Err(val message: String) : Parsed<Nothing>
}

fun parse(s: String): Parsed<Int> =
    s.toIntOrNull()?.let { Parsed.Ok(it) } ?: Parsed.Err("не число: $s")

fun main() {
    println(when (val r = parse("abc")) {
        is Parsed.Ok -> "значение ${r.value}"
        is Parsed.Err -> r.message
    })      // не число: abc
}

Обратите внимание на Parsed<Nothing> для варианта без данных - это работает благодаря out T и тому, что Nothing подтип всего. Подробнее: Any, Unit, Nothing, Вариантность

Состояние UI, события, команды, узлы AST - везде, где набор вариантов фиксирован и у каждого свои данные.

[!info] Отличия от Java В Java аналог появился в 17 - sealed с явным permits:

sealed interface Result permits Success, Failure {}
record Success(String data) implements Result {}
record Failure(Throwable error) implements Result {}
Java 17+ Kotlin
sealed interface X permits A, B sealed interface X (список выводится)
наследники в том же пакете/модуле то же
switch с pattern matching (Java 21) when с is
проверка полноты в switch (Java 21) есть всегда
non-sealed для открытия ветки нет аналога

Kotlin не требует перечислять наследников: компилятор находит их сам в пределах модуля. Это удобнее при добавлении варианта - править объявление не нужно.

До Java 17 то же выражали абстрактным классом с приватным конструктором или паттерном Visitor. Visitor даёт проверку полноты (интерфейс с методом на каждый тип), но требует много кода и меняет все реализации при добавлении варианта.

Сравнить: Абстрактные классы в Java, Полиморфизм подтипов

[!warning] Подводные камни else в when убивает проверку полноты. Главная ошибка:

sealed interface Event
data object Start : Event
data object Stop : Event

fun log(e: Event) = when (e) {
    Start -> "старт"
    else -> "прочее"        // добавление варианта пройдёт незамеченным
}

fun main() { println(log(Stop)) }   // прочее

Если ветвей много и большинство обрабатывается одинаково, лучше перечислить их через запятую, чем писать else.

Проверка полноты не работает из Java. Java-код, читающий sealed-иерархию Kotlin, обязан иметь default в switch.

when по is без исчерпывающего контекста не проверяется. Полнота требуется только там, где when - выражение или где компилятор знает, что иерархия закрыта. Для не-sealed типа проверки нет вообще.

sealed class требует вызова конструктора в наследнике:

sealed class Base(val id: Int)
class Child(id: Int) : Base(id)       // скобки обязательны

fun main() { println(Child(1).id) }   // 1

У sealed interface конструктора нет - на одну причину для ошибки меньше.

Наследник в другом модуле не скомпилируется, даже если пакет совпадает. Это ограничивает расширяемость библиотечного API: если вариантов должно быть больше, sealed не подходит.

sealed класс сам абстрактен - создать его нельзя:

sealed class Shape
// val s = Shape()      // ошибка: sealed-класс абстрактный
class Circle : Shape()

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

Для варианта без данных нужен data object, а не class:

sealed interface State
class Loading1 : State          // каждый экземпляр разный
data object Loading2 : State    // единственный экземпляр

fun main() {
    println(Loading1() == Loading1())       // false — ловушка
    println(Loading2 == Loading2)           // true
}

Дженерик-наследник sealed-типа требует внимания к вариантности. Без out вариант без данных нельзя объявить как Err : Parsed<Nothing>.

Дополнительно: Enum class Далее: Value class