295 lines
13 KiB
Markdown
295 lines
13 KiB
Markdown
---
|
||
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)
|