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

295 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Sealed class и interface
description: Закрытые иерархии, исчерпывающий when, выбор между sealed class и interface
tags:
- kotlin
- oop
---
`sealed` означает: все наследники известны компилятору. Новый наследник может появиться только в том же модуле и пакете.
```kotlin
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` - способ выразить «одно из» в системе типов. Без него альтернативой был бы класс со всеми полями сразу и флагом:
```kotlin
// плохо: половина полей всегда null
class BadResult(val data: String?, val error: Throwable?, val loading: Boolean)
```
Такой класс позволяет создать бессмысленные состояния: данные и ошибка одновременно. `sealed` их запрещает на уровне типов.
## sealed class или sealed interface
```kotlin
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-интерфейсов:
```kotlin
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 наследники должны быть в том же **модуле** и **пакете**, но не обязательно в том же файле:
```kotlin
// файл Result.kt
sealed interface Result2
// файл Success.kt — тот же пакет, тот же модуль
// data class Success(val data: String) : Result2
```
Раньше требовался один файл. Сейчас обычно всё равно держат в одном - иерархию видно целиком.
Вложенное объявление - распространённый стиль:
```kotlin
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:
```kotlin
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`:
```kotlin
sealed interface Event
data object Start : Event
fun log(e: Event?) = when (e) {
Start -> "старт"
null -> "ничего"
}
fun main() { println(log(null)) } // ничего
```
## Иерархия глубже одного уровня
Промежуточный узел тоже может быть `sealed`:
```kotlin
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
}
```
Можно сопоставлять и на промежуточном уровне:
```kotlin
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)))) } // операция
```
## Перебор наследников
```kotlin
sealed interface Status {
data object Active : Status
data object Blocked : Status
}
fun main() {
println(Status::class.sealedSubclasses.map { it.simpleName })
// [Active, Blocked]
}
```
Работает через рефлексию и требует `kotlin-reflect`. Для фиксированных объектов проще держать список руками. Подробнее: [Рефлексия](../../Рефлексия/Рефлексия.md)
## Типичные применения
**Результат операции** вместо исключений:
```kotlin
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](Any,%20Unit,%20Nothing.md), [Вариантность](../../Дженерики/Вариантность.md)
**Состояние UI**, **события**, **команды**, **узлы AST** - везде, где набор вариантов фиксирован и у каждого свои данные.
> [!info] Отличия от Java
> В Java аналог появился в 17 - `sealed` с явным `permits`:
> ```java
> 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](../../../Программирование%20на%20Java/ООП/Абстрактные%20классы.md), [Полиморфизм подтипов](../../../Программирование%20на%20Java/ООП/Полиморфизм/Полиморфизм%20подтипов.md)
> [!warning] Подводные камни
> **`else` в `when` убивает проверку полноты.** Главная ошибка:
> ```kotlin
> 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` требует вызова конструктора в наследнике:**
> ```kotlin
> sealed class Base(val id: Int)
> class Child(id: Int) : Base(id) // скобки обязательны
>
> fun main() { println(Child(1).id) } // 1
> ```
> У `sealed interface` конструктора нет - на одну причину для ошибки меньше.
>
> **Наследник в другом модуле не скомпилируется**, даже если пакет совпадает. Это ограничивает расширяемость библиотечного API: если вариантов должно быть больше, `sealed` не подходит.
>
> **`sealed` класс сам абстрактен** - создать его нельзя:
> ```kotlin
> sealed class Shape
> // val s = Shape() // ошибка: sealed-класс абстрактный
> class Circle : Shape()
>
> fun main() { println(Circle()::class.simpleName) } // Circle
> ```
>
> **Для варианта без данных нужен `data object`, а не `class`:**
> ```kotlin
> 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](Enum%20class.md)
Далее: [Value class](Value%20class.md)