13 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Sealed class и interface | Закрытые иерархии, исчерпывающий when, выбор между sealed class и interface |
|
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, Bsealed 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