Kotlin guide done. (20.10.26)
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user