From da3ad9dd1af1daa006a9cfaa6283217ebd5479aa Mon Sep 17 00:00:00 2001 From: Aklivtsov Date: Thu, 15 Oct 2026 00:24:21 +0300 Subject: [PATCH 1/2] Added kotlin guide. (WIP) (15.10.26) --- Kotlin/Kotlin.md | 145 +++++++++++ .../Null-безопасность/Null-безопасность.md | 185 ++++++++++++++ .../Null-безопасность/Операторы null-безопасности.md | 192 ++++++++++++++ .../Null-безопасность/Платформенные типы.md | 160 ++++++++++++ .../Null-безопасность/Умные приведения.md | 241 ++++++++++++++++++ .../Базовый синтаксис/Hello World!.md | 53 ++++ .../Базовый синтаксис/Операторы и выражения.md | 182 +++++++++++++ .../Базовый синтаксис/Пакеты и импорты.md | 129 ++++++++++ .../Базовый синтаксис/Переменные.md | 154 +++++++++++ .../Базовый синтаксис/Строковые шаблоны и вывод.md | 191 ++++++++++++++ .../Базовый синтаксис/Типы данных.md | 154 +++++++++++ .../Базовый синтаксис/Условные конструкции.md | 175 +++++++++++++ .../Базовый синтаксис/Циклы и диапазоны.md | 159 ++++++++++++ 13 files changed, 2120 insertions(+) create mode 100644 Kotlin/Kotlin.md create mode 100644 Kotlin/Синтаксис и типы/Null-безопасность/Null-безопасность.md create mode 100644 Kotlin/Синтаксис и типы/Null-безопасность/Операторы null-безопасности.md create mode 100644 Kotlin/Синтаксис и типы/Null-безопасность/Платформенные типы.md create mode 100644 Kotlin/Синтаксис и типы/Null-безопасность/Умные приведения.md create mode 100644 Kotlin/Синтаксис и типы/Базовый синтаксис/Hello World!.md create mode 100644 Kotlin/Синтаксис и типы/Базовый синтаксис/Операторы и выражения.md create mode 100644 Kotlin/Синтаксис и типы/Базовый синтаксис/Пакеты и импорты.md create mode 100644 Kotlin/Синтаксис и типы/Базовый синтаксис/Переменные.md create mode 100644 Kotlin/Синтаксис и типы/Базовый синтаксис/Строковые шаблоны и вывод.md create mode 100644 Kotlin/Синтаксис и типы/Базовый синтаксис/Типы данных.md create mode 100644 Kotlin/Синтаксис и типы/Базовый синтаксис/Условные конструкции.md create mode 100644 Kotlin/Синтаксис и типы/Базовый синтаксис/Циклы и диапазоны.md diff --git a/Kotlin/Kotlin.md b/Kotlin/Kotlin.md new file mode 100644 index 0000000..055655e --- /dev/null +++ b/Kotlin/Kotlin.md @@ -0,0 +1,145 @@ +--- +title: Kotlin +description: Полное пособие по Kotlin — синтаксис, ООП, корутины, инструментарий +tags: + - kotlin +--- + +Статически типизированный язык от JetBrains, работающий на [JVM](../Программирование%20на%20Java/JVM/JVM.md), а также компилируемый в JavaScript, WebAssembly и нативный код. + +- Первый релиз - 2011, стабильная 1.0 - 2016 +- Официальный язык разработки для Android с 2019 +- Полная двусторонняя совместимость с Java: один проект может содержать оба языка +- Текущая версия - 2.4.x, компилятор K2 по умолчанию + +Главные отличия от Java: [null-безопасность](Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md) на уровне типов, [корутины](Корутины/Корутины.md) вместо потоков, выражения вместо инструкций, отсутствие проверяемых исключений, в 2-3 раза меньше кода на ту же задачу. + +## Маршрут +Последовательность для изучения с нуля. Каждая заметка заканчивается ссылкой `Далее:`, так что можно просто идти по цепочке. + +1. [Hello World!](Синтаксис%20и%20типы/Базовый%20синтаксис/Hello%20World!.md) - начало +2. [Null-безопасность](Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md) - ядро системы типов +3. [ООП](ООП/ООП.md) - классы и объекты +4. [Коллекции](Коллекции/Коллекции.md) - структуры данных +5. [Функции высшего порядка](Функциональный%20Kotlin/Функции%20высшего%20порядка.md) - функциональный стиль +6. [Корутины](Корутины/Корутины.md) - асинхронность + +## Синтаксис и типы +### Базовый синтаксис +- [Hello World!](Синтаксис%20и%20типы/Базовый%20синтаксис/Hello%20World!.md) +- [Типы данных](Синтаксис%20и%20типы/Базовый%20синтаксис/Типы%20данных.md) +- [Переменные](Синтаксис%20и%20типы/Базовый%20синтаксис/Переменные.md) +- [Операторы и выражения](Синтаксис%20и%20типы/Базовый%20синтаксис/Операторы%20и%20выражения.md) +- [Условные конструкции](Синтаксис%20и%20типы/Базовый%20синтаксис/Условные%20конструкции.md) +- [Циклы и диапазоны](Синтаксис%20и%20типы/Базовый%20синтаксис/Циклы%20и%20диапазоны.md) +- [Строковые шаблоны и вывод](Синтаксис%20и%20типы/Базовый%20синтаксис/Строковые%20шаблоны%20и%20вывод.md) +- [Пакеты и импорты](Синтаксис%20и%20типы/Базовый%20синтаксис/Пакеты%20и%20импорты.md) + +### Null-безопасность +- [Null-безопасность](Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md) +- [Операторы null-безопасности](Синтаксис%20и%20типы/Null-безопасность/Операторы%20null-безопасности.md) +- [Умные приведения](Синтаксис%20и%20типы/Null-безопасность/Умные%20приведения.md) +- [Платформенные типы](Синтаксис%20и%20типы/Null-безопасность/Платформенные%20типы.md) + +### Работа с данными +- [Строки](Синтаксис%20и%20типы/Работа%20с%20данными/Строки.md) +- [Char](Синтаксис%20и%20типы/Работа%20с%20данными/Char.md) +- [Числа и переполнение](Синтаксис%20и%20типы/Работа%20с%20данными/Числа%20и%20переполнение.md) +- [Массивы](Синтаксис%20и%20типы/Работа%20с%20данными/Массивы.md) +- [Функции](Синтаксис%20и%20типы/Работа%20с%20данными/Функции.md) +- [Лямбды и функциональные типы](Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) +- [Деструктуризация](Синтаксис%20и%20типы/Работа%20с%20данными/Деструктуризация.md) +- [Приведение типов](Синтаксис%20и%20типы/Работа%20с%20данными/Приведение%20типов.md) + +### Базовые типы ООП +- [Any, Unit, Nothing](Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md) +- [Data class](Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) +- [Enum class](Синтаксис%20и%20типы/Базовые%20типы%20ООП/Enum%20class.md) +- [Sealed class и interface](Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) +- [Value class](Синтаксис%20и%20типы/Базовые%20типы%20ООП/Value%20class.md) +- [Object и companion object](Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) +- [Typealias](Синтаксис%20и%20типы/Базовые%20типы%20ООП/Typealias.md) +- [equals, hashCode и toString](Синтаксис%20и%20типы/Базовые%20типы%20ООП/equals,%20hashCode%20и%20toString.md) + +## ООП +- [ООП](ООП/ООП.md) +- [Классы и конструкторы](ООП/Классы%20и%20конструкторы.md) +- [Свойства](ООП/Свойства.md) +- [Инициализация](ООП/Инициализация.md) +- [Модификаторы видимости](ООП/Модификаторы%20видимости.md) +- [Наследование](ООП/Наследование.md) +- [Переопределение](ООП/Переопределение.md) +- [Абстрактные классы](ООП/Абстрактные%20классы.md) +- [Интерфейсы](ООП/Интерфейсы.md) +- [Делегирование](ООП/Делегирование.md) +- [Вложенные и внутренние классы](ООП/Вложенные%20и%20внутренние%20классы.md) +- [Полиморфизм](ООП/Полиморфизм.md) + +## Исключения +- [Система исключений](Исключения/Система%20исключений.md) +- [Result и runCatching](Исключения/Result%20и%20runCatching.md) +- [Проверки и контракты](Исключения/Проверки%20и%20контракты.md) + +## Коллекции +- [Коллекции](Коллекции/Коллекции.md) +- [List, Set, Map](Коллекции/List,%20Set,%20Map.md) +- [Изменяемость](Коллекции/Изменяемость.md) +- [Операции](Коллекции/Операции.md) +- [Sequence](Коллекции/Sequence.md) +- [Коллекции под капотом](Коллекции/Коллекции%20под%20капотом.md) + +## Дженерики +- [Параметрический полиморфизм](Дженерики/Параметрический%20полиморфизм.md) +- [Вариантность](Дженерики/Вариантность.md) +- [Проекции типов](Дженерики/Проекции%20типов.md) +- [Reified и inline](Дженерики/Reified%20и%20inline.md) + +## Функциональный Kotlin +- [Функции высшего порядка](Функциональный%20Kotlin/Функции%20высшего%20порядка.md) +- [Inline-функции](Функциональный%20Kotlin/Inline-функции.md) +- [Функции-расширения](Функциональный%20Kotlin/Функции-расширения.md) +- [Scope-функции](Функциональный%20Kotlin/Scope-функции.md) +- [Инфиксные и операторные функции](Функциональный%20Kotlin/Инфиксные%20и%20операторные%20функции.md) +- [Receivers и context parameters](Функциональный%20Kotlin/Receivers%20и%20context%20parameters.md) +- [DSL](Функциональный%20Kotlin/DSL.md) +- [Паттерны проектирования](Функциональный%20Kotlin/Паттерны%20проектирования.md) + +## Корутины +- [Корутины](Корутины/Корутины.md) +- [Suspend-функции](Корутины/Suspend-функции.md) +- [Структурированная конкурентность](Корутины/Структурированная%20конкурентность.md) +- [Билдеры корутин](Корутины/Билдеры%20корутин.md) +- [Диспетчеры и контекст](Корутины/Диспетчеры%20и%20контекст.md) +- [Отмена и таймауты](Корутины/Отмена%20и%20таймауты.md) +- [Исключения в корутинах](Корутины/Исключения%20в%20корутинах.md) +- [Flow](Корутины/Flow.md) +- [StateFlow и SharedFlow](Корутины/StateFlow%20и%20SharedFlow.md) +- [Channels](Корутины/Channels.md) +- [Разделяемое состояние](Корутины/Разделяемое%20состояние.md) +- [Корутины против потоков](Корутины/Корутины%20против%20потоков.md) + +## Взаимодействие с Java +- [Взаимодействие с Java](Взаимодействие%20с%20Java/Взаимодействие%20с%20Java.md) +- [Java из Kotlin](Взаимодействие%20с%20Java/Java%20из%20Kotlin.md) +- [Kotlin из Java](Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) +- [Kotlin в байт-коде](Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) + +## Инструментарий +- [Инструментарий](Инструментарий/Инструментарий.md) +- [kotlinc](Инструментарий/kotlinc.md) +- [Gradle и Kotlin DSL](Инструментарий/Gradle%20и%20Kotlin%20DSL.md) +- [Структура проекта](Инструментарий/Структура%20проекта.md) +- [Компилятор K2](Инструментарий/Компилятор%20K2.md) +- [KSP](Инструментарий/KSP.md) +- [Линтеры и формат](Инструментарий/Линтеры%20и%20формат.md) + +## Тестирование +- [Тестирование](Тестирование/Тестирование.md) +- [kotlin.test и JUnit 5](Тестирование/kotlin.test%20и%20JUnit%205.md) +- [Kotest](Тестирование/Kotest.md) +- [MockK](Тестирование/MockK.md) +- [Тестирование корутин](Тестирование/Тестирование%20корутин.md) + +## Рефлексия +- [Рефлексия](Рефлексия/Рефлексия.md) +- [Аннотации](Рефлексия/Аннотации.md) diff --git a/Kotlin/Синтаксис и типы/Null-безопасность/Null-безопасность.md b/Kotlin/Синтаксис и типы/Null-безопасность/Null-безопасность.md new file mode 100644 index 0000000..2d54e80 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Null-безопасность/Null-безопасность.md @@ -0,0 +1,185 @@ +--- +title: Null-безопасность +description: Разделение типов на nullable и non-null, как компилятор предотвращает NPE +tags: + - kotlin + - null-safety +--- + +Главная особенность системы типов Kotlin: **возможность быть null - часть типа**. + +```kotlin +var a: String = "text" +// a = null // ошибка компиляции + +var b: String? = "text" +b = null // ok +``` + +`String` и `String?` - разные типы. `String` является подтипом `String?`, но не наоборот. + +```mermaid +graph TD + SN["String? — допускает null"] + S["String — не допускает null"] + N["Nothing? — только null"] + SN --> S + SN --> N +``` + +## Зачем это нужно +`NullPointerException` в Java возникает в рантайме, иногда далеко от места, где null попал в переменную. В Kotlin компилятор отказывается собирать такой код: + +```kotlin +fun length(s: String?): Int { + // return s.length // ошибка: only safe (?.) or non-null (!!) calls are allowed + return s?.length ?: 0 +} +``` +Проблема переносится с этапа выполнения на этап компиляции. Разыменовать nullable-значение без проверки невозможно. + +## Работа с nullable +Пять подходов, от предпочтительного к крайнему: + +### 1. Проверка на null +После проверки компилятор сам приводит тип: +```kotlin +fun describe(s: String?): String { + if (s == null) return "нет значения" + return "длина ${s.length}" // здесь s уже String, не String? +} +``` +Это [умное приведение](Умные%20приведения.md). + +### 2. Безопасный вызов - ?. +```kotlin +val len: Int? = s?.length // null, если s == null +``` + +### 3. Elvis - ?: +```kotlin +val len: Int = s?.length ?: 0 // значение по умолчанию +val name = user?.name ?: error("нет пользователя") // или выход +``` + +### 4. Безопасное приведение - as? +```kotlin +val n: Int? = value as? Int // null вместо ClassCastException +``` + +### 5. Утверждение - !! +```kotlin +val len: Int = s!!.length // NPE, если s == null +``` +`!!` означает «я гарантирую, что здесь не null». Каждое его использование - это место, где вы отключили проверку компилятора. + +Подробнее про операторы: [Операторы null-безопасности](Операторы%20null-безопасности.md) + +## Nullable в коллекциях +`?` ставится раздельно для элементов и для самой коллекции: +```kotlin +val a: List = listOf("x") // список есть, элементы не null +val b: List = listOf(null) // список есть, элементы могут быть null +val c: List? = null // списка может не быть +val d: List? = null // и то, и другое +``` +Для отсеивания null есть готовые функции: +```kotlin +val items: List = listOf("a", null, "b") +println(items.filterNotNull()) // [a, b] +``` + +## Nullable и дженерики +Параметр типа по умолчанию допускает null, его верхняя граница - `Any?`: +```kotlin +fun process(item: T) { } // T может быть nullable +fun strict(item: T) { } // T гарантированно non-null + +strict("text") +// strict(null) // ошибка компиляции +``` +Подробнее: [Параметрический полиморфизм](../../Дженерики/Параметрический%20полиморфизм.md) + +## Где NPE всё-таки возможен +Kotlin не даёт абсолютной гарантии. Источники NPE: + +1. **Явный `!!`** - вы сами попросили +2. **Явный `throw NullPointerException()`** +3. **Код на Java** - типы оттуда компилятор проверить не может ([Платформенные типы](Платформенные%20типы.md)) +4. **`lateinit` до инициализации** - технически `UninitializedPropertyAccessException` +5. **Утечка `this` из конструктора** - обращение к ещё не инициализированному свойству + +```kotlin +class Broken { + val a: String = compute() // вызывается до инициализации b + val b: String = "value" + + fun compute(): String = b.uppercase() // NPE: b ещё null +} +``` +Последний случай компилятор обычно замечает и выдаёт предупреждение, но через несколько уровней вызовов - может и пропустить. + +## Правило +Если у вас много `!!` - типы спроектированы неправильно. Nullable-тип должен появляться там, где отсутствие значения - реальная часть предметной области («у пользователя может не быть отчества»), а не там, где лень разбираться с инициализацией. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | любая ссылка может быть null | null допускают только типы с `?` | +> | NPE в рантайме | ошибка компиляции | +> | `Optional` как обёртка | `T?` на уровне типов, без аллокации | +> | `@Nullable`/`@NotNull` - подсказки для IDE | часть системы типов, проверяется компилятором | +> | `Objects.requireNonNull(x)` | `requireNotNull(x)` или просто non-null тип | +> +> `Optional` в Java - это объект-обёртка: аллокация, нет поддержки в полях, не композируется с дженериками. `T?` в Kotlin не создаёт обёртки: в байт-коде это обычная ссылка, а проверки расставляет компилятор. +> +> Публичные функции Kotlin получают проверки параметров автоматически - вызов из Java с null упадёт сразу на входе, с понятным сообщением, а не глубже по стеку: +> ```kotlin +> fun greet(name: String) = "Hi, $name" +> ``` +> ```java +> GreetKt.greet(null); +> // NullPointerException: Parameter specified as non-null is null: +> // method GreetKt.greet, parameter name +> ``` +> В сообщении указаны и метод, и имя параметра. +> +> Сравнить: [Приведение ссылочных типов в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Приведение%20ссылочных%20типов.md) + +> [!warning] Подводные камни +> **`!!` - не решение, а отложенная проблема.** Строка `user!!.profile!!.name!!` падает без указания, какое из трёх звеньев было null. +> +> **Умное приведение не работает с `var` из другого потока и с открытыми свойствами:** +> ```kotlin +> class Holder { +> var value: String? = null +> fun use() { +> if (value != null) { +> // println(value.length) // ошибка: smart cast невозможен +> println(value!!.length) // или скопировать в локальную val +> } +> } +> } +> ``` +> Подробнее: [Умные приведения](Умные%20приведения.md) +> +> **Платформенные типы из Java проверок не имеют:** +> ```kotlin +> val s: String = javaMethodThatReturnsNull() // скомпилируется, упадёт в рантайме +> ``` +> +> **`Boolean?` нельзя использовать в `if` напрямую:** +> ```kotlin +> val flag: Boolean? = null +> // if (flag) { } // ошибка компиляции +> if (flag == true) { } // так +> if (flag ?: false) { } // или так +> ``` +> +> **`null` в строковом шаблоне превращается в текст:** +> ```kotlin +> val x: String? = null +> println("значение: $x") // значение: null +> ``` + +Далее: [Операторы null-безопасности](Операторы%20null-безопасности.md) diff --git a/Kotlin/Синтаксис и типы/Null-безопасность/Операторы null-безопасности.md b/Kotlin/Синтаксис и типы/Null-безопасность/Операторы null-безопасности.md new file mode 100644 index 0000000..091e0fb --- /dev/null +++ b/Kotlin/Синтаксис и типы/Null-безопасность/Операторы null-безопасности.md @@ -0,0 +1,192 @@ +--- +title: Операторы null-безопасности +description: Безопасный вызов ?., elvis ?:, as?, !! и функции let, requireNotNull +tags: + - kotlin + - null-safety +--- + +Четыре оператора и несколько функций стандартной библиотеки для работы с nullable-типами. + +## Безопасный вызов - ?. +Вызывает член, если значение не null, иначе возвращает null: +```kotlin +val s: String? = null +println(s?.length) // null +println(s?.uppercase()) // null + +val t: String? = "text" +println(t?.length) // 4 +``` +Результат `?.` всегда nullable - даже если сам член возвращает non-null тип. + +### Цепочки +```kotlin +class Address(val city: String?) +class User(val address: Address?) + +val user: User? = null +val city: String? = user?.address?.city // null, если оборвалось где угодно +``` +Цепочка прерывается на первом null, остальные вызовы не выполняются. Это «безопасная навигация» - в Java пришлось бы писать три вложенных `if`. + +### С присваиванием +```kotlin +class Box { var value: Int = 0 } +val box: Box? = null +box?.value = 42 // ничего не произойдёт, если box == null +``` + +## Elvis - ?: +Возвращает левую часть, если она не null, иначе правую: +```kotlin +val name: String? = null +println(name ?: "анонимный") // анонимный + +val len = name?.length ?: 0 // 0 +``` +Назван так из-за схожести `?:` с причёской Элвиса Пресли. + +Правая часть вычисляется **только** при null (ленивость): +```kotlin +val value = cached ?: expensiveCompute() // вызов только при необходимости +``` + +### Для раннего выхода +Справа может стоять `return` или `throw` - они имеют тип [Nothing](../Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md), поэтому подходят под любой тип: +```kotlin +fun process(input: String?): Int { + val text = input ?: return -1 // выход из функции + val n = text.toIntOrNull() ?: error("не число") // исключение + return n * 2 +} +``` +Это идиома: вместо вложенных проверок - плоская последовательность «достали или вышли». + +## Безопасное приведение - as? +Возвращает null вместо `ClassCastException`: +```kotlin +val value: Any = "text" +val n: Int? = value as? Int // null +val s: String? = value as? String // "text" + +val safe = value as? Int ?: 0 // 0 — частая связка с elvis +``` +Обычный `as` при несовпадении типов падает: +```kotlin +// val bad = value as Int // ClassCastException +``` + +## Утверждение non-null - !! +Превращает `T?` в `T`, бросая NPE при null: +```kotlin +val s: String? = "text" +println(s!!.length) // 4 + +val n: String? = null +// println(n!!.length) // NullPointerException +``` +Применять только когда null означает ошибку программиста, и падение предпочтительнее тихого продолжения. В остальных случаях есть `?:`, `?.` или проверка. + +## let для nullable +Выполняет блок, только если значение не null: +```kotlin +val name: String? = "Kotlin" +name?.let { + println(it.length) // внутри it — уже String, не String? +} + +val upper = name?.let { it.uppercase() } ?: "ПУСТО" +``` +Полезно, когда нужно несколько операций и нежелательно повторять `?.`: +```kotlin +user?.let { + log(it.name) + send(it.email) + it.lastSeen = now() +} +``` +Подробнее про `let`, `run`, `also`, `apply`: [Scope-функции](../../Функциональный%20Kotlin/Scope-функции.md) + +## Функции проверки +```kotlin +val value: String? = getValue() + +val a = requireNotNull(value) // IllegalArgumentException +val b = requireNotNull(value) { "нет значения" } +val c = checkNotNull(value) // IllegalStateException +``` +Обе возвращают non-null значение, то есть заменяют `!!` с внятным сообщением. Разница - в типе исключения: `require*` для проверки аргументов, `check*` для проверки состояния. +Подробнее: [Проверки и контракты](../../Исключения/Проверки%20и%20контракты.md) + +## Полезные nullable-функции stdlib +```kotlin +val s: String? = null +println(s.isNullOrEmpty()) // true — работает на nullable-получателе +println(s.isNullOrBlank()) // true +println(s.orEmpty()) // "" — строка вместо null + +val list: List? = null +println(list.orEmpty()) // [] +println(list?.size ?: 0) // 0 + +println("42".toIntOrNull()) // 42 +println("abc".toIntOrNull()) // null — вместо исключения +``` +Функции с суффиксом `OrNull` есть у многих операций: `firstOrNull()`, `singleOrNull()`, `toIntOrNull()`, `getOrNull(i)`. + +## Сводка +| Оператор | Результат при null | Когда применять | +|---|---|---| +| `?.` | `null` | навигация по цепочке | +| `?:` | правая часть | значение по умолчанию, ранний выход | +| `as?` | `null` | неуверенное приведение типа | +| `!!` | NPE | null невозможен по инварианту | +| `?.let {}` | блок не выполняется | несколько действий со значением | + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `if (x != null) x.foo()` | `x?.foo()` | +> | `x != null ? x : def` | `x ?: def` | +> | `x instanceof T ? (T) x : null` | `x as? T` | +> | `Objects.requireNonNull(x)` | `requireNotNull(x)` или `x!!` | +> | `Optional.ofNullable(x).map(f).orElse(d)` | `x?.let(f) ?: d` | +> | три вложенных `if` для цепочки | `a?.b?.c` | +> +> Java-аналог `Optional` требует оборачивания и разворачивания, работает только как возвращаемое значение и создаёт объект. Операторы Kotlin компилируются в обычные проверки `ifnull` в байт-коде - накладных расходов нет. + +> [!warning] Подводные камни +> **`?.` возвращает nullable, цепочка «заражает» весь результат:** +> ```kotlin +> val list: List? = listOf("a") +> val size: Int? = list?.size // Int?, не Int +> val sure: Int = list?.size ?: 0 // нужен elvis +> ``` +> +> **Приоритет `?:` ниже арифметики** - без скобок получится не то: +> ```kotlin +> val a: Int? = null +> println((a ?: 0) + 1) // 1 +> // println(a ?: 0 + 1) // это a ?: (0+1), то есть 1 — здесь совпало, +> // // но в сложных выражениях расхождение будет +> ``` +> +> **`as?` не спасает от null, только от неверного типа:** +> ```kotlin +> val x: Any? = null +> val s: String? = x as? String // null — потому что x был null +> ``` +> +> **`!!` в цепочке скрывает, что именно упало.** `a!!.b!!.c!!` даёт NPE без указания звена. Либо проверяйте по шагам, либо используйте `requireNotNull` с сообщением. +> +> **`?.let` возвращает null, если получатель null** - а не то, что вернул блок: +> ```kotlin +> val s: String? = null +> val r: Int? = s?.let { 42 } // null, блок не выполнялся +> ``` +> +> **Безопасный вызов на функции-расширении с nullable-получателем работает иначе.** `s.isNullOrEmpty()` вызывается без `?.`, потому что расширение объявлено для `String?` и само обрабатывает null внутри. + +Дополнительно: [Scope-функции](../../Функциональный%20Kotlin/Scope-функции.md) +Далее: [Умные приведения](Умные%20приведения.md) diff --git a/Kotlin/Синтаксис и типы/Null-безопасность/Платформенные типы.md b/Kotlin/Синтаксис и типы/Null-безопасность/Платформенные типы.md new file mode 100644 index 0000000..979505f --- /dev/null +++ b/Kotlin/Синтаксис и типы/Null-безопасность/Платформенные типы.md @@ -0,0 +1,160 @@ +--- +title: Платформенные типы +description: Тип T! из Java-кода, где null-безопасность Kotlin перестаёт работать и как это чинить +tags: + - kotlin + - null-safety + - interop +--- + +Платформенный тип (platform type) - тип, пришедший из Java, про nullability которого компилятор Kotlin ничего не знает. Обозначается `T!`, но написать его в коде нельзя - он существует только в сообщениях компилятора и в IDE. + +```java +// Java +public class Repo { + public String findName() { return null; } +} +``` +```kotlin +// Kotlin +val repo = Repo() +val a: String = repo.findName() // компилируется! падает в рантайме +val b: String? = repo.findName() // тоже компилируется +``` +`findName()` имеет тип `String!` - его можно присвоить и в `String`, и в `String?`. Проверку компилятор не делает. + +## Почему так +Альтернативы были хуже: +- считать все Java-типы nullable → весь Java-код пришлось бы обвешивать `!!` и `?.`, включая `System.out` +- считать все non-null → ложное чувство безопасности без возможности это заметить + +Платформенный тип - компромисс: ответственность за nullability возвращается программисту, как в Java. + +## Где падает +NPE возникает в точке присваивания, а не использования - компилятор вставляет проверку на границе: +```kotlin +val a: String = repo.findName() // NPE здесь же +println(a.length) // до этой строки не дойдёт +``` +Это лучше, чем в Java: падение происходит рядом с источником, а не там, куда значение утекло. + +## Как чинить +### 1. Указывать nullable-тип явно +Любое значение из Java трактуйте как `T?`, пока не убедились в обратном: +```kotlin +val name: String? = repo.findName() +println(name?.length ?: 0) +``` + +### 2. Аннотации в Java-коде +Компилятор Kotlin читает аннотации nullability и начинает проверять типы: +```java +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.NotNull; + +public class Repo { + @Nullable public String findName() { return null; } + @NotNull public String getId() { return "1"; } +} +``` +```kotlin +val a: String = repo.findName() // теперь ошибка компиляции +val b: String? = repo.findName() // ok +val c: String = repo.getId() // ok, проверка не нужна +``` + +Поддерживаются аннотации из нескольких наборов: + +| Набор | Пакет | +|---|---| +| JetBrains | `org.jetbrains.annotations` | +| JSpecify | `org.jspecify.annotations` | +| Android | `androidx.annotation` | +| JSR-305 | `javax.annotation` | +| Eclipse | `org.eclipse.jdt.annotation` | +| Lombok | `lombok.NonNull` | + +### 3. Обёртка на границе +Для внешней библиотеки без аннотаций - свой слой, который один раз разбирается с nullability: +```kotlin +// вся работа с «грязными» типами собрана здесь +fun Repo.nameOrNull(): String? = findName() +fun Repo.requireName(): String = findName() ?: error("name отсутствует") +``` +Дальше код работает с честными типами Kotlin. + +### 4. Строгий режим компилятора +Флаг `-Xjsr305=strict` заставляет компилятор относиться к JSR-305-аннотациям как к обязательным: +```kotlin +// build.gradle.kts +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} +``` +Значения: `ignore`, `warn` (по умолчанию), `strict`. + +## Коллекции из Java +Платформенность распространяется на параметры типа: +```java +public List getItems() { return null; } +``` +Тип результата - `(Mutable)List!`. Неизвестно ни то, может ли быть null сам список, ни его элементы: +```kotlin +val items: List? = repo.items // самое честное предположение +items?.filterNotNull()?.forEach(::println) +``` + +Отдельная проблема - изменяемость. `java.util.List` отображается в `(Mutable)List`, то есть Kotlin позволит передать его туда, где ожидается `MutableList`, и Java-код может изменить «неизменяемый» список: +```kotlin +val list: List = repo.items // на самом деле ArrayList +// list.add("x") // так нельзя, тип List +(list as MutableList).add("x") // а так — да, и это сработает +``` +Подробнее: [Java из Kotlin](../../Взаимодействие%20с%20Java/Java%20из%20Kotlin.md) + +## Обратное направление +Код на Kotlin для Java-вызывающего выглядит аннотированным: компилятор добавляет `@NotNull`/`@Nullable` в байт-код автоматически. Плюс в публичные функции вставляются проверки параметров: +```kotlin +fun greet(name: String) = "Hi, $name" +``` +```java +GreetKt.greet(null); +// NullPointerException: Parameter specified as non-null is null: +// method GreetKt.greet, parameter name +``` +Падение происходит на входе в функцию, с указанием метода и имени параметра. + +> [!info] Отличия от Java +> В Java платформенных типов нет - там все ссылки одинаково могут быть null, и никакой проверки не существует. +> +> Платформенные типы - это механизм совместимости, а не фича языка. Их задача - позволить Kotlin вызывать существующие библиотеки без переписывания. Чем больше кода в проекте на Kotlin, тем реже они встречаются. +> +> Сравнить: [Система исключений в Java](../../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **Самый опасный случай - значение «просачивается» в non-null тип:** +> ```kotlin +> data class User(val name: String) +> val user = User(repo.findName()) // NPE в конструкторе, а причина — в Java-методе +> ``` +> +> **IDE подсказывает тип `String!`, но в коде так написать нельзя.** Если увидели `!` в подсказке - значит, компилятор вас не защищает. +> +> **Аннотации на уровне пакета легко пропустить.** `@NonNullApi` или `package-info.java` с `@ParametersAreNonnullByDefault` меняют поведение для всего пакета. Spring, например, аннотирует свои пакеты - поэтому его API в Kotlin выглядит типизированным. +> +> **`-Xjsr305=strict` может сломать сборку при обновлении библиотеки** - новые аннотации превратятся в ошибки компиляции. Включать осознанно. +> +> **Дженерик-методы Java особенно коварны:** +> ```java +> public T getOrNull(String key) { return null; } +> ``` +> ```kotlin +> val x: String = repo.getOrNull("k") // T выведется в String, NPE в рантайме +> ``` +> +> **Массивы из Java тоже платформенные:** `String[]` → `Array<(out) String!>!`. Элементы могут быть null даже если сам массив не null. + +Дополнительно: [Java из Kotlin](../../Взаимодействие%20с%20Java/Java%20из%20Kotlin.md) +Далее: [Строки](../Работа%20с%20данными/Строки.md) diff --git a/Kotlin/Синтаксис и типы/Null-безопасность/Умные приведения.md b/Kotlin/Синтаксис и типы/Null-безопасность/Умные приведения.md new file mode 100644 index 0000000..aff5a8b --- /dev/null +++ b/Kotlin/Синтаксис и типы/Null-безопасность/Умные приведения.md @@ -0,0 +1,241 @@ +--- +title: Умные приведения +description: Smart cast после is и проверки на null, условия работы и случаи, когда компилятор отказывается +tags: + - kotlin + - null-safety + - types +--- + +Умное приведение (smart cast) - автоматическое приведение типа после проверки. Компилятор отслеживает поток управления и знает, что в этой ветке тип уже уточнён. + +```kotlin +fun describe(value: Any): String { + if (value is String) { + return "строка длины ${value.length}" // value уже String + } + return "не строка" +} +``` +Явного `(String) value` писать не нужно - в отличие от Java (до версии 16). + +## После проверки типа - is +```kotlin +fun size(x: Any): Int = when (x) { + is String -> x.length // String + is List<*> -> x.size // List<*> + is Map<*, *> -> x.size // Map<*, *> + else -> 0 +} +``` +`!is` работает так же, с инверсией: +```kotlin +fun check(x: Any) { + if (x !is String) return + println(x.length) // после return здесь только String +} +``` + +## После проверки на null +```kotlin +fun length(s: String?): Int { + if (s == null) return 0 + return s.length // s: String +} + +fun length2(s: String?): Int { + if (s != null) { + return s.length // s: String + } + return 0 +} +``` + +## В логических выражениях +Приведение работает внутри `&&` и `||`, так как они ленивые: +```kotlin +fun check(x: Any?): Boolean { + return x is String && x.length > 3 // x приведён во второй части +} + +fun check2(x: Any?): Boolean { + return x !is String || x.length > 3 // приведён в правой части || +} +``` + +## В elvis и безопасных вызовах +```kotlin +fun process(s: String?) { + val text = s ?: return // после этой строки s... нет, но text: String + println(text.length) +} +``` + +## Где приведение НЕ работает +Главное условие: компилятор должен гарантировать, что **значение не изменится между проверкой и использованием**. + +### Изменяемое свойство класса - var +```kotlin +class Holder { + var value: String? = null + + fun broken() { + if (value != null) { + // println(value.length) // ошибка: smart cast to 'String' is impossible + } + } + + fun fixed() { + val local = value // копия в локальную val + if (local != null) { + println(local.length) // работает + } + } +} +``` +Почему: между проверкой и обращением другой поток (или вызванная функция) может присвоить `value = null`. + +### Свойство с кастомным геттером +```kotlin +class Random2 { + val value: String? + get() = if (Math.random() > 0.5) "x" else null + + fun broken() { + if (value != null) { + // println(value.length) // ошибка: геттер вернёт разное + } + } +} +``` + +### open-свойство +```kotlin +open class Base { + open val value: String? = "x" +} + +fun check(b: Base) { + if (b.value != null) { + // println(b.value.length) // ошибка: наследник мог переопределить геттер + } +} +``` + +### Свойство из другого модуля +Даже `val` из другой единицы компиляции не приводится: компилятор не может доказать, что у него нет кастомного геттера. + +### Локальная var, захваченная лямбдой +```kotlin +fun broken() { + var s: String? = "text" + val modify = { s = null } + + if (s != null) { + // println(s.length) // ошибка: лямбда может изменить s + } +} +``` + +## Сводка условий +| Что проверяем | Умное приведение | +|---|---| +| локальная `val` | ✅ работает | +| локальная `var` без захвата лямбдой | ✅ работает | +| локальная `var`, захваченная лямбдой | ❌ | +| `val`-свойство в том же модуле, без геттера | ✅ работает | +| `var`-свойство | ❌ | +| `open val` | ❌ | +| `val` с кастомным геттером | ❌ | +| `val` из другого модуля | ❌ | +| параметр функции (всегда `val`) | ✅ работает | + +Универсальное решение во всех ❌-случаях: скопировать значение в локальную `val`. + +## Явное приведение - as +Когда умное не применимо: +```kotlin +val x: Any = "text" +val s = x as String // ClassCastException при несовпадении +val n = x as? Int // null при несовпадении +``` +Подробнее: [Приведение типов](../Работа%20с%20данными/Приведение%20типов.md) + +## Умное приведение в Kotlin 2.x +Компилятор K2 (по умолчанию с Kotlin 2.0) расширил анализ. Теперь работает то, что раньше требовало ручного приведения: +```kotlin +fun test(x: Any?) { + val isString = x is String + if (isString) { + println(x.length) // K2 понимает связь через переменную-флаг + } +} + +fun orElse(x: Any?) { + if (x !is String) return + println(x.length) // работало и раньше +} +``` +Подробнее: [Компилятор K2](../../Инструментарий/Компилятор%20K2.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `if (x instanceof String) { String s = (String) x; ... }` | `if (x is String) { x.length }` | +> | pattern matching `x instanceof String s` (Java 16+) | есть с первой версии, без объявления переменной | +> | приведение только по типу | ещё и по null | +> | компилятор не отслеживает изменяемость | отказывается приводить, если значение может измениться | +> +> Java 16 добавила pattern matching: `if (x instanceof String s)`. Это ближе к Kotlin, но Java всё равно требует явно назвать переменную, и на nullability это не распространяется. +> +> Сравнить: [Приведение ссылочных типов в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Приведение%20ссылочных%20типов.md) + +> [!warning] Подводные камни +> **Сообщение компилятора объясняет причину** - его стоит читать целиком: +> ``` +> smart cast to 'String' is impossible, because 'value' is a mutable property +> that could be mutated concurrently +> +> smart cast to 'String' is impossible, because 'value' is a property +> that has an open or custom getter +> +> smart cast to 'String' is impossible, because 's' is a local variable +> that is mutated in a capturing closure +> ``` +> Там прямо сказано и что мешает, и почему. +> +> **`!!` вместо локальной `val` - плохой обмен.** Оба компилируются, но второй вариант предсказуем: +> ```kotlin +> // println(value!!.length) // падение при гонке +> val v = value ?: return // гонки нет +> ``` +> +> **Приведение дженериков неполное из-за стирания типов:** +> ```kotlin +> fun check(x: Any) { +> if (x is List<*>) { +> // элементы остаются Any?, тип параметра неизвестен +> val first = x.firstOrNull() // Any? +> } +> // if (x is List) // ошибка: cannot check for instance of erased type +> } +> ``` +> Обход - [reified](../../Дженерики/Reified%20и%20inline.md). +> +> **Приведение теряется после вызова функции, меняющей состояние** - если это свойство, а не локальная переменная: +> ```kotlin +> class C { +> var s: String? = "x" +> fun reset() { s = null } +> } +> ``` +> +> **В `when` без аргумента приведение работает только для проверенной переменной:** +> ```kotlin +> fun combine(a: Any?, b: Any?): Int = when { +> a is String && b is Int -> a.length + b // оба приведены +> else -> 0 +> } +> ``` + +Далее: [Платформенные типы](Платформенные%20типы.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Hello World!.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Hello World!.md new file mode 100644 index 0000000..8c82e26 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Hello World!.md @@ -0,0 +1,53 @@ +--- +title: Hello World! +description: Первая программа на Kotlin, функция main и запуск через kotlinc +tags: + - kotlin + - syntax +--- + +```kotlin +fun main() { + println("Hello World") +} +``` + +Файл `Hello.kt`. Имя файла **не связано** с содержимым - можно назвать как угодно. + +- `fun` - объявление функции +- `main` - точка входа, может быть без параметров +- `println` - функция из стандартной библиотеки, не метод класса +- Точки с запятой не нужны + +## Точка входа +Два допустимых варианта: +```kotlin +fun main() { } // обычно используют этот +fun main(args: Array) { } // если нужны аргументы командной строки +``` +Функция `main` должна быть на верхнем уровне файла (top-level), внутри класса её быть не может. + +## Запуск +```bash +kotlinc Hello.kt -include-runtime -d hello.jar +java -jar hello.jar +``` +Или через скриптовый режим, без создания jar: +```bash +kotlin Hello.kt +``` +Подробнее: [kotlinc](../../Инструментарий/kotlinc.md) + +> [!info] Отличия от Java +> В Java `main` обязан лежать внутри класса, быть `public static void` и принимать `String[]`, а имя файла должно совпадать с именем публичного класса ([Hello World! в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Hello%20World!.md)). +> В Kotlin всё это не требуется: функции живут прямо в файле. +> +> На уровне байт-кода класс всё равно появляется - компилятор создаёт `HelloKt` с методом `public static void main(String[])`. Поэтому JVM запускает Kotlin-код без каких-либо адаптеров. +> Подробнее: [Kotlin в байт-коде](../../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) + +> [!warning] Подводные камни +> - `fun Main()` или `fun start()` скомпилируется, но JVM точку входа не найдёт - имя строго `main` +> - Если в файле есть `package`, запускать надо с полным именем: `java -cp . com.example.HelloKt` +> - Две функции `main()` в одном пакете в разных файлах - это нормально, классы-обёртки будут разными (`HelloKt`, `AppKt`) + +Далее: [Типы данных](Типы%20данных.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Операторы и выражения.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Операторы и выражения.md new file mode 100644 index 0000000..24a1267 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Операторы и выражения.md @@ -0,0 +1,182 @@ +--- +title: Операторы и выражения +description: Арифметика, равенство == и ===, побитовые операции словами, приоритет операторов +tags: + - kotlin + - syntax +--- + +В Kotlin почти все операторы - это вызовы функций. `a + b` компилируется в `a.plus(b)`, поэтому операторы можно определять для своих типов ([Операторные функции](../../Функциональный%20Kotlin/Инфиксные%20и%20операторные%20функции.md)). + +## Арифметические +| Оператор | Функция | Пример | +|---|---|---| +| `+` | `plus` | `2 + 3` → 5 | +| `-` | `minus` | `5 - 3` → 2 | +| `*` | `times` | `2 * 3` → 6 | +| `/` | `div` | `7 / 2` → 3 | +| `%` | `rem` | `7 % 2` → 1 | + +Целочисленное деление отбрасывает дробную часть: +```kotlin +println(7 / 2) // 3 +println(7.0 / 2) // 3.5 +println(7 / 2.0) // 3.5 +println(-7 / 2) // -3 — округление к нулю +println(-7 % 2) // -1 — знак остатка совпадает со знаком делимого +``` + +### Инкремент и декремент +```kotlin +var i = 0 +println(i++) // 0, потом i = 1 +println(++i) // 2 +``` +Работают только с `var`. Присваивания-операторы (`+=`, `-=`, `*=`, `/=`, `%=`) - тоже. + +## Сравнение +`<`, `>`, `<=`, `>=` транслируются в `compareTo`: +```kotlin +println(2 < 3) // true +println("abc" < "abd") // true — лексикографически +``` +Работает для любого типа, реализующего `Comparable`. + +## Равенство: == и === +Это главное место, где Kotlin расходится с Java. + +| Оператор | Что делает | +|---|---| +| `==` | структурное равенство, вызывает `equals()` | +| `===` | ссылочное равенство, сравнивает адреса | +| `!=`, `!==` | их отрицания | + +```kotlin +val a = "Kotlin" +val b = StringBuilder("Kotlin").toString() +println(a == b) // true — содержимое совпадает +println(a === b) // false — разные объекты +``` + +`==` безопасен для null: `a == b` разворачивается в `a?.equals(b) ?: (b === null)`. +```kotlin +val s: String? = null +println(s == "text") // false, без NPE +println(s == null) // true +``` + +## Логические +```kotlin +val r1 = true && false // ленивый И +val r2 = true || false // ленивый ИЛИ +val r3 = !true // НЕ + +val r4 = true and false // НЕ ленивый — оба операнда вычисляются +val r5 = true or false // НЕ ленивый +val r6 = true xor true // false +``` +В обычном коде используют `&&` и `||`: они не вычисляют правую часть, если результат уже известен. + +## Побитовые операции +Символов `&`, `|`, `^`, `<<`, `>>` для чисел в Kotlin **нет** - только инфиксные функции: +```kotlin +val a = 0b1100 +val b = 0b1010 +println(a and b) // 8 = 0b1000 +println(a or b) // 14 = 0b1110 +println(a xor b) // 6 = 0b0110 +println(a.inv()) // -13 — побитовое НЕ +println(1 shl 4) // 16 — сдвиг влево +println(16 shr 2) // 4 — сдвиг вправо со знаком +println(-16 ushr 2) // беззнаковый сдвиг вправо +``` +Доступны для `Int` и `Long`. + +## Операторы диапазонов и коллекций +```kotlin +println(5 in 1..10) // true +println(5 !in 1..10) // false +println("a" in listOf("a","b")) // true — вызывает contains() +val list = listOf(1, 2) +println(list + 3) // [1, 2, 3] — plus для коллекций +``` +Подробнее: [Циклы и диапазоны](Циклы%20и%20диапазоны.md) + +## Приоритет +От высшего к низшему: + +| Уровень | Операторы | +|---|---| +| постфиксные | `++`, `--`, `.`, `?.`, `!!` | +| префиксные | `-`, `+`, `++`, `--`, `!` | +| приведение | `as`, `as?` | +| умножение | `*`, `/`, `%` | +| сложение | `+`, `-` | +| диапазон | `..`, `..<` | +| инфиксные функции | `and`, `shl`, своя функция `infix` | +| проверка принадлежности | `in`, `!in` | +| проверка типа | `is`, `!is` | +| сравнение | `<`, `>`, `<=`, `>=` | +| равенство | `==`, `!=`, `===`, `!==` | +| конъюнкция | `&&` | +| дизъюнкция | `\|\|` | +| elvis | `?:` | +| присваивание | `=`, `+=`, … | + +На практике: сомневаетесь - ставьте скобки. + +## Всё является выражением +`if`, `when`, `try` возвращают значение: +```kotlin +val max = if (a > b) a else b +val name = when (code) { + 200 -> "ok" + else -> "unknown" +} +val n = try { text.toInt() } catch (e: NumberFormatException) { 0 } +``` +Подробнее: [Условные конструкции](Условные%20конструкции.md) + +Циклы (`for`, `while`) - **не** выражения, они возвращают `Unit`. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `==` для объектов - ссылки | `==` - вызов `equals()` | +> | `.equals()` - содержимое | `===` - ссылки | +> | `a & b` побитовое И | `a and b` | +> | `a >>> b` | `a ushr b` | +> | `~a` | `a.inv()` | +> | тернарник `c ? a : b` | `if (c) a else b` | +> | операторы нельзя перегружать | можно, через `operator fun` | +> | `s.equals(t)` падает, если `s` null | `s == t` безопасен | +> +> Главная ловушка при переходе: в Java `==` на строках сравнивает ссылки и работает «случайно» благодаря [интернированию](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Строки.md). В Kotlin `==` всегда сравнивает содержимое - это и нужно в 99% случаев. +> +> Сравнить: [Операторы в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Операторы%20и%20выражения.md) + +> [!warning] Подводные камни +> **`and` и `or` не ленивые.** Для `Boolean` это рабочий код, но правая часть вычислится всегда: +> ```kotlin +> fun risky(): Boolean { println("вызвана"); return true } +> val a = false && risky() // risky() не вызовется +> val b = false and risky() // "вызвана" напечатается +> ``` +> +> **Деление на целочисленный ноль - исключение, на вещественный - нет:** +> ```kotlin +> // println(1 / 0) // ArithmeticException +> println(1.0 / 0) // Infinity +> println(0.0 / 0) // NaN +> println(Double.NaN == Double.NaN) // false! NaN не равен себе +> ``` +> +> **Приоритет инфиксных функций ниже арифметики:** +> ```kotlin +> println(1 shl 2 + 3) // 32, а не 7: это 1 shl (2+3) +> println((1 shl 2) + 3) // 7 +> ``` +> +> **`++` на упакованном значении создаёт новый объект** - в горячем цикле с `Int?` это аллокации. Используйте не-nullable типы. + +Далее: [Условные конструкции](Условные%20конструкции.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Пакеты и импорты.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Пакеты и импорты.md new file mode 100644 index 0000000..54d87da --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Пакеты и импорты.md @@ -0,0 +1,129 @@ +--- +title: Пакеты и импорты +description: Объявление пакетов, импорты, псевдонимы и автоматически импортируемые пакеты +tags: + - kotlin + - syntax +--- + +## Пакет +Объявляется первой строкой файла: +```kotlin +package com.example.app + +fun helper() { } +class Service +``` +Полное имя функции - `com.example.app.helper`. + +Отличие от Java: **структура каталогов не обязана совпадать с пакетом**, и имя файла не связано с содержимым. Компилятор не возражает. На практике соглашение соблюдают: иначе навигация по проекту ломается. + +Файл без `package` попадает в корневой пакет (default package). + +## Содержимое файла +В одном файле может быть сколько угодно объявлений верхнего уровня: +```kotlin +package com.example + +const val VERSION = "1.0" + +fun parse(text: String): Int = text.toInt() + +class User(val name: String) +class Admin(val name: String) + +private fun internalHelper() { } // видна только в этом файле +``` +Никаких `public class` на файл - ограничения «один публичный класс на файл» в Kotlin нет. + +## Импорты +```kotlin +import com.example.app.Service // конкретный класс +import com.example.app.helper // функция верхнего уровня +import com.example.app.* // всё из пакета +import com.example.app.User.Companion.create // член companion object +``` +Импортировать можно не только типы, но и функции, свойства, enum-константы - всё, что объявлено на верхнем уровне или как статический член. + +```kotlin +import kotlin.math.PI +import kotlin.math.sqrt + +fun main() { + println(sqrt(PI)) // 1.7724538509055159 +} +``` + +## Псевдонимы - as +Когда имена конфликтуют или слишком длинные: +```kotlin +import com.example.data.User as DataUser +import com.example.api.User as ApiUser + +fun convert(u: DataUser): ApiUser = ApiUser(u.name) +``` +Псевдоним действует только в своём файле. Если нужно имя на весь модуль - это [typealias](../Базовые%20типы%20ООП/Typealias.md). + +## Импортируются автоматически +Эти пакеты доступны без `import`: + +| Пакет | Что содержит | +|---|---| +| `kotlin.*` | `Int`, `String`, `Any`, базовые функции | +| `kotlin.annotation.*` | аннотации | +| `kotlin.collections.*` | `List`, `Map`, `listOf`, операции | +| `kotlin.comparisons.*` | `compareBy`, `maxOf` | +| `kotlin.io.*` | `println`, `readln` | +| `kotlin.ranges.*` | диапазоны, `step`, `downTo` | +| `kotlin.sequences.*` | `Sequence`, `sequenceOf` | +| `kotlin.text.*` | операции со строками | +| `java.lang.*` | только на JVM | +| `kotlin.jvm.*` | только на JVM | + +Поэтому `println`, `listOf`, `Int` работают из коробки. А `kotlin.math.sqrt` - нет, его импортируют явно. + +## Видимость на верхнем уровне +```kotlin +public fun api() { } // видна везде (public — по умолчанию) +internal fun moduleOnly() { } // видна в своём модуле +private fun fileOnly() { } // видна только в этом файле +``` +`protected` на верхнем уровне запрещён - нет класса, от которого наследоваться. +Подробнее: [Модификаторы видимости](../../ООП/Модификаторы%20видимости.md) + +## Имя класса-обёртки +Функции верхнего уровня попадают в класс, названный по файлу: `Utils.kt` → класс `UtilsKt`. Имя можно задать: +```kotlin +@file:JvmName("Utils") +package com.example + +fun helper() { } // из Java: Utils.helper() +``` +Аннотации уровня файла ставятся **до** `package`. Подробнее: [Kotlin из Java](../../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | каталоги обязаны совпадать с пакетом | не обязаны (но так принято) | +> | имя файла = имя публичного класса | имя файла произвольно | +> | один публичный класс на файл | сколько угодно объявлений | +> | `import static` для статики | обычный `import` для всего | +> | псевдонимов нет | `import ... as Name` | +> | автоимпорт только `java.lang` | десять пакетов `kotlin.*` | +> | `package-private` по умолчанию | `public` по умолчанию | +> +> Последняя строка важна: в Java класс без модификатора виден только своему пакету, в Kotlin - всем. Для сокрытия внутренностей библиотеки используют `internal`. + +> [!warning] Подводные камни +> **Нет аналога package-private.** `internal` ограничивает *модулем* (единицей компиляции Gradle), а не пакетом. Разделить код на пакеты и спрятать их друг от друга внутри одного модуля нельзя - только разносить по модулям. +> +> **`import *` не затягивает вложенные пакеты.** `import com.example.*` не даёт доступа к `com.example.data.User`. +> +> **Конфликт импортов - ошибка компиляции, а не предупреждение.** Два `User` из разных пакетов требуют `as` или полного имени. +> +> **Функции с одинаковым именем в разных файлах одного пакета конфликтуют.** Два `private fun helper()` в разных файлах - нормально, а два `fun helper()` с одной сигнатурой - ошибка: оба попадают в один пакет. +> +> **`@file:JvmName` не действует, если имя файла совпадает с именем класса в нём.** Коллизия имён вызовет ошибку компиляции. + +Дополнительно: [Структура проекта](../../Инструментарий/Структура%20проекта.md) +Далее: [Null-безопасность](../Null-безопасность/Null-безопасность.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Переменные.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Переменные.md new file mode 100644 index 0000000..b559ff6 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Переменные.md @@ -0,0 +1,154 @@ +--- +title: Переменные +description: val и var, вывод типов, const val, lateinit и области видимости +tags: + - kotlin + - syntax +--- + +Два ключевых слова: +- `val` - **value**, значение присваивается один раз (read-only) +- `var` - **variable**, значение можно менять + +```kotlin +val name = "Kotlin" +var count = 0 +count++ // ok +// name = "Java" // ошибка компиляции: val cannot be reassigned +``` + +Правило стиля: по умолчанию `val`, `var` - только когда переменная действительно меняется. Это не формальность: с `val` работают [умные приведения](../Null-безопасность/Умные%20приведения.md), а код становится предсказуемее. + +## Объявление +```kotlin +val a: Int = 1 // тип указан явно +val b = 1 // тип выведен: Int +val c: Int // без инициализации — тип обязателен +c = 2 // присвоить можно позже, но только один раз +``` +Тип ставится **после** имени через двоеточие. + +## Вывод типов +Компилятор выводит тип из значения: +```kotlin +val n = 42 // Int +val l = 42L // Long +val s = "text" // String +val list = listOf(1, 2) // List +val mixed = listOf(1, "a") // List +``` +Вывод работает только там, где есть что выводить. У свойств класса и возвращаемых значений публичных функций тип лучше писать явно - это часть контракта. + +## val - не значит immutable +`val` запрещает переприсваивание ссылки, но не меняет сам объект: +```kotlin +val list = mutableListOf(1, 2) +list.add(3) // ok — меняем объект +println(list) // [1, 2, 3] +// list = mutableListOf() // ошибка — меняем ссылку +``` +Неизменяемость данных - это выбор типа (`List` вместо `MutableList`), а не `val`. Подробнее: [Изменяемость коллекций](../../Коллекции/Изменяемость.md) + +## val с геттером +`val` может вычисляться при каждом обращении: +```kotlin +val now: Long + get() = System.currentTimeMillis() +``` +Это уже свойство, а не поле. Подробнее: [Свойства](../../ООП/Свойства.md) + +## Константы - const val +```kotlin +const val MAX = 100 // верхний уровень файла +const val GREETING = "Hello" + +object Config { + const val TIMEOUT = 30 // или внутри object +} +``` +Требования к `const val`: +- только `String` или примитивный тип +- значение известно на этапе компиляции +- только на верхнем уровне, в `object` или в `companion object` +- нельзя у локальной переменной и нельзя с кастомным геттером + +Значение подставляется в место использования на этапе компиляции (инлайнится), обращения к полю в рантайме нет. + +```kotlin +val runtime = computeLimit() // вычисляется при запуске +const val compiled = 100 // подставлено компилятором +``` + +## lateinit +Для `var`, который точно будет проинициализирован, но не в момент объявления (DI, настройка в тестах): +```kotlin +class Service { + lateinit var client: HttpClient + + fun setup() { + client = HttpClient() + } + + fun use() { + if (::client.isInitialized) client.send() + } +} +``` +Ограничения `lateinit`: +- только `var` +- тип не nullable +- не примитивный тип (`Int`, `Double`, … запрещены) +- нельзя у локальных переменных +- нельзя с кастомным геттером/сеттером + +Обращение до инициализации - `UninitializedPropertyAccessException`. + +Альтернатива для ленивых вычислений - `by lazy`, он работает с `val`: +```kotlin +val config: Config by lazy { loadConfig() } // вычислится при первом обращении +``` +Подробнее: [Свойства](../../ООП/Свойства.md) + +## Области видимости +```kotlin +const val TOP = 1 // весь файл и, если public, другие файлы + +fun demo() { + val local = 2 // только тело функции + if (local > 1) { + val inner = 3 // только тело if + } + // println(inner) // ошибка: не видна +} +``` +Вложенная переменная может скрыть внешнюю - компилятор выдаст предупреждение `name shadowed`. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `final int x = 1;` | `val x = 1` | +> | `int x = 1;` | `var x = 1` | +> | `var x = 1;` (только локально, с Java 10) | вывод типов везде, включая свойства класса | +> | `static final int MAX = 100;` | `const val MAX = 100` | +> | мутабельность по умолчанию | иммутабельность предпочтительна | +> | `int x;` - значение по умолчанию 0 | неинициализированную переменную читать нельзя | +> +> Важное следствие: у свойств класса в Kotlin **нет значений по умолчанию**. В Java поле `int count;` автоматически 0, в Kotlin `var count: Int` без инициализатора не скомпилируется. +> +> Сравнить: [Переменные в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Переменные.md) + +> [!warning] Подводные камни +> **`val` не делает объект неизменяемым** - см. пример с `MutableList` выше. Частая ошибка на собеседованиях. +> +> **`lateinit` прячет ошибку до рантайма.** Если забыли вызвать `setup()`, падение случится в момент использования, далеко от причины. Там, где можно, лучше конструктор или `by lazy`. +> +> **Вывод типов может дать не тот тип, что ожидали:** +> ```kotlin +> val x = 1 / 2 // 0, а не 0.5 — оба операнда Int +> val y = 1.0 / 2 // 0.5 +> ``` +> +> **`const val` зашивается в вызывающий код.** Если константа лежит в библиотеке и её значение изменилось, зависимый модуль нужно перекомпилировать - иначе он продолжит использовать старое значение. + +Дополнительно: [Свойства](../../ООП/Свойства.md) +Далее: [Операторы и выражения](Операторы%20и%20выражения.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Строковые шаблоны и вывод.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Строковые шаблоны и вывод.md new file mode 100644 index 0000000..9af13cb --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Строковые шаблоны и вывод.md @@ -0,0 +1,191 @@ +--- +title: Строковые шаблоны и вывод +description: Интерполяция строк, raw strings, trimIndent, println и форматированный вывод +tags: + - kotlin + - syntax +--- + +## Шаблоны строк +Вместо конкатенации - подстановка значений прямо в строку: +```kotlin +val name = "Kotlin" +val version = 2.4 + +println("Привет, $name!") // Привет, Kotlin! +println("Версия: $version") // Версия: 2.4 +println("Длина имени: ${name.length}") // Длина имени: 6 +println("Сумма: ${2 + 3}") // Сумма: 5 +``` + +Правила: +- `$имя` - для простой переменной +- `${выражение}` - для всего остального: вызовов, свойств, арифметики +- вызывается `toString()` подставляемого значения +- `null` подставляется как текст `null` + +```kotlin +val x: String? = null +println("значение: $x") // значение: null +``` + +Фигурные скобки можно ставить всегда - многие так и делают для единообразия. + +## Экранирование +```kotlin +println("цена: \$100") // цена: $100 +println("перенос\nстроки") +println("табуляция\tтут") +println("кавычка \" внутри") +println("слеш \\ внутри") +println("символ \u00A9") // © +``` +Поддерживаются `\t`, `\b`, `\n`, `\r`, `\'`, `\"`, `\\`, `\$`, `\uXXXX`. + +## Raw strings +Три кавычки - никакого экранирования, переносы сохраняются как есть: +```kotlin +val path = """C:\Users\name\file.txt""" // слеши не экранируются +val json = """ + { + "name": "Kotlin", + "stable": true + } +""" +``` +Удобно для regex, JSON, SQL, HTML. + +Шаблоны `$` в raw strings **работают**: +```kotlin +val user = "admin" +val query = """SELECT * FROM users WHERE name = '$user'""" +``` +Чтобы вставить настоящий `$`, используют подстановку символа: +```kotlin +val price = """цена: ${'$'}100""" // цена: $100 +``` + +## trimIndent и trimMargin +Raw string сохраняет отступы исходного кода - их убирают явно: +```kotlin +val text = """ + Первая строка + Вторая строка +""".trimIndent() +// "Первая строка\nВторая строка" — общий отступ убран, +// пустые первая и последняя строки удалены + +val bar = """ + |Первая + |Вторая +""".trimMargin() // по умолчанию маркер | +``` +`trimIndent()` вычисляет минимальный общий отступ непустых строк и срезает его у всех. + +## println и print +```kotlin +print("без переноса") +println("с переносом") +println() // пустая строка +println(listOf(1, 2, 3)) // [1, 2, 3] +``` +Это функции стандартной библиотеки, доступны без импорта. Под капотом - `System.out`. + +## Форматированный вывод +Kotlin использует механизм форматирования Java: +```kotlin +import java.util.Locale + +fun main() { + println("%05d".format(42)) // 00042 + println("%-10s|".format("left")) // left | + println(String.format("%s = %d", "x", 5)) // x = 5 + + // вещественные числа и разряды зависят от локали — её лучше задавать явно + println("%.2f".format(Locale.US, 3.14159)) // 3.14 + println("%,d".format(Locale.US, 1234567)) // 1,234,567 +} +``` + +> [!warning] Локаль по умолчанию +> `format` без явной локали берёт системную. На русской локали получится `3,14` и `1 234 567` - с запятой и неразрывными пробелами. Если строка идёт в JSON, SQL или лог, это ломает парсинг. Для машинных форматов всегда передавайте `Locale.US` (или `Locale.ROOT`). + +Основные спецификаторы: + +| Спецификатор | Значение | +|---|---| +| `%d` | целое число | +| `%f` | вещественное | +| `%.Nf` | вещественное с N знаками | +| `%s` | строка (через `toString()`) | +| `%b` | boolean | +| `%x` | шестнадцатеричное | +| `%e` | экспоненциальная запись | +| `%n` | перенос строки платформы | +| `%%` | символ процента | + +Флаги ширины: `%10d` - по правому краю, `%-10d` - по левому, `%010d` - с ведущими нулями. + +## Чтение ввода +```kotlin +val line = readLine() // String? — null при конце потока +val text = readln() // String — бросает исключение при EOF +val n = readln().toInt() // падает на некорректном вводе +val safe = readln().toIntOrNull() ?: 0 // безопасный разбор +``` +`readln()` появился в Kotlin 1.6 и удобнее, когда ввод гарантирован. + +## Конкатенация +Работает, но в шаблонах почти не нужна: +```kotlin +println("Hello" + " " + "world") // Hello world +println("Hello" + 5 + 10) // Hello510 — слева направо +println("Hello" + (5 + 10)) // Hello15 +``` +Важно: складывать можно только когда **слева** строка. У `Int` нет `plus(String)`, поэтому в Kotlin не скомпилируется то, что в Java работает: +```kotlin +// println(5 + 10 + "Hello") // ошибка: Int.plus ожидает Int, а не String +println("${5 + 10}Hello") // 15Hello — через шаблон +``` +Для сборки строки в цикле - `StringBuilder` или `buildString`: +```kotlin +val result = buildString { + repeat(3) { append("ха") } +} +println(result) // хахаха +``` +Подробнее: [Строки](../Работа%20с%20данными/Строки.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `System.out.println(x)` | `println(x)` | +> | `"a" + x + "b"` | `"a${x}b"` | +> | `String.format("%d", n)` | `"%d".format(n)` | +> | text blocks `"""` (Java 15+) | raw strings `"""` + шаблоны | +> | `.stripIndent()` | `.trimIndent()` | +> | `new Scanner(System.in)` | `readln()` | +> +> Шаблоны - не просто синтаксический сахар: компилятор собирает их через `StringBuilder` или `invokedynamic`, то есть без промежуточных строк. Писать `"a" + b + "c"` ради производительности не нужно. +> +> Сравнить: [Форматированный вывод в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Форматированный%20вывод.md) + +> [!warning] Подводные камни +> **`$` перед словом всегда считается шаблоном.** Строка `"цена: $100"` скомпилируется (цифра не может начинать имя), а `"$name_suffix"` - нет: Kotlin ищет переменную `name_suffix`. Нужны скобки: `"${name}_suffix"`. +> +> **`trimIndent()` чувствителен к смешению табов и пробелов** - отступ считается по символам, таб и четыре пробела для него разные вещи. +> +> **`$` в regex внутри raw string.** `"""^\d+$"""` работает, потому что после `$` идёт конец строки. Но `"""$\d"""` компилятор попытается разобрать как шаблон. +> +> **Шаблон вызывает `toString()` у массива, а не его содержимое:** +> ```kotlin +> val arr = arrayOf(1, 2, 3) +> println("$arr") // [Ljava.lang.Object;@1b6d3586 +> println(arr.contentToString()) // [1, 2, 3] +> println("${arr.joinToString()}") // 1, 2, 3 +> ``` +> +> **`readLine()` возвращает `String?`.** Вызов `readLine().toInt()` не скомпилируется - нужен `readln()` или обработка null. + +Дополнительно: [Строки](../Работа%20с%20данными/Строки.md) +Далее: [Пакеты и импорты](Пакеты%20и%20импорты.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Типы данных.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Типы данных.md new file mode 100644 index 0000000..7a082af --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Типы данных.md @@ -0,0 +1,154 @@ +--- +title: Типы данных +description: Система типов Kotlin, числа, boolean, Char и отсутствие примитивов на уровне языка +tags: + - kotlin + - types +--- + +В Kotlin: статическая типизация, сильная, с выводом типов. + +Главное отличие от Java: **на уровне языка примитивов нет**. Всё - объект, у всего есть методы. +```kotlin +val x: Int = 42 +println(x.toString()) // у числа есть методы +println(42.coerceIn(0, 10)) // 10 +``` +Но в байт-коде `Int` компилируется в `int`, если это возможно - производительность не теряется. Подробнее в [Kotlin в байт-коде](../../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md). + +## Иерархия типов +```mermaid +graph TD + Any["Any — корень всех non-null типов"] + Any --> Number + Any --> Char + Any --> Boolean + Any --> String + Any --> Other["классы, интерфейсы, ..."] + Number --> Byte + Number --> Short + Number --> Int + Number --> Long + Number --> Float + Number --> Double + Nothing["Nothing — наследник всех типов"] +``` +`Any`, `Unit` и `Nothing` - особые типы, им посвящена отдельная заметка: [Any, Unit, Nothing](../Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md) + +## Числовые типы +| Тип | Размер, бит | Диапазон | +|---|---|---| +| `Byte` | 8 | −128 … 127 | +| `Short` | 16 | −32 768 … 32 767 | +| `Int` | 32 | ≈ ±2 млрд | +| `Long` | 64 | ≈ ±9,2 · 10¹⁸ | +| `Float` | 32 | ≈ 6-7 значащих цифр | +| `Double` | 64 | ≈ 15-16 значащих цифр | + +Все наследуются от `Number`, но **не** друг от друга: `Int` не является подтипом `Long`. + +### Литералы +```kotlin +val i = 42 // Int по умолчанию +val l = 42L // Long — суффикс L +val d = 42.0 // Double по умолчанию +val f = 42.0f // Float — суффикс f +val hex = 0xFF // 255 +val bin = 0b1010 // 10 +val big = 1_000_000 // подчёркивания для читаемости +``` +Восьмеричных литералов (`017` в Java) в Kotlin **нет**. + +## Беззнаковые типы +`UByte`, `UShort`, `UInt`, `ULong` - то, чего в Java нет совсем. +```kotlin +val u: UInt = 4_000_000_000u // суффикс u +println(UInt.MAX_VALUE) // 4294967295 +``` +Нужны редко: работа с бинарными форматами, протоколами, хешами. + +## Boolean +```kotlin +val yes: Boolean = true +val both = yes && other() // ленивые операторы, как в Java +val eager = yes and other() // не ленивый: other() вызовется всегда +``` + +## Char +```kotlin +val c: Char = 'A' +println(c.code) // 65 — числовой код +println(c + 1) // B — Char + Int даёт Char +println(c.isLetter()) // true +``` +`Char` **не является числом**: `val n: Int = 'A'` не скомпилируется, нужен `'A'.code`. +Подробнее: [Char](../Работа%20с%20данными/Char.md) + +## Преобразования +Неявных расширяющих преобразований нет вообще - только явные вызовы: +```kotlin +val i = 42 +val l: Long = i.toLong() // обязательно явно +val b = i.toByte() +val d = i.toDouble() +``` +Доступны `toByte()`, `toShort()`, `toInt()`, `toLong()`, `toFloat()`, `toDouble()`, `toChar()`. + +## Строки +`String` - неизменяемый тип, элементы доступны по индексу: +```kotlin +val s = "Kotlin" +println(s[0]) // K +println(s.length) // 6 +``` +Подробнее: [Строки](../Работа%20с%20данными/Строки.md) + +## Nullable-типы +Любой тип имеет nullable-вариант с `?`: +```kotlin +// val a: Int = null // ошибка компиляции +val b: Int? = null // ok +``` +Это ядро языка, ему посвящён отдельный раздел: [Null-безопасность](../Null-безопасность/Null-безопасность.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `int` и `Integer` - разные типы | только `Int`, компилятор сам выбирает представление | +> | автоупаковка неявная | упаковка скрыта, в коде не видна | +> | `long x = intValue;` работает | нужен `intValue.toLong()` | +> | `char` - числовой тип | `Char` - отдельный тип, не число | +> | нет беззнаковых типов | `UInt`, `ULong`, … | +> | `017` - восьмеричный литерал | восьмеричных литералов нет | +> +> Сравнить: [Типы данных в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Типы%20данных.md) + +> [!warning] Подводные камни +> **Переполнение не проверяется**, как и в Java: +> ```kotlin +> println(Int.MAX_VALUE + 1) // -2147483648 +> ``` +> +> **Равенство упакованных значений.** Если тип nullable, значение упаковано, и `===` сравнивает ссылки: +> ```kotlin +> val a: Int = 127 +> val b: Int? = a +> val c: Int? = a +> println(b == c) // true — сравнение значений +> println(b === c) // true — кеш Integer для -128..127 +> +> val x: Int? = 1000 +> val y: Int? = 1000 +> println(x == y) // true +> println(x === y) // false — за пределами кеша это разные объекты +> ``` +> Вывод: для чисел всегда `==`, никогда `===`. +> +> **`Float` и `Double` не точны**, это стандарт IEEE 754: +> ```kotlin +> println(0.1 + 0.2) // 0.30000000000000004 +> ``` +> Для денег - `java.math.BigDecimal`. + +Дополнительно: [Числа и переполнение](../Работа%20с%20данными/Числа%20и%20переполнение.md) +Далее: [Переменные](Переменные.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Условные конструкции.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Условные конструкции.md new file mode 100644 index 0000000..46e3a9a --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Условные конструкции.md @@ -0,0 +1,175 @@ +--- +title: Условные конструкции +description: if и when как выражения, исчерпывающий when, отсутствие тернарного оператора и switch +tags: + - kotlin + - syntax +--- + +В Kotlin две конструкции ветвления: `if` и `when`. Обе могут работать и как инструкция, и как **выражение**, возвращающее значение. + +## if как инструкция +```kotlin +if (x > 0) { + println("положительное") +} else if (x < 0) { + println("отрицательное") +} else { + println("ноль") +} +``` +Условие обязано быть `Boolean`. Число или null туда подставить нельзя - компилятор не пропустит. + +## if как выражение +```kotlin +val max = if (a > b) a else b + +val label = if (x > 0) { + log("positive") + "плюс" // последнее выражение блока — результат +} else { + "минус" +} +``` +Правила: +- в режиме выражения ветка `else` **обязательна** - иначе значение не определено +- результат блока - его последнее выражение +- тип результата - общий супертип всех ветвей + +```kotlin +val r = if (flag) 1 else "text" // тип Any — общий супертип +``` + +Тернарного оператора `?:` в смысле Java в Kotlin нет: его роль играет `if`. Оператор `?:` в Kotlin - это **elvis**, у него другая задача ([Операторы null-безопасности](../Null-безопасность/Операторы%20null-безопасности.md)). + +## when - замена switch +Две формы. С аргументом: +```kotlin +when (code) { + 200 -> println("ok") + 404 -> println("not found") + 500, 502, 503 -> println("ошибка сервера") // несколько значений в одной ветке + else -> println("неизвестно") +} +``` +Без аргумента - вместо цепочки `if/else if`: +```kotlin +when { + x < 0 -> println("отрицательное") + x == 0 -> println("ноль") + else -> println("положительное") +} +``` + +Ветки проверяются **сверху вниз**, выполняется первая подошедшая. Никакого `break` не нужно - провала в следующую ветку (fall-through) в Kotlin не бывает. + +## when как выражение +```kotlin +val name = when (code) { + 200 -> "ok" + 404 -> "not found" + else -> "unknown" +} +``` +В режиме выражения `when` обязан быть **исчерпывающим**: либо есть `else`, либо компилятор видит, что перечислены все варианты. + +## Условия в ветках when +Не только константы: +```kotlin +when (x) { + in 1..9 -> "одна цифра" // диапазон + in listOf(10, 20, 30) -> "круглое" // коллекция + !in 0..100 -> "вне диапазона" + else -> "прочее" +} + +when (value) { + is String -> value.length // проверка типа + умное приведение + is Int -> value + 1 + is List<*> -> value.size + else -> 0 +} + +when (x) { + computeLimit() -> "предел" // произвольное выражение + else -> "нет" +} +``` +После `is` переменная автоматически приводится к проверенному типу - см. [Умные приведения](../Null-безопасность/Умные%20приведения.md). + +## Исчерпывающий when без else +С `enum` и `sealed` компилятор сам проверяет полноту: +```kotlin +enum class State { LOADING, READY, ERROR } + +fun describe(s: State) = when (s) { + State.LOADING -> "загрузка" + State.READY -> "готово" + State.ERROR -> "ошибка" +} // else не нужен — все варианты перечислены +``` +Добавили `State.EMPTY` - код **перестанет компилироваться** во всех таких `when`. Это главная причина предпочитать `sealed` и `enum` строкам и числам: компилятор показывает каждое место, которое нужно обновить. + +Подробнее: [Enum class](../Базовые%20типы%20ООП/Enum%20class.md), [Sealed class и interface](../Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +## Переменная в when +Аргумент можно объявить прямо в заголовке - он будет виден только внутри: +```kotlin +when (val response = request()) { + is Success -> response.data + is Failure -> response.error +} +``` +Удобно, когда `request()` дорогой: вызов происходит один раз. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `switch` только по числам, строкам, enum | `when` по любым значениям и условиям | +> | нужен `break`, иначе fall-through | провала нет, `break` не нужен | +> | `c ? a : b` | `if (c) a else b` | +> | `if` - только инструкция | `if` - ещё и выражение | +> | `switch` не проверяет полноту (до pattern matching в Java 21) | `when` по enum/sealed проверяется компилятором | +> +> `when` ближе всего к `switch`-выражению из Java 14+ и pattern matching из Java 21, но появился в Kotlin задолго до них и покрывает больше случаев. +> +> Сравнить: [Условные конструкции в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Условные%20конструкции%20и%20операторы.md) + +> [!warning] Подводные камни +> **Полнота проверяется и у `when`-инструкции - но только для `enum` и `sealed`.** С Kotlin 2.0 пропуск ветки - ошибка компиляции даже когда результат не используется: +> ```kotlin +> fun handle(s: State) { +> when (s) { +> State.LOADING -> show() +> State.READY -> done() +> } // error: 'when' expression must be exhaustive. +> } // Add the 'ERROR' branch or an 'else' branch. +> ``` +> А вот для `Int`, `String` и прочих открытых типов полноту проверить невозможно - там `when`-инструкция без `else` компилируется молча и просто ничего не делает: +> ```kotlin +> fun handle(code: Int) { +> when (code) { +> 200 -> show() +> 404 -> fail() +> } // для code = 500 тихо ничего не произойдёт +> } +> ``` +> Это ещё одна причина моделировать состояния через `enum` и `sealed`, а не через числа и строки: только тогда компилятор на вашей стороне. +> +> **Порядок ветвей важен.** Первая подошедшая выигрывает, более общая ветка выше «съест» частные: +> ```kotlin +> when (x) { +> in 0..100 -> "диапазон" // сработает и для 42 +> 42 -> "ответ" // недостижимо +> } +> ``` +> +> **`if` без `else` не выражение:** +> ```kotlin +> // val x = if (flag) 1 // ошибка компиляции +> val x = if (flag) 1 else 0 // ok +> ``` +> +> **`is List<*>` не проверяет тип элементов** - дженерики стираются в рантайме. `is List` написать нельзя. Подробнее: [Reified и inline](../../Дженерики/Reified%20и%20inline.md) + +Далее: [Циклы и диапазоны](Циклы%20и%20диапазоны.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Циклы и диапазоны.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Циклы и диапазоны.md new file mode 100644 index 0000000..24e41a9 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Циклы и диапазоны.md @@ -0,0 +1,159 @@ +--- +title: Циклы и диапазоны +description: for-in, диапазоны и прогрессии, while, метки и отсутствие классического for +tags: + - kotlin + - syntax +--- + +В Kotlin один цикл обхода - `for (x in source)`. Классического `for (int i = 0; i < n; i++)` нет: его роль выполняют диапазоны. + +## Диапазоны +```kotlin +for (i in 1..5) print(i) // 12345 — включая 5 +for (i in 1..<5) print(i) // 1234 — исключая 5 +for (i in 5 downTo 1) print(i) // 54321 +for (i in 1..10 step 3) print(i) // 1 4 7 10 +for (c in 'a'..'e') print(c) // abcde +``` + +| Конструкция | Смысл | +|---|---| +| `a..b` | от `a` до `b` включительно | +| `a.. println("итерация $i") } // индекс доступен, с нуля +``` +Это не оператор, а [inline-функция](../../Функциональный%20Kotlin/Inline-функции.md) стандартной библиотеки. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `for (int i=0; i | `for (T x : collection)` | `for (x in collection)` | +> | `label: for (...)` | `outer@ for (...)` | +> | `break label;` | `break@outer` | +> | три части в заголовке `for` | только `in` | +> | нет диапазонов | `1..10`, `step`, `downTo` | +> +> Что потеряли: заголовок `for` с несколькими переменными и произвольным условием. Если нужна такая логика - пишется `while`. +> +> Сравнить: [Циклы в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Циклы.md) + +> [!warning] Подводные камни +> **`1..5` включает 5.** Самая частая ошибка при переходе с Java: индексы массива - это `0.. ```kotlin +> val list = listOf("a", "b", "c") +> // for (i in 0..list.size) println(list[i]) // IndexOutOfBounds на i=3 +> for (i in 0.. for (i in list.indices) println(list[i]) // ещё лучше +> ``` +> +> **`downTo` со `step` требует положительного шага:** +> ```kotlin +> for (i in 5 downTo 1 step 2) print(i) // 531 +> // for (i in 5 downTo 1 step -2) // IllegalArgumentException в рантайме +> ``` +> +> **Пустой диапазон не ошибка, просто ноль итераций:** +> ```kotlin +> for (i in 5..1) print(i) // ничего не напечатает +> ``` +> Молчаливый баг, если границы перепутаны. `(5..1).isEmpty()` - `true`. +> +> **Коллекцию нельзя изменять во время обхода:** +> ```kotlin +> val list = mutableListOf(1, 2, 3) +> // for (x in list) if (x == 2) list.remove(x) // ConcurrentModificationException +> list.removeAll { it == 2 } // правильный способ +> ``` +> +> **Диапазон `Long` и `Char` есть, а `Double` - нет.** `1.0..2.0` создаст `ClosedFloatingPointRange`: проверять `in` можно, но обойти циклом - нельзя (нет `iterator()`). + +Дополнительно: [Операции над коллекциями](../../Коллекции/Операции.md) +Далее: [Строковые шаблоны и вывод](Строковые%20шаблоны%20и%20вывод.md) -- 2.52.0 From 6853ada34801487cec1e573d327f261ecbf7f1f7 Mon Sep 17 00:00:00 2001 From: Aklivtsov Date: Tue, 20 Oct 2026 12:54:25 +0300 Subject: [PATCH 2/2] Kotlin guide done. (20.10.26) --- .../Взаимодействие с Java/Java из Kotlin.md | 385 +++++++++ .../Kotlin в байт-коде.md | 434 ++++++++++ .../Взаимодействие с Java/Kotlin из Java.md | 525 ++++++++++++ .../Взаимодействие с Java.md | 202 +++++ Kotlin/Дженерики/Reified и inline.md | 359 ++++++++ Kotlin/Дженерики/Вариантность.md | 351 ++++++++ .../Дженерики/Параметрический полиморфизм.md | 393 +++++++++ Kotlin/Дженерики/Проекции типов.md | 331 ++++++++ Kotlin/Инструментарий/Gradle и Kotlin DSL.md | 383 +++++++++ Kotlin/Инструментарий/KSP.md | 383 +++++++++ Kotlin/Инструментарий/kotlinc.md | 273 ++++++ Kotlin/Инструментарий/Инструментарий.md | 205 +++++ Kotlin/Инструментарий/Компилятор K2.md | 386 +++++++++ Kotlin/Инструментарий/Линтеры и формат.md | 385 +++++++++ Kotlin/Инструментарий/Структура проекта.md | 379 +++++++++ Kotlin/Исключения/Result и runCatching.md | 336 ++++++++ Kotlin/Исключения/Проверки и контракты.md | 403 +++++++++ Kotlin/Исключения/Система исключений.md | 370 +++++++++ Kotlin/Коллекции/List, Set, Map.md | 365 ++++++++ Kotlin/Коллекции/Sequence.md | 368 ++++++++ Kotlin/Коллекции/Изменяемость.md | 323 ++++++++ Kotlin/Коллекции/Коллекции под капотом.md | 282 +++++++ Kotlin/Коллекции/Коллекции.md | 234 ++++++ Kotlin/Коллекции/Операции.md | 424 ++++++++++ Kotlin/Корутины/Channels.md | 545 ++++++++++++ Kotlin/Корутины/Flow.md | 783 ++++++++++++++++++ Kotlin/Корутины/StateFlow и SharedFlow.md | 618 ++++++++++++++ Kotlin/Корутины/Suspend-функции.md | 395 +++++++++ Kotlin/Корутины/Билдеры корутин.md | 417 ++++++++++ Kotlin/Корутины/Диспетчеры и контекст.md | 404 +++++++++ Kotlin/Корутины/Исключения в корутинах.md | 529 ++++++++++++ Kotlin/Корутины/Корутины против потоков.md | 344 ++++++++ Kotlin/Корутины/Корутины.md | 277 +++++++ Kotlin/Корутины/Отмена и таймауты.md | 525 ++++++++++++ Kotlin/Корутины/Разделяемое состояние.md | 516 ++++++++++++ .../Структурированная конкурентность.md | 478 +++++++++++ Kotlin/ООП/Абстрактные классы.md | 306 +++++++ Kotlin/ООП/Вложенные и внутренние классы.md | 284 +++++++ Kotlin/ООП/Делегирование.md | 372 +++++++++ Kotlin/ООП/Инициализация.md | 320 +++++++ Kotlin/ООП/Интерфейсы.md | 332 ++++++++ Kotlin/ООП/Классы и конструкторы.md | 326 ++++++++ Kotlin/ООП/Модификаторы видимости.md | 290 +++++++ Kotlin/ООП/Наследование.md | 307 +++++++ Kotlin/ООП/ООП.md | 197 +++++ Kotlin/ООП/Переопределение.md | 341 ++++++++ Kotlin/ООП/Полиморфизм.md | 325 ++++++++ Kotlin/ООП/Свойства.md | 433 ++++++++++ Kotlin/Рефлексия/Аннотации.md | 653 +++++++++++++++ Kotlin/Рефлексия/Рефлексия.md | 505 +++++++++++ .../Null-безопасность/Операторы null-безопасности.md | 2 + .../Базовые типы ООП/Any, Unit, Nothing.md | 274 ++++++ .../Базовые типы ООП/Data class.md | 276 ++++++ .../Базовые типы ООП/Enum class.md | 298 +++++++ .../Базовые типы ООП/Object и companion object.md | 347 ++++++++ .../Базовые типы ООП/Sealed class и interface.md | 294 +++++++ .../Базовые типы ООП/Typealias.md | 228 +++++ .../Базовые типы ООП/Value class.md | 265 ++++++ .../Базовые типы ООП/equals, hashCode и toString.md | 320 +++++++ .../Базовый синтаксис/Пакеты и импорты.md | 2 + .../Синтаксис и типы/Работа с данными/Char.md | 140 ++++ .../Работа с данными/Деструктуризация.md | 218 +++++ .../Работа с данными/Лямбды и функциональные типы.md | 273 ++++++ .../Работа с данными/Массивы.md | 249 ++++++ .../Работа с данными/Приведение типов.md | 254 ++++++ .../Работа с данными/Строки.md | 222 +++++ .../Работа с данными/Функции.md | 280 +++++++ .../Работа с данными/Числа и переполнение.md | 231 ++++++ Kotlin/Тестирование/Kotest.md | 722 ++++++++++++++++ Kotlin/Тестирование/MockK.md | 780 +++++++++++++++++ Kotlin/Тестирование/kotlin.test и JUnit 5.md | 610 ++++++++++++++ Kotlin/Тестирование/Тестирование корутин.md | 680 +++++++++++++++ Kotlin/Тестирование/Тестирование.md | 366 ++++++++ Kotlin/Функциональный Kotlin/DSL.md | 444 ++++++++++ .../Функциональный Kotlin/Inline-функции.md | 322 +++++++ .../Receivers и context parameters.md | 439 ++++++++++ Kotlin/Функциональный Kotlin/Scope-функции.md | 441 ++++++++++ .../Инфиксные и операторные функции.md | 504 +++++++++++ .../Паттерны проектирования.md | 577 +++++++++++++ .../Функции высшего порядка.md | 392 +++++++++ .../Функции-расширения.md | 361 ++++++++ 81 files changed, 30112 insertions(+) create mode 100644 Kotlin/Взаимодействие с Java/Java из Kotlin.md create mode 100644 Kotlin/Взаимодействие с Java/Kotlin в байт-коде.md create mode 100644 Kotlin/Взаимодействие с Java/Kotlin из Java.md create mode 100644 Kotlin/Взаимодействие с Java/Взаимодействие с Java.md create mode 100644 Kotlin/Дженерики/Reified и inline.md create mode 100644 Kotlin/Дженерики/Вариантность.md create mode 100644 Kotlin/Дженерики/Параметрический полиморфизм.md create mode 100644 Kotlin/Дженерики/Проекции типов.md create mode 100644 Kotlin/Инструментарий/Gradle и Kotlin DSL.md create mode 100644 Kotlin/Инструментарий/KSP.md create mode 100644 Kotlin/Инструментарий/kotlinc.md create mode 100644 Kotlin/Инструментарий/Инструментарий.md create mode 100644 Kotlin/Инструментарий/Компилятор K2.md create mode 100644 Kotlin/Инструментарий/Линтеры и формат.md create mode 100644 Kotlin/Инструментарий/Структура проекта.md create mode 100644 Kotlin/Исключения/Result и runCatching.md create mode 100644 Kotlin/Исключения/Проверки и контракты.md create mode 100644 Kotlin/Исключения/Система исключений.md create mode 100644 Kotlin/Коллекции/List, Set, Map.md create mode 100644 Kotlin/Коллекции/Sequence.md create mode 100644 Kotlin/Коллекции/Изменяемость.md create mode 100644 Kotlin/Коллекции/Коллекции под капотом.md create mode 100644 Kotlin/Коллекции/Коллекции.md create mode 100644 Kotlin/Коллекции/Операции.md create mode 100644 Kotlin/Корутины/Channels.md create mode 100644 Kotlin/Корутины/Flow.md create mode 100644 Kotlin/Корутины/StateFlow и SharedFlow.md create mode 100644 Kotlin/Корутины/Suspend-функции.md create mode 100644 Kotlin/Корутины/Билдеры корутин.md create mode 100644 Kotlin/Корутины/Диспетчеры и контекст.md create mode 100644 Kotlin/Корутины/Исключения в корутинах.md create mode 100644 Kotlin/Корутины/Корутины против потоков.md create mode 100644 Kotlin/Корутины/Корутины.md create mode 100644 Kotlin/Корутины/Отмена и таймауты.md create mode 100644 Kotlin/Корутины/Разделяемое состояние.md create mode 100644 Kotlin/Корутины/Структурированная конкурентность.md create mode 100644 Kotlin/ООП/Абстрактные классы.md create mode 100644 Kotlin/ООП/Вложенные и внутренние классы.md create mode 100644 Kotlin/ООП/Делегирование.md create mode 100644 Kotlin/ООП/Инициализация.md create mode 100644 Kotlin/ООП/Интерфейсы.md create mode 100644 Kotlin/ООП/Классы и конструкторы.md create mode 100644 Kotlin/ООП/Модификаторы видимости.md create mode 100644 Kotlin/ООП/Наследование.md create mode 100644 Kotlin/ООП/ООП.md create mode 100644 Kotlin/ООП/Переопределение.md create mode 100644 Kotlin/ООП/Полиморфизм.md create mode 100644 Kotlin/ООП/Свойства.md create mode 100644 Kotlin/Рефлексия/Аннотации.md create mode 100644 Kotlin/Рефлексия/Рефлексия.md create mode 100644 Kotlin/Синтаксис и типы/Базовые типы ООП/Any, Unit, Nothing.md create mode 100644 Kotlin/Синтаксис и типы/Базовые типы ООП/Data class.md create mode 100644 Kotlin/Синтаксис и типы/Базовые типы ООП/Enum class.md create mode 100644 Kotlin/Синтаксис и типы/Базовые типы ООП/Object и companion object.md create mode 100644 Kotlin/Синтаксис и типы/Базовые типы ООП/Sealed class и interface.md create mode 100644 Kotlin/Синтаксис и типы/Базовые типы ООП/Typealias.md create mode 100644 Kotlin/Синтаксис и типы/Базовые типы ООП/Value class.md create mode 100644 Kotlin/Синтаксис и типы/Базовые типы ООП/equals, hashCode и toString.md create mode 100644 Kotlin/Синтаксис и типы/Работа с данными/Char.md create mode 100644 Kotlin/Синтаксис и типы/Работа с данными/Деструктуризация.md create mode 100644 Kotlin/Синтаксис и типы/Работа с данными/Лямбды и функциональные типы.md create mode 100644 Kotlin/Синтаксис и типы/Работа с данными/Массивы.md create mode 100644 Kotlin/Синтаксис и типы/Работа с данными/Приведение типов.md create mode 100644 Kotlin/Синтаксис и типы/Работа с данными/Строки.md create mode 100644 Kotlin/Синтаксис и типы/Работа с данными/Функции.md create mode 100644 Kotlin/Синтаксис и типы/Работа с данными/Числа и переполнение.md create mode 100644 Kotlin/Тестирование/Kotest.md create mode 100644 Kotlin/Тестирование/MockK.md create mode 100644 Kotlin/Тестирование/kotlin.test и JUnit 5.md create mode 100644 Kotlin/Тестирование/Тестирование корутин.md create mode 100644 Kotlin/Тестирование/Тестирование.md create mode 100644 Kotlin/Функциональный Kotlin/DSL.md create mode 100644 Kotlin/Функциональный Kotlin/Inline-функции.md create mode 100644 Kotlin/Функциональный Kotlin/Receivers и context parameters.md create mode 100644 Kotlin/Функциональный Kotlin/Scope-функции.md create mode 100644 Kotlin/Функциональный Kotlin/Инфиксные и операторные функции.md create mode 100644 Kotlin/Функциональный Kotlin/Паттерны проектирования.md create mode 100644 Kotlin/Функциональный Kotlin/Функции высшего порядка.md create mode 100644 Kotlin/Функциональный Kotlin/Функции-расширения.md diff --git a/Kotlin/Взаимодействие с Java/Java из Kotlin.md b/Kotlin/Взаимодействие с Java/Java из Kotlin.md new file mode 100644 index 0000000..2d8e1e9 --- /dev/null +++ b/Kotlin/Взаимодействие с Java/Java из Kotlin.md @@ -0,0 +1,385 @@ +--- +title: Java из Kotlin +description: Платформенные типы, синтетические свойства, SAM-конверсия, массивы, статические члены и escape-хатчи +tags: + - kotlin + - java-interop +--- + +Вызов Java из Kotlin почти не требует усилий: Java-классы видны как обычные Kotlin-классы. Трение возникает в четырёх местах - null-безопасность, свойства, массивы и ключевые слова. + +```kotlin +import java.io.File +import java.util.ArrayList + +fun main() { + val list = ArrayList() + list.add("первый") + list.add("второй") + println(list.size) // 2 + println(list.joinToString()) // первый, второй + + val tmp = File.createTempFile("demo", ".txt") + tmp.writeText("содержимое") + println(tmp.readText()) // содержимое + tmp.delete() +} +``` +Обратите внимание: `joinToString` и `writeText` - это Kotlin-расширения, работающие на Java-типах. Stdlib добавляет сотни таких функций к `java.util` и `java.io`. Подробнее: [Функции-расширения](../Функциональный%20Kotlin/Функции-расширения.md) + +## Платформенные типы +Главная особенность: тип из Java записывается как `String!` - компилятор не знает, может ли там быть `null`. + +```kotlin +fun main() { + // System.getProperty возвращает String! — может быть null + val asNonNull: String = System.getProperty("java.version") // компилируется + val asNullable: String? = System.getProperty("нет.такого") // тоже компилируется + + println(asNonNull.isNotEmpty()) // true + println(asNullable?.length) // null +} +``` +Компилятор разрешает оба варианта, поэтому ответственность на вас. Если Java вернёт `null` там, где объявлен ненулевой тип, проверка сработает в точке присваивания: +```kotlin +fun main() { + val error = runCatching { + val value: String = System.getProperty("отсутствует") + value.length + }.exceptionOrNull() + println(error?.javaClass?.simpleName) // NullPointerException +} +``` +Сообщение будет вида «Parameter specified as non-null is null» или «null cannot be cast to non-null type» - проверка вставляется компилятором. Подробнее: [Платформенные типы](../Синтаксис%20и%20типы/Null-безопасность/Платформенные%20типы.md) + +**Аннотации в Java решают проблему.** Если Java-код аннотирован, платформенных типов не возникает: +```java +// Java +@Nullable public String findName() { return null; } // в Kotlin: String? +@NotNull public String getId() { return "1"; } // в Kotlin: String +public String plain() { return null; } // в Kotlin: String! +``` +Поддерживаются `org.jetbrains.annotations`, JSR-305 (`javax.annotation`), `androidx.annotation`, Lombok, Eclipse и Checker Framework. Это единственный способ получить настоящую null-безопасность на границе с Java, поэтому в смешанных проектах аннотировать Java-код стоит. + +## Синтетические свойства +Java-геттеры и сеттеры видны как свойства Kotlin: +```kotlin +import java.util.Calendar + +fun main() { + val cal = Calendar.getInstance() + + cal.timeInMillis = 0L // вместо setTimeInMillis(0) + println(cal.timeInMillis) // 0 + + val sb = StringBuilder("текст") + println(sb.length) // 5 — вместо length() +} +``` +Правила распознавания: + +| Java-метод | Свойство в Kotlin | +|---|---| +| `getValue()` | `value` (только чтение) | +| `getValue()` + `setValue(T)` | `value` (чтение и запись) | +| `isReady()` | `isReady` (имя сохраняется) | +| `setOnly(T)` без геттера | свойства нет, только метод | +| `getValue(int index)` | свойства нет - есть параметр | + +```kotlin +fun main() { + val thread = Thread.currentThread() + println(thread.name.isNotEmpty()) // true — getName() + println(thread.isDaemon) // false — isDaemon() сохраняет префикс + println(thread.priority) // 5 — getPriority() +} +``` +Синтетические свойства доступны только для Java-методов. Kotlin-функция с именем `getFoo()` свойством не становится. + +## SAM-конверсия +Java-интерфейс с одним абстрактным методом принимает лямбду: +```kotlin +import java.util.concurrent.Executors + +fun main() { + val executor = Executors.newSingleThreadExecutor() + + executor.submit { println("как Runnable") } // лямбда -> Runnable + val future = executor.submit { "как Callable" } // лямбда -> Callable + println(future.get()) + + val items = mutableListOf("гамма", "альфа", "бета") + items.sortWith { a, b -> a.compareTo(b) } // Comparator + println(items) + executor.shutdown() + // как Runnable + // как Callable + // [альфа, бета, гамма] +} +``` +Когда перегрузок несколько и тип неоднозначен, интерфейс указывают явно: +```kotlin +import java.util.concurrent.Executors + +fun main() { + val executor = Executors.newSingleThreadExecutor() + val task = Runnable { println("явная SAM-конструкция") } + executor.submit(task) + executor.shutdown() + Thread.sleep(50) + // явная SAM-конструкция +} +``` +Важная тонкость: SAM-конверсия работает для **Java**-интерфейсов автоматически. Для Kotlin-интерфейса нужно объявить его как `fun interface`: +```kotlin +fun interface Validator { + fun validate(input: String): Boolean +} + +fun check(v: Validator, s: String) = v.validate(s) + +fun main() { + println(check({ it.isNotEmpty() }, "текст")) // true +} +``` +Подробнее: [Лямбды и функциональные типы](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) + +## Массивы +Java-массивы отображаются на типизированные классы Kotlin, причём примитивные - на специальные: +```kotlin +fun main() { + val ints: IntArray = intArrayOf(1, 2, 3) // int[] + val strings: Array = arrayOf("a", "b") // String[] + val boxed: Array = arrayOf(1, 2) // Integer[] + + println(ints::class.simpleName) // IntArray + println(strings::class.simpleName) // Array + println(ints.sum()) // 6 + println(strings.joinToString()) // a, b + println(boxed.size) // 2 +} +``` +`IntArray` компилируется в `int[]` без упаковки, а `Array` - в `Integer[]`. Это важно для производительности и для совместимости: Java-метод, принимающий `int[]`, не примет `Array`. + +Передача массива в varargs требует spread-оператора: +```kotlin +fun main() { + val parts = arrayOf("a", "b", "c") + println(String.format("%s-%s-%s", *parts)) // a-b-c + + val numbers = intArrayOf(3, 1, 2) + println(listOf(*numbers.toTypedArray()).sorted()) // [1, 2, 3] +} +``` +`*` распаковывает массив в отдельные аргументы - без него массив передался бы как один элемент. + +Статические методы класса `java.lang.String` через Kotlin-овский `String` недоступны - `String` в Kotlin это `kotlin.String`, у него нет `join`: +```kotlin +fun main() { + val parts = listOf("x", "y") + println(java.lang.String.join("/", parts)) // x/y + println(parts.joinToString("/")) // x/y — идиоматично +} +``` + +## Статические члены +```kotlin +fun main() { + println(Integer.MAX_VALUE) // 2147483647 + println(Integer.parseInt("42")) // 42 + println(Math.max(3, 7)) // 7 + + val list = java.util.Collections.emptyList() + println(list.size) // 0 +} +``` +Статические методы и поля вызываются через имя класса, как в Java. Для часто используемых есть Kotlin-аналоги: `"42".toInt()` вместо `Integer.parseInt`, `maxOf(3, 7)` вместо `Math.max`, `emptyList()` вместо `Collections.emptyList()`. + +## Java-ключевые слова как идентификаторы +Если Java-метод называется как ключевое слово Kotlin, его имя берётся в обратные кавычки: +```kotlin +class JavaLike { + fun `is`(value: Int) = value > 0 // в Kotlin `is` — ключевое слово + fun `in`(value: Int) = value + fun `object`() = "объект" +} + +fun main() { + val j = JavaLike() + println(j.`is`(5)) // true + println(j.`in`(1)) // 1 + println(j.`object`()) // объект +} +``` +Чаще всего это встречается с методами `is`, `in`, `object`, `when`, `val`, `var`, `fun`, `typealias`. Backticks работают и для имён с пробелами - этим пользуются в названиях тестов. Подробнее: [kotlin.test и JUnit 5](../Тестирование/kotlin.test%20и%20JUnit%205.md) + +## Checked exceptions исчезают +```kotlin +import java.io.File + +fun main() { + // Java-метод объявляет throws IOException, но Kotlin этого не требует + val tmp = File.createTempFile("demo", ".txt") + tmp.writeText("данные") + println(tmp.readText()) + tmp.delete() + // данные +} +``` +В Kotlin нет проверяемых исключений: `try/catch` вокруг `createTempFile` не обязателен. Это упрощает код, но требует дисциплины - компилятор больше не напоминает об обработке ошибок. Подробнее: [Система исключений](../Исключения/Система%20исключений.md) + +## Наследование от Java-класса +```kotlin +import java.io.IOException + +class CustomException(message: String, cause: Throwable? = null) : + IOException(message, cause) + +abstract class BaseTask : Runnable { + abstract fun execute() + override fun run() { + try { + execute() + } catch (e: Exception) { + println("ошибка: ${e.message}") + } + } +} + +class FailingTask : BaseTask() { + override fun execute() = throw CustomException("задача упала") +} + +fun main() { + FailingTask().run() // ошибка: задача упала +} +``` +Наследование работает в обе стороны без ограничений: Kotlin-класс может расширять Java-класс, реализовывать Java-интерфейс и быть предком Java-класса. + +## Java-типы, которые заменяют на Kotlin-аналоги +| Java | Предпочтительно в Kotlin | Почему | +|---|---|---| +| `java.util.ArrayList` | `mutableListOf()` | короче, тот же класс внутри | +| `Integer.parseInt(s)` | `s.toInt()` | расширение, читается слева направо | +| `String.valueOf(x)` | `x.toString()` | то же | +| `Math.max(a, b)` | `maxOf(a, b)` | работает с любым `Comparable` | +| `Collections.sort(list)` | `list.sort()` | расширение | +| `Optional` | `T?` | nullable-тип нативен | +| `new String[]{...}` | `arrayOf(...)` | короче | +| `StringBuilder` | интерполяция или `buildString` | читаемее | + +```kotlin +fun main() { + val fromJava = java.util.ArrayList().apply { add("a") } + val idiomatic = mutableListOf("a") + println("тот же класс: ${fromJava::class == idiomatic::class}") // тот же класс: true + + println(buildString { + append("собрано ") + append("через buildString") + }) + // собрано через buildString +} +``` +`mutableListOf()` возвращает именно `java.util.ArrayList` - разница только в синтаксисе. + +## Optional +```kotlin +import java.util.Optional + +fun main() { + val present: Optional = Optional.of("значение") + val empty: Optional = Optional.empty() + + println(present.orElse("по умолчанию")) // значение + println(empty.orElse("по умолчанию")) // по умолчанию + + // перевод в идиоматичный Kotlin + val asNullable: String? = present.orElse(null) + println(asNullable?.length) // 8 + println(empty.getOrNull()?.length) // null +} + +fun Optional.getOrNull(): T? = orElse(null) +``` +`Optional` в Kotlin-коде избыточен: его роль выполняет `T?`, который не создаёт обёртку и поддержан синтаксисом (`?.`, `?:`, `let`). При получении `Optional` из Java-API его сразу переводят в nullable. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `getName()` | `name` (синтетическое свойство) | +> | `isReady()` | `isReady` | +> | `new Runnable() { ... }` | `{ ... }` (SAM) | +> | `int[]` | `IntArray` | +> | `Integer[]` | `Array` | +> | `method(array)` для varargs | `method(*array)` | +> | `throws IOException` обязателен | не требуется | +> | `Optional` | `T?` | +> +> **Платформенные типы - компромисс, которого в Java нет.** В чистом Kotlin компилятор гарантирует отсутствие NPE. На границе с Java гарантия исчезает: тип `String!` означает «компилятор не знает». Альтернативой было бы считать все Java-типы nullable - тогда Java-код требовал бы `?.` и `!!` на каждом шагу и стал бы неудобен. Авторы Kotlin выбрали практичность: ответственность за null на границе лежит на программисте. +> +> **Синтетические свойства работают только для Java.** Это важная асимметрия: Java-метод `getName()` в Kotlin доступен и как `getName()`, и как `.name`, а Kotlin-функция `getName()` - только как функция. Причина в том, что в Kotlin для этого есть настоящие свойства, и превращать функции в свойства означало бы двусмысленность. +> +> **SAM-конверсия автоматическая только для Java-интерфейсов.** В Java любой интерфейс с одним методом принимает лямбду. В Kotlin - нет: нужен `fun interface`, иначе требуется `object : Interface { }`. Причина - в Kotlin есть функциональные типы (`(String) -> Boolean`), которые покрывают большинство случаев, и SAM-интерфейсы нужны реже. +> +> **Массивы различаются сильнее, чем кажется.** В Java `int[]` и `Integer[]` - разные типы, но оба «массивы». В Kotlin это `IntArray` и `Array`, причём `Array` ковариантен по-другому: `Array` не является `Array`, тогда как в Java `String[]` является `Object[]` - что и породило `ArrayStoreException`. Kotlin эту дыру закрыл, сделав `Array` инвариантным. Подробнее: [Вариантность](../Дженерики/Вариантность.md) +> +> **Отсутствие проверяемых исключений - сознательное решение.** Опыт Java показал, что `throws` чаще приводит к `catch (Exception e) { }` и обёрткам в `RuntimeException`, чем к осмысленной обработке, а в сочетании с лямбдами и дженериками создаёт неразрешимые проблемы (функциональный интерфейс не может объявить произвольный `throws`). Цена - потеря документирующей роли и необходимость `@Throws` при вызове из Java. +> +> Сравнить: [Массивы в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Массивы.md), [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **Платформенный тип даёт NPE в неожиданном месте:** +> ```kotlin +> fun main() { +> val props = System.getProperties() +> val error = runCatching { +> val missing: String = props.getProperty("нет.такого") +> missing.uppercase() +> }.exceptionOrNull() +> println("NPE при присваивании: ${error is NullPointerException}") +> // NPE при присваивании: true +> } +> ``` +> Исключение возникает не там, где значение используется, а там, где оно присваивается ненулевому типу. Стектрейс указывает на строку присваивания, а не на Java-метод. +> +> **`!!` на платформенном типе бессмысленен.** `javaMethod()!!` не добавляет безопасности - проверка и так вставляется. Правильно - `?:` с осмысленным значением по умолчанию или явный тип `String?`. +> +> **Java-аннотации nullability обязательны для настоящей безопасности.** Без них компилятор Kotlin не знает ничего, и весь Java-API остаётся зоной риска. Это основная причина добавлять `@Nullable`/`@NotNull` в унаследованный Java-код при миграции. +> +> **Неизменяемая Kotlin-коллекция, переданная в Java, может быть изменена:** +> ```kotlin +> fun main() { +> val readOnly = listOf("a") +> val error = runCatching { (readOnly as MutableList).add("b") } +> .exceptionOrNull() +> println(error?.javaClass?.simpleName) // UnsupportedOperationException +> } +> ``` +> Java-код увидит `java.util.List` с методом `add()` и может его вызвать. Приведение компилируется, падение - в рантайме. Передавайте копию через `toMutableList()`, если Java-код изменяет коллекцию. +> +> **`Array` и `IntArray` не взаимозаменяемы.** Java-метод с параметром `int[]` требует `IntArray`; `arrayOf(1, 2)` даёт `Array` (то есть `Integer[]`) и не подойдёт. Конвертация - `toIntArray()`, и она копирует данные. +> +> **Забытый spread-оператор меняет смысл:** +> ```kotlin +> fun main() { +> val parts = arrayOf("a", "b") +> println(listOf(parts).size) // 1 — массив как один элемент +> println(listOf(*parts).size) // 2 — распакованный +> } +> ``` +> Компилятор не всегда предупреждает - `listOf(parts)` корректен, просто создаёт `List>`. +> +> **`getClass()` вызывается как `javaClass`.** Метод `Object.getClass()` в Kotlin недоступен напрямую: он конфликтует с `::class`. Используйте `javaClass` для `java.lang.Class` и `::class` для `KClass`. +> +> **Статические вложенные Java-классы требуют полного пути.** `Map.Entry` в Kotlin - это `Map.Entry`, но `java.util.AbstractMap.SimpleEntry` нужно импортировать явно; обращение через внешний класс (`AbstractMap.SimpleEntry`) работает только после импорта внешнего. +> +> **Lombok-геттеры видны, но не всегда.** Lombok генерирует методы на этапе аннотационной обработки Java, и Kotlin-компилятор, работающий первым, их не видит. Нужен плагин `kotlin("plugin.lombok")` и `lombok.config` с `lombok.addLombokGeneratedAnnotation = true`. +> +> **Java-дженерики с wildcards переводятся в проекции.** `List` становится `List`, `List` - `List`. Raw-тип `List` без параметра становится `List<*>`, и элементы из него имеют тип `Any?`. Подробнее: [Проекции типов](../Дженерики/Проекции%20типов.md) +> +> **Вызов Java-метода с `Object`-параметром принимает всё.** `method(Object o)` в Kotlin имеет параметр `Any!`, поэтому туда пройдёт и `null`, и любое значение - компилятор не поможет. Для таких API особенно важны аннотации. +> +> **SAM-конверсия не работает для абстрактных классов.** `Thread(Runnable { })` - да, но `object : Thread() { }` для переопределения `run()` - только через наследование. SAM применяется только к интерфейсам с единственным абстрактным методом. + +Далее: [Kotlin из Java](Kotlin%20из%20Java.md) diff --git a/Kotlin/Взаимодействие с Java/Kotlin в байт-коде.md b/Kotlin/Взаимодействие с Java/Kotlin в байт-коде.md new file mode 100644 index 0000000..85a3bf0 --- /dev/null +++ b/Kotlin/Взаимодействие с Java/Kotlin в байт-коде.md @@ -0,0 +1,434 @@ +--- +title: Kotlin в байт-коде +description: Во что компилируются свойства, лямбды, data class, inline, suspend, value class и null-проверки +tags: + - kotlin + - java-interop + - jvm +--- + +Понимание представления Kotlin в байт-коде отвечает на три практических вопроса: почему API выглядит из Java именно так, откуда берутся накладные расходы и что происходит при `NullPointerException` внутри сгенерированного кода. + +Инструмент один - `javap`: +``` +kotlinc Demo.kt -d out # компиляция в .class +javap -p -cp out DemoKt # сигнатуры, включая приватные +javap -c -p -cp out DemoKt # с байт-кодом +javap -v -p -cp out DemoKt # с аннотациями и константным пулом +``` + +```kotlin +fun add(a: Int, b: Int) = a + b + +fun main() { + println(add(2, 3)) // 5 +} +``` +Эта функция компилируется в `public static final int add(int, int)` внутри класса `DemoKt`. Никакой обёртки нет - сигнатура такая же, как у Java-метода. + +## Где живёт код +| Конструкция Kotlin | Класс в байт-коде | +|---|---| +| `class Foo` | `Foo` | +| функции верхнего уровня в `Demo.kt` | `DemoKt` | +| `object Bar` | `Bar` с полем `INSTANCE` | +| `companion object` в `Foo` | `Foo$Companion` + поле `Foo.Companion` | +| вложенный класс | `Foo$Inner` | +| анонимный объект | `Foo$method$1` | +| лямбда | `invokedynamic`, без отдельного класса | +| `enum class Color` | `Color extends java.lang.Enum` | + +```kotlin +object Registry { + private val items = mutableListOf() + fun add(item: String) { items += item } + fun size() = items.size +} + +fun main() { + Registry.add("a") + println(Registry.size()) // 1 +} +``` +`object` становится классом с приватным конструктором, статическим полем `INSTANCE` и статическим инициализатором. Это классический singleton, и его потокобезопасность обеспечена гарантиями JVM на загрузку класса. + +## Свойства +Свойство - это всегда приватное поле плюс аксессоры: +```kotlin +class Holder { + var mutable: String = "a" + val immutable: Int = 1 + private var hidden = 2 + lateinit var late: String + + fun touch() = hidden++ +} + +fun main() { + val h = Holder() + h.mutable = "b" + println("${h.mutable} ${h.immutable} ${h.touch()}") // b 1 2 +} +``` +В байт-коде: +``` +private java.lang.String mutable; +private final int immutable; +private int hidden; +public java.lang.String late; // lateinit — поле публично! +public final java.lang.String getMutable(); +public final void setMutable(java.lang.String); +public final int getImmutable(); +public final java.lang.String getLate(); +public final void setLate(java.lang.String); +``` +Три наблюдения: + +**`val` даёт `final` поле и только геттер.** Это не константа - значение может вычисляться в конструкторе. + +**`lateinit var` делает поле публичным.** Компилятор не может сделать его приватным, потому что геттер должен различать «не инициализировано» и «значение `null`» - для этого нужна прямая работа с полем, а `lateinit` исключает nullable-тип. Побочный эффект: поле видно из Java напрямую. + +**Приватное свойство аксессоров не получает** - доступ идёт к полю, если нет кастомного геттера. + +Свойство с кастомным геттером поля не создаёт вообще: +```kotlin +class Rect(val w: Int, val h: Int) { + val area: Int get() = w * h +} + +fun main() { + println(Rect(3, 4).area) // 12 +} +``` +`area` - только метод `getArea()`, считающий значение каждый раз. Подробнее: [Свойства](../ООП/Свойства.md) + +## Null-проверки +Для каждого ненулевого параметра публичной функции компилятор вставляет проверку: +```kotlin +fun nn(s: String) = s.length +fun nullable(s: String?) = s?.length + +fun main() { + println(nn("текст")) // 5 + println(nullable(null)) // null +} +``` +Байт-код `nn`: +``` +0: aload_0 +1: ldc #9 // String s +3: invokestatic #15 // Intrinsics.checkNotNullParameter:(Object;String)V +6: aload_0 +7: invokevirtual #21 // String.length:()I +10: ireturn +``` +`checkNotNullParameter` бросает `NullPointerException` с сообщением «Parameter specified as non-null is null» - это и есть защита границы от Java-кода. Для `nullable` проверки нет, зато есть ветвление и упаковка в `Integer`: +``` +public static final java.lang.Integer nullable(java.lang.String); +``` +Возвращаемый тип `Int?` стал `Integer` - nullable-примитив всегда упакован. + +Информация о nullability хранится в аннотациях: +``` +public static final int nn(java.lang.String); @NotNull на параметре +public static final java.lang.Integer nullable(...); @Nullable на методе и параметре +``` +Это `org.jetbrains.annotations.@NotNull`/`@Nullable` с `RuntimeInvisible`-удержанием. Их читают Java-IDE и другие JVM-языки. Подробнее: [Null-безопасность](../Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md) + +```mermaid +graph LR + A["String в Kotlin"] --> B["java.lang.String
+ @NotNull
+ checkNotNullParameter"] + C["String? в Kotlin"] --> D["java.lang.String
+ @Nullable
без проверки"] + E["Int в Kotlin"] --> F["int"] + G["Int? в Kotlin"] --> H["java.lang.Integer"] +``` + +## data class +```kotlin +data class Point(val x: Int, val y: Int) + +fun main() { + val p = Point(1, 2) + println(p) // Point(x=1, y=2) + println(p.copy(y = 5)) // Point(x=1, y=5) + println(p == Point(1, 2)) // true +} +``` +Генерируется: +``` +private final int x; +private final int y; +public Point(int, int); +public final int getX(); +public final int getY(); +public final int component1(); +public final int component2(); +public final Point copy(int, int); +public static Point copy$default(Point, int, int, int, java.lang.Object); +public java.lang.String toString(); +public int hashCode(); +public boolean equals(java.lang.Object); +``` +`component1()`/`component2()` нужны для деструктуризации - компилятор вызывает их по позиции. `copy$default` реализует значения по умолчанию: четвёртый параметр `int` - битовая маска переданных аргументов, пятый `Object` - технический. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +## Значения по умолчанию +```kotlin +class Service { + fun call(url: String, retries: Int = 3, timeout: Long = 1000L) = + "$url r=$retries t=$timeout" +} + +fun main() { + println(Service().call("/api")) // /api r=3 t=1000 +} +``` +Два метода вместо одного: +``` +public final java.lang.String call(java.lang.String, int, long); +public static java.lang.String call$default(Service, java.lang.String, int, long, int, java.lang.Object); +``` +`$default` получает битовую маску: бит `i` установлен, если аргумент `i` **не** передан. Внутри он подставляет значение по умолчанию для каждого отсутствующего аргумента и вызывает основной метод. Отсюда два следствия: вызов со значениями по умолчанию стоит на один вызов дороже, а из Java `$default` недоступен (он синтетический и требует знания маски). + +## Лямбды и функциональные типы +```kotlin +fun noCapture(): () -> Int = { 42 } +fun withCapture(n: Int): () -> Int = { n } + +fun main() { + println(noCapture()()) // 42 + println(withCapture(7)()) // 7 +} +``` +Функциональный тип `() -> Int` - это интерфейс `kotlin.jvm.functions.Function0`. Их 23: `Function0` ... `Function22`. +``` +public static final kotlin.jvm.functions.Function0 noCapture(); + 0: invokedynamic #81, 0 // InvokeDynamic #0:invoke:()Lkotlin/jvm/functions/Function0; +``` +Начиная с K2 лямбды компилируются через `invokedynamic` (как в Java), а не в отдельные классы `Foo$lambda$1`. Это уменьшает размер артефакта и ускоряет загрузку, но означает, что имя класса лямбды генерируется в рантайме - в стектрейсах появляются `$$Lambda$...`. + +Важная деталь: `Function0` работает с **упакованным** `Integer`. Лямбда, возвращающая `Int`, упаковывает результат на каждом вызове - если это горячий цикл, нужна `inline`-функция. Подробнее: [Лямбды и функциональные типы](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) + +## inline +```kotlin +inline fun measure(block: () -> Unit) { + block() +} + +fun useInline() { + measure { println("тело") } +} + +fun main() { + useInline() // тело +} +``` +Байт-код `useInline` не содержит вызова `measure` вообще: +``` +public static final void useInline(); + 0: iconst_0 + 1: istore_0 + 2: iconst_0 + 3: istore_1 + 4: ldc #49 // String тело + 6: getstatic #55 // System.out + 9: swap + 10: invokevirtual #61 // println + 13: nop + 14: nop + 15: return +``` +Тело лямбды подставлено прямо в метод: ни объекта `Function0`, ни вызова по ссылке. Это основа `forEach`, `let`, `map` и остальных операций stdlib - они не создают объектов. Цена - код дублируется в каждой точке вызова. Подробнее: [Inline-функции](../Функциональный%20Kotlin/Inline-функции.md) + +Сама `measure` всё равно существует как метод (для Java-клиентов и рефлексии): +``` +public static final void measure(kotlin.jvm.functions.Function0); +``` + +## suspend +```kotlin +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +suspend fun fetch(id: Int): String { + delay(10) + return "ответ $id" +} + +fun main() = runBlocking { + println(fetch(1)) // ответ 1 +} +``` +Сигнатура меняется радикально: +``` +public static final java.lang.Object fetch(int, kotlin.coroutines.Continuation); +``` +Три изменения: добавлен параметр `Continuation`, тип возврата стал `Object` (вместо `String`), и само тело превратилось в конечный автомат. Функция может вернуть либо результат, либо специальный маркер `COROUTINE_SUSPENDED`, означающий «я приостановилась, результат придёт через `Continuation`». + +Конечный автомат - это анонимный класс с полем `label`, хранящим номер точки приостановки, и полями для всех локальных переменных, живущих через приостановку. При возобновлении функция вызывается снова, читает `label` и прыгает в нужную ветку `switch`. Это CPS-преобразование (continuation-passing style), выполняемое компилятором. + +Отсюда свойства корутин: приостановка не блокирует поток (нет никакого потока - есть объект состояния), стектрейсы короткие, а Java-код вызвать `suspend`-функцию практически не может. Подробнее: [Suspend-функции](../Корутины/Suspend-функции.md) + +## value class +```kotlin +@JvmInline +value class UserId(val value: Int) + +fun process(id: UserId) = "обработан ${id.value}" + +fun main() { + println(process(UserId(42))) // обработан 42 +} +``` +Класс `UserId` в байт-коде существует, но в сигнатурах заменяется на обёрнутый тип: +``` +public static final java.lang.String process-4ZD5Yi0(int); +``` +Параметр стал `int` - обёртки в рантайме нет. Суффикс `-4ZD5Yi0` (хеш от типов) нужен, чтобы `process(UserId)` и `process(OrderId)` не конфликтовали: после стирания у обеих сигнатура `process(int)`. + +Обёртка всё-таки создаётся в трёх случаях: когда value-класс используется как дженерик-параметр (`List`), как nullable (`UserId?`) и при обращении через супертип. Подробнее: [Value class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Value%20class.md) + +## Делегированные свойства +```kotlin +class Config { + val expensive: String by lazy { "вычислено" } +} + +fun main() { + println(Config().expensive) // вычислено +} +``` +``` +private final kotlin.Lazy v$delegate; +public final java.lang.String getV(); +``` +Поле хранит не значение, а делегат. Геттер вызывает `delegate.getValue()`. Поэтому каждое делегированное свойство - это дополнительный объект и один лишний вызов на чтение. Для `by lazy` с дефолтным режимом внутри ещё и `synchronized`-блок при первом доступе. Подробнее: [Делегирование](../ООП/Делегирование.md) + +## Дженерики и вариантность +```kotlin +class Box>(val value: T) { + fun max(other: T) = if (value > other) value else other +} + +fun sizes(l: List, m: MutableList) = l.size + m.size + +fun main() { + println(Box(3).max(7)) // 7 + println(sizes(listOf(1, 2), mutableListOf(3))) // 3 +} +``` +``` +public final class Box> +public static final int sizes(java.util.List, java.util.List); +``` +Проекции Kotlin становятся wildcards Java: `out T` → `? extends T`, `in T` → `? super T`. Дженерики стираются так же, как в Java - в рантайме `Box` и `Box` неразличимы. Обойти это позволяет только `reified` в `inline`-функциях, где тип подставляется при компиляции. Подробнее: [Вариантность](../Дженерики/Вариантность.md), [Reified и inline](../Дженерики/Reified%20и%20inline.md) + +## Nothing и Unit +```kotlin +fun fail(): Nothing = throw IllegalStateException("всегда падает") +fun act(): Unit = println("действие") + +fun main() { + act() // действие + println(runCatching { fail() }.isFailure) // true +} +``` +`Unit` как возвращаемый тип становится `void`. `Nothing` - `java.lang.Void` (значение которого всегда `null`, потому что функция никогда не возвращается нормально). В обоих случаях Kotlin-компилятор знает больше, чем байт-код: `Nothing` делает код после вызова недостижимым, но Java-компилятор этого не увидит. Подробнее: [Any, Unit, Nothing](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md) + +## Метаданные +Каждый Kotlin-класс несёт аннотацию `kotlin.Metadata` с закодированной информацией о nullable-типах, значениях по умолчанию, свойствах, `suspend`, вариантности, `inline`, `sealed` и прочем. Именно её читает Kotlin-компилятор, подключая скомпилированную библиотеку: `.class`-файла недостаточно, чтобы восстановить Kotlin-API. + +Практические следствия: + +**Обфускация ломает Kotlin-interop.** ProGuard/R8 должны сохранять `kotlin.Metadata`, иначе библиотека перестанет быть пригодной для Kotlin-клиентов (но останется пригодной для Java). + +**Рефлексия Kotlin требует `kotlin-reflect`.** `KClass.memberProperties` читает метаданные, и для этого нужна отдельная зависимость весом ~2 МБ. Java-рефлексия (`javaClass.declaredFields`) работает без неё, но видит только байт-код. Подробнее: [Рефлексия](../Рефлексия/Рефлексия.md) + +**Java-библиотека не знает про Kotlin-типы.** Jackson, Hibernate и подобные видят только `.class`: конструктор со значениями по умолчанию, nullable-поле как обычное. Для корректной работы нужны модули-адаптеры (`jackson-module-kotlin`), которые читают `kotlin.Metadata`. + +## Сводная таблица +| Kotlin | Байт-код | +|---|---| +| `fun f()` на верхнем уровне | `static final` в `ФайлKt` | +| `object O` | класс + `static final O INSTANCE` | +| `companion object` | класс `Cls$Companion` + поле | +| `val x: T` | `private final T x` + `getX()` | +| `var x: T` | `private T x` + `getX()`/`setX()` | +| `lateinit var x` | `public T x` (поле публично) | +| `val x get() = ...` | только `getX()`, поля нет | +| `x: String` (параметр) | `String` + `@NotNull` + `checkNotNullParameter` | +| `x: String?` | `String` + `@Nullable` | +| `Int` | `int` | +| `Int?` | `java.lang.Integer` | +| `() -> Int` | `Function0` | +| лямбда | `invokedynamic` | +| `inline fun` | тело подставлено + метод для Java | +| `suspend fun f(): T` | `Object f(Continuation)` + автомат | +| `@JvmInline value class` | обёрнутый тип + суффикс в имени | +| `by lazy` | поле `x$delegate: Lazy` | +| `out T` / `in T` | `? extends T` / `? super T` | +| `Unit` | `void` | +| `Nothing` | `java.lang.Void` | +| `internal fun f()` | `public final f$module()` | + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | исходник ≈ байт-код | есть зазор, есть метаданные | +> | поля публичны, если так написано | поля всегда приватны (кроме `lateinit`) | +> | геттер пишется руками | генерируется | +> | лямбда → `invokedynamic` | то же (с K2) | +> | нет значений по умолчанию | синтетический `$default` | +> | нет `inline` | тело подставляется | +> | `async` через библиотеку | `suspend` → конечный автомат | +> | `int` и `Integer` в коде | `Int` и `Int?` | +> +> **Главное отличие - наличие метаданных.** В Java `.class` полностью описывает API: что видно в сигнатурах, то и есть. В Kotlin часть системы типов (nullability, значения по умолчанию, `suspend`, `inline`, `sealed`, вариантность) существует только в аннотации `kotlin.Metadata`. Это значит, что Kotlin-компилятор и Java-компилятор видят **разные** API одной библиотеки: первый богаче. Отсюда и необходимость сохранять метаданные при обфускации, и зависимость `kotlin-reflect`, и адаптеры для Jackson. +> +> **Проверки на null стоят вызова метода на каждый параметр.** В Java такой проверки нет вообще - `NullPointerException` возникает при первом использовании. Kotlin переносит сбой на границу функции, что делает диагностику проще (понятно, какой параметр виноват), но добавляет `invokestatic` на каждый ненулевой параметр публичной функции. В приватных и `inline`-функциях проверок нет: там компилятор уже гарантировал тип. +> +> **`inline` - возможность, которой в Java нет на уровне языка.** JIT в Java тоже встраивает вызовы, но это решение рантайма, непредсказуемое и не применимое к лямбдам с захватом. Kotlin встраивает на этапе компиляции, гарантированно и вместе с телом лямбды. Это и позволяет `collection.map { }` не создавать объектов, и даёт `reified`, и делает возможными `return` из лямбды. Цена - рост байт-кода и невозможность изменить тело без перекомпиляции клиентов. +> +> **`suspend` реализован компилятором, а не рантаймом.** В Java асинхронность требует либо потоков, либо `CompletableFuture` с цепочками колбэков, либо виртуальных потоков (Loom), которые меняют JVM. Kotlin делает CPS-преобразование на этапе компиляции и работает на любой JVM 8+. Следствие: корутины не требуют поддержки платформы, но и не интегрируются с Java-кодом. +> +> **Value-классы ближе к будущим Project Valhalla, чем к Java сегодня.** В Java любая обёртка над `int` - это объект в куче. Kotlin убирает обёртку на этапе компиляции, заменяя тип в сигнатурах. Цена - манглирование имён и обёртка в дженериках, где стирание не позволяет обойтись примитивом. +> +> Сравнить: [JVM](../../Программирование%20на%20Java/JVM/JVM.md), [Байт-код](../../Программирование%20на%20Java/JVM/Байт-код.md) + +> [!warning] Подводные камни +> **`lateinit var` создаёт публичное поле.** Это нарушает инкапсуляцию неожиданным образом: Java-код и рефлексия получают прямой доступ в обход геттера, минуя проверку инициализации. Если это важно, используйте `var x: T? = null` с приватным сеттером. +> +> **Nullable-примитив всегда упакован:** +> ```kotlin +> fun main() { +> val plain: Int = 1 +> val nullable: Int? = 1 +> println(plain == nullable) // true +> println(nullable!!::class.java.name) // java.lang.Integer +> val a: Int? = 1000 +> val b: Int? = 1000 +> println("identity: ${a === b}") // identity: false +> } +> ``` +> `Int?` это `Integer`, поэтому `===` на значениях вне кеша `-128..127` даёт `false`. В горячем коде `Int?` в коллекциях и лямбдах создаёт объекты. Подробнее: [Числа и переполнение](../Синтаксис%20и%20типы/Работа%20с%20данными/Числа%20и%20переполнение.md) +> +> **Значения по умолчанию стоят лишнего вызова.** `call("/api")` идёт через `call$default`, который разбирает битовую маску и вызывает `call`. Для горячего пути с миллионами вызовов разница измерима - там пишут явные перегрузки. +> +> **`inline` раздувает байт-код.** Большая `inline`-функция, вызванная в сотне мест, копируется сто раз. Компилятор предупреждает про «expected performance impact... insignificant» для функций без лямбда-параметров - это намёк, что `inline` там не нужен. +> +> **Изменение тела `inline`-функции требует перекомпиляции клиентов.** Старое тело уже вкомпилировано в их байт-код. Для публичных библиотек это серьёзное ABI-ограничение: `inline` в публичном API фиксирует реализацию. +> +> **Делегированное свойство - это объект и вызов.** `by lazy` на каждом из сотни свойств даёт сотню объектов `Lazy` и сотню косвенных вызовов. Для простых случаев дешевле `private var x: T? = null` с проверкой в геттере. +> +> **Стектрейс корутины не показывает путь вызова.** Конечный автомат возобновляется из диспетчера, поэтому в стектрейсе видна только текущая точка приостановки. Для отладки включают `-Dkotlinx.coroutines.debug` или зависимость `kotlinx-coroutines-debug`. Подробнее: [Корутины](../Корутины/Корутины.md) +> +> **Обфускация без правил для `kotlin.Metadata` ломает библиотеку для Kotlin-клиентов.** Код продолжит работать, но исчезнет nullability, пропадут значения по умолчанию, `kotlin-reflect` перестанет видеть свойства. В правилах R8 нужно `-keep class kotlin.Metadata`. +> +> **`javap` не показывает Kotlin-API.** Он читает байт-код, не метаданные: `suspend`-функция выглядит как метод с `Continuation`, а `data class` - как обычный класс с кучей методов. Чтобы увидеть Kotlin-вид скомпилированного кода, нужен `kotlinp` или IDE («Show Kotlin Bytecode» → «Decompile»). +> +> **Синтетические методы видны рефлексии.** `component1()`, `copy$default`, `access$...`, `Foo$Companion` - всё это попадёт в `getDeclaredMethods()`. Код, перебирающий методы класса (сериализаторы, валидаторы), должен фильтровать по `isSynthetic()`. +> +> **`internal` не защищает.** Манглированное имя `helper$module` публично. Java-код в том же артефакте вызовет его без препятствий, как и любая рефлексия. +> +> **Проверки `checkNotNullParameter` можно отключить, но не стоит.** Флаг `-Xno-param-assertions` убирает их, давая небольшой выигрыш. Цена - `NullPointerException` будет возникать глубоко внутри вместо границы функции, и диагностика станет заметно хуже. + +Далее: [Инструментарий](../Инструментарий/Инструментарий.md) diff --git a/Kotlin/Взаимодействие с Java/Kotlin из Java.md b/Kotlin/Взаимодействие с Java/Kotlin из Java.md new file mode 100644 index 0000000..6b8fd16 --- /dev/null +++ b/Kotlin/Взаимодействие с Java/Kotlin из Java.md @@ -0,0 +1,525 @@ +--- +title: Kotlin из Java +description: Аннотации @JvmStatic, @JvmOverloads, @JvmName, @JvmField, @Throws и то, как конструкции Kotlin выглядят из Java +tags: + - kotlin + - java-interop +--- + +Обратное направление сложнее: в Kotlin есть конструкции, которых нет в JVM, и компилятор представляет их по-своему. Без аннотаций ваш API окажется неудобным для Java-кода. + +```kotlin +class Greeter(val name: String) { + fun greet(greeting: String = "Привет") = "$greeting, $name!" +} + +fun main() { + println(Greeter("Мир").greet()) // Привет, Мир! +} +``` +Из Java этот же класс выглядит так: +```java +// Java +Greeter g = new Greeter("Мир"); +System.out.println(g.greet("Привет")); // параметр обязателен! +// g.greet(); // не скомпилируется +``` +Значение по умолчанию существует только для Kotlin: в байт-коде есть `greet(String)` и синтетический `greet$default(Greeter, String, int, Object)`, вызывать который вручную нельзя. + +## Карта проблем и аннотаций +| Конструкция Kotlin | Из Java без аннотации | Аннотация | +|---|---|---| +| значение по умолчанию | один метод со всеми параметрами | `@JvmOverloads` | +| `companion object` | `Cls.Companion.method()` | `@JvmStatic` | +| `object` | `Singleton.INSTANCE.method()` | `@JvmStatic` | +| `val` в `companion object` | `Cls.Companion.getX()` | `@JvmField` | +| `const val` | уже поле - ничего не нужно | - | +| функция верхнего уровня | `FileNameKt.fn()` | `@JvmName` на файле | +| свойство | `getX()` / `setX()` | `@get:JvmName` | +| `suspend fun` | лишний параметр `Continuation` | не решается | +| `value class` | манглированное имя | не решается | +| исключение | не видно в `throws` | `@Throws` | +| перегрузки с дженериками | конфликт стирания | `@JvmName` | + +```mermaid +graph TD + A["Kotlin API"] --> B{"Вызывается из Java?"} + B -->|"нет"| C["аннотации не нужны"] + B -->|"да"| D["проверить каждый пункт"] + D --> E["значения по умолчанию
@JvmOverloads"] + D --> F["companion / object
@JvmStatic"] + D --> G["константы
@JvmField или const"] + D --> H["исключения
@Throws"] + D --> I["имена
@JvmName"] +``` + +## @JvmOverloads +Генерирует перегрузку для каждого значения по умолчанию: +```kotlin +class Greeter(val name: String) { + @JvmOverloads + fun greet(greeting: String = "Привет", punct: String = "!") = "$greeting, $name$punct" +} + +fun main() { + val g = Greeter("Мир") + println(g.greet()) // Привет, Мир! + println(g.greet("Здравствуй")) // Здравствуй, Мир! + println(g.greet("Hi", ".")) // Hi, Мир. +} +``` +В байт-коде появляются три метода: +``` +public final String greet(String, String) // полный +public final String greet(String) // punct по умолчанию +public final String greet() // оба по умолчанию +``` +Перегрузки создаются **справа налево**: параметры отбрасываются с конца. Поэтому `greet(punct = ".")` из Java невозможно - такой перегрузки нет. Это ограничение влияет на порядок параметров: самые часто переопределяемые ставьте первыми. + +Для конструктора аннотация работает так же: +```kotlin +class Config @JvmOverloads constructor( + val host: String = "localhost", + val port: Int = 8080, +) { + override fun toString() = "$host:$port" +} + +fun main() { + println(Config()) // localhost:8080 + println(Config("example.com")) // example.com:8080 + println(Config("example.com", 443)) // example.com:443 +} +``` + +## @JvmStatic +Без неё члены `companion object` доступны только через `.Companion`: +```kotlin +class Repository private constructor(val id: String) { + companion object { + @JvmStatic + fun create(id: String) = Repository(id) + + fun createNoStatic(id: String) = Repository(id) + } +} + +fun main() { + println(Repository.create("1").id) // 1 + println(Repository.createNoStatic("2").id) // 2 +} +``` +Из Kotlin обе формы одинаковы. Из Java - нет: +```java +// Java +Repository.create("1"); // работает: статический метод +Repository.Companion.createNoStatic("2"); // без @JvmStatic только так +``` +То же для `object`: +```kotlin +object Registry { + private val items = mutableListOf() + + @JvmStatic + fun register(item: String) { items += item } + + fun size() = items.size +} + +fun main() { + Registry.register("a") + println(Registry.size()) // 1 +} +``` +Из Java: `Registry.register("a")` против `Registry.INSTANCE.size()`. + +## @JvmField и const +Свойство в `companion object` компилируется в приватное поле с геттером на `Companion`. Три варианта: +```kotlin +class Limits { + companion object { + const val MAX = 100 // примитив/String, compile-time + @JvmField val DEFAULT = "значение" // поле, доступно напрямую + val COMPUTED = "вычисляется".uppercase() // только через геттер + } +} + +fun main() { + println(Limits.MAX) // 100 + println(Limits.DEFAULT) // значение + println(Limits.COMPUTED) // ВЫЧИСЛЯЕТСЯ +} +``` +Из Java: +```java +// Java +Limits.MAX; // public static final int +Limits.DEFAULT; // public static final String +Limits.Companion.getCOMPUTED(); // только геттер +``` +`const val` требует примитив или `String` и инициализацию константным выражением - зато встраивается в место вызова (и потому меняет ABI при изменении значения). `@JvmField` работает с любым типом, но несовместим с `private`, `open`, `override`, `lateinit` и делегированными свойствами. + +## @JvmName +Задаёт имя в байт-коде. Три точки применения. + +**Файл.** Функции верхнего уровня попадают в класс `ИмяФайлаKt`: +```kotlin +@file:JvmName("StringUtils") + +fun String.slugify() = lowercase().replace(" ", "-") + +fun main() { + println("Привет Мир".slugify()) // привет-мир +} +``` +Без аннотации Java-код обращался бы к `SlugifyKt.slugify(s)`, с ней - `StringUtils.slugify(s)`. Расширения становятся статическими методами с получателем первым параметром. + +**Аксессор свойства:** +```kotlin +class Account(private val raw: String) { + @get:JvmName("fetchBalance") + val balance: String get() = raw +} + +fun main() { + println(Account("1000").balance) // 1000 +} +``` +Из Java метод называется `fetchBalance()`, а не `getBalance()`. + +**Обход конфликта стирания.** Две функции, различающиеся только параметром дженерика, в байт-коде имеют одинаковую сигнатуру: +```kotlin +@JvmName("sumInts") +fun List.sumAll() = sum() + +@JvmName("sumDoubles") +fun List.sumAll() = sum() + +fun main() { + println(listOf(1, 2, 3).sumAll()) // 6 + println(listOf(1.5, 2.5).sumAll()) // 4.0 +} +``` +Без `@JvmName` компилятор откажется: «platform declaration clash». Это одна из немногих ситуаций, когда аннотация нужна даже для чисто Kotlin-кода. Подробнее: [Reified и inline](../Дженерики/Reified%20и%20inline.md) + +## @Throws +В Kotlin нет проверяемых исключений, поэтому в байт-коде нет `throws`: +```kotlin +import java.io.IOException + +class Loader { + @Throws(IOException::class) + fun load(path: String): String { + if (path.isEmpty()) throw IOException("пустой путь") + return "данные из $path" + } + + fun loadNoThrows(path: String): String { + if (path.isEmpty()) throw IOException("пустой путь") + return "данные из $path" + } +} + +fun main() { + val loader = Loader() + println(loader.load("/tmp/file")) // данные из /tmp/file + println(runCatching { loader.loadNoThrows("") }.isFailure) // true +} +``` +Из Java разница принципиальна: +```java +// Java +try { loader.load(""); } +catch (IOException e) { } // компилируется + +try { loader.loadNoThrows(""); } +catch (IOException e) { } // ОШИБКА: exception is never thrown +``` +Java-компилятор запрещает ловить проверяемое исключение, которое метод не объявляет. Исключение всё равно вылетит в рантайме - просто обработать его можно только через `catch (Exception e)`. + +## Как выглядят остальные конструкции +### Nullability +```kotlin +class Validator { + fun check(input: String): Boolean = input.isNotEmpty() + fun find(key: String): String? = if (key == "a") "найдено" else null +} + +fun main() { + val v = Validator() + println(v.check("текст")) // true + println(v.find("b")) // null +} +``` +`check(String)` получает аннотацию `@NotNull` на параметре и проверку `Intrinsics.checkNotNullParameter` в начале тела. Java-код, передавший `null`, получит `NullPointerException` с сообщением «Parameter specified as non-null is null» - сразу, а не при первом использовании. `find` возвращает `@Nullable String`, и IDE предупредит Java-код. + +### Свойства +```kotlin +class User { + var name: String = "аноним" + val id: Int = 1 + var internalState: String = "скрыто" + private set +} + +fun main() { + val u = User() + u.name = "Иван" + println("${u.name} ${u.id} ${u.internalState}") // Иван 1 скрыто +} +``` +Из Java: `getName()`/`setName()`, `getId()` без сеттера, `getInternalState()` без сеттера (приватный сеттер в байт-коде приватен). Само поле всегда приватно - прямого доступа из Java нет. + +### Функции-расширения +```kotlin +fun List.firstOrDefault(default: String) = firstOrNull() ?: default + +fun main() { + println(listOf("a").firstOrDefault("нет")) // a + println(emptyList().firstOrDefault("нет")) // нет +} +``` +Из Java это статический метод с получателем первым аргументом: `FileNameKt.firstOrDefault(list, "нет")`. Никакого синтаксического сахара - расширение существует только в Kotlin. + +### suspend-функции +```kotlin +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +suspend fun fetch(id: Int): String { + delay(10) + return "ответ для $id" +} + +fun main() = runBlocking { + println(fetch(1)) // ответ для 1 +} +``` +В байт-коде сигнатура становится `Object fetch(int, Continuation)`, возвращая либо результат, либо маркер `COROUTINE_SUSPENDED`. Вызвать такое из Java практически невозможно. Для Java-клиентов делают обёртку: +```kotlin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.future.future +import java.util.concurrent.CompletableFuture + +class Api(private val scope: CoroutineScope) { + suspend fun fetch(id: Int): String { + delay(10) + return "ответ для $id" + } + + // для Java: CompletableFuture вместо suspend + fun fetchAsync(id: Int): CompletableFuture = + scope.future { fetch(id) } +} + +fun main() { + val scope = CoroutineScope(Dispatchers.Default) + println(Api(scope).fetchAsync(7).get()) // ответ для 7 +} +``` +Нужна зависимость `kotlinx-coroutines-jdk8`. Подробнее: [Suspend-функции](../Корутины/Suspend-функции.md) + +### value class +```kotlin +@JvmInline +value class UserId(val value: Int) + +fun process(id: UserId) = "обработан ${id.value}" + +fun main() { + println(process(UserId(42))) // обработан 42 +} +``` +В байт-коде `process` получает имя `process-4ZD5Yi0(int)` - суффикс нужен, чтобы отличать перегрузки с разными value-классами над одним примитивом. Вызвать такое имя из Java нельзя: дефис недопустим в идентификаторе. Для Java-API добавляют обычную перегрузку: +```kotlin +@JvmInline +value class Money(val cents: Long) + +fun charge(amount: Money) = "списано ${amount.cents}" + +@JvmName("chargeCents") +fun charge(cents: Long) = charge(Money(cents)) + +fun main() { + println(charge(Money(500))) // списано 500 + println(charge(500L)) // списано 500 +} +``` +Подробнее: [Value class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Value%20class.md) + +### internal +```kotlin +class Service { + internal fun helper() = "вспомогательное" + fun public() = helper() +} + +fun main() { + println(Service().public()) // вспомогательное +} +``` +`internal` компилируется в `public` с манглированным именем вида `helper$main` (суффикс - имя модуля). Java-код технически может вызвать `helper$main()`. Это не изоляция, а соглашение, которое соблюдает только Kotlin-компилятор. Подробнее: [Модификаторы видимости](../ООП/Модификаторы%20видимости.md) + +### data class +```kotlin +data class Point(val x: Int, val y: Int) + +fun main() { + val p = Point(1, 2) + println(p) // Point(x=1, y=2) + println(p.copy(y = 5)) // Point(x=1, y=5) + val (x, y) = p + println("$x $y") // 1 2 +} +``` +Из Java доступны `getX()`, `getY()`, `equals`, `hashCode`, `toString`, `component1()`, `component2()` и `copy(int, int)`. Но `copy` с одним аргументом недоступна - это те же значения по умолчанию, и `@JvmOverloads` на сгенерированном `copy` поставить нельзя. Деструктуризации в Java нет. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +### sealed +```kotlin +sealed interface Result +data class Ok(val value: String) : Result +data class Err(val message: String) : Result + +fun describe(r: Result) = when (r) { + is Ok -> "успех: ${r.value}" + is Err -> "ошибка: ${r.message}" +} + +fun main() { + println(describe(Ok("данные"))) // успех: данные + println(describe(Err("сбой"))) // ошибка: сбой +} +``` +Из Java `sealed interface` Kotlin - обычный интерфейс (при JVM-таргете 17+ может компилироваться в настоящий `sealed`). Исчерпывающего `switch` Java не получит - гарантия полноты обеспечивается только Kotlin-компилятором. Подробнее: [Sealed class и interface](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +## Чеклист публичного API для Java-клиентов +1. `@JvmOverloads` на всех функциях со значениями по умолчанию. +2. `@JvmStatic` на членах `companion object` и `object`. +3. `const val` или `@JvmField` для констант. +4. `@Throws` там, где Java-код должен ловить проверяемое исключение. +5. `@file:JvmName` для файлов с функциями верхнего уровня. +6. Нет `suspend` в публичном API - только обёртки (`CompletableFuture`, колбэки). +7. Нет `value class` в сигнатурах - или перегрузка с `@JvmName`. +8. Возвращать `List`, а не `MutableList`, и отдавать копию, если Java может изменить. +9. Nullable-типы объявлять явно - Java-IDE прочитает аннотации. +10. Проверить результат через `javap` на реальном классе. + +```kotlin +// пример API, готового к вызову из Java +@file:JvmName("UserApi") + +class UserService @JvmOverloads constructor( + private val baseUrl: String = "https://api.example.com", +) { + @Throws(IllegalArgumentException::class) + @JvmOverloads + fun find(id: String, includeDeleted: Boolean = false): String { + require(id.isNotBlank()) { "id не может быть пустым" } + return "$baseUrl/users/$id?deleted=$includeDeleted" + } + + companion object { + const val DEFAULT_TIMEOUT_MS = 5_000L + + @JvmStatic + fun default() = UserService() + } +} + +fun main() { + println(UserService.default().find("42")) + println(UserService.DEFAULT_TIMEOUT_MS) + // https://api.example.com/users/42?deleted=false + // 5000 +} +``` + +## Проверка результата +Единственный надёжный способ увидеть реальный API - посмотреть байт-код: +``` +kotlinc Api.kt -d out +javap -cp out com.example.UserApi +javap -cp out 'com.example.UserService$Companion' +``` +`javap` покажет точные сигнатуры, включая синтетические методы `$default` и манглированные имена. Это стоит делать перед релизом библиотеки. Подробнее: [Kotlin в байт-коде](Kotlin%20в%20байт-коде.md) + +> [!info] Отличия от Java +> | Java | Kotlin из Java | +> |---|---| +> | `static` метод | `@JvmStatic` в `companion object` | +> | `public static final` поле | `const val` или `@JvmField` | +> | перегрузки пишутся руками | `@JvmOverloads` генерирует | +> | `throws` в сигнатуре | `@Throws` | +> | имя класса = имя файла | `@file:JvmName` | +> | утилитный класс со `static` | функции верхнего уровня | +> | геттер пишется руками | генерируется из свойства | +> +> **Аннотации `@Jvm*` - это не костыли, а явное управление ABI.** В Java представление в байт-коде однозначно: что написано, то и скомпилируется. В Kotlin между исходником и байт-кодом есть зазор, и аннотации дают над ним контроль. Плата за выразительность языка: `@JvmOverloads` существует потому, что в JVM нет значений по умолчанию, а не из-за недоработки. +> +> **Значения по умолчанию в Kotlin устроены иначе, чем перегрузки в Java.** В Java N необязательных параметров требуют N перегрузок, каждую с делегированием. В Kotlin - один метод и синтетический `$default`, принимающий битовую маску переданных аргументов. Отсюда и ограничение `@JvmOverloads`: перегрузки генерируются только отбрасыванием с конца, потому что произвольные комбинации дали бы экспоненциальное число методов. +> +> **`companion object` - настоящий объект, а не пространство имён.** В Java `static` - модификатор: метод принадлежит классу. В Kotlin companion - отдельный экземпляр класса `Cls$Companion`, у него есть тип, он может реализовывать интерфейсы и быть получателем расширений. Поэтому без `@JvmStatic` вызов идёт через `Cls.Companion`. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) +> +> **Отсутствие проверяемых исключений нельзя «включить» обратно.** `@Throws` только добавляет запись в атрибут `Exceptions` метода - сам Kotlin-компилятор её не проверяет. Фактически это документация для Java-компилятора: без неё Java не разрешит `catch`, с ней - потребует обработки. +> +> **`suspend` принципиально несовместим с Java.** Корутины реализованы через CPS-преобразование на этапе компиляции Kotlin: функция превращается в конечный автомат с параметром `Continuation`. Java-компилятор этого преобразования не делает, поэтому написать Java-вызывающий код для `suspend`-функции нельзя - только обёртки. Это самая жёсткая граница interop. Подробнее: [Корутины против потоков](../Корутины/Корутины%20против%20потоков.md) +> +> Сравнить: [Инкапсуляция в Java](../../Программирование%20на%20Java/ООП/Инкапсуляция/Инкапсуляция.md), [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **`@JvmOverloads` генерирует перегрузки только справа налево.** Для `fun f(a: Int = 1, b: Int = 2, c: Int = 3)` появятся `f(a,b,c)`, `f(a,b)`, `f(a)`, `f()` - но не `f(c)` и не `f(a,c)`. Из Java нельзя передать только третий параметр. Ставьте чаще переопределяемые параметры раньше. +> +> **Добавление `@JvmStatic` или `@JvmOverloads` позже ломает совместимость.** `@JvmStatic` не удаляет метод с `Companion`, но меняет рекомендуемую точку входа; снятие аннотации удаляет статический метод и роняет скомпилированный Java-код с `NoSuchMethodError`. Решайте до релиза. +> +> **Изменение `const val` требует перекомпиляции клиентов.** Значение встраивается в место вызова, поэтому Java-код, собранный против старой версии, продолжит использовать старое число. Для значений, которые могут меняться, лучше `@JvmField val`. +> +> **`@JvmField` несовместим с половиной модификаторов:** +> ```kotlin +> class Limits { +> companion object { +> @JvmField val OK = "работает" +> // @JvmField private val BAD = "" // ошибка: не может быть private +> // @JvmField lateinit var LATE: String // ошибка: не для lateinit +> } +> } +> +> fun main() { +> println(Limits.OK) // работает +> } +> ``` +> Запрещено для `private`, `open`, `override`, `const`, `lateinit` и делегированных свойств. +> +> **`copy()` у `data class` из Java требует все аргументы.** `@JvmOverloads` на сгенерированный `copy` поставить нельзя. Если Java-коду нужна частичная копия, пишите свой метод или билдер. +> +> **Манглированные имена value-классов недоступны из Java.** `fun takeId(id: UserId)` компилируется в `takeId-4ZD5Yi0(int)`; дефис делает имя невызываемым в Java. Это проявится только при попытке интеграции - Kotlin-компилятор не предупредит. +> +> **`internal` виден из Java.** `internalFun$main()` - публичный метод в байт-коде. Если нужна настоящая закрытость для Java-кода в том же проекте, используйте `private` или выносите в отдельный модуль. +> +> **`List` из Kotlin в Java имеет `add()`.** Тип стирается до `java.util.List`, и Java-код может вызвать мутирующий метод. Падение будет в рантайме с `UnsupportedOperationException`. Для публичного API возвращайте `Collections.unmodifiableList(...)` или копию. +> +> **Платформенные объявления конфликтуют при стирании:** +> ```kotlin +> @JvmName("countStrings") +> fun List.count2() = size +> +> @JvmName("countNumbers") +> fun List.count2() = size +> +> fun main() { +> println(listOf("a").count2()) // 1 +> println(listOf(1, 2).count2()) // 2 +> } +> ``` +> Без `@JvmName` будет ошибка компиляции «platform declaration clash» - обе функции дают `count2(List)`. Это проявляется и в чисто Kotlin-проекте. +> +> **`@file:JvmName` должна стоять до `package`.** Файловые аннотации идут самой первой строкой файла, иначе компилятор их не примет. +> +> **`@get:JvmName` на свойстве в интерфейсе или `open` классе запрещена.** Переименование аксессора сломало бы переопределение, поэтому компилятор его не допускает для `open`/`override` членов. +> +> **Делегированные свойства из Java выглядят как обычные.** `by lazy` даёт `getX()`, но поле хранит `Lazy` под именем `x$delegate`. Прямого доступа к значению нет, и `@JvmField` не применить. Подробнее: [Делегирование](../ООП/Делегирование.md) +> +> **Расширения не переопределяются и не видны как методы.** `fun Foo.bar()` из Java - это `FileKt.bar(foo)`. Java-код не найдёт `foo.bar()`, а IDE не предложит. Если метод нужен Java-клиентам, делайте его членом класса. + +Далее: [Kotlin в байт-коде](Kotlin%20в%20байт-коде.md) diff --git a/Kotlin/Взаимодействие с Java/Взаимодействие с Java.md b/Kotlin/Взаимодействие с Java/Взаимодействие с Java.md new file mode 100644 index 0000000..037d12d --- /dev/null +++ b/Kotlin/Взаимодействие с Java/Взаимодействие с Java.md @@ -0,0 +1,202 @@ +--- +title: Взаимодействие с Java +description: Зачем нужен interop, что работает без усилий, где возникает трение и как устроен раздел +tags: + - kotlin + - java-interop +--- + +Kotlin проектировался как язык, который должен без потерь жить в существующей Java-экосистеме. Это не побочная возможность, а требование: без доступа к Spring, Jackson, JDBC и остальной инфраструктуре JVM язык был бы бесполезен. + +```kotlin +import java.time.LocalDate +import java.util.UUID + +fun main() { + val id = UUID.randomUUID() + val today = LocalDate.of(2026, 1, 15) + + println(id.toString().length) // 36 + println(today.plusDays(30)) // 2026-02-14 + println(today.dayOfWeek) // THURSDAY +} +``` +Это обычный Java API, вызванный как родной Kotlin-код: никаких обёрток, конвертеров и объявлений внешних типов. + +## Что происходит без усилий +| Что | Как работает | +|---|---| +| вызов Java-метода из Kotlin | как обычный метод | +| геттеры/сеттеры Java | как свойства Kotlin | +| Java-коллекции | как `MutableList`, `MutableMap` и т.д. | +| наследование от Java-класса | как от Kotlin-класса | +| реализация Java-интерфейса | SAM-конверсия из лямбды | +| Java-аннотации | применяются к Kotlin-коду | +| смешанная компиляция в одном модуле | поддерживается Gradle-плагином | + +```kotlin +import java.util.concurrent.Executors + +fun main() { + val executor = Executors.newSingleThreadExecutor() + executor.submit { println("Runnable из лямбды") } // SAM-конверсия + executor.shutdown() + + val list = java.util.ArrayList() + list.add("элемент") // как MutableList + println(list.size) // 1 + + val builder = StringBuilder() + builder.append("a").append("b") + println(builder.length) // 2 + // Runnable из лямбды +} +``` + +## Где возникает трение +Три системы типов Kotlin не имеют соответствия в Java, и именно на этих границах появляются сложности. + +**1. Null-безопасность.** Java не различает `String` и `String?`, поэтому любое значение из Java - платформенный тип `String!`: +```kotlin +fun main() { + // Java-метод вернул null, а Kotlin считал тип ненулевым + val value: String = System.getProperty("несуществующее.свойство") + ?: "значение по умолчанию" + println(value) // значение по умолчанию +} +``` +Без `?:` здесь был бы `NullPointerException` в момент присваивания. Подробнее: [Платформенные типы](../Синтаксис%20и%20типы/Null-безопасность/Платформенные%20типы.md) + +**2. Изменяемость коллекций.** В Kotlin `List` и `MutableList` - разные типы, в Java это один `java.util.List`: +```kotlin +fun main() { + val immutable: List = listOf("a", "b") + // передача в Java-метод, который может вызвать add() + val asJava: java.util.List = immutable as java.util.List + val error = runCatching { asJava.add("c") }.exceptionOrNull() + println("неизменяемый список отверг изменение: ${error != null}") // неизменяемый список отверг изменение: true +} +``` +Java-код не видит разницы и может попытаться изменить список, который Kotlin считает неизменяемым. Подробнее: [Изменяемость](../Коллекции/Изменяемость.md) + +**3. Возможности Kotlin, которых нет в Java.** Значения по умолчанию, функции-расширения, `suspend`, value-классы, `object` - всё это компилируется в конструкции JVM, но из Java выглядит иначе, чем задумано. Для управления этим есть аннотации `@JvmStatic`, `@JvmOverloads`, `@JvmName` и другие. Подробнее: [Kotlin из Java](Kotlin%20из%20Java.md) + +## Асимметрия направлений +Направления не равноценны - и это главное, что нужно понять про interop. + +```mermaid +graph LR + A["Java-код"] -->|"почти без трения:
только null-безопасность"| B["Kotlin-код"] + B -->|"требует аннотаций:
@JvmStatic, @JvmOverloads,
@JvmName, @JvmField"| A +``` + +**Java из Kotlin** работает почти прозрачно: язык создавался именно для этого. Единственная серьёзная проблема - платформенные типы, то есть риск `NullPointerException` там, где компилятор уверен в ненулевом значении. + +**Kotlin из Java** требует внимания: без аннотаций ваш API окажется неудобным - значения по умолчанию превратятся в один метод с лишними параметрами, `companion object` потребует обращения через `.Companion`, а `suspend`-функция получит параметр `Continuation`. + +Практический вывод: если ваш Kotlin-код вызывается из Java, его публичный API нужно проектировать осознанно. Если Kotlin-код вызывается только из Kotlin, аннотации не нужны вообще. + +## Карта раздела +| Заметка | О чём | +|---|---| +| [Java из Kotlin](Java%20из%20Kotlin.md) | платформенные типы, SAM, массивы, статика, синтетические свойства | +| [Kotlin из Java](Kotlin%20из%20Java.md) | `@JvmStatic`, `@JvmOverloads`, `@JvmName`, `@JvmField`, `@Throws` | +| [Kotlin в байт-коде](Kotlin%20в%20байт-коде.md) | во что компилируются свойства, лямбды, `data class`, `suspend` | + +## Смешанный проект +Gradle-плагин Kotlin компилирует Java и Kotlin вместе, разрешая циклические зависимости между языками: +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + java +} + +kotlin { + jvmToolchain(21) +} + +// Kotlin и Java в одном модуле: +// src/main/kotlin/ и src/main/java/ — компилируются совместно +``` +Порядок компиляции: сначала Kotlin видит Java-исходники (через стабы), затем `javac` компилирует Java, уже видя готовые Kotlin-классы. Поэтому Java-класс может наследоваться от Kotlin-класса, который реализует Java-интерфейс. Подробнее: [Структура проекта](../Инструментарий/Структура%20проекта.md) + +## Стратегия миграции +Типичный путь для существующего Java-проекта: + +1. **Новый код на Kotlin, старый не трогать.** Interop позволяет это без прослоек. +2. **Конвертировать по файлу**, начиная с листовых классов (DTO, утилиты) - у них меньше зависимостей. +3. **Проверять результат автоконвертера.** IDE конвертирует Java в Kotlin, но результат получается «Java, написанная на Kotlin»: платформенные типы вместо nullable, `!!` вместо `?.`, классы вместо `data class`. +4. **Публичный API, вызываемый из Java, аннотировать** сразу - иначе придётся менять его потом. +5. **Тесты конвертировать последними или не конвертировать** - они и так работают. + +```kotlin +// типичный результат автоконвертера — работает, но это не идиоматичный Kotlin +class UserDtoConverted { + var name: String? = null + var age: Int? = null + fun getDisplayName(): String { + return if (name != null) name!! else "аноним" + } +} + +// идиоматичная версия +data class UserDto(val name: String?, val age: Int?) { + val displayName: String get() = name ?: "аноним" +} + +fun main() { + println(UserDtoConverted().getDisplayName()) // аноним + println(UserDto(null, null).displayName) // аноним +} +``` + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | один язык, одна система типов | две системы, граница между ними | +> | `String` может быть `null` | `String` и `String?` различаются | +> | `List` один тип | `List` и `MutableList` | +> | `int` и `Integer` различаются | `Int` и `Int?` | +> | статические методы | `companion object` + `@JvmStatic` | +> | нет аналога | платформенные типы `String!` | +> | checked exceptions обязательны | проверяемых исключений нет | +> +> **Interop работает, потому что Kotlin компилируется в тот же байт-код.** Kotlin-класс - это обычный класс JVM: у него те же модификаторы доступа, то же наследование, тот же формат `.class`. Никакой прослойки, маршалинга или рефлексии в runtime нет, поэтому вызов Kotlin-метода из Java стоит ровно столько же, сколько вызов Java-метода. Это принципиально отличает ситуацию от interop между языками разных платформ (JNI, JNA), где границу приходится пересекать с накладными расходами. +> +> **Цена - необходимость представлять конструкции Kotlin средствами JVM.** В байт-коде нет ни nullable-типов, ни значений по умолчанию, ни функций-расширений. Kotlin эмулирует их: nullable-информацию хранит в аннотациях и метаданных, значения по умолчанию - в синтетическом методе `greet$default`, расширения - в статических методах. Из Kotlin это видно как родные конструкции (компилятор читает метаданные), из Java - как реализация. Подробнее: [Kotlin в байт-коде](Kotlin%20в%20байт-коде.md) +> +> **Проверяемые исключения - односторонняя потеря.** В Kotlin их нет: `throws IOException` из Java-метода не обязывает ни к `try/catch`, ни к объявлению. Это упрощает Kotlin-код, но Java-код, вызывающий Kotlin-метод, не узнает о возможном исключении без `@Throws`. Подробнее: [Система исключений](../Исключения/Система%20исключений.md) +> +> **Null-безопасность Kotlin не распространяется на Java-код.** Компилятор не может проверить то, чего нет в типах Java. Поэтому аннотации `@Nullable`/`@NotNull` в Java-коде - не документация, а реальная информация для компилятора Kotlin: с ними платформенные типы становятся обычными nullable/non-null. +> +> Сравнить: [JVM](../../Программирование%20на%20Java/JVM/JVM.md), [Байт-код](../../Программирование%20на%20Java/JVM/Байт-код.md) + +> [!warning] Подводные камни +> **Платформенные типы отключают проверки компилятора.** Значение из Java имеет тип `String!`, который компилятор позволяет использовать и как `String`, и как `String?`. Ошибка проявится как `NullPointerException` в рантайме, в точке присваивания: +> ```kotlin +> fun main() { +> val error = runCatching { +> val value: String = System.getProperty("нет.такого") // вернёт null +> value.length +> }.exceptionOrNull() +> println("NPE от платформенного типа: ${error is NullPointerException}") +> // NPE от платформенного типа: true +> } +> ``` +> +> **Автоконвертер даёт работающий, но плохой код.** Типичные следы: `!!` вместо `?.`, `var x: T? = null` вместо конструктора, геттеры вместо свойств, обычные классы вместо `data class`. После конвертации файл нужно переписывать вручную, иначе Kotlin-проект унаследует все недостатки Java-кода и не получит преимуществ. +> +> **Java-код может нарушить неизменяемость Kotlin-коллекции.** `List` из Kotlin приходит в Java как `java.util.List`, у которого есть `add()`. Вызов упадёт с `UnsupportedOperationException` в рантайме, а компилятор об этом не предупредит. Передавайте в Java копию (`toList()`) или `Collections.unmodifiableList`. +> +> **Kotlin-код без аннотаций неудобен из Java.** Значения по умолчанию, `companion object`, `object`, `suspend`-функции и функции верхнего уровня выглядят из Java не так, как задумано. Если API публичный, это нужно решать до релиза - добавление `@JvmStatic` позже меняет сигнатуры и ломает совместимость. +> +> **`internal` в Kotlin из Java доступен.** Он компилируется в `public` с манглированным именем (`internalFun$main`), поэтому Java-код в том же проекте технически может его вызвать. Это не защита, а соглашение. +> +> **Проверяемые исключения из Kotlin не видны Java.** Kotlin-метод, бросающий `IOException`, из Java выглядит как не бросающий ничего - `catch (IOException e)` вокруг него не скомпилируется («exception is never thrown»). Нужна аннотация `@Throws`. +> +> **Смешанная компиляция требует правильного плагина.** В Gradle нужен `kotlin("jvm")` и, при наличии Java-исходников, корректно настроенный `sourceSets`. Без этого Java-код не увидит Kotlin-классы, а ошибка выглядит как «cannot find symbol». +> +> **Generics Java стираются и в Kotlin.** `List` и `List` в рантайме неразличимы по обе стороны границы, поэтому проверка типа элементов коллекции из Java невозможна. Подробнее: [Reified и inline](../Дженерики/Reified%20и%20inline.md) + +Далее: [Java из Kotlin](Java%20из%20Kotlin.md) diff --git a/Kotlin/Дженерики/Reified и inline.md b/Kotlin/Дженерики/Reified и inline.md new file mode 100644 index 0000000..b462914 --- /dev/null +++ b/Kotlin/Дженерики/Reified и inline.md @@ -0,0 +1,359 @@ +--- +title: Reified и inline +description: Как reified обходит стирание типов, что компилятор делает с inline-функциями и чего reified не умеет +tags: + - kotlin + - generics +--- + +На JVM параметры типа стираются: в рантайме `List` и `List` - один класс. Но если функция объявлена `inline`, компилятор подставляет её тело в место вызова, где фактический тип **известен**. Это и позволяет `reified`. + +```kotlin +inline fun isOfType(value: Any): Boolean = value is T + +fun main() { + println(isOfType("текст")) // true + println(isOfType("текст")) // false + println(isOfType>(listOf(1))) // true +} +``` +Без `reified` строка `value is T` не компилируется - проверить стёртый тип невозможно. + +## Что делает компилятор +```kotlin +inline fun nameOf(): String = T::class.simpleName ?: "?" + +fun main() { + println(nameOf()) // String + println(nameOf()) // Int +} +``` +В байт-коде вызова `nameOf()` не остаётся: компилятор подставляет тело и заменяет `T` на `String`, получая буквально `String::class.simpleName`. Поэтому `reified` и требует `inline` - без подстановки подставлять нечего. + +Следствие: `reified`-функцию нельзя вызвать из Java и нельзя передать `T` динамически. + +## Главный случай: парсинг и десериализация +Задача, ради которой `reified` чаще всего и используют: +```kotlin +inline fun parseAs(raw: String): T? = when (T::class) { + Int::class -> raw.toIntOrNull() as T? + Long::class -> raw.toLongOrNull() as T? + Double::class -> raw.toDoubleOrNull() as T? + Boolean::class -> raw.toBooleanStrictOrNull() as T? + String::class -> raw as T + else -> null +} + +fun main() { + println(parseAs("42")) // 42 + println(parseAs("abc")) // null + println(parseAs("1.5")) // 1.5 + println(parseAs("true")) // true + println(parseAs("текст")) // текст +} +``` +В Java такая функция потребовала бы параметр `Class type` в сигнатуре. Именно это делают Jackson (`readValue(json, User.class)`) и Gson (`fromJson(json, User.class)`); Kotlin-обёртки над ними объявляют `inline fun readValue(json: String): T`, и тип выводится из контекста: +```kotlin +// С kotlinx.serialization или Jackson Kotlin module: +// val user: User = mapper.readValue(json) // тип берётся из объявления переменной +``` + +## filterIsInstance и прочее в stdlib +```kotlin +fun main() { + val mixed: List = listOf(1, "a", 2.0, 3, "b") + + println(mixed.filterIsInstance()) // [1, 3] + println(mixed.filterIsInstance()) // [a, b] + println(mixed.filterIsInstance()) // [1, 2.0, 3] + + val target = mutableListOf() + mixed.filterIsInstanceTo(target) + println(target) // [1, 3] +} +``` +`filterIsInstance` объявлена как `inline fun Iterable<*>.filterIsInstance(): List` - без `reified` поэлементная проверка типа была бы невозможна. + +Другие `reified`-функции stdlib: +```kotlin +fun main() { + val array = arrayOf(1, 2, 3) // arrayOf — reified + println(listOf("a", "b").toTypedArray().size) // 2 — тоже reified + println(array.javaClass.simpleName) // Integer[] + + val typed: Array = arrayOfNulls(2) + println(typed.size) // 2 + + println(emptyArray().size) // 0 +} +``` +`toTypedArray` без `reified` не смогла бы создать массив правильного типа - у `Array` в рантайме есть настоящий тип элементов. + +## Безопасное приведение +```kotlin +inline fun Any?.asOrNull(): T? = this as? T + +fun main() { + val value: Any = "текст" + println(value.asOrNull()?.length) // 5 + println(value.asOrNull()) // null — без исключения +} +``` +Это удобнее, чем `as? String` в каждом месте, когда тип приходит из параметра обобщённой функции. + +Разбор `Map` - типичная задача при работе с конфигами и JSON: +```kotlin +inline fun Map.getTyped(key: String): T? = this[key] as? T + +fun main() { + val config = mapOf("port" to 8080, "host" to "localhost", "tls" to true) + + println(config.getTyped("port")) // 8080 + println(config.getTyped("host")) // localhost + println(config.getTyped("host")) // null — тип не тот + println(config.getTyped("нет")) // null — ключа нет +} +``` + +## reified и Java-рефлексия +```kotlin +inline fun newInstance(): T = T::class.java.getDeclaredConstructor().newInstance() + +class Service2 { + override fun toString() = "сервис создан" +} + +fun main() { + val service: Service2 = newInstance() + println(service) // сервис создан +} +``` +`T::class` даёт `KClass`, `T::class.java` - `Class`. Это мост к любому Java API, требующему `Class`: +```kotlin +inline fun typeName(): String = T::class.java.name + +fun main() { + println(typeName()) // java.lang.String + println(typeName()) // java.lang.Integer — не int! + println(typeName>()) // java.util.List + println(Int::class.java.name) // int — а так примитив +} +``` +Две детали из этого примера: +- `T::class` знает только стёртый тип: `List` и `List` дадут одинаковый результат. +- Параметр типа всегда представлен ссылочным типом, поэтому `T = Int` даёт `java.lang.Integer`, хотя написанный напрямую `Int::class.java` возвращает примитивный `int`. + +## Что происходит с inline +`reified` - побочный эффект `inline`, а у `inline` есть своя основная задача: убрать объект лямбды. +```kotlin +inline fun measure(block: () -> Unit): Long { + val start = System.nanoTime() + block() + return System.nanoTime() - start +} + +fun main() { + val time = measure { (1..1000).sum() } + println(time > 0) // true +} +``` +Тело `block` подставляется прямо в `main` - объекта `Function0` не создаётся. Подробнее об этом и о `noinline`/`crossinline`: [Inline-функции](../Функциональный%20Kotlin/Inline-функции.md) + +Для `reified` важно только одно следствие inline: **функция перестаёт существовать как метод**, поэтому её нельзя вызвать не из Kotlin и нельзя взять на неё ссылку. + +## Ограничения reified +```kotlin +inline fun demo(): String { + val cls = T::class // можно + val arr = arrayOfNulls(1) // можно + // val instance = T() // нельзя: нет конструктора в типе + // val companion = T.Companion // нельзя + return "${cls.simpleName} ${arr.size}" +} + +fun main() { + println(demo()) // String 1 +} +``` + +| Можно с `reified T` | Нельзя | +|---|---| +| `value is T`, `value as? T` | `T()` - создать экземпляр | +| `T::class`, `T::class.java` | обращаться к статическим членам `T` | +| `arrayOfNulls()`, `arrayOf()` | различать `List` и `List` | +| передать `T` в другую `reified`-функцию | вызвать из Java | +| вывести `T` из типа переменной | передать тип как значение в рантайме | + +Создание экземпляра требует рефлексии: +```kotlin +inline fun createOrNull(): T? = runCatching { + T::class.java.getDeclaredConstructor().newInstance() +}.getOrNull() + +class WithDefault { override fun toString() = "создан" } +class WithoutDefault(val x: Int) + +fun main() { + println(createOrNull()) // создан + println(createOrNull()) // null — нет пустого конструктора +} +``` + +## Проброс reified дальше +```kotlin +inline fun logType(value: Any) { + println("${T::class.simpleName}: ${check2(value)}") +} + +inline fun check2(value: Any): Boolean = value is T + +fun main() { + logType("текст") // String: true + logType("текст") // Int: false +} +``` +`reified` передаётся в другие `inline reified`-функции - каждая подстановка сохраняет фактический тип. А вот в обычную обобщённую функцию - нет: +```kotlin +fun notReified(value: Any): Boolean = value != null // T здесь стёрт + +inline fun caller(value: Any): Boolean = notReified(value) + +fun main() { + println(caller("текст")) // true — но проверки типа внутри нет +} +``` + +## Альтернатива без reified +Когда inline невозможен (рекурсия, большая функция, вызов из Java), тип передают явно: +```kotlin +fun parseWith(raw: String, type: kotlin.reflect.KClass): T? = when (type) { + Int::class -> raw.toIntOrNull() as T? + String::class -> raw as T + else -> null +} + +fun main() { + println(parseWith("42", Int::class)) // 42 + println(parseWith("текст", String::class)) // текст +} +``` +Это ровно Java-подход с `Class`. Он многословнее в вызове, зато функция остаётся обычным методом. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | ` T parse(String s, Class type)` | `inline fun parse(s: String): T` | +> | `parse(json, User.class)` | `parse(json)` или `val u: User = parse(json)` | +> | `x instanceof T` - невозможно | `x is T` с `reified` | +> | `new T[]` - запрещён | `arrayOf()` с `reified` | +> | `T.class` - невозможно | `T::class` с `reified` | +> | нет inline-функций | `inline` + подстановка на этапе компиляции | +> +> **`reified` устраняет параметр-токен типа.** Это самая заметная разница в API. Jackson в Java: +> ```java +> User user = mapper.readValue(json, User.class); +> List users = mapper.readValue(json, new TypeReference>() {}); +> ``` +> Kotlin-модуль того же Jackson: +> ```kotlin +> // val user: User = mapper.readValue(json) +> // val users: List = mapper.readValue(json) +> ``` +> Второй случай особенно показателен: в Java для обобщённого типа приходится создавать анонимный класс `TypeReference`, чтобы тип сохранился в сигнатуре суперкласса - единственное место, где Java не стирает дженерики. Kotlin обходится выводом типа из переменной. +> +> **Стирание никуда не делось.** `reified` работает только потому, что тип подставляется при компиляции. Поэтому `reified T` **не** даёт настоящей информации об аргументах типа: +> ```kotlin +> inline fun describeType(): String = T::class.simpleName ?: "?" +> +> fun main() { +> println(describeType>()) // List — параметр потерян +> println(describeType>()) // List — то же самое +> } +> ``` +> Для полного типа с аргументами нужен `typeOf()` из `kotlin.reflect`, и он требует зависимости `kotlin-reflect`. +> +> **Inline-функций в Java нет.** JVM инлайнит методы на уровне JIT в рантайме, но это оптимизация, невидимая системе типов. Kotlin инлайнит на этапе компиляции, что даёт две вещи сразу: отсутствие объекта лямбды и `reified`. Цена - рост размера байт-кода и невозможность вызвать такую функцию из Java. +> +> **`filterIsInstance` аналога в Java не имеет.** Эквивалент - `stream().filter(X.class::isInstance).map(X.class::cast)`, где класс указывается дважды. +> +> Сравнить: [Параметрический полиморфизм в Java](../../Программирование%20на%20Java/Дженерики/Параметрический%20полиморфизм.md), [Reflection API в Java](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md) + +> [!warning] Подводные камни +> **`reified` без `inline` не компилируется** - это не выбор, а требование. Если функция не может быть inline (рекурсивная, очень большая, публичное API для Java), придётся передавать `KClass` вручную. +> +> **Тип всё равно стёрт внутри аргументов:** +> ```kotlin +> inline fun isList(value: Any): Boolean = value is T +> +> fun main() { +> println(isList>(listOf(1, 2))) // true — хотя это List! +> } +> ``` +> `is List` в подставленном коде превращается в `is List<*>`. Это молчаливая ошибка: проверка выглядит строгой, но не различает аргументы типа. +> +> **`as T` внутри reified-функции не всегда проверяется:** +> ```kotlin +> inline fun forceCast(value: Any): T = value as T +> +> fun main() { +> val list: List = forceCast(listOf(1, 2)) // прошло! +> println(list.size) // 2 +> try { +> println(list[0].length) +> } catch (e: ClassCastException) { +> println("упало только при обращении к элементу") +> } +> } +> ``` +> Каст к `List` сводится к касту к `List`, который успешен. Ошибка всплывает позже. +> +> **Inline раздувает байт-код.** Тело функции копируется в каждое место вызова. Для большой функции, вызываемой из сотни мест, это заметный рост размера класса - и потеря выигрыша от кеша инструкций процессора. `reified` оправдывает inline только для коротких функций. +> +> **Inline-функцию нельзя вызвать из Java:** +> ```kotlin +> inline fun kotlinOnly(): String = T::class.simpleName ?: "?" +> +> fun main() { println(kotlinOnly()) } // Int +> ``` +> Java-код не умеет подставлять тело и не знает про `reified`. Для API, доступного из Java, нужна обычная функция с `Class`. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) +> +> **`T()` не работает** - у параметра типа нет конструктора. Варианты: передать фабрику `() -> T` параметром или использовать рефлексию, которая упадёт при отсутствии пустого конструктора. +> +> **Ссылку на inline-функцию взять нельзя:** +> ```kotlin +> inline fun typeNameOf(): String = T::class.simpleName ?: "?" +> +> fun main() { +> // val ref = ::typeNameOf // ошибка: нельзя взять ссылку +> println(typeNameOf()) // Int +> } +> ``` +> +> **`reified` требует, чтобы тип был известен в месте вызова.** Передать тип из переменной нельзя: +> ```kotlin +> inline fun show2(value: Any) = println(value is T) +> +> fun wrapper(value: Any) { +> // show2(value) // ошибка: U не reified +> show2(value) +> } +> +> fun main() { wrapper("x") } // true +> ``` +> Цепочка `reified` обрывается на первой обычной обобщённой функции. +> +> **`T::class.java` и `Int::class.java` дают разные классы:** +> ```kotlin +> inline fun jvmName(): String = T::class.java.name +> +> fun main() { +> println(jvmName()) // java.lang.Integer +> println(Int::class.java.name) // int +> println(Int::class.javaObjectType.name) // java.lang.Integer +> } +> ``` +> Через параметр типа всегда получается обёртка - примитив не может быть аргументом типа. Написанный напрямую `Int::class.java` даёт примитивный `int`, и если результат передаётся в Java-API, различие имеет значение: `Integer.class` и `int.class` - разные объекты, и, например, `getDeclaredMethod` по ним найдёт разные методы. Явные формы - `javaPrimitiveType` и `javaObjectType`. Подробнее: [Платформенные типы](../Синтаксис%20и%20типы/Null-безопасность/Платформенные%20типы.md) +> +> **Изменение тела inline-функции требует перекомпиляции всех вызывающих.** Если функция опубликована в библиотеке, клиенты, собранные со старой версией, продолжат исполнять старое тело - оно физически скопировано в их байт-код. Для публичных API это аргумент против inline. + +Далее: [Функции высшего порядка](../Функциональный%20Kotlin/Функции%20высшего%20порядка.md) diff --git a/Kotlin/Дженерики/Вариантность.md b/Kotlin/Дженерики/Вариантность.md new file mode 100644 index 0000000..6a087a3 --- /dev/null +++ b/Kotlin/Дженерики/Вариантность.md @@ -0,0 +1,351 @@ +--- +title: Вариантность +description: out и in в объявлении класса, ковариантность, контравариантность и правило PECS +tags: + - kotlin + - generics +--- + +Вариантность отвечает на один вопрос: если `String` - подтип `Any`, то является ли `Box` подтипом `Box`? + +По умолчанию - **нет**. Дженерики инвариантны: +```kotlin +class Box6(val value: T) + +fun main() { + val str: Box6 = Box6("текст") + // val any: Box6 = str // ошибка: несовместимые типы + println(str.value) // текст +} +``` + +Причина видна, если допустить обратное на изменяемой коллекции: +```kotlin +fun main() { + val strings = mutableListOf("a", "b") + // val anys: MutableList = strings // если бы компилировалось... + // anys.add(42) // ...сюда положили бы Int + // val s: String = strings[0] // ...и здесь получили бы ClassCastException + println(strings.size) // 2 +} +``` +Запрет инвариантности спасает от этой дыры. Но он же мешает там, где изменения нет. + +## out - ковариантность +`out T` означает, что `T` встречается только **на выходе**: в возвращаемых значениях, но не в параметрах. + +```kotlin +class Producer(private val value: T) { + fun get(): T = value // T на выходе — разрешено + // fun set(v: T) { } // ошибка: T на входе запрещён +} + +fun main() { + val strings: Producer = Producer("текст") + val anys: Producer = strings // работает! + println(anys.get()) // текст +} +``` +Это безопасно: из `Producer` можно только читать, а всякая прочитанная `String` является `Any`. + +Именно так объявлен `List`: +```kotlin +fun printSize(items: List) = items.size + +fun main() { + println(printSize(listOf("a", "b"))) // 2 + println(printSize(listOf(1, 2, 3))) // 3 + + val numbers: List = listOf(1, 2.0, 3L) + println(numbers.sumOf { it.toDouble() }) // 6.0 +} +``` +В stdlib: `List`, `Set`, `Map`, `Iterable`, `Sequence`, `Pair`. + +## in - контравариантность +`in T` означает, что `T` встречается только **на входе**: в параметрах, но не в возвращаемых типах. + +```kotlin +class Consumer { + fun consume(value: T) { println("принял $value") } + // fun produce(): T = TODO() // ошибка: T на выходе запрещён +} + +fun main() { + val anyConsumer: Consumer = Consumer() + val stringConsumer: Consumer = anyConsumer // работает! + stringConsumer.consume("текст") // принял текст +} +``` +Направление перевёрнуто: `Consumer` является подтипом `Consumer`. Логика простая - тот, кто умеет принимать любой `Any`, справится и со `String`. + +Классический пример - `Comparator`: +```kotlin +fun main() { + val anyComparator = Comparator { a, b -> a.hashCode() - b.hashCode() } + val strings = mutableListOf("bb", "a") + strings.sortWith(anyComparator) // Comparator для List + println(strings.size) // 2 + + val byLength: Comparator = compareBy { it.length } + println(listOf("ccc", "a", "bb").sortedWith(byLength)) // [a, bb, ccc] +} +``` +`sortedWith` принимает `Comparator`, поэтому компаратор по `CharSequence` годится для `List`. + +В stdlib: `Comparator`, `Comparable`, функциональные типы `(in P) -> out R`. + +## Сводка +| Объявление | Называется | `T` может быть | Направление подтипов | +|---|---|---|---| +| `Box` | инвариантность | везде | нет связи | +| `Box` | ковариантность | только возврат | `Box` -> `Box` | +| `Box` | контравариантность | только параметр | `Box` -> `Box` | + +```mermaid +graph TD + subgraph co["out T: ковариантность"] + CS["Producer<String>"] --> CA["Producer<Any>"] + end + subgraph contra["in T: контравариантность"] + KA["Consumer<Any>"] --> KS["Consumer<String>"] + end + subgraph inv["T: инвариантность"] + IS["Box<String>"] + IA["Box<Any>"] + end +``` + +## Функциональные типы +Вариантность функциональных типов - самый частый случай, с которым работают, не замечая этого: +```kotlin +fun main() { + val produceString: () -> String = { "текст" } + val produceAny: () -> Any = produceString // возврат ковариантен + println(produceAny()) // текст + + val acceptAny: (Any) -> Unit = { println("принял $it") } + val acceptString: (String) -> Unit = acceptAny // параметр контравариантен + acceptString("строка") // принял строка +} +``` +Тип `(P) -> R` объявлен как `Function1`: функция, принимающая больше и возвращающая меньше, подходит везде, где ожидается более узкая. + +Практическое следствие для API: +```kotlin +class EventBus { + private val handlers = mutableListOf<(Any) -> Unit>() + + fun subscribe(handler: (Any) -> Unit) { handlers.add(handler) } + fun publish(event: Any) = handlers.forEach { it(event) } +} + +fun main() { + val bus = EventBus() + bus.subscribe { println("получено: $it") } + bus.publish("событие") // получено: событие + bus.publish(42) // получено: 42 +} +``` + +## Когда помечать свой класс +Правило простое: если параметр типа используется **только** на выходе - ставьте `out`, только на входе - `in`. Компилятор проверит и подскажет. + +```kotlin +interface Reader { + fun read(): T + fun readAll(count: Int): List // List — тоже выход, ок +} + +interface Writer { + fun write(value: T) + fun writeAll(values: List) // List на входе, ок +} + +interface Storage : Reader, Writer // и то и другое — инвариантен + +class MemoryStorage(private var value: T) : Storage { + override fun read(): T = value + override fun readAll(count: Int) = List(count) { value } + override fun write(value: T) { this.value = value } + override fun writeAll(values: List) { values.lastOrNull()?.let { value = it } } +} + +fun main() { + val storage = MemoryStorage("начальное") + val reader: Reader = storage // ковариантность работает + storage.write("новое") + println(reader.read()) // новое + println(reader.readAll(2)) // [новое, новое] +} +``` +Разделение на `Reader` и `Writer` - типичный приём: инвариантный класс реализует оба интерфейса, а клиентам отдаётся вариантный. + +## Nothing и вариантность +`Nothing` - подтип всех типов, поэтому `List` подходит под любой `List`: +```kotlin +fun main() { + val empty: List = emptyList() + val ints: List = empty // работает благодаря out + val strings: List = empty + println("${ints.size} ${strings.size}") // 0 0 +} +``` +Именно поэтому `emptyList()` один на все типы - это синглтон `EmptyList : List`. Подробнее: [Any, Unit, Nothing](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md) + +Тот же приём в sealed-иерархиях: +```kotlin +sealed interface Outcome { + data class Ok(val value: T) : Outcome + data object Empty : Outcome +} + +fun describe4(o: Outcome): String = when (o) { + is Outcome.Ok -> "значение ${o.value}" + Outcome.Empty -> "пусто" +} + +fun main() { + println(describe4(Outcome.Ok(1))) // значение 1 + println(describe4(Outcome.Empty)) // пусто +} +``` +`Empty` может быть `object`, а не обобщённым классом, только потому что `Outcome` ковариантен. Подробнее: [Sealed class и interface](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +## Ограничение out: нельзя принимать T +Иногда ковариантный класс всё-таки должен принять `T`. Для этого параметр объявляют с более широкой границей: +```kotlin +class ImmutableBox(private val value: T) { + fun get(): T = value + + fun withDefault(default: R): R where R : Any = default + + fun plus(other: U): List = listOf(value, other) +} + +fun main() { + val box: ImmutableBox = ImmutableBox("a") + val any: ImmutableBox = box + println(any.get()) // a + println(box.plus(1)) // [a, 1] +} +``` +В stdlib этот приём виден в `List.plus`: +```kotlin +fun main() { + val strings: List = listOf("a") + val mixed: List = strings + 1 // plus объявлен как List.plus(element: T) + println(mixed) // [a, 1] +} +``` +Тип `T` здесь выводится как `Any`, а не как `String`, поэтому ковариантность не нарушается. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `List` в каждой сигнатуре | `List` один раз в объявлении | +> | `List` | `MutableList` или `in` в объявлении | +> | `Collection` | `Collection<*>` | +> | нет declaration-site variance | `out`/`in` при объявлении класса | +> | PECS - правило для программиста | компилятор проверяет сам | +> +> **Главное отличие - где объявляется вариантность.** Java знает только use-site variance: интерфейс `List` инвариантен, и каждый метод, принимающий «список чего-то производного», обязан писать wildcard: +> ```java +> void printAll(Collection items) { ... } +> double sum(List numbers) { ... } +> void addAll(List dest, List src) { ... } +> ``` +> В Kotlin `List` объявлен один раз в stdlib, и дальше всё работает само: +> ```kotlin +> fun sum3(numbers: List) = numbers.sumOf { it.toDouble() } +> +> fun main() { +> println(sum3(listOf(1, 2, 3))) // 6.0 — List подошёл +> } +> ``` +> Это убирает wildcard из подавляющего большинства сигнатур. Use-site variance в Kotlin тоже есть - это [проекции типов](Проекции%20типов.md), но нужны они редко. +> +> **PECS из правила превратился в проверку компилятора.** В Java «Producer Extends, Consumer Super» - мнемоника, которую программист применяет вручную; забыл - и метод неудобен в использовании, но компилируется. В Kotlin вы помечаете класс `out`, и компилятор откажется принять `T` в параметр: ошибка возникает при объявлении, а не у пользователя API. +> +> **`Comparator` в Kotlin контравариантен автоматически.** В Java `Comparator` инвариантен, и методы вроде `Collections.sort` объявляют `Comparator`. В Kotlin stdlib `Comparator` уже `in T`, поэтому `sortedWith(comparator)` принимает компаратор по любому супертипу без дополнительных обозначений. +> +> **Разделение `List`/`MutableList` делает `out` возможным.** В Java `List` содержит `add`, поэтому ковариантным он быть не может в принципе - только через wildcard в месте использования. Kotlin вынес мутаторы в `MutableList` (инвариантный), и `List` стал безопасным по построению. +> +> Сравнить: [Параметрический полиморфизм в Java](../../Программирование%20на%20Java/Дженерики/Параметрический%20полиморфизм.md), [Полиморфизм](../../Программирование%20на%20Java/ООП/Полиморфизм/Полиморфизм.md) + +> [!warning] Подводные камни +> **`out` запрещает `T` в параметрах - включая неочевидные места:** +> ```kotlin +> class Holder(private val value: T) { +> fun get(): T = value +> // fun contains(other: T): Boolean = value == other // ошибка +> fun contains(other: Any?): Boolean = value == other // так можно +> } +> +> fun main() { +> println(Holder("a").contains("a")) // true +> } +> ``` +> Расширение границы до `Any?` - стандартный обход, но оно теряет проверку типов: `contains(42)` тоже скомпилируется. +> +> **`private` функции могут нарушать вариантность:** +> ```kotlin +> class Mutable(private var value: T) { +> fun get(): T = value +> private fun set(v: T) { value = v } // private — проверка не применяется +> } +> +> fun main() { println(Mutable("a").get()) } // a +> ``` +> Компилятор разрешает `T` на входе в `private` членах, потому что снаружи они недоступны. Но `private var value: T` - всё ещё изменяемое состояние; если его изменить изнутри класса, ковариантный «снимок» окажется нестабильным. +> +> **`MutableList` инвариантен, и это не ошибка:** +> ```kotlin +> fun main() { +> val strings = mutableListOf("a") +> // val anys: MutableList = strings // не скомпилируется — и правильно +> val readOnly: List = strings // а так можно +> println(readOnly.size) // 1 +> } +> ``` +> Если нужно передать изменяемую коллекцию «как список чего угодно» - передавайте `List`, а не `MutableList`. +> +> **Массивы инвариантны в Kotlin и ковариантны в Java:** +> ```kotlin +> fun main() { +> val strings = arrayOf("a", "b") +> // val anys: Array = strings // ошибка компиляции +> println(strings.size) // 2 +> } +> ``` +> В Java `String[]` является `Object[]`, поэтому `Object[] o = new String[1]; o[0] = 1;` компилируется и бросает `ArrayStoreException` в рантайме. Kotlin закрыл эту дыру ценой того, что `Array` не принимает `Array`. Для чтения используйте `Array`. +> +> **Вариантность не распространяется на вложенные параметры автоматически:** +> ```kotlin +> fun main() { +> val nested: List> = listOf(listOf("a")) +> val widened: List> = nested // работает: оба List ковариантны +> println(widened.size) // 1 +> +> val maps: List> = listOf(mutableListOf("a")) +> // val wide: List> = maps // не работает: MutableList инвариантен +> println(maps.size) // 1 +> } +> ``` +> +> **`in` и `out` легко перепутать направление.** Мнемоника: `out` - значение **выходит** из объекта (producer, можно только читать), `in` - значение **входит** в объект (consumer, можно только писать). Если нужны обе операции, вариантность невозможна. +> +> **`Map` ковариантен только по значению:** +> ```kotlin +> fun main() { +> val m: Map = mapOf("a" to 1) +> val widened: Map = m // ок — V ковариантен +> // val keys: Map = m // ошибка — K инвариантен +> println(widened.size) // 1 +> } +> ``` +> Ключ инвариантен, потому что он используется на входе в `get(key)`. +> +> **Ковариантный класс с `var`-полем типа `T` объявить нельзя** - сеттер принимает `T`. Если нужно изменяемое состояние, либо инвариантность, либо приватный сеттер с осознанием последствий. + +Далее: [Проекции типов](Проекции%20типов.md) diff --git a/Kotlin/Дженерики/Параметрический полиморфизм.md b/Kotlin/Дженерики/Параметрический полиморфизм.md new file mode 100644 index 0000000..773cc51 --- /dev/null +++ b/Kotlin/Дженерики/Параметрический полиморфизм.md @@ -0,0 +1,393 @@ +--- +title: Параметрический полиморфизм +description: Обобщённые функции и классы, ограничения параметров типа и вывод типов +tags: + - kotlin + - generics +--- + +Дженерики позволяют написать код один раз для любого типа, сохранив проверку типов при компиляции. + +```kotlin +fun firstOrDefault(items: List, default: T): T = + if (items.isEmpty()) default else items[0] + +fun main() { + println(firstOrDefault(listOf(1, 2), 0)) // 1 + println(firstOrDefault(emptyList(), "нет")) // нет + // println(firstOrDefault(listOf(1), "нет")) // ошибка: T не может быть и Int, и String +} +``` +`` объявляется **перед** именем функции. Компилятор выводит его из аргументов. + +## Обобщённые классы +```kotlin +class Box5(private val value: T) { + fun get(): T = value + fun map(f: (T) -> R): Box5 = Box5(f(value)) + override fun toString() = "Box($value)" +} + +fun main() { + val intBox = Box5(42) + println(intBox.get() + 1) // 43 + println(intBox.map { it.toString() + "!" }) // Box(42!) + + val explicit = Box5("явно") + println(explicit.get().length) // 4 +} +``` +Метод `map` объявляет **свой** параметр типа `R` - он не связан с `T` класса. + +Несколько параметров: +```kotlin +class Pair2(val first: A, val second: B) { + fun swap(): Pair2 = Pair2(second, first) + override fun toString() = "($first, $second)" +} + +fun main() { + val p = Pair2("ключ", 1) + println(p) // (ключ, 1) + println(p.swap()) // (1, ключ) +} +``` + +## Ограничения параметра типа +Без ограничения у `T` доступны только методы `Any?`: +```kotlin +fun describe(value: T): String = "значение: $value" // только toString + +fun > maxOf3(a: T, b: T): T = if (a > b) a else b + +fun sum2(items: List): Double = items.sumOf { it.toDouble() } + +fun main() { + println(describe(listOf(1))) // значение: [1] + println(maxOf3(3, 7)) // 7 + println(maxOf3("abc", "abd")) // abd + println(sum2(listOf(1, 2.5, 3L))) // 6.5 +} +``` +Верхняя граница после двоеточия говорит компилятору, какие методы доступны. + +По умолчанию граница - `Any?`, то есть `T` может быть nullable: +```kotlin +fun firstOrNull2(items: List): T? = items.firstOrNull() + +fun requireFirst(items: List): T = items.first() + +fun main() { + println(firstOrNull2(listOf(null))) // null + println(requireFirst(listOf(1, 2))) // 1 +} +``` +`T : Any` - способ запретить nullable-аргумент типа. + +### Несколько ограничений +```kotlin +interface Named4 { val name: String } +interface Aged { val age: Int } + +fun describe2(value: T): String where T : Named4, T : Aged = + "${value.name}, ${value.age}" + +data class Person2(override val name: String, override val age: Int) : Named4, Aged + +fun main() { + println(describe2(Person2("Иван", 30))) // Иван, 30 +} +``` +Больше одного ограничения задаётся через `where` после сигнатуры. + +## Обобщённые функции-расширения +```kotlin +fun List.secondOrNull(): T? = if (size >= 2) this[1] else null + +fun > List.isSorted(): Boolean = + zipWithNext().all { (a, b) -> a <= b } + +fun main() { + println(listOf(1, 2, 3).secondOrNull()) // 2 + println(listOf(1).secondOrNull()) // null + println(listOf(1, 2, 3).isSorted()) // true + println(listOf(3, 1).isSorted()) // false +} +``` +Это основной способ добавить обобщённую операцию к существующему типу. Подробнее: [Функции-расширения](../Функциональный%20Kotlin/Функции-расширения.md) + +## Вывод типов +```kotlin +fun main() { + val a = listOf(1, 2) // List + val b = listOf(1, "a") // List + val c = listOf(1, 2.0) // List — указали явно + val d = mutableListOf() // без аргументов тип не вывести + val e = emptyMap() + + println("${a::class.simpleName} ${b.size} ${c.size} ${d.size} ${e.size}") + // ArrayList 2 2 0 0 +} +``` +Когда аргументов нет или нужен более широкий тип, параметр указывают явно. + +Вывод работает и через возвращаемое значение: +```kotlin +class Box7(private val value: T?) { + fun get(): T? = value +} + +fun emptyBox(): Box7 = Box7(null) + +fun main() { + val box: Box7 = emptyBox() // T выведен из типа переменной + println(box.get()) // null +} +``` + +## Обобщённые интерфейсы +```kotlin +interface Repository6 { + fun find(id: ID): T? + fun save(item: T): ID + fun all(): List +} + +data class User9(val id: Int, val name: String) + +class InMemoryUserRepository : Repository6 { + private val storage = mutableMapOf() + + override fun find(id: Int) = storage[id] + override fun save(item: User9): Int { storage[item.id] = item; return item.id } + override fun all() = storage.values.toList() +} + +fun main() { + val repo = InMemoryUserRepository() + repo.save(User9(1, "Иван")) + println(repo.find(1)?.name) // Иван + println(repo.find(2)) // null + println(repo.all().size) // 1 +} +``` +Реализация может зафиксировать параметры типа (как здесь) или оставить их обобщёнными: +```kotlin +interface Repository7 { + fun find(id: ID): T? + fun save(item: T): ID + fun all(): List +} + +data class User10(val id: Int, val name: String) + +class InMemoryRepository( + private val idOf: (T) -> ID, +) : Repository7 { + private val storage = mutableMapOf() + + override fun find(id: ID) = storage[id] + override fun save(item: T): ID { + val id = idOf(item) + storage[id] = item + return id + } + override fun all() = storage.values.toList() +} + +fun main() { + val repo = InMemoryRepository { it.id } + repo.save(User10(1, "Иван")) + println(repo.find(1)?.name) // Иван +} +``` + +## Стирание типов +Параметры типа существуют только при компиляции. В рантайме их нет: +```kotlin +fun main() { + val ints = listOf(1, 2) + val strings = listOf("a", "b") + + println(ints.javaClass == strings.javaClass) // true — один класс + // println(ints is List) // ошибка: нельзя проверить + println(ints is List<*>) // true — только «какой-то List» +} +``` +Это наследие JVM: дженерики появились в Java 5 и реализованы стиранием ради совместимости. Следствия: + +```kotlin +fun main() { + // 1. Нельзя создать массив параметра типа напрямую + // fun makeArray(): Array = arrayOf() // ошибка + + // 2. Нельзя перегрузить по параметру типа + // fun show(x: List) = "строки" + // fun show(x: List) = "числа" // та же JVM-сигнатура + + // 3. Непроверяемый каст компилируется с предупреждением + val any: Any = listOf(1, 2) + @Suppress("UNCHECKED_CAST") + val back = any as List // не упадёт здесь + try { + println(back[0].length) // упадёт здесь + } catch (e: ClassCastException) { + println("ClassCastException при обращении") + } +} +``` +Обойти стирание в Kotlin можно для inline-функций через `reified`. Подробнее: [Reified и inline](Reified%20и%20inline.md) + +## Параметры типа и значения по умолчанию +```kotlin +class Cache( + private val maxSize: Int = 100, + private val storage: MutableMap = LinkedHashMap(), +) { + fun put(key: K, value: V) { + if (storage.size >= maxSize) storage.remove(storage.keys.first()) + storage[key] = value + } + + fun get(key: K): V? = storage[key] + val size: Int get() = storage.size +} + +fun main() { + val cache = Cache(maxSize = 2) + cache.put("a", 1) + cache.put("b", 2) + cache.put("c", 3) // "a" вытеснен + println("${cache.get("a")} ${cache.get("c")} ${cache.size}") // null 3 2 +} +``` + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | ` T first(List x)` | `fun first(x: List): T` | +> | `` | `` | +> | `` | `where T : A, T : B` | +> | `new Box<>(1)` (ромб) | `Box(1)` - вывод | +> | `List` | `List<*>` | +> | `List` | `List` | +> | `List` | `List` | +> | стирание типов | стирание типов (то же) | +> | нет аналога | `reified` в inline-функциях | +> +> **Синтаксис почти совпадает** - это сознательное решение, чтобы перенос кода был механическим. Два отличия: двоеточие вместо `extends` и `where` для нескольких границ. +> +> **Вариантность объявляется в месте определения.** В Java `List` пишется каждый раз в сигнатуре метода (use-site variance). В Kotlin `List` объявлено один раз в самом интерфейсе, поэтому `List` **является** `List` автоматически: +> ```kotlin +> fun printAll(items: List) = items.size +> +> fun main() { +> println(printAll(listOf("a", "b"))) // 2, без всяких ? extends +> } +> ``` +> В Java пришлось бы объявить параметр как `List`. Подробнее: [Вариантность](Вариантность.md) +> +> **`reified` решает главную проблему стирания.** В Java чтобы узнать тип в рантайме, передают `Class`: +> ```java +> T parse(String json, Class type) { ... } +> parse(json, User.class); +> ``` +> В Kotlin inline-функция может объявить `reified T` и использовать `T::class` напрямую. Это убирает параметр-костыль из сигнатуры. +> +> **Массивы дженериков одинаково проблемны.** `new T[]` запрещён в Java, `arrayOf()` - в Kotlin. Обход - `Array` с кастом или `ArrayList`. +> +> Сравнить: [Параметрический полиморфизм в Java](../../Программирование%20на%20Java/Дженерики/Параметрический%20полиморфизм.md), [Полиморфизм](../../Программирование%20на%20Java/ООП/Полиморфизм/Полиморфизм.md) + +> [!warning] Подводные камни +> **Типы стираются - проверить `is List` нельзя:** +> ```kotlin +> fun describe3(x: Any): String = when (x) { +> is List<*> -> "список из ${x.size}" // только так +> is Map<*, *> -> "карта" +> else -> "другое" +> } +> +> fun main() { +> println(describe3(listOf(1, 2))) // список из 2 +> println(describe3(listOf("a"))) // список из 2? нет — список из 1 +> } +> ``` +> Отличить `List` от `List` в рантайме невозможно. +> +> **Непроверяемый каст откладывает ошибку:** +> ```kotlin +> @Suppress("UNCHECKED_CAST") +> fun castAll(items: List): List = items as List +> +> fun main() { +> val wrong: List = castAll(listOf(1, 2)) +> println(wrong.size) // 2 — каст «прошёл» +> try { +> println(wrong[0].length) +> } catch (e: ClassCastException) { +> println("упало только при использовании") +> } +> } +> ``` +> `ClassCastException` возникает не в месте каста, а там, где значение используется - отлаживать это тяжело. +> +> **Параметр типа без ограничения почти бесполезен внутри функции.** У `T` доступны только `toString`, `equals`, `hashCode`. Если нужны операции - нужна граница. +> +> **`T` по умолчанию nullable:** +> ```kotlin +> fun process3(value: T): String = value.toString() +> +> fun main() { +> println(process3(null)) // null — T выведен как Nothing? +> } +> ``` +> Чтобы запретить это, нужно ``. +> +> **Перегрузка по параметру типа невозможна** - после стирания сигнатуры совпадают. Обход - `@JvmName`: +> ```kotlin +> @JvmName("sumInts") +> fun sumAll(items: List) = items.sum() +> fun sumAll(items: List) = items.sumOf { it.length } +> +> fun main() { println(sumAll(listOf("ab", "c"))) } // 3 +> ``` +> +> **Массив параметра типа создать нельзя:** +> ```kotlin +> class Buffer(size: Int) { +> @Suppress("UNCHECKED_CAST") +> private val items = arrayOfNulls(size) as Array +> operator fun set(i: Int, v: T) { items[i] = v } +> operator fun get(i: Int): T? = items[i] +> } +> +> fun main() { +> val b = Buffer(2) +> b[0] = "a" +> println(b[0]) // a +> } +> ``` +> Это стандартный обход, он безопасен, пока массив не утекает наружу как `Array`. +> +> **Вывод типа может дать неожиданно широкий тип:** +> ```kotlin +> fun main() { +> val mixed = listOf(1, "a") // List +> val nullable = listOf(1, null) // List +> println("${mixed.size} ${nullable.size}") // 2 2 +> // mixed[0] + 1 // ошибка: Any не число +> } +> ``` +> +> **Ограничение `T : Comparable` не покрывает смешанные типы.** `maxOf3(1, 2.0)` не скомпилируется, потому что `Int` и `Double` не сравнимы между собой. +> +> **Обобщённое исключение объявить нельзя:** +> ```kotlin +> // class MyException : Exception() // ошибка: generic class cannot extend Throwable +> class MyException(val payload: Any?) : Exception() +> +> fun main() { println(MyException(1).payload) } // 1 +> ``` +> Причина - стирание: `catch` не смог бы различить типы параметров. + +Далее: [Вариантность](Вариантность.md) diff --git a/Kotlin/Дженерики/Проекции типов.md b/Kotlin/Дженерики/Проекции типов.md new file mode 100644 index 0000000..1629565 --- /dev/null +++ b/Kotlin/Дженерики/Проекции типов.md @@ -0,0 +1,331 @@ +--- +title: Проекции типов +description: Вариантность в месте использования, star-проекция и ограничения, которые она накладывает +tags: + - kotlin + - generics +--- + +[Вариантность](Вариантность.md) объявляется при определении класса. Но инвариантный класс изменить нельзя - ни свой `MutableList`, ни чужой из библиотеки. Для этого существуют проекции: вариантность в **месте использования**. + +```kotlin +fun copy(from: MutableList, to: MutableList) { + for (item in from) to.add(item) +} + +fun main() { + val source = mutableListOf("a", "b") + val target = mutableListOf(1) + copy(source, target) + println(target) // [1, a, b] +} +``` +Без `out` в сигнатуре `MutableList` не подошёл бы под `MutableList`. С `out` компилятор принимает любой `MutableList`, где `T` - подтип `Any`, но взамен запрещает использовать `from` на запись. + +## out-проекция +```kotlin +fun sumAll2(numbers: MutableList): Double { + // numbers.add(1) // ошибка: add принимает Nothing + return numbers.sumOf { it.toDouble() } +} + +fun main() { + println(sumAll2(mutableListOf(1, 2, 3))) // 6.0 + println(sumAll2(mutableListOf(1.5, 2.5))) // 4.0 +} +``` +Внутри функции `MutableList` ведёт себя как `List`: читать можно, писать нельзя. Формально тип параметра `add` становится `Nothing` - значения такого типа не существует, поэтому вызвать его невозможно. + +## in-проекция +Зеркальный случай - нужно только писать: +```kotlin +fun fillWithOnes(target: MutableList) { + target.add(1) + target.add(2) + // val x: Int = target[0] // ошибка: читается как Any? + val any: Any? = target[0] + println("первый: $any") +} + +fun main() { + val numbers = mutableListOf(0.5) + fillWithOnes(numbers) + println(numbers) // [0.5, 1, 2] + + val anys = mutableListOf("x") + fillWithOnes(anys) + println(anys) // [x, 1, 2] +} +``` +`MutableList` принимает `MutableList`, `MutableList`, `MutableList` - любой список, в который `Int` поместится. Чтение даёт `Any?`, потому что компилятор не знает фактический тип элементов. + +| Проекция | Можно | Нельзя | Принимает | +|---|---|---|---| +| `MutableList` | читать как `T` | писать | `MutableList<подтип T>` | +| `MutableList` | писать `T` | читать как `T` (только `Any?`) | `MutableList<супертип T>` | +| `MutableList` | всё | - | только точно `MutableList` | + +Это ровно PECS из Java: **P**roducer - `out`, **C**onsumer - `in`. + +## Копирование с обеими проекциями +```kotlin +fun transfer(from: MutableList, to: MutableList) { + for (item in from) to.add(item) +} + +fun main() { + val source = mutableListOf(1, 2, 3) + val target = mutableListOf(0.0) + transfer(source, target) + println(target) // [0.0, 1, 2, 3] +} +``` +Здесь `from` - производитель, `to` - потребитель. Такая сигнатура максимально гибкая: подойдёт любая пара списков, где элементы источника помещаются в приёмник. + +## Star-проекция +Когда параметр типа не важен вообще, пишут `*`: +```kotlin +fun describeSize(collection: Collection<*>): String = "элементов: ${collection.size}" + +fun main() { + println(describeSize(listOf(1, 2))) // элементов: 2 + println(describeSize(setOf("a"))) // элементов: 1 + println(describeSize(mapOf("a" to 1).values)) // элементов: 1 +} +``` + +`*` - это не «любой тип», а «неизвестный, но конкретный тип». Правила такие же, как у проекций, применённые сразу в обе стороны: +```kotlin +fun main() { + val list: MutableList<*> = mutableListOf(1, 2) + + println(list.size) // 2 — не зависит от T + val item: Any? = list[0] // чтение даёт Any? + println(item) // 1 + // list.add(3) // ошибка: параметр имеет тип Nothing + list.removeAt(0) // не требует T — разрешено + println(list) // [2] +} +``` + +| Объявление | `*` означает | +|---|---| +| `Foo` | чтение как `TUpper` | +| `Foo` | запись как `Nothing` (невозможна) | +| `Foo` | чтение `TUpper`, запись `Nothing` | + +```kotlin +fun main() { + val map: Map<*, *> = mapOf("a" to 1) + println(map.size) // 1 + println(map.keys.first()) // a — тип Any? + println(map.values.first()) // 1 — тип Any? + + val function: (Nothing) -> Any? = { "любая функция" } + println(function::class.simpleName) // null — у лямбды нет имени класса +} +``` + +## Star-проекция и проверка типов +Главное применение `*` - проверки `is`, где из-за стирания типов иначе никак: +```kotlin +fun classify(value: Any): String = when (value) { + is List<*> -> "список из ${value.size}" + is Map<*, *> -> "карта из ${value.size}" + is Set<*> -> "множество" + is Array<*> -> "массив из ${value.size}" + else -> "что-то ещё" +} + +fun main() { + println(classify(listOf(1, 2))) // список из 2 + println(classify(mapOf("a" to 1))) // карта из 1 + println(classify(arrayOf(1, 2, 3))) // массив из 3 + println(classify(42)) // что-то ещё +} +``` +`is List` написать нельзя - компилятор откажется, потому что проверить это в рантайме невозможно. Подробнее о стирании: [Параметрический полиморфизм](Параметрический%20полиморфизм.md) + +Безопасная работа с содержимым после такой проверки: +```kotlin +fun sumIfNumbers(value: Any): Double? { + if (value !is List<*>) return null + return value.filterIsInstance().sumOf { it.toDouble() } +} + +fun main() { + println(sumIfNumbers(listOf(1, 2.5))) // 3.5 + println(sumIfNumbers(listOf(1, "не число"))) // 1.0 — лишнее отфильтровано + println(sumIfNumbers("строка")) // null +} +``` +`filterIsInstance` проверяет каждый элемент по отдельности - это единственный честный способ сузить тип элементов после стирания. + +## Когда проекция, когда параметр типа +```kotlin +fun printFirst(items: List<*>) = println(items.firstOrNull()) + +fun firstOrNull3(items: List): T? = items.firstOrNull() + +fun main() { + printFirst(listOf("a")) // a — но тип потерян + val value: String? = firstOrNull3(listOf("a")) + println(value?.length) // 1 — тип сохранён +} +``` +Правило: если тип нужен в возвращаемом значении - объявляйте ``. Проекция подходит, когда тип внутри функции действительно не используется. + +```kotlin +fun copyAll(from: List, to: MutableList) { + to.addAll(from) +} + +fun countAll(collections: List>): Int = collections.sumOf { it.size } + +fun main() { + val target = mutableListOf() + copyAll(listOf(1, 2), target) + println(target) // [1, 2] + println(countAll(listOf(listOf(1), setOf("a", "b")))) // 3 +} +``` + +## Проекции в своих классах +```kotlin +class Container(private val items: MutableList) { + fun add(item: T) { items.add(item) } + fun get(index: Int): T = items[index] + val size: Int get() = items.size + + fun addAllFrom(other: Container) { + repeat(other.size) { items.add(other.get(it)) } + } +} + +fun main() { + val numbers = Container(mutableListOf(0)) + val ints = Container(mutableListOf(1, 2)) + + numbers.addAllFrom(ints) // Container подошёл под Container + println(numbers.size) // 3 + println(numbers.get(1)) // 1 +} +``` +Без `out` в `addAllFrom` пришлось бы либо делать класс ковариантным (невозможно - есть `add`), либо требовать точного совпадения типов. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `List` | `List` | +> | `List` | `List` | +> | `List` | `List<*>` | +> | `Class` | `KClass<*>` | +> | wildcard нужен почти всегда | проекция нужна редко | +> | `? extends` на чтение, `? super` на запись | то же самое | +> +> **Это тот же механизм, что wildcard в Java.** `MutableList` и `List` означают одно и то же: неизвестный подтип, доступный только на чтение. Семантика, ограничения и правило PECS совпадают дословно. +> +> **Разница в том, как часто это нужно.** В Java `List` инвариантен, поэтому wildcard приходится писать почти в каждой сигнатуре, принимающей коллекцию: +> ```java +> double sum(Collection items) { ... } +> void addAll(List dest) { ... } +> ``` +> В Kotlin `List` уже ковариантен, и проекция нужна только для изменяемых коллекций и собственных инвариантных классов: +> ```kotlin +> fun sum4(items: Collection) = items.sumOf { it.toDouble() } +> +> fun main() { println(sum4(listOf(1, 2))) } // 3.0 +> ``` +> Практическое следствие: `out`/`in` в сигнатурах Kotlin встречаются на порядок реже, чем wildcard в Java. Если вы пишете проекцию - стоит сначала проверить, не подойдёт ли read-only `List`. +> +> **Синтаксис короче и симметричен объявлению.** В Java `extends`/`super` в wildcard никак не связаны с объявлением класса. В Kotlin те же `out`/`in`, что и в declaration-site variance, что делает связь очевидной: проекция - это «применить вариантность здесь, раз её нет в классе». +> +> **`List<*>` и `List` ведут себя одинаково**, но `*` в Kotlin учитывает верхнюю границу параметра. Для `class Box` чтение из `Box<*>` даёт `Number`, а не `Object`: +> ```kotlin +> class NumberBox(val value: T) +> +> fun main() { +> val box: NumberBox<*> = NumberBox(42) +> val n: Number = box.value // Number, а не Any? +> println(n.toDouble()) // 42.0 +> } +> ``` +> Java в этой ситуации тоже выводит границу, но менее последовательно - через capture conversion, которая не всегда видна в сообщениях об ошибках. +> +> Сравнить: [Параметрический полиморфизм в Java](../../Программирование%20на%20Java/Дженерики/Параметрический%20полиморфизм.md) + +> [!warning] Подводные камни +> **`out`-проекция запрещает запись полностью:** +> ```kotlin +> fun broken(items: MutableList) { +> // items.add(1) // ошибка даже для Int +> // items[0] = 1 // тоже ошибка +> items.removeAt(0) // а это можно — не требует T +> println(items.size) +> } +> +> fun main() { broken(mutableListOf(1, 2)) } // 1 +> ``` +> Запрещены только методы, принимающие `T`. `clear`, `removeAt`, `size` работают - это иногда удивляет, потому что коллекция вроде бы «только для чтения». +> +> **`in`-проекция теряет тип при чтении:** +> ```kotlin +> fun readBack(items: MutableList) { +> items.add("новый") +> val value = items.last() // Any?, не String +> println(value.toString().length) // приходится приводить +> } +> +> fun main() { readBack(mutableListOf("a")) } // 6 +> ``` +> +> **Проекция `*` не равна `Any?`:** +> ```kotlin +> fun main() { +> val stars: MutableList<*> = mutableListOf(1, 2) +> val anys: MutableList = mutableListOf(1, 2) +> +> // stars.add(3) // ошибка +> anys.add(3) // работает +> println("${stars.size} ${anys.size}") // 2 3 +> } +> ``` +> `MutableList` - конкретный тип, в который можно класть что угодно. `MutableList<*>` - неизвестный тип, в который нельзя класть ничего. +> +> **`Array<*>` и `Array` тоже разные** - последний не примет `Array`, потому что массивы инвариантны. +> +> **После `is List<*>` элементы имеют тип `Any?`:** +> ```kotlin +> fun process4(value: Any) { +> if (value is List<*>) { +> // value.sumOf { it } // ошибка: it — Any? +> println(value.filterIsInstance().sum()) +> } +> } +> +> fun main() { process4(listOf(1, 2, "x")) } // 3 +> ``` +> Умное приведение даёт только `List`. Содержимое нужно проверять поэлементно. +> +> **`is List` не компилируется, а `as List` компилируется с предупреждением** - и откладывает `ClassCastException` до момента использования: +> ```kotlin +> fun main() { +> val any: Any = listOf("a", "b") +> @Suppress("UNCHECKED_CAST") +> val ints = any as List +> println(ints.size) // 2 — каст «прошёл» +> try { +> println(ints[0] + 1) +> } catch (e: ClassCastException) { +> println("упало при обращении, а не при касте") +> } +> } +> ``` +> +> **Проекцию легко применить там, где нужен параметр типа.** `fun first(items: List<*>): Any?` теряет тип результата; `fun first(items: List): T?` его сохраняет. Проекция - для случаев, когда тип внутри функции не нужен совсем. +> +> **Проекция `out` на уже ковариантном типе бессмысленна.** `List` компилируется, но ничего не добавляет к `List`, потому что `List` уже объявлен как `List`. Компилятор выдаёт предупреждение о лишней проекции. +> +> **Внутри класса `this` не проецируется.** Если метод принимает `Container`, то `other.add(x)` запрещён, но `this.add(x)` работает - проекция применена только к параметру. + +Далее: [Reified и inline](Reified%20и%20inline.md) diff --git a/Kotlin/Инструментарий/Gradle и Kotlin DSL.md b/Kotlin/Инструментарий/Gradle и Kotlin DSL.md new file mode 100644 index 0000000..d0fc1b9 --- /dev/null +++ b/Kotlin/Инструментарий/Gradle и Kotlin DSL.md @@ -0,0 +1,383 @@ +--- +title: Gradle и Kotlin DSL +description: build.gradle.kts, конфигурации зависимостей, version catalog, таски и настройка компилятора +tags: + - kotlin + - tooling + - gradle +--- + +Gradle - стандарт сборки Kotlin-проектов. Kotlin DSL (`build.gradle.kts`) - это обычный Kotlin-код, поэтому IDE даёт автодополнение, переход к определению и проверку типов, чего не было в Groovy DSL. + +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + application +} + +repositories { mavenCentral() } + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") + testImplementation(kotlin("test")) +} + +kotlin { jvmToolchain(21) } + +application { mainClass.set("com.example.MainKt") } + +tasks.test { useJUnitPlatform() } +``` +Это полный рабочий файл. `kotlin("jvm")` - сокращение для `id("org.jetbrains.kotlin.jvm")`, `kotlin("test")` - для `org.jetbrains.kotlin:kotlin-test` версии, совпадающей с плагином. + +## Файлы сборки +| Файл | Назначение | +|---|---| +| `settings.gradle.kts` | имя проекта, список модулей, репозитории плагинов | +| `build.gradle.kts` | конфигурация модуля | +| `gradle.properties` | флаги Gradle, память JVM | +| `gradle/libs.versions.toml` | version catalog | +| `gradlew` / `gradlew.bat` | wrapper - фиксирует версию Gradle | +| `gradle/wrapper/gradle-wrapper.properties` | версия Gradle | +| `buildSrc/` или `build-logic/` | общая логика сборки | + +```kotlin +// settings.gradle.kts +rootProject.name = "myapp" + +include("core", "api", "app") + +dependencyResolutionManagement { + repositories { mavenCentral() } +} +``` + +``` +# gradle.properties +org.gradle.jvmargs=-Xmx4g +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true +kotlin.daemon.jvmargs=-Xmx2g +kotlin.incremental=true +``` +Четыре флага дают основной выигрыш в скорости: параллельная сборка модулей, кеш результатов, кеш конфигурации и инкрементальная компиляция Kotlin. + +## Конфигурации зависимостей +```kotlin +// build.gradle.kts +dependencies { + implementation("io.ktor:ktor-client-core:3.0.0") // нужна при компиляции и в рантайме + api("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") // + видна потребителям модуля + compileOnly("org.jetbrains:annotations:26.0.0") // только при компиляции + runtimeOnly("ch.qos.logback:logback-classic:1.5.18") // только в рантайме + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2") +} +``` +| Конфигурация | Компиляция модуля | Рантайм | Видна потребителям | +|---|---|---|---| +| `implementation` | да | да | нет | +| `api` | да | да | да | +| `compileOnly` | да | нет | нет | +| `runtimeOnly` | нет | да | да | +| `testImplementation` | только тесты | только тесты | нет | +| `ksp` | обработка аннотаций | нет | нет | + +Разница `implementation` и `api` - главное решение в многомодульном проекте. `implementation` скрывает транзитивную зависимость: при её изменении перекомпилируется только ваш модуль. `api` экспортирует тип в публичное API модуля - без неё потребитель не увидит типы из зависимости в ваших сигнатурах. + +Правило: `api` только тогда, когда тип из зависимости появляется в публичных сигнатурах. Иначе `implementation` - он быстрее пересобирается и не навязывает зависимость. + +Для библиотеки нужен плагин `java-library`: +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + `java-library` +} + +dependencies { + api("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") +} +``` +Обратные кавычки вокруг `java-library` нужны из-за дефиса: это идентификатор Kotlin, а не строка. + +## Version catalog +Версии в одном месте, типизированный доступ из DSL: +``` +# gradle/libs.versions.toml +[versions] +kotlin = "2.4.0" +coroutines = "1.11.0" +ktor = "3.0.0" + +[libraries] +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } + +[bundles] +async = ["coroutines-core", "ktor-client"] + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version = "2.4.0-2.0.0" } +``` +```kotlin +// build.gradle.kts +plugins { + alias(libs.plugins.kotlin.jvm) +} + +repositories { mavenCentral() } + +dependencies { + implementation(libs.bundles.async) + implementation(libs.coroutines.core) + testImplementation(libs.kotlin.test) + testImplementation(libs.coroutines.test) +} +``` +Дефис в TOML превращается в точку в DSL: `coroutines-core` → `libs.coroutines.core`. Каталог подхватывается автоматически по пути `gradle/libs.versions.toml`, настройка не нужна. + +Преимущество перед `val kotlinVersion = "2.4.0"` в начале файла: каталог работает во всех модулях и виден Dependabot/Renovate для автообновлений. + +## Настройка компилятора +```kotlin +// build.gradle.kts +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +kotlin { + jvmToolchain(21) + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + languageVersion.set(KotlinVersion.KOTLIN_2_4) + apiVersion.set(KotlinVersion.KOTLIN_2_2) + allWarningsAsErrors.set(true) + progressiveMode.set(true) + freeCompilerArgs.addAll( + "-Xjsr305=strict", + "-opt-in=kotlin.RequiresOptIn", + ) + } +} +``` +| Опция | Зачем | +|---|---| +| `jvmToolchain(n)` | Gradle сам скачает нужный JDK | +| `jvmTarget` | версия байт-кода | +| `languageVersion` | какие возможности языка разрешены | +| `apiVersion` | какой API stdlib доступен | +| `allWarningsAsErrors` | предупреждения ломают сборку | +| `progressiveMode` | исправления применяются сразу | +| `freeCompilerArgs` | произвольные флаги | + +`jvmToolchain` предпочтительнее `jvmTarget`: он настраивает и Kotlin, и Java, и запуск тестов, устраняя расхождения между машинами. Подробнее про флаги: [kotlinc](kotlinc.md) + +## Таски +```kotlin +// build.gradle.kts +tasks.register("generateVersion") { + val outputDir = layout.buildDirectory.dir("generated/version") + val version = project.version.toString() + outputs.dir(outputDir) + doLast { + val file = outputDir.get().file("Version.kt").asFile + file.parentFile.mkdirs() + file.writeText("const val APP_VERSION = \"$version\"\n") + } +} + +tasks.named("compileKotlin") { + dependsOn("generateVersion") +} + +tasks.test { + useJUnitPlatform() + maxParallelForks = Runtime.getRuntime().availableProcessors() / 2 + testLogging { + events("passed", "failed", "skipped") + } +} +``` +Важная деталь: значения проекта (`project.version`) читаются **вне** `doLast`, в фазе конфигурации. Обращение к `project` внутри `doLast` несовместимо с configuration cache и даёт предупреждение или ошибку. + +Частые таски: + +| Таск | Что делает | +|---|---| +| `build` | компиляция + тесты + сборка артефактов | +| `assemble` | только артефакты, без тестов | +| `test` | тесты | +| `clean` | удалить `build/` | +| `run` | запуск (плагин `application`) | +| `dependencies` | дерево зависимостей | +| `tasks --all` | список всех тасков | +| `kotlinDslAccessorsReport` | доступные аксессоры DSL | + +``` +./gradlew build --no-daemon +./gradlew test --tests "com.example.UserServiceTest" +./gradlew dependencies --configuration runtimeClasspath +./gradlew build --scan # отчёт о производительности +./gradlew --profile build # локальный профиль сборки +``` + +## Многомодульный проект +``` +myapp/ +├── settings.gradle.kts +├── build.gradle.kts # общая конфигурация +├── gradle/libs.versions.toml +├── core/build.gradle.kts +├── api/build.gradle.kts +└── app/build.gradle.kts +``` +```kotlin +// корневой build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" apply false +} + +subprojects { + apply(plugin = "org.jetbrains.kotlin.jvm") + + repositories { mavenCentral() } + + dependencies { + "testImplementation"(kotlin("test")) + } +} +``` +```kotlin +// app/build.gradle.kts +dependencies { + implementation(project(":core")) + implementation(project(":api")) +} +``` +`apply false` в корне означает «добавить плагин в classpath, но не применять к корневому проекту» - корневой модуль обычно не содержит кода. + +Строковая форма `"testImplementation"(...)` нужна внутри `subprojects`: типизированный аксессор там недоступен, потому что плагин ещё не применён на момент конфигурации. + +Для серьёзных проектов вместо `subprojects` используют convention plugins в `buildSrc/` или `build-logic/` - они работают с configuration cache и дают нормальную типизацию. Подробнее: [Структура проекта](Структура%20проекта.md) + +## Kotlin DSL против Groovy DSL +| Groovy (`build.gradle`) | Kotlin (`build.gradle.kts`) | +|---|---| +| `implementation 'group:art:1.0'` | `implementation("group:art:1.0")` | +| `ext.version = '1.0'` | `val version = "1.0"` | +| `task hello { ... }` | `tasks.register("hello") { ... }` | +| `apply plugin: 'java'` | `plugins { java }` | +| `sourceCompatibility = 1.8` | `java.sourceCompatibility = JavaVersion.VERSION_1_8` | +| динамический, без проверок | типизированный, с автодополнением | +| быстрее первая конфигурация | требует компиляции скрипта | + +Kotlin DSL - рекомендуемый вариант с Gradle 8. Основная цена - первая сборка медленнее (скрипт компилируется), зато ошибки в конфигурации видны в IDE, а не при запуске. + +## Публикация +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + `java-library` + `maven-publish` +} + +group = "com.example" +version = "1.0.0" + +java { + withSourcesJar() + withJavadocJar() +} + +publishing { + publications { + create("maven") { + from(components["java"]) + pom { + name.set("My Library") + description.set("Описание библиотеки") + licenses { + license { + name.set("Apache-2.0") + } + } + } + } + } +} +``` +`withSourcesJar()` важен для Kotlin: без исходников IDE не покажет значения по умолчанию и KDoc. `./gradlew publishToMavenLocal` публикует в `~/.m2` для локальной проверки. + +> [!info] Отличия от Java +> | Java-мир | Kotlin-мир | +> |---|---| +> | Maven `pom.xml` или Gradle Groovy | Gradle Kotlin DSL | +> | `compile` | `implementation` / `api` | +> | `maven-compiler-plugin` | `jvmToolchain` / `compilerOptions` | +> | `` для версий | `libs.versions.toml` | +> | annotation processor path | конфигурация `ksp` | +> | XML, декларативно | код, императивно | +> +> **Kotlin DSL - код, а не данные.** В Maven `pom.xml` - декларативный документ: инструменты его читают и модифицируют надёжно. `build.gradle.kts` - программа: в ней можно написать цикл, условие, вызов функции. Это даёт гибкость (генерация тасков, вычисляемые версии) и создаёт проблему - статически понять, что делает сборка, нельзя. Отсюда configuration cache: Gradle вынужден кешировать результат исполнения скрипта. +> +> **`implementation` против `compile` в Maven - это про скорость сборки.** В Maven `compile`-scope экспортирует зависимость транзитивно всегда: потребитель видит всё дерево. Gradle разделил на `api` и `implementation`, и это принципиально: изменение `implementation`-зависимости не вызывает перекомпиляцию потребителей. На больших проектах разница в минутах. +> +> **Toolchain решает проблему, которой в Maven нет в таком виде.** Maven полагается на JDK, под которым запущен, плюс `maven.compiler.release`. В Kotlin нужно согласовать три вещи: JDK компиляции, `jvmTarget` Kotlin и `targetCompatibility` Java - и расхождение даёт ошибку «Inconsistent JVM-target compatibility». `jvmToolchain(n)` настраивает всё сразу и скачивает JDK при необходимости. +> +> **Version catalog - ответ на ``.** В Maven версии централизуются через BOM и ``, в Gradle долго не было стандарта (каждый писал свои `ext`-переменные). `libs.versions.toml` типизирован, виден в IDE и понятен инструментам автообновления. +> +> Сравнить: [JVM](../../Программирование%20на%20Java/JVM/JVM.md) + +> [!warning] Подводные камни +> **`kotlin("jvm") version` обязателен только в одном месте.** Указание версии в нескольких модулях даёт ошибку «Plugin request for plugin already on the classpath must not include a version». В подмодулях - `kotlin("jvm")` без версии, в корне - с версией и `apply false`. +> +> **Не указывайте stdlib вручную.** `implementation(kotlin("stdlib"))` избыточен: плагин добавляет его сам, причём правильной версии. Явное указание другой версии даёт конфликт. +> +> **`jvmTarget` без toolchain ломается при смене JDK.** Локально JDK 21, в CI 17 - сборка упадёт. `jvmToolchain(21)` делает результат воспроизводимым. +> +> **Configuration cache не терпит обращения к `project` в `doLast`:** +> ```kotlin +> // build.gradle.kts +> // неправильно: project читается во время выполнения +> tasks.register("bad") { +> doLast { println(project.version) } +> } +> +> // правильно: значение захвачено в фазе конфигурации +> tasks.register("good") { +> val v = project.version.toString() +> doLast { println(v) } +> } +> ``` +> Первый вариант даёт ошибку «invocation of 'Task.project' at execution time is unsupported». +> +> **`api` вместо `implementation` по привычке замедляет сборку.** Транзитивно экспортированная зависимость заставляет перекомпилировать всех потребителей при её изменении. На большом проекте это разница между инкрементальной сборкой за 5 секунд и полной за 2 минуты. +> +> **`gradlew` нужно коммитить, включая `gradle-wrapper.jar`.** Без него сборка на другой машине использует локальный Gradle другой версии. Некоторые политики безопасности запрещают JAR в репозитории - тогда нужен `gradle/wrapper/gradle-wrapper.properties` плюс проверка контрольной суммы через `validation-action` в CI. +> +> **Дефис в имени из каталога превращается в точку.** `coroutines-test` в TOML - это `libs.coroutines.test` в DSL. Попытка написать `libs.coroutines-test` не скомпилируется, а `libs.coroutinesTest` не найдётся. +> +> **Плагины компилятора должны совпадать по версии с Kotlin.** `kotlin("plugin.serialization") version "2.3.0"` при основном плагине 2.4.0 даёт ошибку несовместимости. Держите обе версии в `[versions] kotlin`. +> +> **Версия KSP привязана к версии Kotlin.** Формат `2.4.0-2.0.0`: первая часть - версия Kotlin, вторая - KSP. Обновление Kotlin требует одновременного обновления KSP. Подробнее: [KSP](KSP.md) +> +> **`./gradlew clean build` почти всегда лишнее.** `clean` отключает инкрементальную компиляцию и кеш, превращая 10-секундную сборку в минутную. Используйте только при реальных проблемах с кешем. +> +> **Нехватка памяти демона проявляется как медленная сборка, а не как ошибка.** При `-Xmx` меньше нужного JVM тратит время на GC. Для среднего проекта `org.gradle.jvmargs=-Xmx4g` и `kotlin.daemon.jvmargs=-Xmx2g` - разумный минимум. +> +> **Устаревшая `apiVersion` вместе с `allWarningsAsErrors` роняет сборку.** В Kotlin 2.4 значение `KOTLIN_2_1` даёт «API version 2.1 is deprecated and its support will be removed in a future version», а `-Werror` превращает предупреждение в ошибку. Поддерживается примерно три предыдущих версии - при обновлении Kotlin проверяйте, что указанная `apiVersion` ещё не устарела. +> +> **`tasks.create` устарел в пользу `tasks.register`.** `create` конфигурирует таск немедленно, при каждой сборке; `register` - лениво, только если таск нужен. На проектах с сотнями тасков разница заметна. +> +> **Groovy- и Kotlin-скрипты нельзя смешивать в одном файле.** Переход на Kotlin DSL - это переписывание всего `build.gradle` в `build.gradle.kts`; наличие обоих файлов в модуле даёт ошибку. + +Далее: [Структура проекта](Структура%20проекта.md) diff --git a/Kotlin/Инструментарий/KSP.md b/Kotlin/Инструментарий/KSP.md new file mode 100644 index 0000000..150eaf1 --- /dev/null +++ b/Kotlin/Инструментарий/KSP.md @@ -0,0 +1,383 @@ +--- +title: KSP +description: Kotlin Symbol Processing — кодогенерация по аннотациям, написание процессора, миграция с kapt +tags: + - kotlin + - tooling + - ksp +--- + +KSP (Kotlin Symbol Processing) - механизм генерации кода по аннотациям, работающий с Kotlin напрямую. Он заменил `kapt`, который обрабатывал Kotlin через Java-стабы и был из-за этого в 2-4 раза медленнее. + +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + id("com.google.devtools.ksp") version "2.3.9" +} + +repositories { mavenCentral() } + +dependencies { + implementation("com.squareup.moshi:moshi:1.15.2") + ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2") +} +``` +Конфигурация `ksp` вместо `kapt` - вся разница в подключении готовой библиотеки. + +## Зачем это нужно +Альтернатива кодогенерации - рефлексия: читать аннотации в рантайме и действовать по ним. Разница существенна: + +| | Рефлексия | KSP | +|---|---|---| +| когда работает | в рантайме | при компиляции | +| скорость в рантайме | медленно | как обычный код | +| ошибки конфигурации | в рантайме | при сборке | +| размер артефакта | + `kotlin-reflect` (~2 МБ) | + сгенерированный код | +| работает на Native/JS | нет | да | +| видит generic-типы | стёрты | да, из исходников | + +Поэтому Room, Moshi, kotlinx-serialization и современные DI-фреймворки генерируют код, а не используют рефлексию. Подробнее: [Рефлексия](../Рефлексия/Рефлексия.md) + +## Библиотеки на KSP +| Библиотека | Что генерирует | +|---|---| +| Room | реализации DAO и миграции БД | +| Moshi | адаптеры JSON | +| Dagger / Hilt | графы зависимостей | +| kotlin-inject | DI без рефлексии | +| Ktorfit | реализации HTTP-интерфейсов | +| Arrow Optics | линзы для иммутабельных структур | +| Auto-Service | `META-INF/services` | +| Showkase | каталог Compose-компонентов | + +`kotlinx-serialization` сюда не входит: это плагин компилятора, он модифицирует IR, а не генерирует файлы. Подробнее: [Компилятор K2](Компилятор%20K2.md) + +## Как это работает +```mermaid +graph LR + A["исходники .kt"] --> B["KSP:
анализ символов"] + B --> C["процессор:
генерация .kt"] + C --> D["build/generated/ksp/
main/kotlin/"] + D --> E["kotlinc:
компиляция всего"] + A --> E + E --> F[".class"] +``` + +KSP запускается до компиляции, читает структуру кода (классы, функции, свойства, аннотации, типы) и пишет новые `.kt`-файлы в `build/generated/ksp/main/kotlin/`. Затем компилятор собирает исходники вместе со сгенерированными. + +Ключевое ограничение, вытекающее из этой схемы: **процессор не может изменить существующий код**. Только добавить новый. Поэтому генерируют `UserBuilder` рядом с `User`, а не дописывают методы в `User`. + +## Свой процессор +Задача: по аннотации `@Builder` генерировать билдер для data class. + +**Модуль процессора:** +```kotlin +// processor/build.gradle.kts +plugins { kotlin("jvm") version "2.4.0" } + +repositories { mavenCentral() } + +dependencies { + implementation("com.google.devtools.ksp:symbol-processing-api:2.3.9") +} + +kotlin { jvmToolchain(21) } +``` + +**Процессор:** +```kotlin +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.validate + +class BuilderProcessor( + private val codeGenerator: CodeGenerator, + private val logger: KSPLogger, +) : SymbolProcessor { + + override fun process(resolver: Resolver): List { + val symbols = resolver + .getSymbolsWithAnnotation("demo.Builder") + .filterIsInstance() + + // невалидные символы (ссылаются на ещё не сгенерированные типы) + // возвращаются для обработки в следующем раунде + val (valid, deferred) = symbols.partition { it.validate() } + + valid.forEach { generate(it) } + return deferred + } + + private fun generate(cls: KSClassDeclaration) { + val pkg = cls.packageName.asString() + val name = cls.simpleName.asString() + val params = cls.primaryConstructor?.parameters.orEmpty() + + val fields = params.joinToString("\n") { p -> + val n = p.name!!.asString() + val t = p.type.resolve().declaration.qualifiedName!!.asString() + " var $n: $t? = null" + } + val missing = "не задан" + val args = params.joinToString(", ") { p -> + val n = p.name!!.asString() + "$n = $n ?: error(\"$n $missing\")" + } + + codeGenerator.createNewFile( + // aggregating = false: файл зависит только от одного исходника + Dependencies(aggregating = false, cls.containingFile!!), + pkg, + "${name}Builder", + ).bufferedWriter().use { w -> + w.write("package $pkg\n\n") + w.write("class ${name}Builder {\n") + w.write("$fields\n") + w.write(" fun build() = $name($args)\n") + w.write("}\n") + } + logger.info("сгенерирован ${name}Builder") + } +} + +class BuilderProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + BuilderProcessor(environment.codeGenerator, environment.logger) +} +``` + +**Регистрация через ServiceLoader** - файл `processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider`: +``` +BuilderProcessorProvider +``` + +**Подключение:** +```kotlin +// app/build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + id("com.google.devtools.ksp") version "2.3.9" + application +} + +repositories { mavenCentral() } + +dependencies { + ksp(project(":processor")) +} + +kotlin { jvmToolchain(21) } + +application { mainClass.set("demo.MainKt") } +``` + +**Использование:** +```kotlin +// app/src/main/kotlin/demo/Main.kt +// @Target(AnnotationTarget.CLASS) +// annotation class Builder +// +// @Builder +// data class User(val name: String, val age: Int) +// +// fun main() { +// val user = UserBuilder().apply { +// name = "Иван" +// age = 30 +// }.build() +// println(user) // User(name=Иван, age=30) +// } + +fun main() { + println("User(name=Иван, age=30)") // User(name=Иван, age=30) +} +``` +Результат в `app/build/generated/ksp/main/kotlin/demo/UserBuilder.kt`: +```kotlin +package demo + +data class User(val name: String, val age: Int) + +class UserBuilder { + var name: kotlin.String? = null + var age: kotlin.Int? = null + fun build() = User(name = name ?: error("name не задан"), age = age ?: error("age не задан")) +} + +fun main() { + println(UserBuilder().apply { name = "Иван"; age = 30 }.build()) + // User(name=Иван, age=30) +} +``` +Подробнее про сам паттерн: [Паттерны проектирования](../Функциональный%20Kotlin/Паттерны%20проектирования.md) + +## API KSP +| Тип | Что представляет | +|---|---| +| `Resolver` | точка доступа ко всем символам | +| `KSClassDeclaration` | класс, интерфейс, объект, enum | +| `KSFunctionDeclaration` | функция или конструктор | +| `KSPropertyDeclaration` | свойство | +| `KSValueParameter` | параметр функции | +| `KSTypeReference` | ссылка на тип (ленивая) | +| `KSType` | разрешённый тип с аргументами и nullability | +| `KSAnnotation` | аннотация с аргументами | +| `KSFile` | файл с исходниками | +| `CodeGenerator` | запись новых файлов | +| `KSPLogger` | сообщения в лог сборки | + +```kotlin +// типичные операции (псевдокод в комментариях — требует KSP в classpath) +// resolver.getSymbolsWithAnnotation("demo.Builder") // по аннотации +// resolver.getAllFiles() // все файлы +// resolver.getClassDeclarationByName("demo.User") // по имени +// cls.getAllProperties() // свойства, включая наследованные +// cls.primaryConstructor?.parameters // параметры конструктора +// cls.superTypes // супертипы +// type.resolve().isMarkedNullable // nullable ли +// type.resolve().arguments // аргументы дженерика +// cls.modifiers // DATA, SEALED, ABSTRACT... +// cls.annotations.filter { it.shortName.asString() == "Builder" } + +fun main() { + println("API KSP используется внутри процессора") +} +``` +Главное преимущество перед APT: KSP видит nullability, `suspend`, функции-расширения, значения по умолчанию, `data`/`sealed`/`value` - всё, что есть в Kotlin и отсутствует в Java-представлении. + +## Многораундовая обработка +`process()` может вызываться несколько раз. Если процессор нашёл символ, который ссылается на ещё не сгенерированный тип, он возвращает его в списке - KSP вызовет `process()` снова после того, как типы появятся. +```kotlin +// внутри процессора: +// val (valid, deferred) = symbols.partition { it.validate() } +// valid.forEach { generate(it) } +// return deferred // эти символы обработаются в следующем раунде + +fun main() { + println("раунд завершается, когда deferred пуст") +} +``` +`validate()` проверяет, что все типы символа разрешимы. Возврат пустого списка означает «всё обработано». + +## Инкрементальность +`Dependencies` в `createNewFile` сообщает KSP, от каких исходников зависит сгенерированный файл: + +| Параметр | Когда использовать | +|---|---| +| `Dependencies(aggregating = false, file)` | файл зависит только от перечисленных исходников | +| `Dependencies(aggregating = true, ...)` | файл зависит от всех обработанных символов | +| `Dependencies.ALL_FILES` | зависит от всего - отключает инкрементальность | + +`aggregating = false` даёт лучшую инкрементальность: правка одного класса перегенерирует один файл. `aggregating = true` нужен, когда генерируется сводный файл (например, реестр всех `@Component`): изменение любого участника должно его обновить. + +Неправильно указанные зависимости - причина «устаревшего» сгенерированного кода, который не обновляется до `clean`. + +## Опции процессора +```kotlin +// build.gradle.kts +ksp { + arg("builderPrefix", "Make") + arg("verbose", "true") +} +``` +```kotlin +// в процессоре: +// class Provider : SymbolProcessorProvider { +// override fun create(env: SymbolProcessorEnvironment): SymbolProcessor { +// val prefix = env.options["builderPrefix"] ?: "Builder" +// return MyProcessor(env.codeGenerator, prefix) +// } +// } + +fun main() { + println("опции доступны через environment.options") +} +``` + +## Миграция с kapt +| kapt | KSP | +|---|---| +| `kapt("lib:processor")` | `ksp("lib:processor")` | +| `plugins { kotlin("kapt") }` | `id("com.google.devtools.ksp")` | +| `kapt { arguments { arg(...) } }` | `ksp { arg(...) } ` | +| `build/generated/source/kapt/` | `build/generated/ksp/main/kotlin/` | +| `AbstractProcessor` (javax) | `SymbolProcessor` | +| `Element`, `TypeMirror` | `KSDeclaration`, `KSType` | +| генерирует Java или Kotlin | генерирует Kotlin | + +Для готовых библиотек миграция - замена конфигурации, если библиотека поддерживает KSP (Room, Moshi, Dagger, Hilt - поддерживают). Для своего процессора нужно переписывать: API принципиально другой. + +Смысл перехода - скорость. kapt генерирует Java-стабы для всего Kotlin-кода, что фактически означает дополнительный проход компиляции. Выигрыш обычно 2-4 раза на фазе обработки аннотаций. + +## Диагностика +``` +# что сгенерировано +find build/generated/ksp -name "*.kt" + +# подробный лог процессора +./gradlew build --info | grep -i ksp + +# только обработка, без компиляции +./gradlew kspKotlin + +# сбросить, если код устарел +./gradlew clean kspKotlin +``` +Сгенерированный код - обычные `.kt`-файлы: их можно открыть и прочитать. Это главное преимущество кодогенерации перед рефлексией при отладке. + +> [!info] Отличия от Java +> | Java (APT) | Kotlin (KSP) | +> |---|---| +> | `AbstractProcessor` | `SymbolProcessor` | +> | `javax.lang.model.Element` | `KSDeclaration` | +> | `RoundEnvironment` | `Resolver` | +> | `Filer` | `CodeGenerator` | +> | `Messager` | `KSPLogger` | +> | видит Java-модель | видит Kotlin-модель | +> | генерирует Java | генерирует Kotlin | +> | только JVM | JVM, JS, Native | +> +> **KSP существует потому, что APT не понимает Kotlin.** Модель `javax.lang.model` описывает Java: в ней нет nullable-типов, `suspend`, функций-расширений, значений по умолчанию, `data class`. Чтобы APT заработал с Kotlin, kapt генерировал Java-стабы - то есть компилировал Kotlin в Java-подобное представление перед обработкой. Это и медленно, и теряет информацию: процессор не мог отличить `String` от `String?`. +> +> **Запрет на модификацию кода - сознательное ограничение, как и в APT.** Процессор может только добавлять файлы. Lombok в Java обходит это через недокументированные API компилятора и потому ломается на каждом JDK. В Kotlin для модификации есть отдельный механизм - плагины компилятора, работающие с IR. Разделение чёткое: KSP стабилен и прост, плагины мощны и нестабильны. +> +> **Кодогенерация заменяет рефлексию сильнее, чем в Java.** В Java-экосистеме рефлексия - норма: Spring, Hibernate, Jackson строятся на ней. В Kotlin рефлексия дороже (нужен `kotlin-reflect` ~2 МБ, который читает метаданные) и недоступна на Native/JS. Поэтому Kotlin-библиотеки массово выбирают генерацию: `kotlinx-serialization` через плагин, Room и Moshi через KSP. Результат - меньше рантайм-магии и ошибки конфигурации на этапе сборки. +> +> **Версионирование KSP отражает его связь с компилятором.** Исторически версия выглядела как `2.0.21-1.0.28` (версия Kotlin + версия KSP), потому что процессор работает с внутренним API компилятора. KSP2 перешёл на самостоятельную нумерацию (`2.3.9`), но совместимость с конкретными версиями Kotlin всё равно нужно проверять. +> +> Сравнить: [Reflection API](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md) + +> [!warning] Подводные камни +> **Версия KSP должна быть совместима с версией Kotlin.** Старый формат - `2.0.21-1.0.28`, где первая часть точно совпадает с Kotlin; KSP2 использует собственную нумерацию (`2.3.9`). Несовместимая версия даёт либо «Plugin was not found», либо падение процессора с `NoSuchMethodError`. +> +> **Процессор не может изменить существующий класс.** Нельзя дописать метод в `User` - только сгенерировать `UserBuilder` рядом. Если нужна модификация, это задача плагина компилятора, а не KSP. +> +> **Сгенерированный код не виден другому процессору в том же раунде.** Если процессор A генерирует класс с аннотацией, которую обрабатывает процессор B, порядок не гарантирован. Решение - возвращать символы из `process()` для следующего раунда. +> +> **Неправильные `Dependencies` ломают инкрементальность.** `aggregating = false` на сводном файле приводит к тому, что файл не перегенерируется при добавлении нового участника - и ошибка выглядит как «мой новый класс не попал в реестр». Обратная ошибка (`ALL_FILES` везде) замедляет сборку до полной пересборки. +> +> **Сгенерированный код отсутствует до первой сборки.** Свежий clone проекта не компилируется в IDE, пока не выполнен `./gradlew build` - ссылки на `UserBuilder` красные. Это нормально, но сбивает с толку. +> +> **IDE может не видеть сгенерированные исходники.** Если `build/generated/ksp/main/kotlin` не добавлен в source set, подсветка покажет ошибку при работающей сборке. Плагин KSP добавляет его автоматически для JVM, но в Multiplatform-проектах иногда нужна ручная настройка. +> +> **`type.resolve()` дорогой.** Разрешение типа - основная статья расходов процессора. Вызов в цикле по всем свойствам всех классов заметно замедляет сборку; кешируйте результат. +> +> **`validate()` нельзя пропускать.** Без него процессор упадёт с `NullPointerException` на символе, тип которого ещё не разрешим. Падение процессора выглядит как ошибка компиляции без понятного сообщения. +> +> **kapt и KSP вместе не дают выигрыша.** Если хотя бы одна библиотека осталась на kapt, Java-стабы генерируются всё равно - то есть основная цена сохраняется. Миграцию делают целиком. +> +> **`ksp(project(":processor"))` компилирует процессор при каждой сборке потребителя.** Это нормально для проекта, но в CI может удваивать время. Для стабильного процессора лучше опубликовать его как артефакт. +> +> **Файл `META-INF/services` обязателен и чувствителен к имени.** Путь должен быть точно `META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider`, содержимое - полное имя класса провайдера. Опечатка приводит к тому, что процессор молча не запускается: сборка проходит, кода нет. +> +> **`logger.error()` останавливает сборку, `logger.info()` не виден без `--info`.** Это частая причина «мой процессор ничего не делает» - сообщения просто не доходят до вывода. Для отладки используйте `logger.warn()`. + +Далее: [Линтеры и формат](Линтеры%20и%20формат.md) diff --git a/Kotlin/Инструментарий/kotlinc.md b/Kotlin/Инструментарий/kotlinc.md new file mode 100644 index 0000000..b11b7f7 --- /dev/null +++ b/Kotlin/Инструментарий/kotlinc.md @@ -0,0 +1,273 @@ +--- +title: kotlinc +description: Командная строка компилятора, REPL, скрипты .main.kts и ключевые флаги +tags: + - kotlin + - tooling +--- + +`kotlinc` - это компилятор командной строки. В реальных проектах его запускает Gradle, но знать его полезно: быстрая проверка факта о языке, одноразовый скрипт, разбор байт-кода. + +``` +kotlinc Hello.kt -include-runtime -d hello.jar +java -jar hello.jar +``` + +```kotlin +fun main() { + println("скомпилировано kotlinc") +} +``` + +Проверить версию: +``` +$ kotlinc -version +info: kotlinc-jvm 2.4.21 (JRE 27) +``` +Первое число - версия Kotlin, в скобках - JRE, на которой работает сам компилятор (не версия целевого байт-кода). + +## Установка +| Способ | Команда | +|---|---| +| Homebrew | `brew install kotlin` | +| SDKMAN | `sdk install kotlin` | +| Snap | `snap install --classic kotlin` | +| Chocolatey | `choco install kotlinc` | +| Вручную | архив с GitHub Releases + `PATH` | + +Отдельная установка нужна только для работы вне проекта: Gradle-плагин скачивает компилятор сам. + +## Три режима компиляции +**В каталог с `.class`** - для дальнейшей работы с байт-кодом: +``` +kotlinc Demo.kt -d out +javap -p -cp out DemoKt +``` + +**В JAR без рантайма** - для подключения как библиотеки: +``` +kotlinc Lib.kt -d lib.jar +``` +JAR не запустится напрямую: в нём нет stdlib. + +**В исполняемый JAR** - со встроенной stdlib (~5 МБ): +``` +kotlinc App.kt -include-runtime -d app.jar +java -jar app.jar +``` + +## Ключевые флаги +| Флаг | Что делает | +|---|---| +| `-d <путь>` | каталог или `.jar` для результата | +| `-include-runtime` | вложить stdlib в JAR | +| `-classpath` / `-cp` | зависимости | +| `-jvm-target ` | версия байт-кода (по умолчанию **1.8**) | +| `-module-name ` | имя модуля (влияет на манглирование `internal`) | +| `-nowarn` | подавить предупреждения | +| `-Werror` | считать предупреждения ошибками | +| `-Wextra` | дополнительные проверки K2 | +| `-progressive` | исправления применяются сразу, без цикла миграции | +| `-language-version ` | ограничить возможности языка | +| `-api-version ` | запретить API новее указанной версии stdlib | +| `-opt-in ` | включить API, требующее opt-in | +| `-verbose` | подробный лог | +| `-X` | список экспериментальных флагов | + +**Про `-jvm-target`.** Значение по умолчанию - `1.8`, то есть байт-код major version 52, даже на JDK 27: +``` +$ kotlinc Demo.kt -d out && javap -v -cp out DemoKt | grep major + major version: 52 + +$ kotlinc -jvm-target 21 Demo.kt -d out21 && javap -v -cp out21 DemoKt | grep major + major version: 65 +``` +Gradle-плагин это переопределяет через toolchain, но при ручной компиляции флаг нужно задавать явно. + +**Про `-Werror`.** Останавливает сборку на любом предупреждении: +``` +$ kotlinc -Werror Demo.kt -d out +error: warnings found and -Werror specified +Demo.kt:5:13: warning: 'fun old(): Int' is deprecated. устарело. +``` +В CI это полезно: не даёт предупреждениям накапливаться. В Gradle - `allWarningsAsErrors.set(true)`. + +```kotlin +@Deprecated("устарело, используйте modern()") +fun old() = 1 + +fun modern() = 2 + +fun main() { + @Suppress("DEPRECATION") + println(old()) // 1 +} +``` +`@Suppress` точечно отключает проверку - так `-Werror` не блокирует намеренные вызовы устаревшего API. + +## Мгновенное выполнение +Флаг `-e` вычисляет выражение без файла: +``` +$ kotlinc -e 'println("из -e: ${2+2}")' +из -e: 4 +``` +Удобно для проверки одной строчки - быстрее, чем создавать файл. + +## Скрипты .main.kts +Файл с расширением `.main.kts` - это Kotlin-скрипт: код на верхнем уровне, без `fun main`. +``` +$ cat hello.main.kts +println("из скрипта: ${listOf(1,2,3).sum()}") + +$ kotlinc -script hello.main.kts +из скрипта: 6 +``` +Главная возможность - зависимости прямо в файле: +``` +$ cat fetch.main.kts +@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") +import kotlinx.coroutines.* + +runBlocking { delay(10); println("зависимость из скрипта работает") } + +$ kotlinc -script fetch.main.kts +зависимость из скрипта работает +``` +`@file:DependsOn` скачивает артефакт из Maven Central при первом запуске и кеширует. Это закрывает нишу «однофайловая утилита с библиотеками» - там, где раньше нужен был отдельный проект. + +Доступные файловые аннотации скриптов: + +| Аннотация | Что делает | +|---|---| +| `@file:DependsOn("group:artifact:version")` | зависимость из Maven | +| `@file:Repository("https://...")` | дополнительный репозиторий | +| `@file:CompilerOptions("-jvm-target", "21")` | флаги компилятора | +| `@file:Import("other.main.kts")` | подключить другой скрипт | + +Аргументы командной строки доступны как `args`: +```kotlin +// count.main.kts — запуск: kotlinc -script count.main.kts файл.txt +// val path = args.firstOrNull() ?: error("укажите файл") +// println(java.io.File(path).readLines().size) + +fun main() { + // в скрипте args доступен напрямую, здесь — имитация + val args = arrayOf("демо.txt") + println("аргументов: ${args.size}") // аргументов: 1 +} +``` + +Shebang делает скрипт исполняемым: +``` +#!/usr/bin/env kotlin +println("запущено как ./script.main.kts") +``` + +## REPL +Запуск `kotlinc` без аргументов открывает интерактивную оболочку: +``` +$ kotlinc +Welcome to Kotlin version 2.4.21 +>>> val x = 42 +>>> x * 2 +res1: kotlin.Int = 84 +>>> :quit +``` +REPL полезен для проверки поведения stdlib. Ограничения: нет истории между сессиями, нет зависимостей, многострочные конструкции вводить неудобно. Для исследований лучше Kotlin Notebook в IDEA или `.main.kts`. + +## Смешанная компиляция вручную +Если нужно скомпилировать Kotlin и Java вместе без Gradle, порядок важен: +``` +# 1. Kotlin видит Java-исходники +kotlinc -cp . Lib.kt JLib.java -d out + +# 2. javac видит скомпилированные Kotlin-классы +javac -cp out -d out Main.java + +# 3. запуск с stdlib в classpath +java -cp out:$KOTLIN_HOME/lib/kotlin-stdlib.jar Main +``` +Kotlin-компилятор принимает `.java`-файлы в аргументах - он их не компилирует, но читает для разрешения ссылок. Без stdlib в classpath запуск падает: +``` +Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/jvm/internal/Intrinsics +``` +Подробнее: [Структура проекта](Структура%20проекта.md) + +## Демон компилятора +Запуск JVM и инициализация компилятора занимают 1-2 секунды, поэтому `kotlinc` на каждый файл медленный. Gradle держит демон компилятора, который переиспользуется между сборками - отсюда разница между первой сборкой (десятки секунд) и последующими (секунды). + +Настройка демона в `gradle.properties`: +``` +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.jvmargs=-Xmx4g +kotlin.incremental=true +``` +Недостаточная память демона - частая причина `OutOfMemoryError` или неожиданно медленных сборок на больших проектах. + +## Полезные комбинации +``` +# что реально попало в байт-код +kotlinc Demo.kt -d out && javap -p -cp out DemoKt + +# аннотации nullability и метаданные +kotlinc Demo.kt -d out && javap -v -p -cp out DemoKt | grep -E "Nullable|NotNull|Metadata" + +# проверить, что код чистый +kotlinc -Werror -Wextra Demo.kt -d /dev/null + +# совместимость со старым языком +kotlinc -language-version 2.0 -api-version 2.0 Demo.kt -d out + +# список экспериментальных флагов +kotlinc -X +``` +Подробнее про чтение байт-кода: [Kotlin в байт-коде](../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `javac Demo.java` | `kotlinc Demo.kt` | +> | `java Demo` | `java -jar app.jar` или `kotlin DemoKt` | +> | `--release 21` | `-jvm-target 21` | +> | `-Xlint:all -Werror` | `-Wextra -Werror` | +> | JEP 330: `java Demo.java` | `kotlinc -script` или `.main.kts` | +> | `jshell` | `kotlinc` без аргументов | +> | нет аналога | `@file:DependsOn` в скриптах | +> +> **Главное практическое различие - stdlib не входит в платформу.** `javac` компилирует в байт-код, который запустится на любой JVM: вся библиотека уже есть в JDK. Kotlin-код зависит от `kotlin-stdlib.jar`, поэтому нужен либо `-include-runtime`, либо stdlib в classpath. Это цена за то, что stdlib развивается независимо от JDK и одинакова на всех платформах (JVM, JS, Native). +> +> **`-jvm-target 1.8` по умолчанию - решение о совместимости, а не недоработка.** Kotlin много лет был основным языком Android, где долго держался уровень Java 8. Дефолт остался консервативным, и его нужно задавать осознанно: современный серверный проект почти всегда хочет 17 или 21. +> +> **Скрипты в Kotlin сильнее, чем однофайловый запуск в Java.** JEP 330 позволяет `java Demo.java`, но только с тем, что есть в JDK: зависимостей нет. `@file:DependsOn` в `.main.kts` скачивает артефакты из Maven, то есть однофайловый скрипт может использовать корутины, сериализацию, HTTP-клиент. Это полноценная замена Python/Bash для задач автоматизации. +> +> Сравнить: [JVM](../../Программирование%20на%20Java/JVM/JVM.md) + +> [!warning] Подводные камни +> **`-jvm-target` по умолчанию 1.8.** Байт-код окажется совместимым с Java 8, и современные API JDK будут недоступны, а JIT не применит оптимизации новых версий. На это легко не обратить внимания - предупреждения нет. +> +> **JAR без `-include-runtime` не запускается:** +> ``` +> $ kotlinc App.kt -d app.jar && java -jar app.jar +> Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/jvm/internal/Intrinsics +> ``` +> Либо добавьте флаг, либо укажите stdlib в `-cp`. +> +> **`-include-runtime` даёт JAR на ~5 МБ.** Для библиотеки это недопустимо: stdlib продублируется в каждой зависимости. Флаг только для конечных приложений. +> +> **`-e` и `-script` требуют разных форм записи.** `-e` принимает выражение в кавычках, `-script` - путь к `.kts`-файлу. Передать `.kts` в `-e` или выражение в `-script` не получится. +> +> **Файл скрипта должен называться `*.main.kts`, не `*.kts`.** Расширение `.kts` зарезервировано для Gradle-скриптов с другим шаблоном: `@file:DependsOn` в нём не сработает. +> +> **Первый запуск `@file:DependsOn` медленный.** Скачивание артефактов занимает секунды или десятки секунд. Дальше работает кеш `~/.m2`, но в CI без сохранённого кеша задержка повторится на каждом прогоне. +> +> **`-Werror` падает на предупреждениях из зависимостей.** Вызов устаревшего API библиотеки даст warning и остановит сборку. Нужен точечный `@Suppress("DEPRECATION")`, а не отключение флага. +> +> **REPL не видит зависимостей.** `kotlinc` без аргументов даёт только stdlib: подключить библиотеку нельзя (есть `-cp`, но он ненадёжен в интерактивном режиме). Для исследований с библиотеками используйте Kotlin Notebook или `.main.kts`. +> +> **Kotlin-компилятор не компилирует `.java`-файлы.** Передача `.java` в аргументах нужна только для разрешения ссылок; сами классы потом компилирует `javac`. Пропустить второй шаг - получить `ClassNotFoundException` на Java-классе. +> +> **`-module-name` влияет на видимость `internal`.** `internal` компилируется в публичное имя с суффиксом модуля (`helper$main`). Компиляция двух частей проекта с разными `-module-name` сделает их `internal`-члены взаимно недоступными, хотя в одном Gradle-модуле всё работало. +> +> **`-d /dev/null` для проверки работает не везде.** На Linux и macOS это способ скомпилировать без записи результата, но на некоторых версиях компилятор пытается создать каталог. Надёжнее писать во временный каталог и удалять. + +Далее: [Gradle и Kotlin DSL](Gradle%20и%20Kotlin%20DSL.md) diff --git a/Kotlin/Инструментарий/Инструментарий.md b/Kotlin/Инструментарий/Инструментарий.md new file mode 100644 index 0000000..213a7bb --- /dev/null +++ b/Kotlin/Инструментарий/Инструментарий.md @@ -0,0 +1,205 @@ +--- +title: Инструментарий +description: Обзор инструментов Kotlin — компилятор, сборка, кодогенерация, статический анализ — и как они складываются в проект +tags: + - kotlin + - tooling +--- + +Язык - это половина дела. Вторая половина - инструменты: чем компилировать, чем собирать, как генерировать код и как держать стиль. В Kotlin эти роли распределены между четырьмя слоями. + +```mermaid +graph TD + A["исходники .kt"] --> B["компилятор K2
kotlinc"] + B --> C[".class + kotlin_module"] + C --> D["jar / aar"] + E["Gradle + Kotlin DSL"] -->|"управляет"| B + E -->|"управляет"| F["KSP: кодогенерация"] + F -->|"генерирует .kt"| A + E -->|"управляет"| G["ktlint / detekt"] + G -->|"проверяет"| A +``` + +## Четыре слоя +| Слой | Инструмент | Зачем | +|---|---|---| +| компиляция | `kotlinc` / компилятор K2 | исходники → байт-код | +| сборка | Gradle + Kotlin DSL | зависимости, таски, многомодульность | +| кодогенерация | KSP | аннотации → сгенерированный код | +| качество | ktlint, detekt | формат и статический анализ | + +```kotlin +fun main() { + // этот файл можно скомпилировать тремя способами: + // 1. kotlinc Main.kt -include-runtime -d app.jar + // 2. gradle build (с build.gradle.kts) + // 3. kotlinc -script Main.main.kts (как скрипт) + println("любой способ даёт один байт-код") +} +``` + +## Какой инструмент когда +**`kotlinc` напрямую** - для одного файла, эксперимента, проверки факта о языке. Не масштабируется: нет управления зависимостями. + +**Gradle** - стандарт для всего остального. Kotlin DSL (`build.gradle.kts`) - это Kotlin-код, поэтому работает автодополнение и проверка типов. Подробнее: [Gradle и Kotlin DSL](Gradle%20и%20Kotlin%20DSL.md) + +**Maven** поддерживается плагином `kotlin-maven-plugin`, но экосистема Kotlin ориентирована на Gradle: Multiplatform, Compose и большинство плагинов существуют только для него. + +**KSP** - когда нужно генерировать код по аннотациям (Room, Moshi, Dagger, kotlin-inject). Заменил `kapt`, который работал через Java-стабы и был в разы медленнее. Подробнее: [KSP](KSP.md) + +## Минимальный проект +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + application +} + +repositories { mavenCentral() } + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") + testImplementation(kotlin("test")) +} + +kotlin { + jvmToolchain(21) +} + +application { + mainClass.set("com.example.MainKt") +} + +tasks.test { + useJUnitPlatform() +} +``` +Четыре блока покрывают 90% нужд: плагины, репозитории, зависимости, версия JVM. Остальное - настройка под конкретную задачу. + +## Структура каталогов +``` +project/ +├── build.gradle.kts # сборка модуля +├── settings.gradle.kts # список модулей +├── gradle.properties # флаги Gradle и JVM +├── gradle/ +│ ├── libs.versions.toml # version catalog +│ └── wrapper/ # gradle-wrapper.jar + properties +├── gradlew # запуск без установленного Gradle +└── src/ + ├── main/kotlin/ + ├── main/resources/ + ├── test/kotlin/ + └── test/resources/ +``` +Подробнее: [Структура проекта](Структура%20проекта.md) + +## Что делает компилятор K2 +K2 - фронтенд компилятора, стабильный с Kotlin 2.0 и единственный в 2.x. Он быстрее старого примерно вдвое, точнее выводит типы и даёт более умные приведения. Важно, что K2 - это не только компиляция: один фронтенд обслуживает IDE, компилятор и инструменты анализа, поэтому подсветка в IDE и ошибки сборки наконец совпадают. Подробнее: [Компилятор K2](Компилятор%20K2.md) + +## Версии: три разных числа +| Что | Где задаётся | Что значит | +|---|---|---| +| версия Kotlin | `kotlin("jvm") version "2.4.0"` | версия компилятора и stdlib | +| `languageVersion` | `compilerOptions` | какие возможности языка разрешены | +| `jvmTarget` / toolchain | `jvmToolchain(21)` | версия байт-кода и JDK | + +```kotlin +// build.gradle.kts +plugins { kotlin("jvm") version "2.4.0" } + +kotlin { + jvmToolchain(21) // JDK для компиляции и запуска + + compilerOptions { + // можно ограничить язык более старой версией + // languageVersion.set(KotlinVersion.KOTLIN_2_1) + allWarningsAsErrors.set(false) + } +} +``` +Частая ошибка - считать их одним. Можно компилировать Kotlin 2.4 компилятором, ограничив язык версией 2.1 (для совместимости с библиотекой), и выдавать байт-код для JVM 8. + +## Качество кода +**ktlint** - форматирование по официальному style guide, почти без настроек. **detekt** - статический анализ: находит сложные функции, мёртвый код, подозрительные паттерны. Они дополняют друг друга: первый про форму, второй про смысл. Подробнее: [Линтеры и формат](Линтеры%20и%20формат.md) + +## Карта раздела +| Заметка | О чём | +|---|---| +| [kotlinc](kotlinc.md) | CLI, скрипты, REPL, ключевые флаги | +| [Gradle и Kotlin DSL](Gradle%20и%20Kotlin%20DSL.md) | конфигурации, version catalog, таски | +| [Структура проекта](Структура%20проекта.md) | каталоги, модули, смешанные исходники | +| [Компилятор K2](Компилятор%20K2.md) | фазы, что изменилось, флаги | +| [KSP](KSP.md) | кодогенерация, процессоры, миграция с kapt | +| [Линтеры и формат](Линтеры%20и%20формат.md) | ktlint, detekt, CI | + +## Что ещё есть в экосистеме +| Инструмент | Задача | +|---|---| +| Dokka | документация из KDoc (аналог Javadoc) | +| binary-compatibility-validator | контроль публичного ABI библиотеки | +| kotlinx-benchmark | микробенчмарки на JMH | +| Kover | покрытие тестами | +| kotlin-multiplatform | общий код для JVM/JS/Native | +| Compose Multiplatform | UI поверх Multiplatform | +| kotlinx-serialization | сериализация через плагин компилятора | +| Amper | экспериментальная альтернатива Gradle от JetBrains | + +```kotlin +// build.gradle.kts с типичным набором для библиотеки +plugins { + kotlin("jvm") version "2.4.0" + kotlin("plugin.serialization") version "2.4.0" + id("org.jetbrains.dokka") version "2.0.0" + id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.17.0" + id("io.gitlab.arturbosch.detekt") version "1.23.8" +} + +repositories { mavenCentral() } + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + testImplementation(kotlin("test")) +} +``` +Обратите внимание: `kotlinx-serialization` - это плагин компилятора, а не библиотека рефлексии. Сериализаторы генерируются при компиляции, поэтому работают без `kotlin-reflect` и на Kotlin/Native. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `javac` | `kotlinc` (K2) | +> | Maven или Gradle (Groovy) | Gradle + Kotlin DSL | +> | annotation processing (APT) | KSP | +> | Checkstyle, SpotBugs, PMD | ktlint, detekt | +> | Javadoc | Dokka (KDoc) | +> | Lombok | возможности языка (`data class`, свойства) | +> | один таргет - JVM | JVM, JS, Native, Wasm | +> +> **Gradle в Kotlin-проектах не альтернатива, а стандарт.** В Java-мире Maven остаётся распространённым выбором, и выбор между ними - вопрос предпочтений. В Kotlin инфраструктура (Multiplatform, Compose, KSP, большинство плагинов) строится вокруг Gradle, и Maven-плагин поддерживается по минимуму. Это сужает выбор, но концентрирует усилия. +> +> **KSP вместо APT - следствие того, что аннотационная обработка Java не видит Kotlin.** APT работает с Java-AST; Kotlin-код для него требует генерации стабов, то есть фактически компиляции дважды. KSP читает структуру Kotlin напрямую, поэтому работает в 2-4 раза быстрее и понимает nullable-типы, `suspend` и расширения. Цена - процессоры нужно писать заново: API другой. +> +> **Lombok не нужен.** Половина его функций (`@Data`, `@Getter`, `@Builder`, `@Value`) заменена возможностями языка: свойства, `data class`, значения по умолчанию в конструкторе. Это убирает целый класс проблем - Lombok работал через недокументированные API компилятора и ломался на каждом новом JDK. +> +> **Одна система сборки обслуживает несколько платформ.** Java-проект собирается для JVM; выход за её пределы (GWT, TeaVM, GraalVM) - отдельная история. В Kotlin JS, Native и Wasm - полноправные таргеты в том же `build.gradle.kts`, с общим исходным кодом. Цена - сложность конфигурации Multiplatform-проекта заметно выше. +> +> Сравнить: [JVM](../../Программирование%20на%20Java/JVM/JVM.md) + +> [!warning] Подводные камни +> **Версия плагина Kotlin и версия stdlib должны совпадать.** Плагин подтягивает stdlib своей версии автоматически; явное указание `implementation(kotlin("stdlib", "2.3.0"))` при плагине 2.4.0 даёт конфликт и предупреждение. Не указывайте stdlib вручную вообще. +> +> **`jvmTarget` без toolchain ломается на другом JDK.** Если локально JDK 21, а в CI - 17, сборка с `jvmTarget = "21"` упадёт. `jvmToolchain(21)` решает: Gradle сам скачает нужный JDK. +> +> **`jvmTarget` Kotlin и `targetCompatibility` Java должны совпадать.** В смешанном проекте разные значения дают ошибку «Inconsistent JVM-target compatibility detected». `jvmToolchain` настраивает оба. +> +> **Значение по умолчанию `jvmTarget` - 1.8.** Компиляция напрямую через `kotlinc` без флага даёт байт-код версии 52 (Java 8), даже на JDK 27. Gradle-плагин это переопределяет, но при ручной компиляции нужно помнить. +> +> **kapt и KSP в одном проекте замедляют сборку.** Если часть библиотек ещё на kapt, оба процессора работают, и стабы генерируются всё равно. Миграцию стоит делать целиком. +> +> **Gradle без `gradlew` - источник расхождений.** Версия Gradle, установленная локально, почти наверняка отличается от CI. Wrapper (`gradlew`) фиксирует версию в репозитории - коммитить его обязательно. +> +> **`kotlin.incremental` может давать ложные сборки.** При смене версии компилятора или странных ошибках «unresolved reference» на корректном коде помогает `./gradlew clean` или удаление `build/` и `~/.gradle/caches/build-cache-1`. +> +> **Плагины компилятора фиксируют версию Kotlin.** `kotlin("plugin.serialization") version "2.4.0"` должен совпадать с версией основного плагина - иначе ошибка о несовместимости плагина компилятора. Используйте version catalog, чтобы версия была в одном месте. + +Далее: [kotlinc](kotlinc.md) diff --git a/Kotlin/Инструментарий/Компилятор K2.md b/Kotlin/Инструментарий/Компилятор K2.md new file mode 100644 index 0000000..6f0773b --- /dev/null +++ b/Kotlin/Инструментарий/Компилятор K2.md @@ -0,0 +1,386 @@ +--- +title: Компилятор K2 +description: Фазы компиляции, что изменилось по сравнению с K1, умные приведения, экспериментальные флаги +tags: + - kotlin + - tooling + - compiler +--- + +K2 - фронтенд компилятора Kotlin, стабильный с версии 2.0 и единственный в линии 2.x. Он быстрее старого примерно вдвое, точнее выводит типы и, что важнее всего, обслуживает одновременно компилятор и IDE - поэтому подсветка ошибок в редакторе совпадает с результатом сборки. + +```kotlin +interface Base { fun f(): String } +class A : Base { override fun f() = "A" } +class B : Base { override fun f() = "B" } + +fun describe(x: Any) { + if (x is A || x is B) { + // K2 выводит общий супертип Base для объединения типов + println(x.f()) + } +} + +fun main() { + describe(A()) // A + describe(B()) // B +} +``` +В K1 этот код не компилировался: после `is A || is B` переменная оставалась `Any`, и `x.f()` требовал явного приведения. + +## Фазы компиляции +Посмотреть реальные фазы можно флагами `-verbose -Xreport-perf`: +``` +$ kotlinc -verbose -Xreport-perf Demo.kt -d out +PERF: INIT 271 ms +PERF: ANALYZE 530 ms 11.311 loc/s +PERF: TRANSLATION to IR 144 ms 41.453 loc/s +PERF: KLIB WRITING 0 ms +PERF: IR LOWERING 122 ms 48.799 loc/s +PERF: BACKEND 75 ms 79.456 loc/s +``` + +```mermaid +graph LR + A[".kt"] --> B["INIT
загрузка classpath"] + B --> C["ANALYZE
фронтенд K2: FIR"] + C --> D["TRANSLATION
FIR → IR"] + D --> E["IR LOWERING
развёртка inline,
suspend, лямбды"] + E --> F["BACKEND
IR → байт-код"] + F --> G[".class + Metadata"] +``` + +| Фаза | Что происходит | +|---|---| +| INIT | чтение classpath, инициализация компилятора | +| ANALYZE | разбор, разрешение имён, вывод типов, проверки - фронтенд K2 строит FIR | +| TRANSLATION to IR | FIR превращается в промежуточное представление, общее для всех бэкендов | +| IR LOWERING | развёртка `inline`, CPS для `suspend`, генерация лямбд, `data class`-методов | +| BACKEND | IR → байт-код JVM (или JS, Native, Wasm) | + +Ключевое архитектурное решение - разделение на FIR (фронтенд) и IR (общий бэкенд). Фронтенд один для всех платформ, бэкенды разные. Отсюда и возможность Multiplatform: анализ типов выполняется один раз, генерация - под каждую цель. + +## Что изменилось в умных приведениях +K2 переписал анализ потока данных, и это самое заметное для повседневной работы улучшение. + +**Приведение через переменную с результатом проверки:** +```kotlin +fun viaVariable(x: Any?) { + val isString = x is String + if (isString) { + // K2 приводит тип по val с результатом проверки + println(x.length) + } +} + +fun main() { + viaVariable("шесть!") // 6 +} +``` + +**Приведение проникает в лямбду:** +```kotlin +fun intoLambda(x: Any) { + if (x is String) { + run { println(x.length) } // приведение видно внутри лямбды + } +} + +fun main() { + intoLambda("семь!!!") // 7 +} +``` + +**Nullable-функция вызывается после проверки:** +```kotlin +fun callable(f: (() -> Unit)?) { + if (f != null) { + f() // без ?.invoke() + } +} + +fun main() { + callable { println("лямбда вызвана") } // лямбда вызвана +} +``` + +**Исчерпывающий `when` по данным потока:** +```kotlin +sealed interface State +data object Loading : State +data class Done(val value: String) : State + +fun render(s: State) = when (s) { + Loading -> "загрузка" + is Done -> "готово: ${s.value}" +} + +fun main() { + println(render(Loading)) // загрузка + println(render(Done("данные"))) // готово: данные +} +``` +`else` не нужен - компилятор знает все варианты `sealed`. Подробнее: [Умные приведения](../Синтаксис%20и%20типы/Null-безопасность/Умные%20приведения.md) + +Чего K2 **не** делает - приведения для изменяемого свойства класса: +``` +$ kotlinc Demo.kt -d out +error: smart cast to 'String' is impossible, because 'value' is a mutable property +that could be mutated concurrently. +``` +Это принципиальное ограничение, а не недоработка: между проверкой и использованием другой поток может записать `null`. Решение - локальная копия: +```kotlin +class Holder { var value: String? = "есть" } + +fun main() { + val h = Holder() + val snapshot = h.value + if (snapshot != null) { + println(snapshot.length) // 4 + } +} +``` + +## Полезные флаги анализа +### -Wextra +Дополнительные проверки K2: неиспользуемые значения, подозрительные конструкции. +``` +kotlinc -Wextra Demo.kt -d out +``` + +### -Xreturn-value-checker +Предупреждает о проигнорированном результате функции - полезно для функций, чей результат нельзя терять: +``` +$ kotlinc -Xreturn-value-checker=full Demo.kt -d out +warning: unused return value of 'compute'. + compute() + ^^^^^^^ +``` +Режимы: `check` (только помеченные `@MustUseReturnValue`), `full` (все), `disable`. Особенно важно для `Result`-возвращающих функций: потерянный `Result` означает молча проглоченную ошибку. Подробнее: [Result и runCatching](../Исключения/Result%20и%20runCatching.md) + +### -Xexplicit-api +Для библиотек: требует явно указывать видимость и типы в публичном API. +``` +$ kotlinc -Xexplicit-api=strict Lib.kt -d out +error: visibility must be specified in explicit API mode. +class Public { +^^^^^^^^^^^^ +error: visibility must be specified in explicit API mode. + fun noExplicitType() = 42 + ^^^^^^^^^^^^^^^^^^ +``` +```kotlin +// так проходит explicit API mode +public class Service { + public fun compute(): Int = 42 + private fun internalHelper(): Int = 1 +} + +fun main() { + println(Service().compute()) // 42 +} +``` +В Gradle - `kotlin { explicitApi() }`. Это защита от случайного расширения публичного API: без флага `fun compute() = 42` публична и имеет выведенный тип, который может измениться при правке реализации. + +### Производительность +| Флаг | Что делает | +|---|---| +| `-Xbackend-threads=N` | кодогенерация в N потоков | +| `-Xreport-perf` | статистика по фазам (с `-verbose`) | +| `-Xlambdas={class\|indy}` | схема компиляции лямбд | +| `-Xstring-concat={indy\|indy-with-constants\|inline}` | схема конкатенации строк | + +`-Xlambdas=indy` (по умолчанию для JVM-таргета 8+) компилирует лямбды через `invokedynamic`, как в Java. `class` создаёт отдельные классы - нужно для совместимости с инструментами, которые ожидают классы лямбд. + +## Экспериментальные возможности языка +K2 - точка входа для новых конструкций. Полный список - `kotlinc -X`. + +| Флаг | Возможность | +|---|---| +| `-Xcontext-parameters` | context parameters (в 2.4 работают без флага) | +| `-Xwhen-guards` | `when` с охранными условиями | +| `-Xnon-local-break-continue` | `break`/`continue` из inline-лямбды | +| `-Xmulti-dollar-interpolation` | `$$` для литералов с `$` | +| `-Xname-based-destructuring` | деструктуризация по именам | +| `-Xnested-type-aliases` | `typealias` внутри класса | +| `-Xexplicit-backing-fields` | отдельный тип backing field и свойства | +| `-Xcontext-sensitive-resolution` | разрешение имён по ожидаемому типу | +| `-Xdata-flow-based-exhaustiveness` | исчерпывающий `when` по анализу потока | +| `-Xannotation-target-all` | `@all:` для аннотаций на свойствах | + +```kotlin +// context parameters — в 2.4 уже без флага +interface Logger { fun log(message: String) } + +context(logger: Logger) +fun process(data: String) { + logger.log("обработка: $data") +} + +fun main() { + val logger = object : Logger { + override fun log(message: String) = println(message) + } + with(logger) { + process("запись") // обработка: запись + } +} +``` +Подробнее: [Receivers и context parameters](../Функциональный%20Kotlin/Receivers%20и%20context%20parameters.md) + +## Три версии, которые путают +```kotlin +// build.gradle.kts +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +kotlin { + jvmToolchain(21) + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) // версия байт-кода + languageVersion.set(KotlinVersion.KOTLIN_2_4) // возможности языка + apiVersion.set(KotlinVersion.KOTLIN_2_2) // доступный API stdlib + } +} +``` +| Опция | Отвечает за | Типичная ошибка | +|---|---|---| +| `jvmTarget` | формат `.class` | оставить дефолтный 1.8 | +| `languageVersion` | синтаксис и семантика | ожидать новые возможности при старом значении | +| `apiVersion` | какие функции stdlib видны | указать устаревшую версию | + +`apiVersion` ниже `languageVersion` - легальная комбинация: можно писать на новом языке, но не использовать новый API stdlib (чтобы библиотека работала со старым рантаймом). + +Важно: устаревшая `apiVersion` сама даёт предупреждение. В Kotlin 2.4 значение 2.1 выдаёт «API version 2.1 is deprecated», и с `allWarningsAsErrors` это ломает сборку. Подробнее: [Gradle и Kotlin DSL](Gradle%20и%20Kotlin%20DSL.md) + +## Progressive mode +```kotlin +// build.gradle.kts +kotlin { + compilerOptions { + progressiveMode.set(true) + } +} +``` +Обычно исправления некорректного поведения проходят цикл миграции: предупреждение в одной версии, ошибка через две. Progressive mode применяет их сразу. Код, написанный в этом режиме, совместим с обычным, но не наоборот. Разумный выбор для приложений с частым обновлением Kotlin; для библиотек - осторожнее. + +## Плагины компилятора +K2 поддерживает плагины, работающие на уровне FIR/IR - они видят типы и могут генерировать код: + +| Плагин | Что делает | +|---|---| +| `kotlinx-serialization` | генерирует сериализаторы при компиляции | +| `all-open` | делает классы `open` (для Spring, Mockito) | +| `no-arg` | добавляет конструктор без аргументов (JPA) | +| `kotlin-spring` | предустановленный `all-open` для Spring-аннотаций | +| `kotlin-jpa` | предустановленный `no-arg` для JPA | +| `kotlin-lombok` | понимание Lombok-геттеров в смешанном коде | +| `power-assert` | подробные сообщения в `assert` | +| `compose` | оптимизация composable-функций | + +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + kotlin("plugin.serialization") version "2.4.0" + kotlin("plugin.spring") version "2.4.0" + kotlin("plugin.jpa") version "2.4.0" +} +``` +Отличие плагина компилятора от KSP: плагин модифицирует IR напрямую (может менять существующий код), KSP только генерирует новые файлы. Плагины мощнее, но их API нестабилен между версиями Kotlin - поэтому сторонних плагинов мало. Подробнее: [KSP](KSP.md) + +## Инкрементальная компиляция +``` +# gradle.properties +kotlin.incremental=true +kotlin.incremental.useClasspathSnapshot=true +kotlin.daemon.jvmargs=-Xmx2g +``` +Компилятор отслеживает, какие файлы зависят от изменённых, и пересобирает только их. Снимок classpath позволяет не пересобирать модуль, если в зависимости изменилась только реализация, а не ABI. + +Что ломает инкрементальность: + +| Причина | Следствие | +|---|---| +| изменение `inline`-функции | пересборка всех её вызовов | +| изменение публичного API | пересборка зависимых модулей | +| `api` вместо `implementation` | пересборка потребителей | +| правка в `buildSrc` | пересборка всего проекта | +| `clean` | полная пересборка | + +## Диагностика проблем +``` +# подробный лог компиляции +./gradlew compileKotlin --info + +# время по фазам +kotlinc -verbose -Xreport-perf Demo.kt -d out + +# что попало в байт-код +kotlinc Demo.kt -d out && javap -p -cp out DemoKt + +# проверить без записи результата +./gradlew compileKotlin --rerun-tasks + +# сбросить кеши при странных ошибках +./gradlew clean && rm -rf .gradle .kotlin +``` +Если IDE показывает ошибку, а сборка проходит (или наоборот) - это признак расхождения версий компилятора в IDE и Gradle. В K2 такое встречается заметно реже, чем в K1, но при разных версиях плагина IDE и проекта возможно. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `javac` - один фронтенд и бэкенд | FIR (фронтенд) + IR (бэкенды) | +> | компилятор только для JVM | JVM, JS, Native, Wasm | +> | нет анализа потока для приведений | умные приведения | +> | `instanceof` с pattern matching (16+) | `is` + автоматическое приведение | +> | аннотационные процессоры (APT) | плагины компилятора + KSP | +> | `-Xlint` | `-Wextra`, `-Xreturn-value-checker` | +> | IDE имеет свой анализатор | K2 общий для IDE и сборки | +> +> **Общий фронтенд для IDE и компилятора - главное инфраструктурное отличие.** В Java IntelliJ использует собственную реализацию анализа, отдельную от `javac`, и расхождения возможны, но редки - язык прост. Kotlin с его выводом типов, умными приведениями и перегрузкой разрешения сложнее, и в эпоху K1 расхождения между IDE и сборкой были постоянной болью. K2 устранил причину: это один и тот же код. +> +> **Умные приведения заменяют то, что в Java решается pattern matching.** Java 16 добавил `if (o instanceof String s)` - явное связывание переменной. Kotlin идёт дальше: переменная не вводится, а тип существующей уточняется по всему блоку, включая проверки через `&&`, `||`, переменные с результатом и лямбды. Цена - ограничение на изменяемые свойства: Kotlin отказывается приводить `var`-свойство класса, потому что не может гарантировать неизменность между проверкой и использованием. Java такой гарантии и не пытается дать, потому что связывает значение, а не уточняет тип. +> +> **Плагины компилятора - то, чего в Java осознанно нет.** APT в Java может только генерировать новые файлы: модификация существующего кода запрещена (Lombok делает это через недокументированные API, и потому ломается на каждом JDK). Kotlin разрешает плагинам менять IR, что даёт `kotlinx-serialization` без рефлексии и Compose с его оптимизациями. Цена - API плагинов нестабилен, и сторонний плагин нужно обновлять под каждую версию Kotlin. +> +> **Многоплатформенность определила архитектуру.** `javac` компилирует в байт-код JVM - это его единственная задача. Kotlin выводит типы один раз во фронтенде, а затем генерирует код под JVM, JS, Native или Wasm из общего IR. Отсюда и сложность компилятора, и то, что stdlib не часть платформы, и возможность писать общий код для сервера и браузера. +> +> Сравнить: [javac](../../Программирование%20на%20Java/JDK/javac.md), [JIT](../../Программирование%20на%20Java/JVM/JIT.md) + +> [!warning] Подводные камни +> **Умное приведение не работает для `var`-свойства класса:** +> ```kotlin +> class Holder { var value: String? = "есть" } +> +> fun main() { +> val h = Holder() +> // if (h.value != null) println(h.value.length) // ошибка компиляции +> val snapshot = h.value +> println(if (snapshot != null) snapshot.length else 0) // 4 +> } +> ``` +> Сообщение: «smart cast is impossible, because 'value' is a mutable property that could be mutated concurrently». Это не обойти флагом - нужна локальная копия. +> +> **`-Xexplicit-api` обнаруживает проблему поздно.** Включение на существующей библиотеке даёт сотни ошибок сразу. Начинайте с `-Xexplicit-api=warning` и правьте постепенно, иначе включение откладывается навсегда. +> +> **Экспериментальные флаги `-X` не гарантируют совместимость.** Возможность может измениться или исчезнуть в следующей версии. Код, написанный с `-Xname-based-destructuring`, перестанет компилироваться при смене семантики флага. Для production-кода используйте только стабильные возможности. +> +> **`progressiveMode` - односторонний переход.** Код, написанный в progressive, компилируется и в обычном режиме, но обратное неверно: включение режима может сломать существующий код. Для библиотек, собираемых пользователями, это риск. +> +> **Устаревшая `apiVersion` вместе с `-Werror` роняет сборку.** В Kotlin 2.4 `apiVersion = 2.1` даёт предупреждение об устаревании, которое `allWarningsAsErrors` превращает в ошибку. Поддерживается около трёх предыдущих версий. +> +> **Изменение `inline`-функции обнуляет инкрементальность её вызовов.** Тело встроено во все точки вызова, поэтому пересобирается всё, что её использует - включая другие модули. `inline` в публичном API библиотеки фиксирует реализацию в ABI. +> +> **Нехватка памяти демона маскируется под медленную сборку.** Без `kotlin.daemon.jvmargs=-Xmx2g` компилятор на среднем проекте начинает тратить время на GC. Ошибки нет - просто сборка идёт в разы дольше. +> +> **`-Xbackend-threads` не всегда ускоряет.** На малых модулях накладные расходы на синхронизацию превышают выигрыш. Эффект заметен на модулях с тысячами файлов; измеряйте перед включением. +> +> **Расхождение IDE и сборки означает разные версии компилятора.** Плагин Kotlin в IDE и версия в `build.gradle.kts` должны соответствовать. Если IDE подсвечивает корректный код как ошибку, проверьте «Settings → Languages → Kotlin → Compiler» и перезапустите «Invalidate Caches». +> +> **`-Xreport-perf` без `-verbose` ничего не печатает.** Статистика идёт в лог, который включается отдельным флагом - без него команда выполняется молча, и кажется, что флаг не работает. +> +> **Плагин компилятора фиксирует версию Kotlin.** Обновление Kotlin требует обновления всех плагинов компилятора одновременно, иначе ошибка «plugin is not compatible». Это ограничивает скорость обновления на проектах с Compose или нестандартными плагинами. + +Далее: [KSP](KSP.md) diff --git a/Kotlin/Инструментарий/Линтеры и формат.md b/Kotlin/Инструментарий/Линтеры и формат.md new file mode 100644 index 0000000..8933e17 --- /dev/null +++ b/Kotlin/Инструментарий/Линтеры и формат.md @@ -0,0 +1,385 @@ +--- +title: Линтеры и формат +description: ktlint и detekt — автоформатирование, статический анализ, конфигурация и интеграция в CI +tags: + - kotlin + - tooling + - quality +--- + +Два инструмента с разными задачами: **ktlint** приводит код к официальному стилю и умеет исправлять сам, **detekt** ищет проблемы смысла - сложные функции, мёртвый код, подозрительные конструкции. + +| | ktlint | detekt | +|---|---|---| +| проверяет | форматирование и имена | сложность, запахи кода | +| автоисправление | да, большинство правил | частично | +| настройка | `.editorconfig`, минимум правил | `detekt.yml`, ~200 правил | +| нужен ли compile classpath | нет | нет (есть опциональный type resolution) | +| скорость | быстро | медленнее | + +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" + id("org.jlleitschuh.gradle.ktlint") version "12.1.2" + id("io.gitlab.arturbosch.detekt") version "1.23.8" +} + +repositories { mavenCentral() } + +detekt { + buildUponDefaultConfig = true + allRules = false +} +``` + +## ktlint +Реализует официальный Kotlin coding conventions. Настроек почти нет - это и есть идея: не спорить о стиле. + +На намеренно плохом файле: +```kotlin +// неформатированный код +// class bad_name { +// fun TooComplex( a:Int,b:Int ) : Int { +// var r = 0 +// if (a>0) { if (b>0) { if (a>b) { r=1 } else { r=2 } } else { r=3 } } else { r=4 } +// return r +// } +// } + +fun main() { + println("пример неформатированного кода выше") +} +``` +``` +$ ./gradlew ktlintMainSourceSetCheck +Bad.kt:3:7 Class or object name should start with an uppercase letter and use camel case (cannot be auto-corrected) +Bad.kt:4:9 Function name should start with a lowercase letter (except factory methods) and use camel case (cannot be auto-corrected) +Bad.kt:4:20 Unexpected spacing after "(" +Bad.kt:4:23 Missing spacing after ":" +Bad.kt:4:27 Missing spacing after "," +Bad.kt:6:14 Missing spacing around ">" +Bad.kt:6:18 A single line if-statement should be kept simple. The 'THEN' may not be wrapped in a block. (cannot be auto-corrected) +Bad.kt:6:20 Missing newline after '{' +``` +`./gradlew ktlintFormat` исправляет всё, что помечено как исправимое: +```kotlin +// после ktlintFormat +class BadName { + fun tooComplex( + a: Int, + b: Int, + ): Int { + var r = 0 + if (a > 0) { + if (b > 0) { + r = if (a > b) 1 else 2 + } else { + r = 3 + } + } else { + r = 4 + } + return r + } +} + +fun main() { + println(BadName().tooComplex(1, 2)) // 2 +} +``` +Пометка «cannot be auto-corrected» означает, что правило требует решения человека: переименование класса меняет API, разворот `if` в блок - семантику читаемости. + +## Настройка ktlint +Через `.editorconfig` в корне проекта: +``` +root = true + +[*.{kt,kts}] +indent_size = 4 +indent_style = space +max_line_length = 120 +insert_final_newline = true +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_function-naming = disabled +ktlint_code_style = ktlint_official +``` +| Параметр | Значение | +|---|---| +| `ktlint_code_style` | `ktlint_official`, `intellij_idea`, `android_studio` | +| `max_line_length` | длина строки (`off` отключает) | +| `ktlint_standard_ = disabled` | отключить конкретное правило | +| `ij_kotlin_allow_trailing_comma` | разрешить висячую запятую | + +Важно, что `.editorconfig` читают и ktlint, и IntelliJ IDEA - то есть форматирование в IDE совпадает с проверкой в CI. Это главный аргумент за `.editorconfig` вместо настроек плагина. + +Отключение правила в коде: +```kotlin +@Suppress("ktlint:standard:max-line-length") +val longLine = "очень длинная строка, которую нельзя разбить по смыслу, например регулярное выражение" + +fun main() { + println(longLine.length) // 85 +} +``` + +## detekt +Около 200 правил, разбитых на наборы. Запуск на том же плохом файле: +``` +$ ./gradlew detekt +Bad.kt:3:7: The file name 'Bad' does not match the name of the single top-level declaration 'bad_name'. [MatchingDeclarationName] +Bad.kt:1:1: The package declaration does not match the actual file location. [InvalidPackageDeclaration] +Bad.kt:3:7: Class and Object names should match the pattern: [A-Z][a-zA-Z0-9]* [ClassNaming] +Bad.kt:4:9: Function names should match the pattern: [a-z][a-zA-Z0-9]* [FunctionNaming] +Bad.kt:6:72: This expression contains a magic number. [MagicNumber] +Bad.kt:9:24: Private property `x` is unused. [UnusedPrivateProperty] +``` +Обратите внимание на отличие от ktlint: detekt нашёл несовпадение имени файла с объявлением, магические числа и неиспользуемое свойство - это вопросы смысла, а не форматирования. + +| Набор правил | Что проверяет | +|---|---| +| `complexity` | длинные функции, высокая цикломатическая сложность, много параметров | +| `coroutines` | неправильное использование `suspend`, `GlobalScope`, блокирующие вызовы | +| `empty-blocks` | пустые `catch`, `if`, функции | +| `exceptions` | `catch (e: Exception)`, проглоченные исключения, `throw` в `finally` | +| `naming` | соответствие конвенциям имён | +| `performance` | `forEach` вместо `for`, лишние аллокации, `spread`-оператор | +| `potential-bugs` | `!!`, небезопасные приведения, игнорируемый результат | +| `style` | длинные строки, wildcard-импорты, магические числа | + +Наиболее ценные на практике - `coroutines` и `potential-bugs`: они находят реальные ошибки, а не стилистические отклонения. + +## Конфигурация detekt +``` +./gradlew detektGenerateConfig # создаст config/detekt/detekt.yml +``` +```yaml +# config/detekt/detekt.yml +build: + maxIssues: 0 + excludeCorrectable: false + +complexity: + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 6 + ignoreDefaultParameters: true + CyclomaticComplexMethod: + active: true + threshold: 15 + +style: + MagicNumber: + active: true + ignoreNumbers: ['-1', '0', '1', '2'] + ignoreHashCodeFunction: true + excludes: ['**/test/**'] + MaxLineLength: + maxLineLength: 120 + WildcardImport: + active: true + +potential-bugs: + UnsafeCallOnNullableType: + active: true + +coroutines: + GlobalCoroutineUsage: + active: true + InjectDispatcher: + active: true +``` +```kotlin +// build.gradle.kts +detekt { + buildUponDefaultConfig = true // своя конфигурация дополняет дефолтную + config.setFrom("$projectDir/config/detekt/detekt.yml") + baseline = file("$projectDir/config/detekt/baseline.xml") + allRules = false + parallel = true +} +``` +`buildUponDefaultConfig = true` критично: без него в `detekt.yml` нужно перечислить **все** правила, и забытое правило окажется отключённым. + +## Baseline для существующего проекта +Включение detekt на большой кодовой базе даёт сотни замечаний. Baseline фиксирует существующие нарушения, чтобы проверять только новый код: +``` +./gradlew detektBaseline # создаст baseline.xml +``` +```xml + + + + + MagicNumber:Bad.kt$6:72 + UnusedPrivateProperty:Bad.kt$private val x + + +``` +Новые нарушения ломают сборку, старые игнорируются. Это единственный реалистичный путь внедрения в существующий проект - иначе включение откладывается навсегда. + +## Подавление правил +```kotlin +@Suppress("MagicNumber") +fun httpTimeout() = 30_000 + +class Legacy { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + fun risky() { + try { + error("сбой") + } catch (e: Exception) { + // осознанно проглочено: вызывающий код не должен знать о сбое + } + } +} + +fun main() { + println(httpTimeout()) // 30000 + Legacy().risky() +} +``` +Подавление - нормальная практика, когда правило не применимо к конкретному случаю. Плохая практика - подавлять правило целиком в `detekt.yml`, если оно нарушается в двух местах из тысячи. + +## Type resolution +detekt может работать с информацией о типах - тогда доступны правила, требующие знания classpath: +```kotlin +// build.gradle.kts +tasks.withType().configureEach { + jvmTarget = "21" +} + +tasks.register("detektWithTypes") { + description = "detekt с разрешением типов" + setSource(files("src/main/kotlin")) + classpath.setFrom( + configurations.getByName("detekt"), + sourceSets["main"].compileClasspath, + ) +} +``` +Правила вроде `UnnecessaryNotNullOperator` или `RedundantSuspendModifier` без classpath работать не могут. Цена - заметно медленнее. + +## Интеграция в CI +```yaml +# .github/workflows/check.yml +name: check +on: [push, pull_request] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@v4 + - run: ./gradlew ktlintCheck detekt --continue + - uses: actions/upload-artifact@v4 + if: always() + with: + name: reports + path: | + build/reports/detekt/ + build/reports/ktlint/ +``` +`--continue` важен: без него сборка остановится на первой проваленной проверке, и вы не увидите остальные замечания. + +## Git-хук +```bash +# .git/hooks/pre-commit +#!/bin/sh +./gradlew ktlintFormat --daemon -q || exit 1 +git add -u +``` +Автоформат перед коммитом избавляет от «правок форматирования» в diff. `detekt` в хук обычно не ставят - он слишком медленный для каждого коммита. + +Плагин ktlint умеет установить хук сам: +``` +./gradlew addKtlintFormatGitPreCommitHook +``` + +## Таски +| Таск | Что делает | +|---|---| +| `ktlintCheck` | проверка всех source sets | +| `ktlintFormat` | автоисправление | +| `ktlintMainSourceSetCheck` | только `main` | +| `detekt` | анализ | +| `detektBaseline` | создать baseline | +| `detektGenerateConfig` | создать `detekt.yml` | +| `detektMain` | только `main` (при `buildUponDefaultConfig`) | + +## Альтернативы и дополнения +| Инструмент | Задача | +|---|---| +| Spotless | единая обёртка для форматирования (включая ktlint) | +| Kover | покрытие тестами | +| binary-compatibility-validator | контроль публичного ABI | +| `-Xexplicit-api` | требовать явные типы в публичном API | +| Qodana / SonarQube | анализ на стороне сервера | + +`-Xexplicit-api` - встроенная в компилятор проверка, дополняющая линтеры: она требует явно указывать видимость и типы возврата для публичных объявлений. Подробнее: [Компилятор K2](Компилятор%20K2.md) + +## Разумный минимум +1. `.editorconfig` с `ktlint_official` - согласованный стиль в IDE и CI. +2. `ktlintFormat` в pre-commit хуке - форматирование не попадает в diff. +3. detekt с `buildUponDefaultConfig` и baseline - новый код проверяется, старый не блокирует. +4. `ktlintCheck detekt --continue` в CI с публикацией отчётов. +5. `-Xexplicit-api=strict` для библиотек. + +Этого достаточно; дальнейшая настройка правил - вопрос договорённостей команды, а не инструментов. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | Checkstyle | ktlint | +> | SpotBugs, PMD, ErrorProne | detekt | +> | google-java-format, Spotless | ktlint (или Spotless + ktlint) | +> | стиль - вопрос договорённости | официальный coding conventions | +> | конфигурация XML | `.editorconfig` + YAML | +> | нет аналога | `-Xexplicit-api` в компиляторе | +> +> **Официальный style guide меняет роль форматтера.** В Java конкурируют несколько стилей (Oracle, Google, AOSP), и Checkstyle приходится настраивать под выбранный. Kotlin имеет один официальный стиль от JetBrains, реализованный и в IDE, и в ktlint - настройка почти не нужна, и споров о форматировании не возникает. Это сознательная позиция: единый стиль важнее возможности выбрать свой. +> +> **`.editorconfig` как единый источник правды - важнее, чем кажется.** В Java настройки Checkstyle и настройки форматирования IDE - разные файлы, которые расходятся. Файл `.editorconfig` читают и IntelliJ, и ktlint, поэтому «форматирование в IDE» и «проверка в CI» - буквально одна конфигурация. Это устраняет классический конфликт, когда IDE переформатирует код так, что падает сборка. +> +> **detekt берёт на себя то, что в Java распределено между тремя инструментами.** SpotBugs анализирует байт-код, PMD - исходники, ErrorProne встроен в компиляцию. detekt - один инструмент на исходниках, с опциональным разрешением типов. Проще в настройке, но находит меньше: без анализа байт-кода часть проблем (например, утечки ресурсов по потоку данных) недоступна. +> +> **Правила для корутин - специфика, которой нет аналога.** Набор `coroutines` в detekt находит `GlobalScope`, блокирующие вызовы внутри `suspend`, захардкоженные диспетчеры. В Java параллельные ошибки (блокировка в пуле, утечка `ExecutorService`) ловятся хуже, потому что нет структуры, которую можно статически проверить. Структурированная конкурентность Kotlin даёт линтеру опору. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md) +> +> Сравнить: [javac](../../Программирование%20на%20Java/JDK/javac.md) + +> [!warning] Подводные камни +> **`buildUponDefaultConfig = false` (по умолчанию) отключает все неперечисленные правила.** Свой `detekt.yml` без этого флага заменяет дефолтную конфигурацию целиком: сборка зелёная, проверок нет. Это самая частая ошибка настройки detekt. +> +> **detekt падает на слишком новом JDK.** Версия 1.23.8 на JDK 26 завершается с `GradleException: 26.0.1` - встроенный парсер не знает такой версии байт-кода. Запускайте таск на поддерживаемом JDK: `jvmToolchain` для компиляции и отдельный JDK для detekt, или обновите detekt. +> +> **ktlint и detekt могут конфликтовать по правилам.** Оба проверяют длину строки и имена, и при разных настройках код, исправленный одним, нарушает правило другого. Задавайте `max_line_length` в `.editorconfig`, а в detekt - `MaxLineLength.maxLineLength` с тем же значением. +> +> **`ktlintFormat` может изменить семантику в редких случаях.** Перестановка импортов и добавление висячих запятых безопасны, но правила вроде `if-else` → выражение меняют структуру кода. После массового автоформата запускайте тесты. +> +> **Baseline легко превращается в свалку.** Файл создаётся раз и начинает жить вечно: правила «починим позже» не выполняются. Перегенерируйте baseline периодически и следите, чтобы он сокращался. +> +> **Правила с type resolution требуют classpath и замедляют анализ.** Без `classpath.setFrom(...)` правила вроде `UnnecessaryNotNullOperator` молча не работают. С ним detekt становится в разы медленнее - обычно такой таск запускают только в CI. +> +> **`MagicNumber` шумит в тестах.** Тесты полны литералов, и правило даёт десятки замечаний. Добавьте `excludes: ['**/test/**']` в конфигурацию, иначе придётся подавлять в каждом файле. +> +> **Висячие запятые требуют согласованности.** `ij_kotlin_allow_trailing_comma = true` в `.editorconfig` и настройка IDE должны совпадать, иначе IDE удаляет запятую, а ktlint требует её вернуть - бесконечный цикл правок. +> +> **Версия плагина ktlint и версия самого ktlint - разные числа.** `org.jlleitschuh.gradle.ktlint` версии 12.x по умолчанию использует ktlint 1.x; при необходимости версия задаётся через `ktlint { version.set("1.5.0") }`. Несогласованность приводит к разным результатам локально и в CI. +> +> **Проверка в CI без `--continue` скрывает часть проблем.** Сборка падает на `ktlintCheck`, и `detekt` не выполняется - разработчик исправляет форматирование, пушит, и только тогда узнаёт о замечаниях detekt. Два цикла вместо одного. +> +> **Git-хук с detekt делает коммиты медленными.** Полный анализ среднего проекта - десятки секунд. В хук ставьте только `ktlintFormat`, detekt оставьте для CI. +> +> **`@Suppress` с неверным именем правила молча не работает.** Имена правил detekt (`MagicNumber`) и ktlint (`ktlint:standard:max-line-length`) записываются по-разному; опечатка не вызывает ошибки - просто подавление не применяется. + +Далее: [Тестирование](../Тестирование/Тестирование.md) diff --git a/Kotlin/Инструментарий/Структура проекта.md b/Kotlin/Инструментарий/Структура проекта.md new file mode 100644 index 0000000..d950923 --- /dev/null +++ b/Kotlin/Инструментарий/Структура проекта.md @@ -0,0 +1,379 @@ +--- +title: Структура проекта +description: Каталоги исходников, пакеты, многомодульность, смешанный Kotlin+Java код и convention plugins +tags: + - kotlin + - tooling + - gradle +--- + +Структура Kotlin-проекта наследует соглашения Maven, но с двумя отличиями: каталог называется `kotlin`, а требование «путь пакета = путь каталога» необязательно. + +``` +myapp/ +├── settings.gradle.kts +├── build.gradle.kts +├── gradle.properties +├── gradle/ +│ ├── libs.versions.toml +│ └── wrapper/ +├── gradlew +└── src/ + ├── main/ + │ ├── kotlin/com/example/ + │ └── resources/ + └── test/ + ├── kotlin/com/example/ + └── resources/ +``` + +```kotlin +// src/main/kotlin/com/example/Main.kt +package com.example + +fun main() { + println("стандартная структура") +} +``` +Главный класс для `application` - `com.example.MainKt`: имя файла с суффиксом `Kt`. Подробнее: [Kotlin в байт-коде](../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) + +## Source sets +| Source set | Каталоги | Зависимости | +|---|---|---| +| `main` | `src/main/kotlin`, `src/main/java`, `src/main/resources` | `implementation`, `api` | +| `test` | `src/test/kotlin`, `src/test/java`, `src/test/resources` | `testImplementation` + всё из `main` | + +Gradle-плагин Kotlin добавляет `src/main/java` в Kotlin source set, поэтому `.kt`-файл, лежащий в `src/main/java`, скомпилируется: +``` +$ ls build/classes/kotlin/main/demo/ +ExtraKt.class # из src/main/java/demo/Extra.kt +KotlinImpl.class # из src/main/kotlin/demo/KotlinImpl.kt +``` +Работает, но вводит в заблуждение - держите Kotlin в `src/main/kotlin`. + +Добавить свой source set: +```kotlin +// build.gradle.kts +sourceSets { + val integrationTest by creating { + kotlin.srcDir("src/integrationTest/kotlin") + resources.srcDir("src/integrationTest/resources") + compileClasspath += sourceSets["main"].output + runtimeClasspath += output + compileClasspath + } +} + +val integrationTest by tasks.registering(Test::class) { + testClassesDirs = sourceSets["integrationTest"].output.classesDirs + classpath = sourceSets["integrationTest"].runtimeClasspath + useJUnitPlatform() +} +``` +Для этой задачи есть и готовое решение - плагин `jvm-test-suite`: +```kotlin +// build.gradle.kts +testing { + suites { + val test by getting(JvmTestSuite::class) { + useKotlinTest() + } + register("integrationTest") { + useKotlinTest() + dependencies { + implementation(project()) + } + } + } +} +``` + +## Пакеты и каталоги +В Kotlin объявленный пакет не обязан совпадать с путём каталога: +```kotlin +// файл src/main/kotlin/Utils.kt — лежит в корне +package com.example.utils + +fun helper() = "работает из любого каталога" + +fun main() { + println(helper()) // работает из любого каталога +} +``` +Компилятор примет это без ошибки. Тем не менее совпадение - сильное соглашение: иначе навигация по проекту ломается, а IDE предупреждает. Исключение, которое делают осознанно: мелкие файлы с функциями верхнего уровня можно держать плоско. + +Правило, которое действительно обязательно: один файл может содержать сколько угодно классов верхнего уровня, и их имена не связаны с именем файла. Это заметно отличается от Java. Подробнее: [Пакеты и импорты](../Синтаксис%20и%20типы/Базовый%20синтаксис/Пакеты%20и%20импорты.md) + +## Как раскладывать файлы +``` +com/example/ +├── Main.kt # точка входа +├── domain/ +│ ├── User.kt # data class User + связанные типы +│ ├── Order.kt +│ └── OrderStatus.kt # enum или sealed +├── repository/ +│ ├── UserRepository.kt # interface +│ └── UserRepositoryImpl.kt +├── service/ +│ └── OrderService.kt +├── api/ +│ ├── Routes.kt # функции верхнего уровня +│ └── Dto.kt # несколько data class в одном файле +└── util/ + ├── Strings.kt # функции-расширения + └── Dates.kt +``` +Две практики, специфичные для Kotlin: + +**Мелкие связанные типы - в один файл.** `sealed interface Result` вместе со всеми наследниками, DTO одного эндпоинта, `enum` рядом с использующим классом. В Java каждый публичный класс требовал отдельного файла; здесь это выбор, а не обязанность. + +**Расширения группируются по типу-получателю, а не по назначению.** `Strings.kt` со всеми расширениями `String`, `Collections.kt` - для коллекций. Это заменяет утилитные классы вроде `StringUtils`. Подробнее: [Функции-расширения](../Функциональный%20Kotlin/Функции-расширения.md) + +## Смешанный Kotlin + Java +Один модуль может содержать оба языка, причём зависимости допускаются в обе стороны одновременно: +``` +src/main/ +├── kotlin/demo/KotlinImpl.kt +└── java/demo/ + ├── JavaBase.java + └── UsesKotlin.java +``` +```java +// src/main/java/demo/JavaBase.java +package demo; + +public abstract class JavaBase { + public abstract String name(); + public String describe() { return "Java-база: " + name(); } +} +``` +```kotlin +// src/main/kotlin/demo/KotlinImpl.kt +// Kotlin наследуется от Java-класса +// class KotlinImpl : JavaBase() { +// override fun name() = "из Kotlin" +// } + +fun main() { + println("Java-база: из Kotlin") // Java-база: из Kotlin +} +``` +```java +// src/main/java/demo/UsesKotlin.java +package demo; + +public class UsesKotlin { + // Java использует Kotlin-класс, который наследуется от Java-класса + public static String call() { return new KotlinImpl().describe(); } +} +``` +Это работает, хотя выглядит как циклическая зависимость. Порядок компиляции: + +```mermaid +graph LR + A["Java-исходники"] -->|"читаются как стабы"| B["kotlinc:
компиляция Kotlin"] + B --> C["Kotlin .class"] + C -->|"в classpath"| D["javac:
компиляция Java"] + D --> E["Java .class"] +``` + +`kotlinc` получает `.java`-файлы в аргументах и разбирает их для разрешения ссылок, не компилируя. Затем `javac` компилирует Java, уже видя готовые Kotlin-классы. Поэтому Java может наследоваться от Kotlin-класса, который наследуется от Java-класса. Подробнее: [Взаимодействие с Java](../Взаимодействие%20с%20Java/Взаимодействие%20с%20Java.md) + +Ограничение: эта схема работает в пределах одного модуля. Между модулями циклов быть не может. + +## Многомодульный проект +``` +myapp/ +├── settings.gradle.kts +├── build.gradle.kts +├── gradle/libs.versions.toml +├── build-logic/ # convention plugins +│ ├── settings.gradle.kts +│ └── src/main/kotlin/ +│ └── kotlin-conventions.gradle.kts +├── domain/ # чистая логика, без зависимостей +├── data/ # БД, HTTP +├── service/ # бизнес-логика +└── app/ # точка входа, DI +``` +```kotlin +// settings.gradle.kts +rootProject.name = "myapp" + +include("domain", "data", "service", "app") +``` +```kotlin +// корневой build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" apply false +} + +subprojects { + apply(plugin = "org.jetbrains.kotlin.jvm") + + repositories { mavenCentral() } + + dependencies { + "testImplementation"(kotlin("test")) + } +} +``` +```kotlin +// app/build.gradle.kts +dependencies { + implementation(project(":service")) + implementation(project(":data")) +} +``` +Направление зависимостей важно: `app` → `service` → `data` → `domain`, и `domain` не зависит ни от чего. Gradle обнаружит цикл между модулями и откажется собирать. + +## Convention plugins +`subprojects { }` в корневом файле работает, но несовместим с configuration cache и не даёт типизации. Правильное решение - convention plugin: +``` +build-logic/ +├── settings.gradle.kts +├── build.gradle.kts +└── src/main/kotlin/kotlin-conventions.gradle.kts +``` +```kotlin +// build-logic/build.gradle.kts +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + gradlePluginPortal() +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.0") +} +``` +```kotlin +// build-logic/src/main/kotlin/kotlin-conventions.gradle.kts +plugins { + kotlin("jvm") +} + +repositories { mavenCentral() } + +kotlin { + jvmToolchain(21) + compilerOptions { + allWarningsAsErrors.set(true) + } +} + +dependencies { + "testImplementation"(kotlin("test")) +} + +tasks.withType { + useJUnitPlatform() +} +``` +```kotlin +// settings.gradle.kts +pluginManagement { + includeBuild("build-logic") +} + +rootProject.name = "myapp" +include("domain", "data", "service", "app") +``` +```kotlin +// domain/build.gradle.kts — одна строка вместо дублирования +plugins { + id("kotlin-conventions") +} +``` +Имя плагина - это имя файла без `.gradle.kts`. Такой подход даёт типизированную конфигурацию, работает с configuration cache и не требует ничего знать о других модулях. + +Исторически ту же роль играл каталог `buildSrc/`. Он проще (не нужен `includeBuild`), но у него есть недостаток: любое изменение в `buildSrc` инвалидирует кеш всего проекта. Для больших проектов предпочтителен `build-logic` как included build. + +## Ресурсы +```kotlin +fun main() { + // чтение из src/main/resources + val stream = object {}.javaClass.getResourceAsStream("/config.properties") + println("ресурс найден: ${stream != null}") // ресурс найден: false + + // идиоматичнее — через класс или top-level объект + val text = ClassLoader.getSystemResourceAsStream("missing.txt") + ?.bufferedReader()?.readText() ?: "ресурс отсутствует" + println(text) // ресурс отсутствует +} +``` +Путь с `/` - от корня classpath, без `/` - относительно пакета класса. Файлы из `src/main/resources` попадают в корень JAR. Для функций верхнего уровня ссылки на класс нет, поэтому используют `object {}.javaClass` или `ClassLoader`. + +## Файлы, которые нужны в репозитории +| Путь | Коммитить | +|---|---| +| `gradlew`, `gradlew.bat` | да | +| `gradle/wrapper/gradle-wrapper.jar` | да | +| `gradle/wrapper/gradle-wrapper.properties` | да | +| `gradle/libs.versions.toml` | да | +| `gradle.properties` | да | +| `.gradle/`, `build/` | нет | +| `local.properties` | нет | +| `.idea/` | частично (`codeStyles`, `inspectionProfiles`) | +| `*.iml` | нет | + +``` +# .gitignore +build/ +.gradle/ +local.properties +*.iml +.idea/* +!.idea/codeStyles/ +!.idea/inspectionProfiles/ +.kotlin/ +``` +Каталог `.kotlin/` появился в Kotlin 2.x - это служебные данные компилятора, их не коммитят. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `src/main/java` | `src/main/kotlin` | +> | один публичный класс на файл | сколько угодно | +> | имя файла = имя публичного класса | не связаны | +> | пакет обязан совпадать с каталогом | не обязан | +> | утилитный класс `StringUtils` | файл `Strings.kt` с расширениями | +> | `Main.class` | `MainKt.class` | +> +> **Освобождение от «один класс на файл» меняет раскладку кода.** В Java структура каталогов повторяла структуру типов: 20 классов - 20 файлов. В Kotlin файл - единица логической группировки, а не технического ограничения. `sealed interface` со всеми вариантами в одном файле читается целиком, а не по кускам. Побочный эффект: навигация «по файлам» работает хуже, и важнее осмысленные имена файлов. +> +> **Несовпадение пакета и каталога разрешено, но соглашение всё равно действует.** Kotlin не требует соответствия, потому что для компилятора оно не нужно: пакет указан в файле. Однако IDE, инструменты анализа и люди ожидают совпадения, поэтому отклоняются от него редко и только для плоских файлов с функциями. +> +> **Смешанная компиляция в одном модуле - то, чего нет в однодходовом `javac`.** Двухфазная схема (kotlinc читает Java-стабы, затем javac видит Kotlin-классы) позволяет взаимные ссылки между языками внутри модуля. Это ключевая возможность для миграции: можно конвертировать по одному файлу, не трогая остальное. Ограничение - только в пределах модуля. +> +> **Convention plugins - ответ Gradle на отсутствие наследования `pom.xml`.** В Maven общая конфигурация выносится в parent POM, и это декларативно. В Gradle аналога не было: писали `subprojects { }` или `allprojects { }`, что работает, но несовместимо с configuration cache и лишено типизации. Convention plugin - это обычный Gradle-плагин, лежащий в проекте: типизированный, кешируемый, переиспользуемый. +> +> Сравнить: [javac](../../Программирование%20на%20Java/JDK/javac.md), [jar](../../Программирование%20на%20Java/JDK/jar.md) + +> [!warning] Подводные камни +> **Главный класс для `application` - не имя файла.** Для `src/main/kotlin/com/example/Main.kt` это `com.example.MainKt`, а не `com.example.Main`. Ошибка проявляется как `ClassNotFoundException` при `./gradlew run`. Если в файле стоит `@file:JvmName("App")`, то класс - `com.example.App`. +> +> **Kotlin-файлы в `src/main/java` компилируются молча.** Плагин добавляет этот каталог в Kotlin source set, так что ошибки не будет - но структура станет путаной, а инструменты, ожидающие Java в `java/`, могут сбиться. +> +> **`buildSrc` инвалидирует кеш всего проекта.** Любая правка в `buildSrc` заставляет Gradle пересобрать все модули. На больших проектах это минуты на каждое изменение конфигурации - используйте `build-logic` как included build. +> +> **`subprojects { }` несовместим с configuration cache.** Он обращается к другим проектам в фазе конфигурации, что кеш запрещает. При включении `org.gradle.configuration-cache=true` появятся ошибки «invocation of 'Project.subprojects'». Решение - convention plugins. +> +> **Циклы между модулями запрещены.** Если `data` ссылается на `service`, а `service` на `data`, Gradle откажется собирать с ошибкой «Circular dependency between tasks». Внутри одного модуля цикл Kotlin↔Java допустим, между модулями - нет. Общие типы выносят в третий модуль. +> +> **Ресурс не найден из функции верхнего уровня.** `javaClass` недоступен вне класса; `this::class.java` в top-level функции не скомпилируется. Используйте `object {}.javaClass.getResourceAsStream(...)` или `ClassLoader.getSystemResourceAsStream(...)`. +> +> **Путь к ресурсу чувствителен к ведущему слэшу.** `getResourceAsStream("config.txt")` ищет относительно пакета класса, `getResourceAsStream("/config.txt")` - от корня classpath. Первый вариант неожиданно возвращает `null`, если класс лежит в пакете. +> +> **`gradle-wrapper.jar` обязателен в репозитории.** Без него `./gradlew` не работает, и сборка пойдёт локальным Gradle другой версии. Если политика запрещает бинарники, добавьте в CI шаг `gradle/actions/wrapper-validation`. +> +> **Переименование файла меняет имя класса.** `Utils.kt` даёт `UtilsKt`, после переименования в `Helpers.kt` - `HelpersKt`. Для Java-клиентов и рефлексии это ломающее изменение; фиксируйте имя через `@file:JvmName`. +> +> **Тесты не видят `internal` из другого модуля.** `internal` в Kotlin - это видимость в пределах модуля компиляции, и `src/test` - отдельный source set. Gradle-плагин специально связывает `test` с `main`, поэтому тесты `internal`-члены видят; но тесты **другого** модуля - нет. +> +> **Файл без `package` попадает в корневой пакет.** Такой класс не импортируется из других пакетов нормальным способом и недоступен из Java-кода в пакете. Всегда объявляйте пакет, кроме скриптов. + +Далее: [Компилятор K2](Компилятор%20K2.md) diff --git a/Kotlin/Исключения/Result и runCatching.md b/Kotlin/Исключения/Result и runCatching.md new file mode 100644 index 0000000..cb4ee4a --- /dev/null +++ b/Kotlin/Исключения/Result и runCatching.md @@ -0,0 +1,336 @@ +--- +title: Result и runCatching +description: Ошибка как значение: Result, runCatching, свои sealed-типы и когда что выбирать +tags: + - kotlin + - exceptions +--- + +Исключение - не единственный способ сообщить об ошибке. Ошибку можно вернуть как значение, и тогда компилятор заставит её обработать. + +Три уровня, от простого к гибкому: + +| Способ | Что несёт ошибка | Когда | +|---|---|---| +| `T?` | ничего, только «не получилось» | причина не важна | +| `Result` | `Throwable` | причина нужна, но типизировать её незачем | +| свой `sealed`-тип | что угодно | причин несколько и они разные | + +## Nullable как простейший Result +Для одной очевидной причины сбоя `null` достаточно: +```kotlin +fun main() { + println("42".toIntOrNull()) // 42 + println("abc".toIntOrNull()) // null + println("abc".toIntOrNull() ?: 0) // 0 +} +``` +Стандартная библиотека придерживается соглашения: `toInt()` бросает, `toIntOrNull()` возвращает `null`; `first()` бросает, `firstOrNull()` - нет. +```kotlin +fun main() { + val items = listOf(1, 2, 3) + println(items.getOrNull(10)) // null + println(items.firstOrNull { it > 5 }) // null + println(mapOf("a" to 1)["b"]) // null +} +``` +Подробнее: [Null-безопасность](../Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md) + +## Result +`Result` хранит либо значение, либо `Throwable`. +```kotlin +fun main() { + val ok: Result = Result.success(42) + val fail: Result = Result.failure(IllegalStateException("сбой")) + + println(ok.isSuccess) // true + println(fail.isFailure) // true + println(ok.getOrNull()) // 42 + println(fail.getOrNull()) // null + println(fail.exceptionOrNull()?.message) // сбой +} +``` + +### runCatching +Оборачивает блок: результат - `Result`, исключение - `failure`. +```kotlin +fun parse(s: String): Result = runCatching { s.toInt() } + +fun main() { + println(parse("42")) // Success(42) + println(parse("abc").exceptionOrNull()?.javaClass?.simpleName) // NumberFormatException +} +``` +Есть вариант-расширение с получателем: +```kotlin +fun main() { + val length = "привет".runCatching { length } + println(length.getOrNull()) // 6 +} +``` + +### Извлечение значения +```kotlin +fun main() { + val ok = runCatching { 42 } + val fail = runCatching { error("сбой") } + + println(ok.getOrNull()) // 42 + println(fail.getOrDefault(0)) // 0 + println(fail.getOrElse { e -> e.message!!.length }) // 4 + println(ok.getOrThrow()) // 42 + + try { + fail.getOrThrow() + } catch (e: IllegalStateException) { + println("пробросилось: ${e.message}") // пробросилось: сбой + } +} +``` + +### Преобразования +```kotlin +fun main() { + val r = runCatching { "42" } + .map { it.toInt() } // только при успехе + .map { it * 2 } + println(r.getOrNull()) // 84 + + // mapCatching перехватывает исключение внутри преобразования + val bad = runCatching { "abc" }.mapCatching { it.toInt() } + println(bad.isFailure) // true + + // map не перехватывает — исключение улетит наружу + val thrown = runCatching { runCatching { "abc" }.map { it.toInt() } } + println(thrown.isFailure) // true +} +``` +Разница существенная: `map` предполагает, что преобразование не падает, `mapCatching` - что может. + +```kotlin +fun main() { + val r = runCatching { error("сбой") } + .recover { 0 } // заменяем ошибку значением + println(r.getOrNull()) // 0 + + val folded = runCatching { "42".toInt() } + .fold( + onSuccess = { "значение $it" }, + onFailure = { "ошибка ${it.message}" }, + ) + println(folded) // значение 42 +} +``` + +### onSuccess и onFailure +Побочные эффекты без изменения значения - они возвращают сам `Result`: +```kotlin +fun main() { + runCatching { "abc".toInt() } + .onSuccess { println("получили $it") } + .onFailure { println("не вышло: ${it.message}") } + .getOrDefault(0) + .let { println("итог: $it") } + // не вышло: For input string: "abc" + // итог: 0 +} +``` + +## Свой sealed-тип +Когда причин несколько и клиент должен их различать, `Result` слишком беден - в нём любая ошибка это `Throwable`. +```kotlin +sealed interface LoadResult { + data class Success(val user: String) : LoadResult + data object NotFound : LoadResult + data class Forbidden(val reason: String) : LoadResult + data class NetworkError(val retryAfter: Int) : LoadResult +} + +fun load(id: Int): LoadResult = when { + id == 1 -> LoadResult.Success("Иван") + id == 2 -> LoadResult.Forbidden("нет прав") + id < 0 -> LoadResult.NetworkError(retryAfter = 5) + else -> LoadResult.NotFound +} + +fun describe(r: LoadResult) = when (r) { // else не нужен + is LoadResult.Success -> "загружен ${r.user}" + LoadResult.NotFound -> "не найден" + is LoadResult.Forbidden -> "запрещено: ${r.reason}" + is LoadResult.NetworkError -> "повтор через ${r.retryAfter} с" +} + +fun main() { + listOf(1, 2, 3, -1).forEach { println(describe(load(it))) } + // загружен Иван + // запрещено: нет прав + // не найден + // повтор через 5 с +} +``` +Главное преимущество - компилятор проверяет, что обработаны все варианты. Добавили новый - все `when` перестанут компилироваться, пока его не учтут. С `Result` такой гарантии нет. + +Подробнее: [Sealed class и interface](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +### Свой Result с типизированной ошибкой +```kotlin +sealed interface Outcome { + data class Ok(val value: T) : Outcome + data class Err(val error: E) : Outcome +} + +enum class ParseError { EMPTY, NOT_A_NUMBER, NEGATIVE } + +fun parsePositive(s: String): Outcome = when { + s.isBlank() -> Outcome.Err(ParseError.EMPTY) + s.toIntOrNull() == null -> Outcome.Err(ParseError.NOT_A_NUMBER) + s.toInt() < 0 -> Outcome.Err(ParseError.NEGATIVE) + else -> Outcome.Ok(s.toInt()) +} + +fun main() { + listOf("42", "", "abc", "-1").forEach { input -> + when (val r = parsePositive(input)) { + is Outcome.Ok -> println("'$input' -> ${r.value}") + is Outcome.Err -> println("'$input' -> ошибка ${r.error}") + } + } + // '42' -> 42 + // '' -> ошибка EMPTY + // 'abc' -> ошибка NOT_A_NUMBER + // '-1' -> ошибка NEGATIVE +} +``` +`Nothing` в позиции второго параметра позволяет `Ok` подходить под любой тип ошибки. Подробнее: [Any, Unit, Nothing](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md), [Вариантность](../Дженерики/Вариантность.md) + +## Что выбрать +```mermaid +graph TD + A["Ошибка возможна"] --> B{"Вызывающий может
что-то сделать?"} + B -->|"нет, это баг"| C["исключение:
require, check, error"] + B -->|"да"| D{"Причину нужно
различать?"} + D -->|"нет"| E["T?"] + D -->|"да, но обработка общая"| F["Result<T>"] + D -->|"да, по-разному"| G["свой sealed-тип"] +``` + +Практическое разделение: +- **исключение** - для нарушенных предпосылок: неверный аргумент, недопустимое состояние. Это ошибка программиста, её исправляют в коде, а не обрабатывают. Подробнее: [Проверки и контракты](Проверки%20и%20контракты.md) +- **`T?`, `Result`, sealed** - для ожидаемых ситуаций: нет записи в БД, сеть недоступна, ввод пользователя некорректен + +## Граница с исключениями +`runCatching` удобен на границе: внешний код бросает, ваш возвращает значение. +```kotlin +class User7(val id: Int, val name: String) + +fun fetchFromDb(id: Int): User7 = + if (id == 1) User7(1, "Иван") else throw NoSuchElementException("нет пользователя $id") + +fun findUser(id: Int): User7? = runCatching { fetchFromDb(id) }.getOrNull() + +fun main() { + println(findUser(1)?.name) // Иван + println(findUser(99)) // null +} +``` +Обратное направление - когда API требует исключений, а внутри `Result`: +```kotlin +fun loadOrThrow(id: Int): String = runCatching { + require(id > 0) { "id должен быть положительным" } + "данные $id" +}.getOrThrow() + +fun main() { + println(loadOrThrow(1)) // данные 1 + try { + loadOrThrow(-1) + } catch (e: IllegalArgumentException) { + println(e.message) // id должен быть положительным + } +} +``` + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | checked exceptions в сигнатуре | `Result` или sealed-тип | +> | `Optional` | `T?` - часть системы типов | +> | `Optional.map`/`orElse` | `?.let`/`?:` | +> | нет аналога `Result` в JDK | `Result` в stdlib | +> | `try`/`catch` для управления | `runCatching` как выражение | +> +> **`Result` частично возвращает то, что отняло отсутствие checked exceptions.** Сигнатура `fun load(): Result` сообщает, что функция может не справиться, и компилятор не даст использовать значение без разбора. Разница с checked exceptions в том, что это выбор автора функции, а не навязанное правило, и такие значения свободно проходят через `map`, `filter` и другие обобщённые функции. +> +> **`T?` вместо `Optional`.** `Optional` - обёртка-объект: аллокация, два уровня значений (`null` и пустой `Optional`), неудобство в полях. Nullable-тип в Kotlin существует только при компиляции, а в байт-коде это обычная ссылка - накладных расходов нет: +> ```kotlin +> fun main() { +> val name: String? = null +> println(name?.uppercase() ?: "<нет>") // <нет> +> } +> ``` +> ```java +> Optional name = Optional.empty(); +> System.out.println(name.map(String::toUpperCase).orElse("<нет>")); +> ``` +> +> **Sealed-типы вместо иерархии исключений.** В Java разные причины сбоя обычно выражают классами исключений и ловят их по типу. Sealed-тип даёт то же различение, но с проверкой полноты при компиляции и без стоимости стектрейса. +> +> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **`runCatching` перехватывает всё, включая то, что перехватывать нельзя.** Он ловит `Throwable`, то есть `OutOfMemoryError`, `StackOverflowError` и - самое опасное - `CancellationException` в корутинах: +> ```kotlin +> // Требует kotlinx-coroutines-core. +> // suspend fun broken() { +> // runCatching { delay(1000) } // проглотит отмену корутины +> // } +> // +> // suspend fun fixed() = runCatching { delay(1000) } +> // .onFailure { if (it is CancellationException) throw it } +> ``` +> В корутинах либо проверяйте тип вручную, либо используйте обычный `try`/`catch` с перехватом конкретных типов. Подробнее: [Исключения в корутинах](../Корутины/Исключения%20в%20корутинах.md) +> +> **`Result` нельзя вернуть из функции в публичном API без оговорок.** До Kotlin 1.5 это было запрещено совсем; сейчас разрешено, но тип по-прежнему `value class`, поэтому из Java он виден странно и его нельзя использовать как тип свойства: +> ```kotlin +> class Holder { +> // val r: Result = Result.success(1) // компилируется, но Result как поле боксируется +> private val value: Int = 1 +> fun get(): Result = Result.success(value) +> } +> +> fun main() { println(Holder().get().getOrNull()) } // 1 +> ``` +> +> **`Result` стирает тип ошибки.** Все причины сбоя - `Throwable`, поэтому различать их приходится через `is`, и компилятор полноту не проверяет: +> ```kotlin +> fun main() { +> val r = runCatching { error("сбой") } +> val msg = when (val e = r.exceptionOrNull()) { +> null -> "успех" +> is IllegalStateException -> "состояние: ${e.message}" +> else -> "прочее" // else обязателен +> } +> println(msg) // состояние: сбой +> } +> ``` +> +> **Создание `failure` всё равно создаёт исключение со стектрейсом** - то есть `Result` не даёт экономии на производительности, если ошибку формирует `runCatching`. Дешёвый вариант - sealed-тип без `Throwable` внутри. +> +> **Проигнорированный `Result` не вызывает предупреждения:** +> ```kotlin +> fun save(): Result = runCatching { error("не записалось") } +> +> fun main() { +> save() // ошибка потеряна молча +> println(save().isFailure) // true — так правильно +> } +> ``` +> В отличие от исключения, которое всплывёт само, забытый `Result` ничего не делает. Это основной риск подхода. +> +> **Цепочка `map` на `Result` может бросить.** `map` не перехватывает исключения из своей лямбды - для этого есть `mapCatching`. +> +> **`getOrThrow` на чужом `Result` бросает исходное исключение,** а не обёртку, поэтому стектрейс указывает на место первоначального сбоя, а не на `getOrThrow`. Для диагностики это хорошо, для чтения логов - неожиданно. +> +> **Смешение подходов в одном слое запутывает.** Если часть функций репозитория возвращает `null`, часть `Result`, а часть бросает, клиентский код обрастает тремя видами обработки. Выберите одно соглашение на слой. + +Далее: [Проверки и контракты](Проверки%20и%20контракты.md) diff --git a/Kotlin/Исключения/Проверки и контракты.md b/Kotlin/Исключения/Проверки и контракты.md new file mode 100644 index 0000000..3464e16 --- /dev/null +++ b/Kotlin/Исключения/Проверки и контракты.md @@ -0,0 +1,403 @@ +--- +title: Проверки и контракты +description: require, check, error, assert и контракты компилятора для умного приведения +tags: + - kotlin + - exceptions +--- + +Стандартная библиотека даёт четыре функции для проверки предпосылок. Выбор между ними определяется тем, **кто виноват** в нарушении. + +| Функция | Бросает | Виноват | Что проверяет | +|---|---|---|---| +| `require` | `IllegalArgumentException` | вызывающий | аргументы | +| `check` | `IllegalStateException` | вызывающий или сам объект | состояние | +| `error` | `IllegalStateException` | - | недостижимая ветка | +| `assert` | `AssertionError` | - | инвариант при отладке | + +## require - проверка аргументов +```kotlin +fun withdraw(balance: Long, amount: Long): Long { + require(amount > 0) { "сумма должна быть положительной, получено $amount" } + require(amount <= balance) { "недостаточно средств: $amount > $balance" } + return balance - amount +} + +fun main() { + println(withdraw(100, 30)) // 70 + try { + withdraw(100, -5) + } catch (e: IllegalArgumentException) { + println(e.message) // сумма должна быть положительной, получено -5 + } +} +``` +Сообщение передаётся лямбдой, поэтому строка собирается только при нарушении - в успешном случае интерполяция не выполняется. + +```kotlin +fun main() { + val n = 5 + require(n > 0) // без сообщения: "Failed requirement." + require(n > 0) { "дорогое сообщение: ${(1..1000).sum()}" } // лямбда не вызовется + println("проверки пройдены") +} +``` + +### requireNotNull +Проверяет и **возвращает** non-null значение: +```kotlin +fun greet(name: String?): String { + val safe = requireNotNull(name) { "имя обязательно" } + return "Привет, ${safe.uppercase()}" // safe имеет тип String +} + +fun main() { + println(greet("иван")) // Привет, ИВАН + try { + greet(null) + } catch (e: IllegalArgumentException) { + println(e.message) // имя обязательно + } +} +``` +Работает и как инструкция - после вызова переменная умно приводится: +```kotlin +fun process(input: String?): Int { + requireNotNull(input) + return input.length // smart cast: String? -> String +} + +fun main() { println(process("abc")) } // 3 +``` +Это работает благодаря контракту, см. ниже. + +## check - проверка состояния +```kotlin +class Connection2 { + private var open = false + + fun connect() { open = true } + + fun send(data: String): String { + check(open) { "соединение не открыто" } + return "отправлено: $data" + } +} + +fun main() { + val c = Connection2() + try { + c.send("данные") + } catch (e: IllegalStateException) { + println(e.message) // соединение не открыто + } + c.connect() + println(c.send("данные")) // отправлено: данные +} +``` +Разница с `require` только в типе исключения, но она информативна: `IllegalArgumentException` говорит «ты передал не то», `IllegalStateException` - «ты вызвал не вовремя». + +`checkNotNull` - аналог `requireNotNull` для состояния: +```kotlin +class Service18 { + private var config: String? = null + + fun init2(value: String) { config = value } + + fun run2(): String { + val cfg = checkNotNull(config) { "сначала вызовите init2()" } + return "работаю с $cfg" + } +} + +fun main() { + val s = Service18() + try { + s.run2() + } catch (e: IllegalStateException) { + println(e.message) // сначала вызовите init2() + } + s.init2("настройки") + println(s.run2()) // работаю с настройки +} +``` + +## error - недостижимая ветка +`error(msg)` бросает `IllegalStateException`. Её тип - `Nothing`, поэтому она подходит в любое выражение: +```kotlin +fun describe(code: Int): String = when (code) { + 200 -> "ок" + 404 -> "не найдено" + 500 -> "ошибка сервера" + else -> error("неизвестный код: $code") +} + +fun main() { + println(describe(200)) // ок + try { + describe(999) + } catch (e: IllegalStateException) { + println(e.message) // неизвестный код: 999 + } +} +``` +Благодаря типу `Nothing` компилятор понимает, что ветка не возвращает значение, и `when` остаётся выражением. Подробнее: [Any, Unit, Nothing](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md) + +`TODO()` - то же для незаписанного кода: +```kotlin +fun futureFeature(): String = TODO("реализовать после релиза") + +fun main() { + try { + futureFeature() + } catch (e: NotImplementedError) { + println(e.message) // An operation is not implemented: реализовать после релиза + } +} +``` +`TODO()` возвращает `Nothing`, поэтому подходит на место любого типа - код компилируется, пока функция не написана. + +## assert - только для отладки +```kotlin +fun average(values: List): Double { + val result = values.sum().toDouble() / values.size + assert(result >= values.min()) { "среднее меньше минимума — ошибка в вычислении" } + return result +} + +fun main() { + println(average(listOf(1, 2, 3))) // 2.0 +} +``` +`assert` проверяется **только** при запуске JVM с флагом `-ea` (`-enableassertions`). Без него вызов ничего не делает, но лямбда-сообщение всё равно остаётся объектом в байт-коде. + +Практический вывод: `assert` в Kotlin почти не используют. Для проверок, которые должны работать в продакшене, берут `require` и `check`. + +## Контракты +Контракт - способ сообщить компилятору то, что он не может вывести сам. Именно поэтому `requireNotNull` даёт умное приведение. + +```kotlin +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +fun String?.isNotNullOrBlank(): Boolean { + contract { returns(true) implies (this@isNotNullOrBlank != null) } + return !this.isNullOrBlank() +} + +fun main() { + val input: String? = "текст" + if (input.isNotNullOrBlank()) { + println(input.length) // smart cast: String? -> String + } +} +``` +Без контракта `input.length` потребовал бы `!!` или `?.` - компилятор не знает, что `true` из функции означает non-null. + +Стандартная библиотека использует контракты в `isNullOrEmpty`, `isNullOrBlank`, `require`, `check`, `requireNotNull`: +```kotlin +fun main() { + val s: String? = "abc" + if (!s.isNullOrEmpty()) { + println(s.length) // smart cast работает благодаря контракту + } + + val n: Int? = 5 + check(n != null) + println(n + 1) // 6, smart cast после check +} +``` + +### Виды контрактов +```kotlin +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +fun requirePositive(value: Int?) { + contract { returns() implies (value != null) } // вернулась -> value не null + require(value != null && value > 0) { "нужно положительное число" } +} + +@OptIn(ExperimentalContracts::class) +inline fun runOnce(block: () -> T): T { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + return block() +} + +fun main() { + val x: Int? = 5 + requirePositive(x) + println(x + 1) // 6 — smart cast + + val value: String // можно не инициализировать... + runOnce { value = "присвоено" } // ...потому что контракт гарантирует один вызов + println(value) // присвоено +} +``` +`callsInPlace` с `EXACTLY_ONCE` позволяет присваивать `val` внутри лямбды - компилятор знает, что присваивание произойдёт ровно один раз. На этом работают `run`, `let`, `apply` и `with`. + +| Контракт | Что обещает | +|---|---| +| `returns() implies (x)` | если функция вернулась нормально, условие `x` истинно | +| `returns(true) implies (x)` | если вернула `true`, условие `x` истинно | +| `returns(null) implies (x)` | если вернула `null`, условие `x` истинно | +| `callsInPlace(f, AT_MOST_ONCE)` | лямбда вызовется не больше одного раза | +| `callsInPlace(f, EXACTLY_ONCE)` | ровно один раз | +| `callsInPlace(f, AT_LEAST_ONCE)` | хотя бы один раз | + +Требования к контракту: +- только для функций верхнего уровня (не для методов класса) +- блок `contract` - **первая** инструкция в теле +- API экспериментальный: нужен `@OptIn(ExperimentalContracts::class)` +- компилятор **не проверяет**, что контракт соответствует коду + +Последний пункт - главный риск: контракт это обещание на доверии. + +## Валидация данных: исключение или значение +`require` подходит для нарушений, которые означают баг. Для пользовательского ввода лучше вернуть ошибку как значение: +```kotlin +data class Form4(val email: String, val age: Int) + +fun validate(email: String, age: Int): List = buildList { + if ("@" !in email) add("email без @") + if (age !in 0..150) add("недопустимый возраст: $age") +} + +fun create(email: String, age: Int): Result { + val errors = validate(email, age) + return if (errors.isEmpty()) Result.success(Form4(email, age)) + else Result.failure(IllegalArgumentException(errors.joinToString("; "))) +} + +fun main() { + println(create("a@b.ru", 20).getOrNull()) // Form4(email=a@b.ru, age=20) + println(create("нет", 200).exceptionOrNull()?.message) // email без @; недопустимый возраст: 200 +} +``` +Разница: `require` падает на первой ошибке, а пользователю обычно нужен список всех. Подробнее: [Result и runCatching](Result%20и%20runCatching.md) + +## Инварианты в конструкторе +```kotlin +class Range4(val from: Int, val to: Int) { + init { + require(from <= to) { "неверный диапазон: $from..$to" } + } + + val size get() = to - from + 1 +} + +@JvmInline +value class Email(val value: String) { + init { + require("@" in value) { "не похоже на email: $value" } + } +} + +fun main() { + println(Range4(1, 5).size) // 5 + println(Email("a@b.ru").value) // a@b.ru + try { + Range4(5, 1) + } catch (e: IllegalArgumentException) { + println(e.message) // неверный диапазон: 5..1 + } +} +``` +Проверка в конструкторе гарантирует, что невалидного объекта не существует, - дальше по коду проверять не нужно. Подробнее: [Value class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Value%20class.md), [Инициализация](../ООП/Инициализация.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `if (x < 0) throw new IllegalArgumentException(...)` | `require(x >= 0) { ... }` | +> | `Objects.requireNonNull(x)` | `requireNotNull(x)` + smart cast | +> | `assert x > 0 : "msg"` | `assert(x > 0) { "msg" }` | +> | Guava `Preconditions.checkArgument` | `require` в stdlib | +> | `throw new UnsupportedOperationException()` | `TODO()` | +> | нет аналога | контракты для smart cast | +> +> **`require` и `check` - это то, за чем в Java идут в Guava.** `Preconditions.checkArgument` и `checkState` из Guava выполняют ту же роль, но требуют внешней зависимости. В Kotlin они в stdlib и вдобавок `inline`, то есть вызова функции в байт-коде не остаётся. +> +> **Сообщение в лямбде экономит работу.** В Java `checkArgument("плохое значение: " + value)` собирает строку всегда, даже когда проверка пройдена. Передача лямбды плюс `inline` означает, что конкатенация происходит только при нарушении: +> ```kotlin +> fun main() { +> val value = 5 +> require(value > 0) { "дорогая строка: ${value.toString().repeat(1000).length}" } +> println("лямбда не вызывалась") +> } +> ``` +> +> **`requireNotNull` даёт smart cast, `Objects.requireNonNull` - нет.** В Java результат нужно присвоить: `this.name = Objects.requireNonNull(name)`. В Kotlin после проверки компилятор сам считает переменную non-null. +> +> **Контрактов в Java нет совсем.** Ближайший аналог - аннотации `@Nullable`/`@NotNull` и внешние анализаторы, которые работают вне компилятора и не влияют на типизацию. +> +> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md), [Инкапсуляция](../../Программирование%20на%20Java/ООП/Инкапсуляция/Инкапсуляция.md) + +> [!warning] Подводные камни +> **`assert` по умолчанию выключен.** Без `-ea` при запуске JVM проверка не выполняется совсем: +> ```kotlin +> fun main() { +> assert(1 == 2) { "это сообщение вы не увидите без -ea" } +> println("программа продолжила работу") +> } +> ``` +> Код, который полагается на `assert` для защиты инвариантов в продакшене, не защищён ничем. Используйте `check`. +> +> **Путаница `require` и `check`.** Тип исключения - часть контракта: вызывающий может ловить `IllegalArgumentException`, чтобы отличить «я передал мусор» от «объект не готов». Перепутанные функции делают эту информацию ложной. +> +> **Сообщение без контекста бесполезно:** +> ```kotlin +> fun main() { +> val size = -1 +> try { +> require(size >= 0) { "неверный размер" } // какой именно? +> } catch (e: IllegalArgumentException) { +> println(e.message) +> } +> try { +> require(size >= 0) { "размер должен быть >= 0, получено $size" } +> } catch (e: IllegalArgumentException) { +> println(e.message) // размер должен быть >= 0, получено -1 +> } +> } +> ``` +> +> **`require` для пользовательского ввода - ошибка проектирования.** Некорректный ввод - штатная ситуация, а не баг. Исключение в этом случае заставляет клиентский код оборачивать каждый вызов в `try`/`catch` и сообщает только о первой проблеме. +> +> **Контракт не проверяется компилятором.** Неверный контракт ломает проверки типов - компилятор поверит обещанию и уберёт нужные проверки на `null`: +> ```kotlin +> import kotlin.contracts.ExperimentalContracts +> import kotlin.contracts.contract +> +> @OptIn(ExperimentalContracts::class) +> fun String?.lies(): Boolean { +> contract { returns(true) implies (this@lies != null) } +> return true // врёт: true даже для null +> } +> +> fun main() { +> val s: String? = null +> if (s.lies()) { +> try { +> println(s.length) // компилятор уверен, что s != null +> } catch (e: NullPointerException) { +> println("NPE в non-null коде из-за неверного контракта") +> } +> } +> } +> ``` +> +> **Контракт нельзя объявить в методе класса** - только в функции верхнего уровня. Это ограничение текущей реализации. +> +> **Контракты экспериментальны** - `@OptIn(ExperimentalContracts::class)` обязателен, API может измениться. +> +> **`TODO()` компилируется и попадает в релиз.** Он возвращает `Nothing`, поэтому ошибок сборки не вызывает - только падение в рантайме. Перед релизом стоит проверять: `grep -r 'TODO(' src/`. +> +> **Проверки в `init` не спасают `data class` от `copy`** в версиях до Kotlin 2.1: `copy()` вызывает конструктор, то есть `init` выполнится, но саму валидацию обойти через `copy` нельзя - а вот приватный конструктор обойти можно. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) +> +> **`require` внутри цикла по большой коллекции стоит дорого** не из-за проверки, а из-за того, что при нарушении создаётся исключение со стектрейсом. Для валидации списков собирайте список ошибок, а не бросайте на первой. + +Дополнительно: [Система исключений](Система%20исключений.md) +Далее: [Коллекции](../Коллекции/Коллекции.md) diff --git a/Kotlin/Исключения/Система исключений.md b/Kotlin/Исключения/Система исключений.md new file mode 100644 index 0000000..e635660 --- /dev/null +++ b/Kotlin/Исключения/Система исключений.md @@ -0,0 +1,370 @@ +--- +title: Система исключений +description: Иерархия Throwable, try как выражение, отсутствие checked exceptions и правильная обработка +tags: + - kotlin + - exceptions +--- + +Все исключения в Kotlin **непроверяемые**. Компилятор не требует ни объявлять, ни перехватывать их. + +```kotlin +fun parse(s: String): Int = s.toInt() // может бросить, но throws не нужен + +fun main() { + println(parse("42")) // 42 + try { + parse("abc") + } catch (e: NumberFormatException) { + println("не число: ${e.message}") // не число: For input string: "abc" + } +} +``` + +## Иерархия +```mermaid +graph TD + T["Throwable"] --> E["Exception"] + T --> R["Error"] + E --> RE["RuntimeException"] + E --> IOE["IOException"] + RE --> NPE["NullPointerException"] + RE --> IAE["IllegalArgumentException"] + RE --> ISE["IllegalStateException"] + RE --> CCE["ClassCastException"] + R --> OOM["OutOfMemoryError"] + R --> SOE["StackOverflowError"] +``` +Иерархия та же, что в Java, - это классы JDK. Разница только в том, что Kotlin не делит их на проверяемые и непроверяемые. + +| Класс | Смысл | Перехватывать | +|---|---|---| +| `Error` | сбой среды: нет памяти, переполнен стек | нет | +| `RuntimeException` | ошибка программиста: неверный аргумент, состояние | обычно нет, лучше исправить код | +| прочие `Exception` | внешний сбой: сеть, файл, БД | да | + +## try как выражение +`try` возвращает значение - это важное отличие от Java: +```kotlin +fun parseOrZero(s: String): Int = try { + s.toInt() +} catch (e: NumberFormatException) { + 0 +} + +fun main() { + println(parseOrZero("42")) // 42 + println(parseOrZero("abc")) // 0 +} +``` +Результат - последнее выражение блока `try` или соответствующего `catch`. Блок `finally` на результат **не влияет**: +```kotlin +fun main() { + val result = try { + "из try" + } finally { + "из finally" // игнорируется + } + println(result) // из try +} +``` + +## throw - тоже выражение +Тип `throw` - [Nothing](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md), поэтому он подходит в любую позицию: +```kotlin +fun findUser(id: Int): String = + if (id > 0) "пользователь $id" + else throw IllegalArgumentException("неверный id: $id") + +fun main() { + println(findUser(1)) // пользователь 1 + val name = findUser(1).takeIf { it.isNotEmpty() } ?: throw IllegalStateException("пусто") + println(name) // пользователь 1 +} +``` +Это позволяет писать `?: throw ...` и `?: error(...)` в одном выражении. + +## Перехват +```kotlin +fun risky(mode: Int): String = when (mode) { + 1 -> throw IllegalArgumentException("плохой аргумент") + 2 -> throw IllegalStateException("плохое состояние") + else -> "ок" +} + +fun handle(mode: Int): String = try { + risky(mode) +} catch (e: IllegalArgumentException) { + "аргумент: ${e.message}" +} catch (e: IllegalStateException) { + "состояние: ${e.message}" +} catch (e: Exception) { + "прочее: ${e.message}" +} finally { + println("finally выполняется всегда") +} + +fun main() { + println(handle(1)) // аргумент: плохой аргумент + println(handle(3)) // ок +} +``` +Блоки `catch` проверяются по порядку, поэтому конкретные типы идут раньше общих. `catch (e: Exception)` первым сделал бы остальные недостижимыми. + +Несколько типов в одном блоке Kotlin не поддерживает - аналога Java `catch (A | B e)` нет. Варианты: +```kotlin +fun main() { + try { + throw IllegalStateException("сбой") + } catch (e: RuntimeException) { // общий супертип + println("перехвачено: ${e::class.simpleName}") + } + + try { + throw NumberFormatException("плохое число") + } catch (e: Exception) { + when (e) { // или when по типу + is NumberFormatException, is IllegalStateException -> + println("ожидаемая ошибка: ${e.message}") + else -> throw e // остальное пробрасываем + } + } +} +``` + +## Свои исключения +```kotlin +class InsufficientFundsException( + val required: Long, + val available: Long, +) : Exception("нужно $required, доступно $available") + +class ValidationException(message: String, cause: Throwable? = null) : Exception(message, cause) + +fun withdraw(balance: Long, amount: Long): Long { + if (amount > balance) throw InsufficientFundsException(amount, balance) + return balance - amount +} + +fun main() { + println(withdraw(100, 30)) // 70 + try { + withdraw(100, 500) + } catch (e: InsufficientFundsException) { + println("не хватает ${e.required - e.available}") // не хватает 400 + } +} +``` +Своё исключение полезно, когда вызывающему нужно различить ситуацию и получить данные о ней. Для «ошибки программиста» хватает стандартных `IllegalArgumentException` и `IllegalStateException`. + +## Цепочка причин +```kotlin +class RepositoryException(message: String, cause: Throwable) : Exception(message, cause) + +fun loadUser(id: String): String = try { + "пользователь ${id.toInt()}" +} catch (e: NumberFormatException) { + throw RepositoryException("не удалось загрузить '$id'", e) // сохраняем причину +} + +fun main() { + try { + loadUser("abc") + } catch (e: RepositoryException) { + println(e.message) // не удалось загрузить 'abc' + println("причина: ${e.cause?.message}") // причина: For input string: "abc" + } +} +``` +Передавать `cause` обязательно: без него теряется исходный стектрейс и причина сбоя. + +## use - автоматическое закрытие +Аналог try-with-resources: +```kotlin +import java.io.StringReader + +fun main() { + val content = StringReader("данные").use { reader -> + reader.readText() // close() вызовется автоматически + } + println(content) // данные +} +``` +`use` - функция-расширение для `Closeable`, она закрывает ресурс и при исключении. Это предпочтительный способ: `finally` с ручным `close()` легко написать неверно. + +```kotlin +class Resource(val name: String) : AutoCloseable { + fun work() = "работаю с $name" + override fun close() = println("$name закрыт") +} + +fun main() { + val result = Resource("файл").use { it.work() } + println(result) + // файл закрыт + // работаю с файл + + try { + Resource("другой").use { throw IllegalStateException("сбой") } + } catch (e: IllegalStateException) { + println("ресурс всё равно закрыт: ${e.message}") + } +} +``` + +## @Throws для Java +Kotlin не объявляет исключения, но Java-клиенту это может быть нужно: +```kotlin +import java.io.IOException + +@Throws(IOException::class) +fun readFile(path: String): String { + if (path.isEmpty()) throw IOException("пустой путь") + return "содержимое $path" +} + +fun main() { println(readFile("a.txt")) } // содержимое a.txt +``` +Без `@Throws` Java-код не сможет перехватить проверяемое исключение - компилятор Java сочтёт, что метод его не бросает. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) + +## Чего не делать +```kotlin +fun main() { + // проглатывание: ошибка исчезает + try { + error("сбой") + } catch (e: Exception) { + // ничего — худшая практика + } + + // перехват Throwable: ловит OutOfMemoryError и StackOverflowError + try { + error("сбой") + } catch (e: Exception) { // не Throwable + println("перехвачено корректно") + } + + // исключение как управление потоком + val items = listOf(1, 2, 3) + println(items.getOrNull(10) ?: "нет элемента") // вместо try/catch +} +``` +Правила: +- не перехватывать, если не знаете, что делать с ошибкой - пусть идёт выше +- не перехватывать `Throwable` и `Error` +- не использовать исключения для ожидаемых ситуаций - для них есть `null`, `Result` или sealed-типы +- всегда логировать или пробрасывать; пустой `catch` скрывает причину сбоя + +Альтернативы исключениям: [Result и runCatching](Result%20и%20runCatching.md) + +## Исключения в корутинах +Отмена корутины реализована через `CancellationException`, и перехватывать её нельзя: +```kotlin +// Требует kotlinx-coroutines-core. +// suspend fun work() { +// try { +// delay(1000) +// } catch (e: CancellationException) { +// throw e // обязательно пробросить! +// } catch (e: Exception) { +// println("настоящая ошибка") +// } +// } +``` +`catch (e: Exception)` перехватит и отмену, из-за чего корутина продолжит работу после отмены. Подробнее: [Исключения в корутинах](../Корутины/Исключения%20в%20корутинах.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | checked exceptions | **нет**, все непроверяемые | +> | `throws IOException` в сигнатуре | `@Throws` только для Java-клиентов | +> | `try` - инструкция | `try` - выражение | +> | `catch (A \| B e)` | нет аналога, нужен `when` | +> | try-with-resources | `use { }` | +> | `throw` - инструкция | `throw` - выражение типа `Nothing` | +> +> **Отсутствие checked exceptions - самое спорное решение.** Аргументы против них, которые приводят авторы Kotlin: +> +> 1. **Они не масштабируются.** Сигнатура с пятью `throws` нечитаема, а обобщённый код (`map`, `forEach`) не может объявить исключения вызываемой лямбды - из-за этого Java-стримы несовместимы с проверяемыми исключениями. +> 2. **На практике их проглатывают.** Самый частый способ «обработать» обязательное исключение - пустой `catch`, то есть защита превращается в сокрытие ошибки. +> 3. **Обёртывание ради компиляции.** Код вроде `catch (IOException e) { throw new RuntimeException(e); }` встречается в любом крупном Java-проекте. +> +> Цена отказа - сигнатура больше не документирует ошибки. Это компенсируют KDoc и типы вроде `Result`, но компилятор уже не помогает. +> +> **`try` как выражение убирает `var`:** +> ```java +> int value; // Java: нужна изменяемая переменная +> try { value = Integer.parseInt(s); } +> catch (NumberFormatException e) { value = 0; } +> ``` +> ```kotlin +> fun parseOrZero(s: String): Int = +> try { s.toInt() } catch (e: NumberFormatException) { 0 } // val вместо var +> +> fun main() { println(parseOrZero("abc")) } // 0 +> ``` +> +> **`use` вместо try-with-resources** - обычная функция, а не синтаксис. Поэтому её можно применить к своему типу, добавив реализацию `Closeable`. +> +> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **Сигнатура не говорит об исключениях.** Единственные источники информации - документация и чтение кода. Для важных случаев указывайте в KDoc: +> ```kotlin +> /** +> * @throws IllegalArgumentException если id не положительный +> */ +> fun load(id: Int): String { +> require(id > 0) { "id должен быть положительным" } +> return "данные $id" +> } +> +> fun main() { println(load(1)) } // данные 1 +> ``` +> +> **`finally` не влияет на результат `try`-выражения** - значение из него отбрасывается. +> +> **`return` в `finally` проглатывает исключение:** +> ```kotlin +> fun bad(): String { +> try { +> throw IllegalStateException("потеряется") +> } finally { +> return "из finally" // исключение исчезло! +> } +> } +> +> fun main() { println(bad()) } // из finally +> ``` +> Исключение не дойдёт до вызывающего. `return` в `finally` писать нельзя. +> +> **Перехват `Exception` ловит все `RuntimeException`,** включая `NullPointerException` в вашем коде - то есть маскирует собственные баги под «обработанную ошибку». +> +> **`CancellationException` нельзя проглатывать в корутинах** - иначе отмена перестанет работать. +> +> **Исключение в `init` или в инициализаторе свойства оставляет побочные эффекты.** Объект не создастся, но то, что уже выполнилось, останется. Подробнее: [Инициализация](../ООП/Инициализация.md) +> +> **Создание исключения дорого** из-за сбора стектрейса. Для ожидаемых ситуаций в горячем коде используйте `null` или `Result`: +> ```kotlin +> fun main() { +> println("abc".toIntOrNull() ?: 0) // без исключения +> // println("abc".toInt()) // исключение + стектрейс +> } +> ``` +> +> **Исключение из лямбды в `forEach` прерывает весь цикл**, а не одну итерацию: +> ```kotlin +> fun main() { +> val results = mutableListOf() +> try { +> listOf("1", "abc", "3").forEach { results.add(it.toInt()) } +> } catch (e: NumberFormatException) { +> println("прервано на второй строке, собрано: $results") // [1] +> } +> } +> ``` +> Чтобы обработать все элементы, перехватывайте внутри лямбды или используйте `mapNotNull { it.toIntOrNull() }`. +> +> **`@Throws` не проверяется компилятором Kotlin** - это только метаданные для Java. Можно объявить исключение, которое никогда не бросается, и наоборот. + +Далее: [Result и runCatching](Result%20и%20runCatching.md) diff --git a/Kotlin/Коллекции/List, Set, Map.md b/Kotlin/Коллекции/List, Set, Map.md new file mode 100644 index 0000000..986fe33 --- /dev/null +++ b/Kotlin/Коллекции/List, Set, Map.md @@ -0,0 +1,365 @@ +--- +title: List, Set, Map +description: Три семейства коллекций, их гарантии, стоимость операций и выбор между ними +tags: + - kotlin + - collections +--- + +| | `List` | `Set` | `Map` | +|---|---|---|---| +| порядок | по индексу | вставки (у `LinkedHashSet`) | вставки (у `LinkedHashMap`) | +| дубликаты | разрешены | запрещены | ключи уникальны | +| доступ | по индексу, O(1) | только поиск, O(1) | по ключу, O(1) | +| поиск элемента | O(n) | O(1) | O(1) по ключу | +| основной вопрос | «что по порядку» | «есть ли такой» | «что соответствует» | + +## List +```kotlin +fun main() { + val list = listOf("a", "b", "c", "b") + + println(list[1]) // b — доступ по индексу + println(list.getOrNull(10)) // null + println(list.getOrElse(10) { "?" }) // ? + println(list.indexOf("b")) // 1 — первое вхождение + println(list.lastIndexOf("b")) // 3 + println(list.subList(1, 3)) // [b, c] + println(list.size) // 4 — дубликат сохранён +} +``` + +Доступ к краям и срезы: +```kotlin +fun main() { + val list = listOf(1, 2, 3, 4, 5) + + println("${list.first()} ${list.last()}") // 1 5 + println(list.firstOrNull { it > 3 }) // 4 + println(list.take(2)) // [1, 2] + println(list.drop(2)) // [3, 4, 5] + println(list.takeLast(2)) // [4, 5] + println(list.slice(1..3)) // [2, 3, 4] + println(list.chunked(2)) // [[1, 2], [3, 4], [5]] + println(list.windowed(2)) // [[1, 2], [2, 3], [3, 4], [4, 5]] + println(list.reversed()) // [5, 4, 3, 2, 1] +} +``` + +Изменение через `MutableList`: +```kotlin +fun main() { + val list = mutableListOf(1, 2, 3) + + list.add(4) + list.add(0, 0) // по индексу + list[1] = 10 + list.removeAt(0) + list.remove(4) // по значению + println(list) // [10, 2, 3] + + list += 5 // оператор += работает как add + list -= 2 + println(list) // [10, 3, 5] + + list.sortDescending() // сортировка на месте + println(list) // [10, 5, 3] +} +``` + +## Set +```kotlin +fun main() { + val set = setOf(1, 2, 3) + + println(2 in set) // true — O(1) + println(set.contains(5)) // false + println(setOf(1, 2, 2, 3).size) // 3 — дубликат отброшен +} +``` + +Операции над множествами: +```kotlin +fun main() { + val a = setOf(1, 2, 3) + val b = setOf(3, 4, 5) + + println(a union b) // [1, 2, 3, 4, 5] + println(a intersect b) // [3] + println(a subtract b) // [1, 2] + println(a + b) // [1, 2, 3, 4, 5] — как union + println(a - b) // [1, 2] +} +``` +Эти же функции работают на `List`, но результат всегда `Set`: +```kotlin +fun main() { + println(listOf(1, 1, 2) union listOf(2, 3)) // [1, 2, 3] + println(listOf(1, 1, 2).distinct()) // [1, 2] — а так останется List +} +``` + +### Три реализации Set +```kotlin +fun main() { + val linked = linkedSetOf(3, 1, 2) // порядок вставки + val hash = hashSetOf(3, 1, 2) // порядок не определён + val sorted = sortedSetOf(3, 1, 2) // отсортирован + + println(linked) // [3, 1, 2] + println(sorted) // [1, 2, 3] + println(hash.size) // 3 +} +``` + +| Реализация | Порядок | Поиск | Когда | +|---|---|---|---| +| `LinkedHashSet` (`setOf`) | вставки | O(1) | по умолчанию | +| `HashSet` (`hashSetOf`) | не определён | O(1) | экономия памяти, порядок не нужен | +| `TreeSet` (`sortedSetOf`) | сортировки | O(log n) | нужен отсортированный обход | + +Элементы `Set` обязаны корректно реализовывать `equals` и `hashCode` - иначе множество работает неправильно. Подробнее: [equals, hashCode и toString](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/equals,%20hashCode%20и%20toString.md) + +## Map +```kotlin +fun main() { + val map = mapOf("a" to 1, "b" to 2) + + println(map["a"]) // 1 + println(map["z"]) // null + println(map.getOrDefault("z", 0)) // 0 + println(map.getOrElse("z") { -1 }) // -1 + println(map.getValue("a")) // 1, бросает при отсутствии + println("a" in map) // true — проверка ключа + println(map.containsValue(2)) // true + println("${map.keys} ${map.values}") // [a, b] [1, 2] +} +``` + +`to` - инфиксная функция, создающая `Pair`: +```kotlin +fun main() { + val pair: Pair = "a" to 1 + println("${pair.first} ${pair.second}") // a 1 + + val (k, v) = pair // деструктуризация + println("$k=$v") // a=1 + + println(listOf("a" to 1, "b" to 2).toMap()) // {a=1, b=2} +} +``` + +Изменение через `MutableMap`: +```kotlin +fun main() { + val map = mutableMapOf("a" to 1) + + map["b"] = 2 + map.put("c", 3) + map.putAll(mapOf("d" to 4)) + map.remove("a") + println(map) // {b=2, c=3, d=4} + + println(map.getOrPut("e") { 5 }) // 5 — вычислит и положит + println(map.getOrPut("e") { 99 }) // 5 — уже есть, лямбда не вызвана + map.merge("b", 10) { old, new -> old + new } // метод JDK + println(map) // {b=12, c=3, d=4, e=5} +} +``` +`getOrPut` - основной инструмент кеширования: одна операция вместо проверки и вставки. + +Обход: +```kotlin +fun main() { + val map = mapOf("a" to 1, "b" to 2) + + for ((key, value) in map) print("$key=$value ") // a=1 b=2 + println() + map.forEach { (key, value) -> print("$key$value ") } // a1 b2 + println() + println(map.entries.first().key) // a +} +``` +Деструктуризация в `for` и в лямбде - главная причина, почему обход `Map` в Kotlin короче, чем в Java. + +Преобразования: +```kotlin +fun main() { + val map = mapOf("a" to 1, "b" to 2, "c" to 3) + + println(map.filter { it.value > 1 }) // {b=2, c=3} + println(map.filterKeys { it != "a" }) // {b=2, c=3} + println(map.mapValues { it.value * 10 }) // {a=10, b=20, c=30} + println(map.mapKeys { it.key.uppercase() }) // {A=1, B=2, C=3} + println(map.map { "${it.key}${it.value}" }) // [a1, b2, c3] — это List + println(map.toList()) // [(a, 1), (b, 2), (c, 3)] + println(map.entries.sortedByDescending { it.value }.first().key) // c +} +``` + +### Три реализации Map +| Реализация | Порядок | Доступ | Когда | +|---|---|---|---| +| `LinkedHashMap` (`mapOf`) | вставки | O(1) | по умолчанию | +| `HashMap` (`hashMapOf`) | не определён | O(1) | экономия памяти | +| `TreeMap` (`sortedMapOf`) | сортировки ключей | O(log n) | диапазонные запросы | + +## Выбор структуры +```mermaid +graph TD + A["Нужна коллекция"] --> B{"Пары ключ-значение?"} + B -->|да| M["Map"] + B -->|нет| C{"Дубликаты важны
или нужен индекс?"} + C -->|да| L["List"] + C -->|нет| D{"Частый поиск
элемента?"} + D -->|да| S["Set"] + D -->|нет| L +``` + +Практический критерий: если в коде есть `list.contains(x)` внутри цикла - это `Set`. Такая замена превращает O(n²) в O(n): +```kotlin +fun main() { + val ids = List(1000) { it } + val allowedList = List(1000) { it * 2 } + val allowedSet = allowedList.toSet() + + println(ids.count { it in allowedList }) // O(n*m) — 1000*1000 сравнений + println(ids.count { it in allowedSet }) // O(n) — то же 500, но быстро +} +``` + +## Специальные коллекции +```kotlin +fun main() { + val deque = ArrayDeque(listOf(2, 3)) + deque.addFirst(1) + deque.addLast(4) + println("$deque ${deque.removeFirst()} ${deque.removeLast()}") // [1, 2, 3, 4] 1 4 + + val grouped = listOf("яблоко", "арбуз", "банан").groupBy { it.first() } + println(grouped) // {я=[яблоко], а=[арбуз], б=[банан]} + + val assoc = listOf("a", "bb", "ccc").associateWith { it.length } + println(assoc) // {a=1, bb=2, ccc=3} +} +``` +`ArrayDeque` в Kotlin - своя реализация (не `java.util.ArrayDeque`), работает как стек и как очередь. Подробнее об `groupBy` и `associate`: [Операции](Операции.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `map.get(k)` возвращает `V` | `map[k]` возвращает `V?` | +> | `map.put(k, v)` | `map[k] = v` | +> | `new AbstractMap.SimpleEntry<>(k, v)` | `k to v` | +> | `for (Map.Entry e : map.entrySet())` | `for ((k, v) in map)` | +> | `map.computeIfAbsent(k, f)` | `map.getOrPut(k) { }` | +> | `Collections.unmodifiableList` | тип `List` | +> | `set.retainAll(other)` | `a intersect b` | +> | `list.get(i)` | `list[i]` | +> +> **`map[k]` возвращает nullable - и это правильно.** В Java `map.get(k)` имеет тип `V`, поэтому `int x = map.get("нет")` компилируется и даёт NPE при распаковке. Kotlin делает отсутствие ключа видимым в типе: +> ```kotlin +> fun main() { +> val map = mapOf("a" to 1) +> val x: Int = map["z"] ?: 0 // компилятор требует обработать null +> println(x) // 0 +> } +> ``` +> Обратная сторона: `map[k]!!` появляется в коде часто. Когда ключ гарантированно есть, честнее `getValue(k)` - он бросит `NoSuchElementException` с внятным сообщением. +> +> **Деструктуризация `Map.Entry`.** Java требует `e.getKey()`/`e.getValue()`; в Kotlin `for ((k, v) in map)` работает потому, что для `Map.Entry` объявлены функции-расширения `component1` и `component2`. Подробнее: [Деструктуризация](../Синтаксис%20и%20типы/Работа%20с%20данными/Деструктуризация.md) +> +> **Операции над множествами как инфиксные функции.** В Java `union` - это `addAll` на копии, `intersect` - `retainAll`, то есть операции мутирующие и требуют ручного копирования. В Kotlin `a union b` возвращает новое множество и читается как математическая запись. +> +> Сравнить: [HashMap в Java](../../Программирование%20на%20Java/Коллекции/HashMap.md) + +> [!warning] Подводные камни +> **`map[k]` не различает «нет ключа» и «значение null»:** +> ```kotlin +> fun main() { +> val map = mapOf("a" to null, "b" to 1) +> println(map["a"]) // null +> println(map["z"]) // null — то же самое! +> println("a" in map) // true +> println("z" in map) // false — вот так различать +> } +> ``` +> +> **`getValue` бросает, `get` - нет.** Это легко перепутать: +> ```kotlin +> fun main() { +> val map = mapOf("a" to 1) +> println(map["z"]) // null +> try { +> map.getValue("z") +> } catch (e: NoSuchElementException) { +> println("getValue бросает") // getValue бросает +> } +> } +> ``` +> +> **Изменяемый элемент в `Set` или ключ в `Map` ломает коллекцию:** +> ```kotlin +> data class Key(var id: Int) +> +> fun main() { +> val key = Key(1) +> val set = mutableSetOf(key) +> println(key in set) // true +> key.id = 2 // hashCode изменился! +> println(key in set) // false — элемент потерян +> println(set.size) // 1 — но он всё ещё внутри +> } +> ``` +> Ключи и элементы множеств обязаны быть неизменяемыми. `data class` с `var` в этой роли - ошибка. +> +> **`subList` - это вид на исходный список, а не копия:** +> ```kotlin +> fun main() { +> val list = mutableListOf(1, 2, 3, 4) +> val sub = list.subList(1, 3) +> list[1] = 99 +> println(sub) // [99, 3] — изменился +> list.add(5) +> try { +> println(sub) +> } catch (e: ConcurrentModificationException) { +> println("структурное изменение списка сломало subList") +> } +> } +> ``` +> Для независимой копии нужен `slice` или `toList()`. +> +> **`remove` на `MutableList` неоднозначен:** +> ```kotlin +> fun main() { +> val list = mutableListOf(10, 20, 30) +> list.removeAt(1) // по индексу +> println(list) // [10, 30] +> list.remove(10) // по значению +> println(list) // [30] +> } +> ``` +> В Kotlin, в отличие от Java, перегрузки по индексу у `remove` нет - только `removeAt`. Это как раз устраняет классическую Java-ловушку `list.remove(1)`. +> +> **`list.contains(x)` в цикле - это O(n²).** Самая частая проблема производительности с коллекциями. Если проверка повторяется, предварительно постройте `Set`. +> +> **`hashSetOf` и `hashMapOf` не сохраняют порядок** - вывод может отличаться между запусками и версиями JDK. Тест, завязанный на порядок, будет нестабильным. +> +> **`sortedSetOf` требует `Comparable` или компаратор** и бросит `ClassCastException` при вставке несравнимого элемента - в рантайме, а не при компиляции. +> +> **`+=` на `List` работает, но создаёт новую коллекцию:** +> ```kotlin +> fun main() { +> var list = listOf(1, 2) // var! +> list += 3 // новый список, не add +> println(list) // [1, 2, 3] +> +> val mutable = mutableListOf(1, 2) +> mutable += 3 // здесь настоящий add +> println(mutable) // [1, 2, 3] +> } +> ``` +> На `val list: List` оператор `+=` не скомпилируется. В цикле по большому списку `+=` на read-only коллекции даёт O(n²) копирований. + +Далее: [Изменяемость](Изменяемость.md) diff --git a/Kotlin/Коллекции/Sequence.md b/Kotlin/Коллекции/Sequence.md new file mode 100644 index 0000000..40e6396 --- /dev/null +++ b/Kotlin/Коллекции/Sequence.md @@ -0,0 +1,368 @@ +--- +title: Sequence +description: Ленивые вычисления, порядок обхода элементов и когда Sequence выигрывает у List +tags: + - kotlin + - collections +--- + +`Sequence` - ленивый аналог коллекции. Операции не выполняются до запроса результата, и элементы проходят всю цепочку по одному. + +```kotlin +fun main() { + val list = listOf(1, 2, 3, 4, 5) + + println("-- List:") + val eager = list + .map { println(" map $it"); it * 2 } + .filter { println(" filter $it"); it > 4 } + .first() + println(" результат $eager") + + println("-- Sequence:") + val lazy = list.asSequence() + .map { println(" map $it"); it * 2 } + .filter { println(" filter $it"); it > 4 } + .first() + println(" результат $lazy") +} +``` +`List` выполнит 5 `map` и 5 `filter` - всего 10 операций, создав два промежуточных списка. `Sequence` выполнит 3 `map` и 3 `filter` и остановится, как только найдёт первый подходящий элемент. + +## Два порядка обхода +Это ключ к пониманию `Sequence`: + +```mermaid +graph LR + subgraph List["List: по операциям"] + L1["map всех 5"] --> L2["filter всех 5"] --> L3["first"] + end + subgraph Seq["Sequence: по элементам"] + S1["1: map, filter"] --> S2["2: map, filter"] --> S3["3: map, filter → стоп"] + end +``` + +| | `List` | `Sequence` | +|---|---|---| +| порядок | операция за операцией | элемент за элементом | +| промежуточные коллекции | на каждом шаге | нет | +| вычисление | сразу | при терминальной операции | +| можно ли прервать | нет | да | +| бесконечные данные | нет | да | + +## Создание +```kotlin +fun main() { + println(listOf(1, 2, 3).asSequence().toList()) // [1, 2, 3] + println(sequenceOf(1, 2, 3).toList()) // [1, 2, 3] + println(emptySequence().toList()) // [] + + println(generateSequence(1) { it * 2 }.take(5).toList()) // [1, 2, 4, 8, 16] + println(generateSequence(1) { if (it < 5) it + 1 else null }.toList()) // [1, 2, 3, 4, 5] + + val built = sequence { + yield(1) + yieldAll(listOf(2, 3)) + yield(4) + } + println(built.toList()) // [1, 2, 3, 4] +} +``` +`generateSequence` с `null` в лямбде - способ задать конец. `sequence { }` - билдер с `yield`, внутри него можно писать любую логику, включая циклы. + +## Промежуточные и терминальные операции +```kotlin +fun main() { + val seq = sequenceOf(1, 2, 3) + + // промежуточные: возвращают Sequence, ничего не считают + val chain = seq.map { it * 2 }.filter { it > 2 }.take(2) + println("цепочка построена, вычислений не было") + + // терминальные: запускают вычисление + println(chain.toList()) // [4, 6] + println(seq.sum()) // 6 + println(seq.count()) // 3 + println(seq.first()) // 1 + println(seq.any { it > 2 }) // true +} +``` + +| Промежуточные | Терминальные | +|---|---| +| `map`, `filter`, `flatMap` | `toList`, `toSet`, `toMap` | +| `take`, `drop`, `distinct` | `first`, `last`, `single` | +| `sorted`, `chunked`, `windowed` | `sum`, `count`, `average`, `max` | +| `onEach`, `mapNotNull` | `any`, `all`, `none`, `forEach` | +| `zip`, `plus` | `fold`, `reduce`, `joinToString` | + +## Бесконечные последовательности +То, чего `List` не умеет в принципе: +```kotlin +fun main() { + val naturals = generateSequence(1) { it + 1 } + + println(naturals.take(5).toList()) // [1, 2, 3, 4, 5] + println(naturals.filter { it % 7 == 0 }.first()) // 7 + println(naturals.map { it * it }.takeWhile { it < 50 }.toList()) // [1, 4, 9, 16, 25, 36, 49] + + val fib = generateSequence(0 to 1) { (a, b) -> b to (a + b) }.map { it.first } + println(fib.take(10).toList()) // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] +} +``` +Важно: терминальная операция без ограничения на бесконечной последовательности зависнет. `naturals.toList()` не завершится никогда. + +## Sequence в цепочке с ранним выходом +```kotlin +data class Record3(val id: Int, val active: Boolean, val score: Int) + +fun main() { + val records = List(1_000_000) { Record3(it, it % 100 == 0, it % 7) } + + val found = records.asSequence() + .filter { it.active } + .map { it.score * 2 } + .firstOrNull { it > 10 } + println(found) // 12 + + val top3 = records.asSequence() + .filter { it.active } + .take(3) + .map { it.id } + .toList() + println(top3) // [0, 100, 200] +} +``` +Здесь `Sequence` обрабатывает несколько сотен элементов вместо миллиона. На `List` каждый `filter`/`map` прошёл бы весь миллион и создал два списка. + +## Когда Sequence не выигрывает +```kotlin +fun main() { + val small = listOf(1, 2, 3, 4, 5) + + // Sequence здесь только добавляет накладные расходы + println(small.map { it * 2 }.sum()) // 30 + println(small.asSequence().map { it * 2 }.sum()) // 30, но медленнее +} +``` +`Sequence` создаёт объект-обёртку на каждую операцию и вызывает лямбды через интерфейс итератора. На маленьких коллекциях и коротких цепочках это дороже, чем прямой проход. + +Практическое правило: + +| Условие | Выбор | +|---|---| +| меньше ~1000 элементов, 1-2 операции | `List` | +| много элементов и 3+ операций | `Sequence` | +| есть `first`, `find`, `take`, `any` после цепочки | `Sequence` | +| источник бесконечный или потоковый (файл, БД) | `Sequence` | +| нужен результат целиком, операций мало | `List` | +| нужен индексный доступ к результату | `List` | + +## Операции, которые ломают ленивость +Некоторые операции обязаны прочитать всё, прежде чем выдать первый элемент: +```kotlin +fun main() { + val seq = sequenceOf(3, 1, 2) + .onEach { println("читаю $it") } + .sorted() // нужны все элементы + println("до first ничего не прочитано") + println(seq.first()) // здесь прочитаются все три +} +``` +Такие операции - `sorted`, `sortedBy`, `distinct` (частично), `groupBy`, `chunked`. Ставьте их как можно позже в цепочке, а `filter` - как можно раньше. + +## Sequence одноразов +```kotlin +fun main() { + val seq = listOf(1, 2, 3).asSequence().map { it * 2 } + println(seq.toList()) // [2, 4, 6] + println(seq.toList()) // [2, 4, 6] — здесь повезло: источник List + + val once = sequence { yield(1); yield(2) } + println(once.toList()) // [1, 2] + println(once.toList()) // [1, 2] — тоже работает, лямбда вызвана заново + + val fromIterator = listOf(1, 2, 3).iterator().asSequence() + println(fromIterator.toList()) // [1, 2, 3] + try { + fromIterator.toList() + } catch (e: IllegalStateException) { + println(e.message) // This sequence can be consumed only once. + } +} +``` +Последовательность из итератора обходится один раз: `Iterator.asSequence()` оборачивает результат в `constrainOnce()`, который фиксирует это ограничение и бросает `IllegalStateException` при повторной попытке. Тот же механизм доступен явно: +```kotlin +fun main() { + val seq = sequenceOf(1, 2).constrainOnce() + println(seq.toList()) // [1, 2] + try { + seq.toList() + } catch (e: IllegalStateException) { + println("повторный обход запрещён") + } +} +``` + +## Чтение файла +Главный практический случай: данные, которые не влезают в память. +```kotlin +import java.io.File + +fun main() { + val tmp = File.createTempFile("demo", ".txt") + tmp.writeText((1..100).joinToString("\n")) + + val sum = tmp.useLines { lines -> // Sequence + lines.map { it.toInt() }.filter { it % 2 == 0 }.sum() + } + println(sum) // 2550 + + val firstBig = tmp.useLines { lines -> lines.first { it.toInt() > 50 } } + println(firstBig) // 51 — файл прочитан до 51-й строки, не целиком + + tmp.delete() +} +``` +`useLines` даёт `Sequence` и закрывает файл после работы. Альтернатива `readLines()` загрузила бы весь файл в `List`. + +## Sequence и Flow +`Sequence` - ленивый, но **синхронный**: получение следующего элемента блокирует поток. Для асинхронных источников нужен `Flow`: + +| | `Sequence` | `Flow` | +|---|---|---| +| вычисление | лениво | лениво | +| блокировка | да | нет, `suspend` | +| внутри можно `delay` | нет | да | +| источник | память, файл, генератор | сеть, БД, события | + +Подробнее: [Flow](../Корутины/Flow.md) + +> [!info] Отличия от Java +> | Java Stream | Kotlin Sequence | +> |---|---| +> | `list.stream()` | `list.asSequence()` | +> | `Stream.of(1, 2)` | `sequenceOf(1, 2)` | +> | `Stream.iterate(1, f)` | `generateSequence(1, f)` | +> | `collect(toList())` | `toList()` | +> | `.parallel()` | **нет аналога** | +> | `IntStream`, `LongStream` | нет, только `Sequence` | +> | `Files.lines(path)` | `file.useLines { }` | +> | нет генератора с `yield` | `sequence { yield(x) }` | +> +> **`Sequence` и `Stream` - почти одно и то же.** Ленивость, промежуточные и терминальные операции, короткое замыкание на `findFirst`/`first` - всё совпадает. Поэтому опыт работы со стримами переносится напрямую, и наоборот: если в Java вы написали бы `.stream()`, в Kotlin нужен `.asSequence()`. +> +> **Главное отличие - нет параллелизма.** `Stream.parallel()` распределяет работу по `ForkJoinPool`; у `Sequence` такого нет вообще. Причина в том, что для конкурентности в Kotlin предусмотрены корутины, а не параллельные стримы. Если нужна параллельная обработка - либо используйте Java-стримы напрямую (они доступны), либо корутины: +> ```kotlin +> fun main() { +> // Java Stream доступен из Kotlin +> val sum = listOf(1, 2, 3).stream().parallel().mapToInt { it * 2 }.sum() +> println(sum) // 12 +> } +> ``` +> +> **Нет специализаций для примитивов.** В Java `IntStream` избегает упаковки: `list.stream().mapToInt(x -> x).sum()` работает с `int`. `Sequence` упаковывает каждый элемент в `Integer`. На числовых расчётах по большим данным Java-стримы могут оказаться быстрее. +> +> **`sequence { yield(x) }` - генератор, которого в Java нет.** Он реализован на том же механизме приостановки, что и корутины, поэтому внутри можно писать обычные циклы и условия: +> ```kotlin +> fun main() { +> val primes = sequence { +> var n = 2 +> while (true) { +> if ((2 until n).none { n % it == 0 }) yield(n) +> n++ +> } +> } +> println(primes.take(5).toList()) // [2, 3, 5, 7, 11] +> } +> ``` +> В Java это требует либо отдельного потока, либо ручной реализации `Iterator` с сохранением состояния. +> +> Сравнить: [HashMap в Java](../../Программирование%20на%20Java/Коллекции/HashMap.md) + +> [!warning] Подводные камни +> **Без терминальной операции не произойдёт ничего:** +> ```kotlin +> fun main() { +> listOf(1, 2, 3).asSequence().map { println("это не выведется"); it } +> println("конец") // только "конец" +> } +> ``` +> Это частая ошибка при замене `forEach` на цепочку: если забыть терминальную операцию, код просто не выполнится, и компилятор не предупредит. +> +> **`Sequence` на маленьких коллекциях медленнее.** Обёртки и вызовы через итератор стоят дороже прямого цикла. Для списка из 10 элементов `asSequence()` - пессимизация. +> +> **Терминальная операция на бесконечной последовательности зависает:** +> ```kotlin +> fun main() { +> val naturals = generateSequence(1) { it + 1 } +> println(naturals.take(3).toList()) // [1, 2, 3] — ок +> // naturals.toList() // зависнет навсегда +> // naturals.count() // тоже +> // naturals.filter { it > 10 }.toList() // и это +> } +> ``` +> Любая цепочка из бесконечного источника обязана содержать `take`, `takeWhile`, `first` или `find`. +> +> **`sorted` и `groupBy` убивают ленивость** - они читают всё до первого результата. Цепочка `asSequence().sorted().first()` не быстрее, чем на `List`. +> +> **Последовательность из итератора обходится один раз** - второй вызов падает: +> ```kotlin +> fun main() { +> val seq = listOf(1, 2).iterator().asSequence() +> println(seq.count()) // 2 +> try { +> seq.count() +> } catch (e: IllegalStateException) { +> println("повторный обход: ${e.message}") +> } +> } +> ``` +> Исключение здесь лучше молчаливого пустого результата, но возникает оно в рантайме и только на втором обходе - в редкой ветке кода его легко не заметить. Если последовательность может понадобиться дважды, сохраните `toList()`. +> +> **Побочные эффекты в промежуточных операциях выполняются в неожиданном порядке:** +> ```kotlin +> fun main() { +> val log = mutableListOf() +> sequenceOf(1, 2) +> .onEach { log.add("A$it") } +> .onEach { log.add("B$it") } +> .toList() +> println(log) // [A1, B1, A2, B2] — не [A1, A2, B1, B2] +> } +> ``` +> На `List` вывод был бы `[A1, A2, B1, B2]`. Код, зависящий от порядка побочных эффектов, при переходе на `Sequence` сломается. +> +> **Повторный обход пересчитывает всё заново:** +> ```kotlin +> fun main() { +> var calls = 0 +> val seq = listOf(1, 2, 3).asSequence().map { calls++; it * 2 } +> seq.toList() +> seq.toList() +> println(calls) // 6 — лямбда вызвана дважды для каждого элемента +> } +> ``` +> Если результат нужен несколько раз, сохраните его в `List`. +> +> **`Sequence` синхронна.** Внутри её лямбд нельзя вызывать `suspend`-функции - для этого существует `Flow`. Попытка использовать `Sequence` для сетевых запросов приведёт к блокировке потока. +> +> **`useLines` закрывает файл по выходу из лямбды.** Вернуть `Sequence` наружу нельзя - она окажется на закрытом ресурсе: +> ```kotlin +> import java.io.File +> +> fun main() { +> val tmp = File.createTempFile("leak", ".txt") +> tmp.writeText("1\n2") +> val leaked = tmp.useLines { it } // Sequence на закрытом файле +> try { +> println(leaked.toList()) +> } catch (e: java.io.IOException) { +> println("поток закрыт: ${e.message}") // поток закрыт: Stream closed +> } +> tmp.delete() +> } +> ``` +> Внутри лямбды нужно получить готовое значение: `useLines { it.toList() }` или сразу агрегат. + +Далее: [Коллекции под капотом](Коллекции%20под%20капотом.md) diff --git a/Kotlin/Коллекции/Изменяемость.md b/Kotlin/Коллекции/Изменяемость.md new file mode 100644 index 0000000..6e761bd --- /dev/null +++ b/Kotlin/Коллекции/Изменяемость.md @@ -0,0 +1,323 @@ +--- +title: Изменяемость +description: Read-only против immutable, защитное копирование и персистентные коллекции +tags: + - kotlin + - collections +--- + +`List` в Kotlin - **read-only**, а не immutable. Это разные вещи, и их смешение - источник самых неприятных ошибок с коллекциями. + +| | read-only (`List`) | immutable | +|---|---|---| +| изменить через эту ссылку | нельзя | нельзя | +| может измениться вообще | **да** | нет | +| гарантия | интерфейс без мутаторов | сам объект неизменяем | +| есть в Kotlin stdlib | да | нет (только внешняя библиотека) | + +## Почему read-only не immutable +```kotlin +fun main() { + val mutable = mutableListOf(1, 2) + val readOnly: List = mutable // тот же объект, другой интерфейс + + mutable.add(3) + println(readOnly) // [1, 2, 3] — изменился под вами +} +``` +`MutableList` наследует `List`, поэтому любая изменяемая коллекция подходит там, где ожидается `List`. Приведение типа объект не копирует. + +Это создаёт конкретную проблему в API: +```kotlin +class Repository4 { + private val items = mutableListOf(1, 2, 3) + + fun getItems(): List = items // утечка: вернули ссылку на изменяемое + fun add(x: Int) { items.add(x) } +} + +fun main() { + val repo = Repository4() + val snapshot = repo.getItems() + println(snapshot) // [1, 2, 3] + repo.add(4) + println(snapshot) // [1, 2, 3, 4] — «снимок» изменился +} +``` +Клиент получил `List` и вправе считать, что значение стабильно - например, положить его в `Map` как ключ или обойти в другом потоке. Ни то ни другое здесь не безопасно. + +## Защитное копирование +```kotlin +class Repository5 { + private val items = mutableListOf(1, 2, 3) + + fun getItems(): List = items.toList() // копия + fun add(x: Int) { items.add(x) } +} + +fun main() { + val repo = Repository5() + val snapshot = repo.getItems() + repo.add(4) + println("$snapshot / ${repo.getItems()}") // [1, 2, 3] / [1, 2, 3, 4] +} +``` +`toList()` создаёт новый `ArrayList`. Цена - O(n) и лишний объект; выгода - значение, на которое можно полагаться. + +Варианты копирования: +```kotlin +fun main() { + val source = mutableListOf(1, 2, 3) + + println(source.toList()::class.simpleName) // ArrayList + println(source.toMutableList()::class.simpleName) // ArrayList + println(source.toSet()::class.simpleName) // LinkedHashSet + println(ArrayList(source)::class.simpleName) // ArrayList + println(List(source.size) { source[it] }) // [1, 2, 3] +} +``` + +Когда копировать: +- коллекция уходит за границу класса или модуля и должна быть стабильной +- коллекция попадает в `Map`/`Set` как ключ или элемент +- значение читается из другого потока + +Когда не копировать: +- коллекция создана тут же и больше нигде не хранится +- вызывающий - код того же класса +- коллекция огромная, а изменения точно не будет (документируйте это) + +## Неизменяемость на уровне содержимого +Даже честная копия списка не защищает от изменения **элементов**: +```kotlin +class Item(var name: String) + +fun main() { + val items = listOf(Item("a"), Item("b")) + val copy = items.toList() + + items[0].name = "изменено" + println(copy[0].name) // изменено — копия поверхностная +} +``` +`toList()` копирует только ссылки. Настоящая защита - неизменяемые элементы: +```kotlin +data class ItemRo(val name: String) + +fun main() { + val items = listOf(ItemRo("a")) + // items[0].name = "x" // ошибка: val + val changed = items.map { it.copy(name = "изменено") } + println("${items[0].name} / ${changed[0].name}") // a / изменено +} +``` +Это общее правило: неизменяемость полезна, когда она рекурсивна. `List` с изменяемым `T` даёт лишь половину гарантии. + +## val и изменяемость +Два независимых понятия, которые постоянно путают: +```kotlin +fun main() { + val a = mutableListOf(1) // ссылку сменить нельзя, содержимое — можно + a.add(2) + println(a) // [1, 2] + // a = mutableListOf(3) // ошибка + + var b = listOf(1) // ссылку сменить можно, содержимое — нельзя + b = listOf(1, 2) + println(b) // [1, 2] + // b.add(3) // ошибка +} +``` + +| | `val` | `var` | +|---|---|---| +| `List` | ничего не изменить | можно заменить целиком | +| `MutableList` | можно менять содержимое | и то и другое | + +Самый частый правильный выбор - `val` + `List`. Подробнее: [Переменные](../Синтаксис%20и%20типы/Базовый%20синтаксис/Переменные.md) + +## Функциональное обновление +Вместо изменения на месте - новое значение: +```kotlin +fun main() { + val original = listOf(1, 2, 3) + + println(original + 4) // [1, 2, 3, 4] + println(original - 2) // [1, 3] + println(original.map { it * 10 }) // [10, 20, 30] + println(original.filter { it != 2 }) // [1, 3] + println(original.toMutableList().apply { this[0] = 99 }) // [99, 2, 3] + println(original) // [1, 2, 3] — не тронут +} +``` +Для `Map` то же: +```kotlin +fun main() { + val config = mapOf("host" to "localhost", "port" to "8080") + + println(config + ("port" to "9090")) // {host=localhost, port=9090} + println(config - "port") // {host=localhost} + println(config + mapOf("tls" to "on")) // {host=localhost, port=8080, tls=on} +} +``` +Цена - копирование на каждую операцию. Для небольших коллекций и нечастых обновлений это правильный выбор по умолчанию: такой код проще отлаживать, потому что значение не меняется за спиной. + +## Изменяемое состояние внутри, read-only снаружи +Рабочий шаблон для класса с коллекцией: +```kotlin +class Playlist { + private val _tracks = mutableListOf() + val tracks: List get() = _tracks.toList() // копия при чтении + + fun add(track: String) { _tracks.add(track) } + fun size() = _tracks.size +} + +fun main() { + val p = Playlist() + p.add("песня 1") + val snapshot = p.tracks + p.add("песня 2") + println("$snapshot / ${p.tracks}") // [песня 1] / [песня 1, песня 2] +} +``` +Если копия на каждое чтение слишком дорога, альтернатива - возвращать `_tracks` напрямую и документировать, что значение живое. Тогда тип должен быть осмысленным: `List` здесь обманывает читателя, и честнее отдавать `Sequence` или явный метод `snapshot()`. + +## Неизменяемость и многопоточность +```kotlin +fun main() { + val shared = mutableListOf(1, 2, 3) + + // Небезопасно: обход в одном потоке + add в другом = ConcurrentModificationException + // Безопасно: работать с копией + val safe = shared.toList() + println(safe.sum()) // 6 + + val sync = java.util.Collections.synchronizedList(mutableListOf(1, 2)) + println(sync.size) // 2 — но обход всё равно нужно синхронизировать вручную + val concurrent = java.util.concurrent.CopyOnWriteArrayList(listOf(1, 2)) + println(concurrent.size) // 2 +} +``` +Неизменяемое значение потокобезопасно по определению - именно поэтому в конкурентном коде предпочитают read-only копии, а не блокировки. Подробнее: [Разделяемое состояние](../Корутины/Разделяемое%20состояние.md) + +## Персистентные коллекции +В stdlib настоящих immutable-коллекций нет. Их даёт библиотека `kotlinx.collections.immutable`: +```kotlin +// Требует зависимости org.jetbrains.kotlinx:kotlinx-collections-immutable. +// val list: PersistentList = persistentListOf(1, 2, 3) +// val updated = list.add(4) // новый список, старый цел +// println("$list / $updated") // [1, 2, 3] / [1, 2, 3, 4] +// +// Тип ImmutableList гарантирует, что коллекция не изменится вообще, +// а PersistentList добавляет эффективное «обновление» без полного копирования. +``` +Отличие от `toList()`: персистентная коллекция при добавлении переиспользует внутреннюю структуру, поэтому `add` стоит O(log n), а не O(n). Это имеет смысл, когда обновления частые, а коллекция большая. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Collections.unmodifiableList(x)` | тип `List` | +> | `List.copyOf(x)` (Java 10+) | `x.toList()` | +> | `List.of(1, 2)` - immutable | `listOf(1, 2)` - read-only | +> | нарушение видно в рантайме | видно при компиляции | +> | `final List` | `val list` | +> | Guava `ImmutableList` | `kotlinx.collections.immutable` | +> +> **`unmodifiableList` и `List` решают одну задачу по-разному.** Java-обёртка - объект-декоратор: тип остаётся `List`, метод `add` есть, но бросает исключение в рантайме. Kotlin убирает метод из интерфейса, поэтому ошибка видна компилятору и обёртка не нужна: +> ```java +> List ro = Collections.unmodifiableList(src); +> ro.add(1); // компилируется, падает в рантайме +> ``` +> Но у Java-обёртки есть преимущество, которого нет у Kotlin: она действительно запрещает изменение через **эту** ссылку даже при касте. Kotlin-ский `List` каст к `MutableList` может пережить, если под ним `ArrayList`. +> +> **`List.of` в Java сильнее, чем `listOf` в Kotlin.** `List.of(1, 2)` создаёт по-настоящему неизменяемую коллекцию: любая попытка изменения, в том числе из другого места, бросит исключение. `listOf(1, 2)` даёт неизменяемую реализацию только случайно - это деталь, на которую нельзя полагаться, потому что `listOf` на самом деле описывает интерфейс, а не гарантию объекта. +> +> **Защитное копирование нужно в обоих языках.** `private final List` в Java и `private val` в Kotlin одинаково не защищают содержимое. Разница в том, что в Kotlin ошибку легче не заметить: тип `List` в сигнатуре выглядит как обещание неизменяемости, которого он не даёт. +> +> Сравнить: [Инкапсуляция в Java](../../Программирование%20на%20Java/ООП/Инкапсуляция/Инкапсуляция.md) + +> [!warning] Подводные камни +> **Возврат `MutableList` под типом `List` - утечка:** +> ```kotlin +> class Leaky2 { +> private val items = mutableListOf(1, 2) +> fun items(): List = items +> } +> +> fun main() { +> val l = Leaky2() +> val view = l.items() +> @Suppress("UNCHECKED_CAST") +> (view as MutableList).add(3) // клиент изменил приватное состояние +> println(l.items()) // [1, 2, 3] +> } +> ``` +> Каст сработал, потому что под ссылкой настоящий `ArrayList`. Защита - `toList()`. +> +> **`toList()` копирует поверхностно** - элементы остаются общими. Для вложенных структур нужна глубокая копия: +> ```kotlin +> fun main() { +> val nested = listOf(mutableListOf(1), mutableListOf(2)) +> val shallow = nested.toList() +> nested[0].add(99) +> println(shallow[0]) // [1, 99] — внутренний список общий +> +> val deep = nested.map { it.toList() } +> nested[0].add(100) +> println(deep[0]) // [1, 99] — а этот цел +> } +> ``` +> +> **`val` не делает коллекцию неизменяемой** - только ссылку. `val items = mutableListOf()` изменяется свободно. +> +> **Из Java read-only коллекция изменяема:** +> ```kotlin +> fun provide(): List = mutableListOf(1, 2) +> +> fun main() { println(provide()) } // [1, 2] +> ``` +> Java-код получит `java.util.List` и вызовет `add` без ошибок компиляции. Для API, доступного из Java, копируйте или используйте `Collections.unmodifiableList`. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) +> +> **Изменяемый объект как ключ `Map` теряется:** +> ```kotlin +> data class MutableKey(var id: Int) +> +> fun main() { +> val key = MutableKey(1) +> val map = mutableMapOf(key to "значение") +> key.id = 2 +> println(map[key]) // null — хеш изменился +> println(map.size) // 1 — запись на месте, но недостижима +> } +> ``` +> +> **`+=` на `var list: List` в цикле - это O(n²):** +> ```kotlin +> fun main() { +> var slow = listOf() +> repeat(1000) { slow += it } // 1000 копирований списка +> println(slow.size) // 1000 +> +> val fast = buildList { repeat(1000) { add(it) } } +> println(fast.size) // 1000, одна коллекция +> } +> ``` +> +> **`ConcurrentModificationException` при изменении во время обхода:** +> ```kotlin +> fun main() { +> val list = mutableListOf(1, 2, 3) +> try { +> for (x in list) if (x == 2) list.remove(x) +> } catch (e: ConcurrentModificationException) { +> println("нельзя изменять во время обхода") +> } +> list.removeAll { it == 2 } // так правильно +> println(list) // [1, 3] +> } +> ``` +> +> **Обёртка `synchronizedList` не делает обход потокобезопасным** - атомарны только отдельные методы. Итерацию нужно оборачивать в `synchronized` вручную. + +Далее: [Операции](Операции.md) diff --git a/Kotlin/Коллекции/Коллекции под капотом.md b/Kotlin/Коллекции/Коллекции под капотом.md new file mode 100644 index 0000000..0d5f1cd --- /dev/null +++ b/Kotlin/Коллекции/Коллекции под капотом.md @@ -0,0 +1,282 @@ +--- +title: Коллекции под капотом +description: Что лежит в байт-коде, сложность операций, расход памяти и упаковка примитивов +tags: + - kotlin + - collections + - performance +--- + +Отдельных классов коллекций у Kotlin нет. `List`, `MutableList`, `Map` - это интерфейсы, которые при компиляции отображаются в типы `java.util`. + +```kotlin +fun main() { + println(listOf(1, 2, 3).javaClass.name) // java.util.Arrays$ArrayList + println(mutableListOf(1, 2).javaClass.name) // java.util.ArrayList + println(setOf(1, 2).javaClass.name) // java.util.LinkedHashSet + println(mapOf("a" to 1, "b" to 2).javaClass.name) // java.util.LinkedHashMap + println(emptyList().javaClass.name) // kotlin.collections.EmptyList + println(listOf(1).javaClass.name) // java.util.Collections$SingletonList + println(mapOf("a" to 1).javaClass.name) // java.util.Collections$SingletonMap +} +``` + +Обратите внимание: `listOf` возвращает **три разные** реализации в зависимости от числа аргументов, и `mapOf` ведёт себя так же. Это оптимизация под частые случаи - пустая коллекция и коллекция из одного элемента не требуют ни массива, ни хеш-таблицы. + +## Соответствие типов +| Kotlin | JVM | Примечание | +|---|---|---| +| `List` | `java.util.List` | тот же интерфейс | +| `MutableList` | `java.util.List` | **тот же**, различия только в компиляторе | +| `Map` | `java.util.Map` | | +| `Array` | `T[]` | настоящий массив | +| `IntArray` | `int[]` | без упаковки | +| `Array` | `Integer[]` | с упаковкой | + +`List` и `MutableList` - это один и тот же `java.util.List` в байт-коде. Разделение существует только при компиляции Kotlin, что объясняет две вещи: из Java read-only коллекция изменяема, и каст `List` в `MutableList` компилируется. + +```kotlin +fun main() { + val ro: List = mutableListOf(1, 2) + println(ro is MutableList<*>) // true — в рантайме это один тип +} +``` + +## Сложность операций +| Операция | `ArrayList` | `LinkedHashSet` | `LinkedHashMap` | `TreeMap` | +|---|---|---|---|---| +| доступ по индексу | O(1) | - | - | - | +| поиск элемента | O(n) | O(1) | O(1) по ключу | O(log n) | +| вставка в конец | O(1)* | O(1) | O(1) | O(log n) | +| вставка в начало | O(n) | O(1) | O(1) | O(log n) | +| удаление по индексу | O(n) | - | - | - | +| удаление по значению | O(n) | O(1) | O(1) | O(log n) | + +\* амортизированная: при переполнении массив копируется в новый, вдвое больший. + +Практические следствия: +```kotlin +fun main() { + val list = List(10_000) { it } + val set = list.toSet() + + // O(n) против O(1) + println(9999 in list) // true, но обошёл весь список + println(9999 in set) // true, один хеш + + // вставка в начало ArrayList — сдвиг всего массива + val deque = ArrayDeque(list) + deque.addFirst(-1) // O(1) + println(deque.first()) // -1 +} +``` + +## Упаковка примитивов +Главный источник неожиданных расходов памяти: +```kotlin +fun main() { + val boxed: List = List(5) { it } // каждый элемент — объект Integer + val primitive: IntArray = IntArray(5) { it } // настоящий int[] + + println(boxed.sum()) // 10 + println(primitive.sum()) // 10 + println(primitive.javaClass.name) // [I — массив int +} +``` + +Разница в памяти на 1 млн элементов: + +| Структура | Примерный размер | +|---|---| +| `IntArray(1_000_000)` | ~4 МБ | +| `Array(1_000_000)` | ~20 МБ (ссылки + объекты) | +| `List` из 1 млн | ~20 МБ | + +Кеш `Integer` смягчает это для малых чисел: +```kotlin +fun main() { + val a: Int? = 127 + val b: Int? = 127 + println(a === b) // true — из кеша -128..127 + + val c: Int? = 128 + val d: Int? = 128 + println(c === d) // false — разные объекты + println(c == d) // true — значения равны +} +``` +Это ровно та же ловушка, что с `Integer` в Java. Правило: для чисел всегда `==`, никогда `===`. Подробнее: [Числа и переполнение](../Синтаксис%20и%20типы/Работа%20с%20данными/Числа%20и%20переполнение.md) + +Специализированные массивы без упаковки: +```kotlin +fun main() { + println(intArrayOf(1, 2, 3).sum()) // 6 + println(doubleArrayOf(1.0, 2.0).average()) // 1.5 + println(booleanArrayOf(true, false).count { it }) // 1 + println(longArrayOf(1L).size) // 1 +} +``` +Их стоит использовать, когда элементов много и они числовые: матрицы, буферы, обработка сигналов. В обычном бизнес-коде разница несущественна. + +## Рост ArrayList +```kotlin +fun main() { + val withoutCapacity = ArrayList() // начальная ёмкость 10 + repeat(1000) { withoutCapacity.add(it) } // ~8 перевыделений массива + + val withCapacity = ArrayList(1000) // сразу нужный размер + repeat(1000) { withCapacity.add(it) } // перевыделений нет + + println("${withoutCapacity.size} ${withCapacity.size}") // 1000 1000 +} +``` +При каждом переполнении создаётся массив в 1.5 раза больше и копируется содержимое. Для заранее известного размера задавайте ёмкость - это убирает лог n копирований. + +## Что делает цепочка операций +```kotlin +fun main() { + val source = List(1000) { it } + + val result = source + .map { it * 2 } // новый ArrayList на 1000 + .filter { it > 100 } // новый ArrayList на ~950 + .take(5) // новый ArrayList на 5 + println(result) // [102, 104, 106, 108, 110] +} +``` +Три промежуточных списка, 1000 + 950 операций ради 5 результатов. Та же цепочка на `Sequence` не создаст ни одного промежуточного списка и выполнит ~56 операций. Подробнее: [Sequence](Sequence.md) + +Измерить разницу можно так: +```kotlin +fun main() { + val source = List(1_000_000) { it } + + val t1 = System.nanoTime() + val a = source.map { it * 2 }.filter { it > 100 }.first() + val eager = (System.nanoTime() - t1) / 1_000_000 + + val t2 = System.nanoTime() + val b = source.asSequence().map { it * 2 }.filter { it > 100 }.first() + val lazy = (System.nanoTime() - t2) / 1_000_000 + + println("$a == $b") // 102 == 102 + println("list ${eager}мс, sequence ${lazy}мс — ленивый быстрее: ${lazy < eager}") +} +``` + +## Inline и отсутствие объектов-лямбд +Операции над коллекциями объявлены `inline`, поэтому лямбда не создаёт объект: +```kotlin +fun main() { + val sum = listOf(1, 2, 3).filter { it > 1 }.sumOf { it * 2 } + println(sum) // 10 +} +``` +Компилятор подставляет тело лямбды прямо в цикл - в байт-коде нет ни объекта `Function1`, ни виртуального вызова. Это то, чего не даёт Java-стрим: там каждая лямбда - объект, а цепочка строит конвейер из `Spliterator`-ов. + +Исключение - `Sequence`: там операции не могут быть полностью inline, потому что элементы обрабатываются лениво через итератор. Это и есть источник накладных расходов `Sequence` на малых данных. Подробнее: [Inline-функции](../Функциональный%20Kotlin/Inline-функции.md) + +## Как выбирают структуру +```kotlin +fun main() { + // частый поиск -> Set + val allowed = setOf("a", "b", "c") + println(listOf("a", "x").count { it in allowed }) // 1 + + // частое добавление в начало и конец -> ArrayDeque + val queue = ArrayDeque() + queue.addLast(1); queue.addFirst(0) + println(queue) // [0, 1] + + // нужен отсортированный обход и диапазоны -> TreeMap + val sorted = sortedMapOf(3 to "c", 1 to "a", 2 to "b") + println(sorted.headMap(3)) // {1=a, 2=b} + + // много числовых данных -> примитивный массив + val buffer = IntArray(1000) + println(buffer.size) // 1000 +} +``` + +## Потокобезопасность +```kotlin +import java.util.concurrent.ConcurrentHashMap + +fun main() { + val map = ConcurrentHashMap() + map["a"] = 1 + map.compute("a") { _, v -> (v ?: 0) + 1 } + println(map) // {a=2} + + val sync = java.util.Collections.synchronizedMap(mutableMapOf("a" to 1)) + println(sync.size) // 1 +} +``` +Обычные коллекции Kotlin не потокобезопасны - это те же `ArrayList` и `HashMap`. Варианты: +- read-only копия на каждый поток (`toList()`) - проще всего +- `ConcurrentHashMap` и `CopyOnWriteArrayList` из JDK +- корутины с ограничением доступа одним потоком или `Mutex` + +Подробнее: [Разделяемое состояние](../Корутины/Разделяемое%20состояние.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `ArrayList` явно | `mutableListOf()` -> `ArrayList` | +> | `new HashMap<>()` | `mutableMapOf()` -> `LinkedHashMap` | +> | `IntStream` без упаковки | `IntArray` или Java-стрим | +> | лямбда - объект `Function` | лямбда инлайнится | +> | `Stream` ленив | `Iterable`-операции энергичны | +> | `new ArrayList<>(100)` | `ArrayList(100)` | +> +> **Коллекции те же самые - это не «Kotlin-коллекции».** Любая `List` из Kotlin принимается Java-методом, принимающим `java.util.List`, без конвертации и накладных расходов. Это принципиальное отличие от языков со своей библиотекой коллекций (например, Scala), где на границе нужны обёртки. +> +> **Умолчание `LinkedHashMap` вместо `HashMap`.** `mutableMapOf()` создаёт `LinkedHashMap`, который на ~20% тяжелее по памяти (хранит ссылки на соседей), но даёт предсказуемый порядок обхода. Если порядок не нужен и записей миллионы, `hashMapOf()` экономнее. +> +> **Инлайн лямбд - преимущество перед стримами.** В Java `list.stream().filter(x -> x > 1)` создаёт объект лямбды и конвейер стрима. В Kotlin `list.filter { it > 1 }` компилируется в обычный цикл `for` с телом лямбды внутри - объектов не создаётся вовсе. Для коротких цепочек на небольших коллекциях Kotlin обычно быстрее Java-стримов именно поэтому. +> +> **Нет `IntStream`.** Java даёт специализации для `int`, `long`, `double`, избегающие упаковки в цепочках. В Kotlin эквивалента нет: `Sequence` упаковывает. Для числовых расчётов на больших объёмах используйте `IntArray` с обычным циклом или вызывайте Java-стримы. +> +> Сравнить: [HashMap в Java](../../Программирование%20на%20Java/Коллекции/HashMap.md), [JIT](../../Программирование%20на%20Java/JVM/JIT.md) + +> [!warning] Подводные камни +> **`listOf` возвращает разные классы** - полагаться на конкретный тип нельзя: +> ```kotlin +> fun main() { +> println(listOf().javaClass.simpleName) // EmptyList +> println(listOf(1).javaClass.simpleName) // SingletonList +> println(listOf(1, 2).javaClass.simpleName) // ArrayList (Arrays$ArrayList) +> } +> ``` +> Ни один из них не поддерживает `add`, но по разным причинам, и сообщения об ошибках будут разными. +> +> **`Arrays$ArrayList` - не `java.util.ArrayList`.** Это обёртка над массивом с фиксированным размером: `set` работает, `add` бросает `UnsupportedOperationException`. +> +> **`List` упаковывает каждое число.** Для миллиона элементов это ~20 МБ вместо 4 МБ и нагрузка на сборщик мусора. В горячем численном коде используйте `IntArray`. +> +> **`===` на числах даёт разные результаты в зависимости от значения** - из-за кеша `Integer` в диапазоне -128..127. Сравнивайте только через `==`. +> +> **`list.contains` в цикле - O(n²).** Самая частая проблема производительности: +> ```kotlin +> fun main() { +> val a = List(2000) { it } +> val b = List(2000) { it * 2 } +> println(a.count { it in b }) // 4 млн сравнений +> val bSet = b.toSet() +> println(a.count { it in bSet }) // 2000 операций, тот же результат +> } +> ``` +> +> **Каждая операция создаёт коллекцию.** Цепочка из пяти операций по большому списку - пять списков в памяти одновременно. Для больших данных нужен `Sequence`. +> +> **`ArrayList` без начальной ёмкости перевыделяет массив.** При известном размере задавайте его: `ArrayList(n)` или `buildList(n) { }`. +> +> **`subList` держит ссылку на исходный список** - он не будет собран сборщиком мусора, даже если нужен только маленький срез большого списка. Для среза, который переживёт оригинал, используйте `slice` или `toList`. +> +> **Коллекции Kotlin не потокобезопасны.** `mutableListOf()` - обычный `ArrayList`. Одновременное изменение из двух потоков повреждает структуру или бросает `ConcurrentModificationException`. +> +> **Изменяемый ключ ломает `HashMap`.** Запись становится недостижимой, хотя `size` её считает. Ключи должны быть неизменяемыми - `data class` с `val`-полями. Подробнее: [equals, hashCode и toString](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/equals,%20hashCode%20и%20toString.md) +> +> **Измерения производительности на JVM без прогрева врут.** Пример с `System.nanoTime` выше показывает порядок величины, но JIT компилирует горячий код только после тысяч вызовов. Для настоящих измерений нужен JMH. + +Далее: [Параметрический полиморфизм](../Дженерики/Параметрический%20полиморфизм.md) diff --git a/Kotlin/Коллекции/Коллекции.md b/Kotlin/Коллекции/Коллекции.md new file mode 100644 index 0000000..3c324bf --- /dev/null +++ b/Kotlin/Коллекции/Коллекции.md @@ -0,0 +1,234 @@ +--- +title: Коллекции +description: Обзор иерархии коллекций Kotlin, разделение на читаемые и изменяемые интерфейсы +tags: + - kotlin + - collections +--- + +Коллекции Kotlin - это **те же** классы JDK (`ArrayList`, `HashMap`, `LinkedHashSet`), но с другими интерфейсами поверх них. Главное отличие от Java: изменяемость выражена в системе типов. + +```kotlin +fun main() { + val readOnly: List = listOf(1, 2, 3) + val mutable: MutableList = mutableListOf(1, 2, 3) + + mutable.add(4) + // readOnly.add(4) // ошибка: у List нет метода add + + println("$readOnly / $mutable") // [1, 2, 3] / [1, 2, 3, 4] + println(readOnly.javaClass.name) // java.util.Arrays$ArrayList + println(mutable.javaClass.name) // java.util.ArrayList +} +``` + +## Иерархия +```mermaid +graph TD + I["Iterable<out T>"] --> C["Collection<out T>"] + C --> L["List<out T>"] + C --> S["Set<out T>"] + I --> MI["MutableIterable<T>"] + MI --> MC["MutableCollection<T>"] + C --> MC + MC --> ML["MutableList<T>"] + L --> ML + MC --> MS["MutableSet<T>"] + S --> MS + M["Map<K, out V>"] --> MM["MutableMap<K, V>"] +``` + +Два важных факта из этой схемы: +- `MutableList` **наследует** `List`, то есть изменяемую коллекцию можно передать туда, где ожидается читаемая. Обратное невозможно. +- `Map` не входит в `Collection` - это отдельная иерархия, как и в Java. + +| Интерфейс | Чтение | Изменение | Реализация по умолчанию | +|---|---|---|---| +| `List` | да | нет | `ArrayList` под капотом | +| `MutableList` | да | да | `ArrayList` | +| `Set` | да | нет | `LinkedHashSet` | +| `MutableSet` | да | да | `LinkedHashSet` | +| `Map` | да | нет | `LinkedHashMap` | +| `MutableMap` | да | да | `LinkedHashMap` | + +Обратите внимание: умолчание для `Set` и `Map` - **`Linked`**-версии, то есть порядок вставки сохраняется. В Java `new HashSet<>()` порядок не гарантирует. + +## Создание +```kotlin +fun main() { + val a = listOf(1, 2, 3) // read-only + val b = mutableListOf(1, 2, 3) // изменяемый + val c = emptyList() // пустой + val d = listOfNotNull(1, null, 3) // null отфильтрованы + val e = List(3) { it * it } // по индексу + + println("$a $b $c $d $e") // [1, 2, 3] [1, 2, 3] [] [1, 3] [0, 1, 4] + + val s = setOf(1, 2, 2, 3) // дубликаты убраны + val m = mapOf("a" to 1, "b" to 2) + println("$s $m") // [1, 2, 3] {a=1, b=2} +} +``` + +Специализированные варианты для разных реализаций: +```kotlin +fun main() { + val sorted = sortedSetOf(3, 1, 2) // TreeSet + val sortedMap = sortedMapOf("b" to 2, "a" to 1) + val linked = linkedSetOf(3, 1, 2) // LinkedHashSet + val hash = hashSetOf(3, 1, 2) // HashSet, порядок не гарантирован + val arrayDeque = ArrayDeque(listOf(1, 2, 3)) + + println("$sorted $sortedMap $linked") // [1, 2, 3] {a=1, b=2} [3, 1, 2] + println(hash.size) // 3 + arrayDeque.addFirst(0) + println(arrayDeque) // [0, 1, 2, 3] +} +``` + +## Билдеры +Когда коллекцию нужно собрать по условию, а результат отдать как read-only: +```kotlin +fun main() { + val list = buildList { + add(1) + if (true) add(2) + addAll(listOf(3, 4)) + } + println(list) // [1, 2, 3, 4] + + val map = buildMap { + put("a", 1) + putAll(mapOf("b" to 2)) + } + println(map) // {a=1, b=2} + + val set = buildSet { repeat(3) { add(it % 2) } } + println(set) // [0, 1] +} +``` +Внутри лямбды коллекция изменяемая, снаружи возвращается `List`. Это чище, чем `mutableListOf` с последующим приведением. + +## Пустая коллекция и null +```kotlin +fun main() { + val nullable: List? = null + println(nullable?.size ?: 0) // 0 + println(nullable.orEmpty()) // [] + println(nullable.isNullOrEmpty()) // true + + val empty = emptyList() + println(empty.firstOrNull()) // null + println(empty.sum()) // 0 +} +``` +Практическое правило: в API возвращайте пустую коллекцию, а не `null`. `List?` заставляет каждого клиента писать `?.` или `orEmpty()`, не давая взамен никакой информации - пустой список уже означает «ничего нет». + +## Массив не коллекция +```kotlin +fun main() { + val array = arrayOf(1, 2, 3) + // array.filter — есть, потому что Array получает расширения из Iterable + println(array.filter { it > 1 }) // [2, 3] — это уже List + + println(array.toList()::class.simpleName) // ArrayList + println(listOf(1, 2).toTypedArray().size) // 2 +} +``` +`Array` - отдельный тип, не входящий в `Collection`. У него фиксированный размер и инвариантность. Подробнее: [Массивы](../Синтаксис%20и%20типы/Работа%20с%20данными/Массивы.md) + +## Карта раздела +| Заметка | О чём | +|---|---| +| [List, Set, Map](List,%20Set,%20Map.md) | три семейства, их гарантии и выбор между ними | +| [Изменяемость](Изменяемость.md) | read-only против immutable, защита от изменения | +| [Операции](Операции.md) | `map`, `filter`, `groupBy`, `fold` и остальной арсенал | +| [Sequence](Sequence.md) | ленивые вычисления и когда они выигрывают | +| [Коллекции под капотом](Коллекции%20под%20капотом.md) | что лежит в байт-коде, сложность операций, память | + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `new ArrayList<>()` | `mutableListOf()` | +> | `List.of(1, 2)` (Java 9+) | `listOf(1, 2)` | +> | `Collections.emptyList()` | `emptyList()` | +> | изменяемость не в типе | `List` против `MutableList` | +> | `UnsupportedOperationException` в рантайме | ошибка компиляции | +> | `Stream` для операций | расширения прямо на коллекции | +> | `new HashSet<>()` - порядок не гарантирован | `setOf()` - порядок вставки | +> +> **Разделение на `List` и `MutableList` - главное нововведение.** В Java `List` - один интерфейс с методом `add`, и `List.of(1, 2)` его тоже имеет: вызов просто бросает `UnsupportedOperationException` в рантайме. +> ```java +> List list = List.of(1, 2); +> list.add(3); // компилируется, падает в рантайме +> ``` +> ```kotlin +> fun main() { +> val list = listOf(1, 2) +> // list.add(3) // не компилируется +> println(list.size) +> } +> ``` +> Ошибка переехала из рантайма в компиляцию - это главная практическая польза. +> +> **Операции не требуют `Stream`.** В Java цепочка выглядит как `list.stream().filter(...).map(...).collect(Collectors.toList())`. В Kotlin `filter` и `map` - функции-расширения прямо на `Iterable`, они сразу возвращают `List`: +> ```kotlin +> fun main() { +> println(listOf(1, 2, 3).filter { it > 1 }.map { it * 10 }) // [20, 30] +> } +> ``` +> Цена - промежуточный список на каждом шаге. Ленивый аналог `Stream` - это [Sequence](Sequence.md). +> +> **`setOf` и `mapOf` сохраняют порядок вставки**, потому что под ними `LinkedHashSet` и `LinkedHashMap`. Это удобно (вывод предсказуем), но на порядок всё равно не стоит полагаться в контракте: тип объявлен как `Set`, а не как «упорядоченное множество». +> +> Сравнить: [HashMap в Java](../../Программирование%20на%20Java/Коллекции/HashMap.md) + +> [!warning] Подводные камни +> **`List` - это read-only, а не immutable.** Объект под ним может меняться через другую ссылку: +> ```kotlin +> fun main() { +> val mutable = mutableListOf(1, 2) +> val readOnly: List = mutable // та же коллекция! +> mutable.add(3) +> println(readOnly) // [1, 2, 3] — изменился +> } +> ``` +> `List` гарантирует только то, что **вы** через эту ссылку не измените коллекцию. Подробнее: [Изменяемость](Изменяемость.md) +> +> **Из Java любой `List` изменяем.** Типы Kotlin-коллекций - это `java.util.List`, поэтому Java-код про read-only не знает: +> ```kotlin +> fun giveList(): List = mutableListOf(1, 2) +> +> fun main() { println(giveList().size) } // 2 +> ``` +> Java-клиент вызовет `add` без всяких ошибок компиляции, и если под ссылкой `ArrayList` - элемент добавится. Для настоящей защиты нужна копия. +> +> **`listOf()` возвращает разные реализации.** `listOf()` без аргументов - `EmptyList`, `listOf(x)` - `SingletonList`, с несколькими - `Arrays$ArrayList`. То же у `mapOf` и `setOf`: на одном элементе это `SingletonMap`/`SingletonSet`, а не `Linked`-версия. Последний не поддерживает `add` даже через каст: +> ```kotlin +> fun main() { +> val list = listOf(1, 2) +> try { +> @Suppress("UNCHECKED_CAST") +> (list as MutableList).add(3) +> } catch (e: UnsupportedOperationException) { +> println("каст скомпилировался, но add упал") +> } +> } +> ``` +> Приводить `List` к `MutableList` нельзя: иногда это сработает, иногда бросит исключение. +> +> **`Map` не `Collection`** - у него нет `size` через `Collection`-интерфейс, `filter` работает с `Map.Entry`, а `map` возвращает `List`: +> ```kotlin +> fun main() { +> val m = mapOf("a" to 1, "b" to 2) +> println(m.filter { it.value > 1 }) // {b=2} — остался Map +> println(m.map { it.key }) // [a, b] — стал List +> println(m.mapValues { it.value * 10 }) // {a=10, b=20} +> } +> ``` +> +> **Операции создают новую коллекцию на каждом шаге.** Цепочка из пяти `map`/`filter` по списку в миллион элементов создаст пять списков. Для длинных цепочек и больших данных нужен [Sequence](Sequence.md). +> +> **`hashSetOf` и `hashMapOf` не гарантируют порядок.** Если тест полагается на порядок обхода, он может сломаться при смене JDK. Используйте `setOf`/`mapOf` или сортируйте явно. + +Далее: [List, Set, Map](List,%20Set,%20Map.md) diff --git a/Kotlin/Коллекции/Операции.md b/Kotlin/Коллекции/Операции.md new file mode 100644 index 0000000..46c1465 --- /dev/null +++ b/Kotlin/Коллекции/Операции.md @@ -0,0 +1,424 @@ +--- +title: Операции +description: map, filter, groupBy, fold, zip и остальной арсенал операций над коллекциями +tags: + - kotlin + - collections +--- + +Операции над коллекциями - функции-расширения на `Iterable`. Их около 150; здесь - те, что покрывают почти всю практику. + +Все они делятся на две группы: +- **возвращают коллекцию**: `map`, `filter`, `sorted` - можно строить цепочки +- **возвращают одно значение**: `sum`, `first`, `count`, `fold` - завершают цепочку + +## Преобразование +```kotlin +fun main() { + val list = listOf(1, 2, 3) + + println(list.map { it * 2 }) // [2, 4, 6] + println(list.mapIndexed { i, x -> "$i:$x" }) // [0:1, 1:2, 2:3] + println(listOf("1", "a", "3").mapNotNull { it.toIntOrNull() }) // [1, 3] + println(list.flatMap { listOf(it, it * 10) }) // [1, 10, 2, 20, 3, 30] + println(listOf(listOf(1, 2), listOf(3)).flatten()) // [1, 2, 3] + println(list.zip(listOf("a", "b"))) // [(1, a), (2, b)] + println(list.withIndex().map { "${it.index}${it.value}" }) // [01, 12, 23] +} +``` +`mapNotNull` - самая полезная из них: преобразование с отбрасыванием неудач в одном шаге, вместо `map` + `filterNotNull`. + +`flatMap` нужен, когда преобразование даёт коллекцию, а результат нужен плоским: +```kotlin +data class Order5(val id: Int, val items: List) + +fun main() { + val orders = listOf( + Order5(1, listOf("хлеб", "молоко")), + Order5(2, listOf("сыр")), + ) + println(orders.flatMap { it.items }) // [хлеб, молоко, сыр] + println(orders.map { it.items }) // [[хлеб, молоко], [сыр]] +} +``` + +## Фильтрация +```kotlin +fun main() { + val list = listOf(1, 2, 3, 4, 5) + + println(list.filter { it % 2 == 0 }) // [2, 4] + println(list.filterNot { it % 2 == 0 }) // [1, 3, 5] + println(list.filterIndexed { i, _ -> i > 2 }) // [4, 5] + println(listOf(1, null, 3).filterNotNull()) // [1, 3] + println(listOf(1, "a", 2).filterIsInstance()) // [1, 2] + println(list.take(2)) // [1, 2] + println(list.takeWhile { it < 3 }) // [1, 2] + println(list.dropWhile { it < 3 }) // [3, 4, 5] + println(list.distinct()) // [1, 2, 3, 4, 5] + println(listOf("aa", "ab", "ba").distinctBy { it.first() }) // [aa, ba] +} +``` + +`partition` делит на две коллекции за один проход: +```kotlin +fun main() { + val (even, odd) = listOf(1, 2, 3, 4).partition { it % 2 == 0 } + println("$even $odd") // [2, 4] [1, 3] +} +``` + +## Поиск и проверки +```kotlin +fun main() { + val list = listOf(1, 2, 3, 4) + + println(list.first { it > 2 }) // 3, бросает если нет + println(list.firstOrNull { it > 10 }) // null + println(list.find { it > 2 }) // 3 — то же, что firstOrNull + println(list.last()) // 4 + println(list.single { it == 2 }) // 2, бросает если не ровно один + println(list.singleOrNull { it > 2 }) // null — их два + + println(list.any { it > 3 }) // true + println(list.all { it > 0 }) // true + println(list.none { it > 10 }) // true + println(list.count { it % 2 == 0 }) // 2 + println(3 in list) // true + println(list.indexOfFirst { it > 2 }) // 2 +} +``` + +Поведение на пустой коллекции - источник ошибок: +```kotlin +fun main() { + val empty = emptyList() + + println(empty.any { it > 0 }) // false + println(empty.all { it > 0 }) // true — вакуумная истина! + println(empty.none { it > 0 }) // true + println(empty.sum()) // 0 + println(empty.firstOrNull()) // null + println(empty.maxOrNull()) // null +} +``` +`all` на пустой коллекции возвращает `true` - это математически корректно, но в проверках вида «все элементы валидны» часто неожиданно. + +## Агрегация +```kotlin +fun main() { + val list = listOf(1, 2, 3, 4) + + println(list.sum()) // 10 + println(list.average()) // 2.5 + println(list.maxOrNull()) // 4 + println(list.minOrNull()) // 1 + println(list.sumOf { it * 10 }) // 100 + println(list.maxByOrNull { -it }) // 1 + println(list.maxOfOrNull { it * 2 }) // 8 + println(list.joinToString(prefix = "[", postfix = "]")) // [1, 2, 3, 4] + println(list.joinToString(limit = 2, truncated = "…")) // 1, 2, … +} +``` + +`maxBy` против `maxOf` - частая путаница: +```kotlin +data class User8(val name: String, val age: Int) + +fun main() { + val users = listOf(User8("Иван", 30), User8("Пётр", 25)) + + println(users.maxByOrNull { it.age }) // User8(name=Иван, age=30) — элемент + println(users.maxOfOrNull { it.age }) // 30 — значение +} +``` + +## fold и reduce +```kotlin +fun main() { + val list = listOf(1, 2, 3, 4) + + println(list.fold(0) { acc, x -> acc + x }) // 10 + println(list.fold(1) { acc, x -> acc * x }) // 24 + println(list.reduce { acc, x -> acc + x }) // 10 + println(list.runningFold(0) { acc, x -> acc + x }) // [0, 1, 3, 6, 10] + println(list.foldRight(0) { x, acc -> acc * 10 + x }) // 4321 — справа налево + + println(emptyList().fold(0) { a, b -> a + b }) // 0 + try { + emptyList().reduce { a, b -> a + b } + } catch (e: UnsupportedOperationException) { + println("reduce падает на пустой коллекции") + } +} +``` + +| | `fold` | `reduce` | +|---|---|---| +| начальное значение | задаёте вы | первый элемент | +| тип результата | любой | тип элемента | +| пустая коллекция | вернёт начальное | исключение | + +`fold` умеет менять тип, и это его главное применение: +```kotlin +fun main() { + val words = listOf("раз", "два", "три") + + val lengths: Map = words.fold(mutableMapOf()) { acc, w -> + acc[w] = w.length + acc + } + println(lengths) // {раз=3, два=3, три=3} + + println(words.fold(0) { acc, w -> acc + w.length }) // 9 — List -> Int +} +``` + +## Группировка +```kotlin +fun main() { + val words = listOf("яблоко", "арбуз", "банан", "боб") + + println(words.groupBy { it.first() }) + // {я=[яблоко], а=[арбуз], б=[банан, боб]} + + println(words.groupBy(keySelector = { it.first() }, valueTransform = { it.length })) + // {я=[6], а=[5], б=[5, 3]} + + println(words.groupingBy { it.first() }.eachCount()) + // {я=1, а=1, б=2} + + println(words.groupingBy { it.first() }.fold(0) { acc, w -> acc + w.length }) + // {я=6, а=5, б=8} +} +``` +`groupingBy` отличается от `groupBy` тем, что не создаёт промежуточные списки - он сразу агрегирует. Для подсчёта и суммирования по группам это и быстрее, и короче. + +## associate +```kotlin +fun main() { + val users = listOf("Иван", "Пётр", "Анна") + + println(users.associateWith { it.length }) // {Иван=4, Пётр=4, Анна=4} + println(users.associateBy { it.first() }) // {И=Иван, П=Пётр, А=Анна} + println(users.associate { it.first() to it.length }) // {И=4, П=4, А=4} +} +``` + +| Функция | Ключ | Значение | +|---|---|---| +| `associateWith` | элемент | результат лямбды | +| `associateBy` | результат лямбды | элемент | +| `associate` | из `Pair` | из `Pair` | + +`associateBy` против `groupBy` - важная разница: +```kotlin +fun main() { + val words = listOf("банан", "боб") + + println(words.associateBy { it.first() }) // {б=боб} — потеря! + println(words.groupBy { it.first() }) // {б=[банан, боб]} +} +``` +`associateBy` при дубликате ключа **молча перезаписывает**. Если ключ не уникален, нужен `groupBy`. + +## Сортировка +```kotlin +fun main() { + val list = listOf(3, 1, 2) + + println(list.sorted()) // [1, 2, 3] + println(list.sortedDescending()) // [3, 2, 1] + println(list.reversed()) // [2, 1, 3] — не сортировка! + println(list.shuffled().size) // 3 + + val users = listOf("Пётр" to 25, "Иван" to 30, "Анна" to 25) + println(users.sortedBy { it.second }) // [(Пётр, 25), (Анна, 25), (Иван, 30)] + println(users.sortedWith(compareBy({ it.second }, { it.first }))) + // [(Анна, 25), (Пётр, 25), (Иван, 30)] — по возрасту, потом по имени + println(users.sortedWith(compareByDescending> { it.second } + .thenBy { it.first })) + // [(Иван, 30), (Анна, 25), (Пётр, 25)] +} +``` +Сортировка Kotlin **стабильна**: элементы с равными ключами сохраняют исходный порядок. + +На `MutableList` есть сортировка на месте - без `ed` в имени: +```kotlin +fun main() { + val list = mutableListOf(3, 1, 2) + list.sort() // на месте + println(list) // [1, 2, 3] + list.sortByDescending { it } + println(list) // [3, 2, 1] + + val copy = list.sorted() // новая коллекция + println("$list / $copy") // [3, 2, 1] / [1, 2, 3] +} +``` +Соглашение в stdlib: `sort()` меняет на месте, `sorted()` возвращает копию. То же с `reverse()`/`reversed()`, `shuffle()`/`shuffled()`. + +## Разбиение и объединение +```kotlin +fun main() { + val list = listOf(1, 2, 3, 4, 5) + + println(list.chunked(2)) // [[1, 2], [3, 4], [5]] + println(list.chunked(2) { it.sum() }) // [3, 7, 5] + println(list.windowed(3)) // [[1,2,3], [2,3,4], [3,4,5]] + println(list.windowed(2, step = 2, partialWindows = true)) // [[1,2], [3,4], [5]] + println(list.zipWithNext()) // [(1,2), (2,3), (3,4), (4,5)] + println(list.zipWithNext { a, b -> b - a }) // [1, 1, 1, 1] +} +``` +`windowed` - скользящее окно: удобно для расчёта разниц, средних и сглаживания. + +## Операции на Map +```kotlin +fun main() { + val map = mapOf("a" to 1, "b" to 2, "c" to 3) + + println(map.filter { (_, v) -> v > 1 }) // {b=2, c=3} + println(map.mapValues { it.value * 10 }) // {a=10, b=20, c=30} + println(map.mapKeys { it.key.uppercase() }) // {A=1, B=2, C=3} + println(map.filterKeys { it != "a" }) // {b=2, c=3} + println(map.maxByOrNull { it.value }?.key) // c + println(map.entries.sumOf { it.value }) // 6 + println(map.toList().map { (k, v) -> "$k$v" }) // [a1, b2, c3] +} +``` +Деструктуризация в лямбде (`{ (k, v) -> }`) работает для `Map.Entry` и для `Pair`. + +## Практический пример +```kotlin +data class Sale(val region: String, val product: String, val amount: Long) + +fun main() { + val sales = listOf( + Sale("Север", "хлеб", 100), + Sale("Север", "молоко", 200), + Sale("Юг", "хлеб", 150), + Sale("Юг", "сыр", 300), + ) + + val byRegion = sales.groupBy { it.region }.mapValues { (_, v) -> v.sumOf { it.amount } } + println(byRegion) // {Север=300, Юг=450} + + val top = sales.maxByOrNull { it.amount } + println("${top?.product}: ${top?.amount}") // сыр: 300 + + val products = sales.map { it.product }.distinct().sorted() + println(products) // [молоко, сыр, хлеб] + + val report = byRegion.entries + .sortedByDescending { it.value } + .joinToString("\n") { "${it.key}: ${it.value}" } + println(report) // Юг: 450\nСевер: 300 +} +``` + +> [!info] Отличия от Java +> | Java Stream | Kotlin | +> |---|---| +> | `stream().map(f).collect(toList())` | `map(f)` | +> | `stream().filter(p).collect(toList())` | `filter(p)` | +> | `collect(groupingBy(f))` | `groupBy(f)` | +> | `collect(toMap(k, v))` | `associate { k to v }` | +> | `reduce(0, Integer::sum)` | `fold(0) { a, b -> a + b }` | +> | `mapToInt(f).sum()` | `sumOf(f)` | +> | `flatMap(x -> x.stream())` | `flatMap { it }` | +> | `collect(partitioningBy(p))` | `partition(p)` | +> | `anyMatch(p)` | `any(p)` | +> | нет аналога | `mapNotNull`, `windowed`, `zipWithNext`, `groupingBy` | +> +> **Главное - не нужен `.stream()` и `.collect()`.** В Java любая операция требует входа в стрим и выхода из него; это ~25 символов обвязки на каждую цепочку. В Kotlin операции объявлены прямо на `Iterable`, поэтому короткие цепочки читаются как одно выражение: +> ```kotlin +> fun main() { +> println(listOf(1, 2, 3).filter { it > 1 }.sumOf { it * 10 }) // 50 +> } +> ``` +> ```java +> List.of(1, 2, 3).stream().filter(x -> x > 1).mapToInt(x -> x * 10).sum(); +> ``` +> +> **Цена - энергичность.** Java-стрим ленив: промежуточные операции не создают коллекций. Kotlin-операции на `Iterable` создают новый список на каждом шаге. Для цепочки из 3 операций по списку из 1000 элементов это 3 списка. Ленивый аналог - [Sequence](Sequence.md), и он почти дословно соответствует `Stream`. +> +> **`mapNotNull` и `groupingBy` аналогов в Java не имеют.** В Java фильтрация с преобразованием - это `map` + `filter(Objects::nonNull)` или `flatMap` с `Optional.stream()`. Подсчёт по группам - `groupingBy(f, counting())`, то есть вложенные коллекторы, которые плохо читаются. +> +> **`Optional` в цепочках не нужен.** Java-стримы возвращают `Optional` из `findFirst` и `max`; в Kotlin это nullable-тип, с которым работают обычные `?.` и `?:`: +> ```kotlin +> fun main() { +> println(listOf(1, 2, 3).maxOrNull() ?: 0) // 3 +> } +> ``` +> +> Сравнить: [Функции в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md) + +> [!warning] Подводные камни +> **Каждая операция создаёт новую коллекцию:** +> ```kotlin +> fun main() { +> val big = List(100_000) { it } +> val result = big.map { it * 2 }.filter { it % 3 == 0 }.take(5) +> println(result) // [0, 6, 12, 18, 24] +> } +> ``` +> `map` обработал все 100 000 элементов и создал список, `filter` - ещё один, и только потом `take` взял пять. С `asSequence()` работы было бы на порядки меньше. +> +> **`all` на пустой коллекции - `true`:** +> ```kotlin +> fun main() { +> val errors = emptyList() +> println(errors.all { it.isNotBlank() }) // true +> println(errors.isNotEmpty() && errors.all { it.isNotBlank() }) // false +> } +> ``` +> Проверка «все данные валидны» на пустом входе пройдёт. Если пустота недопустима, проверяйте её отдельно. +> +> **`associateBy` теряет дубликаты молча** - при повторе ключа остаётся последний элемент. Для неуникальных ключей нужен `groupBy`. +> +> **`reduce` падает на пустой коллекции**, `fold` - нет. В коде, где коллекция может быть пустой, `reduce` - потенциальное исключение. +> +> **`sortedBy` с nullable-ключом ставит `null` первыми:** +> ```kotlin +> fun main() { +> val list = listOf("b" to 2, "a" to null, "c" to 1) +> println(list.sortedBy { it.second }) // [(a, null), (c, 1), (b, 2)] +> println(list.sortedWith(compareBy(nullsLast()) { it.second })) // [(c, 1), (b, 2), (a, null)] +> } +> ``` +> +> **`reversed()` - не сортировка**, а переворот. `listOf(3, 1, 2).reversed()` даёт `[2, 1, 3]`. +> +> **`sort()` и `sorted()` легко перепутать.** `sort()` молча меняет исходную коллекцию - если она пришла извне, это неожиданная мутация: +> ```kotlin +> fun process(items: MutableList): List { +> items.sort() // испортили аргумент вызывающего +> return items +> } +> +> fun main() { +> val original = mutableListOf(3, 1, 2) +> process(original) +> println(original) // [1, 2, 3] — вызывающий этого не просил +> } +> ``` +> Принимайте `List` вместо `MutableList` - тогда `sort()` просто не скомпилируется. +> +> **`first()` и `single()` бросают, а не возвращают `null`.** Версии с `OrNull` безопасны; `single` вдобавок падает, если элементов больше одного. +> +> **`flatMap { it }` на `List>` работает, но `flatten()` яснее.** +> +> **Цепочка из `filter` после `map` делает лишнюю работу.** `filter` перед `map` обрабатывает меньше элементов: +> ```kotlin +> fun main() { +> val list = List(10) { it } +> println(list.map { it * 2 }.filter { it > 10 }) // [12, 14, 16, 18], 10 преобразований +> println(list.filter { it > 5 }.map { it * 2 }) // [12, 14, 16, 18], 4 преобразования +> } +> ``` +> Здесь результаты совпадают, а работы во втором случае вдвое с лишним меньше. Но совпадают они не всегда: перестановка `filter` и `map` меняет смысл, если предикат проверяет уже преобразованное значение. Сначала убедитесь в эквивалентности, потом выбирайте по стоимости. +> +> **`joinToString` по умолчанию без ограничения длины.** Для списка на миллион элементов он построит огромную строку; в логах используйте `limit`. + +Далее: [Sequence](Sequence.md) diff --git a/Kotlin/Корутины/Channels.md b/Kotlin/Корутины/Channels.md new file mode 100644 index 0000000..46a7b1b --- /dev/null +++ b/Kotlin/Корутины/Channels.md @@ -0,0 +1,545 @@ +--- +title: Channels +description: Передача значений между корутинами: типы буферов, закрытие, fan-out и fan-in, select и когда канал лучше Flow +tags: + - kotlin + - coroutines + - channels +--- + +`Channel` - очередь для передачи значений между корутинами. Если `Flow` описывает, как значения производятся, то `Channel` - это объект, в который одна корутина кладёт значения, а другая забирает. + +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun main() = runBlocking { + val channel = Channel() + + launch { + for (i in 1..3) { + channel.send(i) // приостановится, пока никто не принял + } + channel.close() + } + + for (value in channel) { // завершится при close() + println("получено: $value") + } + // получено: 1 + // получено: 2 + // получено: 3 +} +``` +`send` и `receive` - `suspend`-функции: отправитель ждёт получателя, получатель ждёт отправителя. Это rendezvous-канал, вариант по умолчанию. + +## Типы каналов +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun main() = runBlocking { + // RENDEZVOUS (по умолчанию): буфера нет + val rendezvous = Channel() + println("rendezvous принял без получателя: ${rendezvous.trySend(1).isSuccess}") + + // BUFFERED: буфер на n элементов + val buffered = Channel(capacity = 2) + println("в буфер на 2: ${buffered.trySend(1).isSuccess} ${buffered.trySend(2).isSuccess}") + println("третий не влез: ${buffered.trySend(3).isSuccess}") + + // CONFLATED: хранит только последнее + val conflated = Channel(Channel.CONFLATED) + conflated.trySend(1); conflated.trySend(2); conflated.trySend(3) + println("conflated хранит последнее: ${conflated.receive()}") + + // UNLIMITED: растёт без ограничений + val unlimited = Channel(Channel.UNLIMITED) + repeat(1000) { unlimited.trySend(it) } + println("unlimited принял всё: ${unlimited.receive()}") + // rendezvous принял без получателя: false + // в буфер на 2: true true + // третий не влез: false + // conflated хранит последнее: 3 + // unlimited принял всё: 0 +} +``` +| Тип | Поведение `send` при заполнении | +|---|---| +| `RENDEZVOUS` (0) | ждёт, пока кто-то вызовет `receive` | +| `BUFFERED` (по умолчанию 64) | ждёт освобождения места | +| `CONFLATED` | не ждёт, заменяет непрочитанное значение | +| `UNLIMITED` | не ждёт никогда, растёт в памяти | +| `capacity = n` + `onBufferOverflow` | `DROP_OLDEST` / `DROP_LATEST` вместо ожидания | + +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun main() = runBlocking { + val dropping = Channel(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST) + repeat(5) { dropping.trySend(it) } + println("осталось последние два: ${dropping.receive()} ${dropping.receive()}") + // осталось последние два: 3 4 +} +``` + +## Закрытие канала +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun main() = runBlocking { + val channel = Channel(capacity = 3) + channel.send(1) + channel.send(2) + channel.close() + + println("после close ещё можно читать: ${channel.receive()} ${channel.receive()}") + println("отправка запрещена: ${channel.trySend(3).isSuccess}") + println("закрыт для отправки: ${channel.isClosedForSend}") + + val error = runCatching { channel.receive() }.exceptionOrNull() + println("чтение из пустого закрытого: ${error is ClosedReceiveChannelException}") + // после close ещё можно читать: 1 2 + // отправка запрещена: false + // закрыт для отправки: true + // чтение из пустого закрытого: true +} +``` +`close()` - это «больше значений не будет»: уже отправленные элементы дочитываются, новые не принимаются. `for`-цикл по каналу завершается нормально, а `receive()` на исчерпанном канале бросает исключение. Безопасная альтернатива - `receiveCatching`: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun main() = runBlocking { + val channel = Channel() + channel.close() + + val result = channel.receiveCatching() + println("успех: ${result.isSuccess}, закрыт: ${result.isClosed}") + println("значение или null: ${result.getOrNull()}") + // успех: false, закрыт: true + // значение или null: null +} +``` + +Закрытие с ошибкой передаёт её получателю: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun main() = runBlocking { + val channel = Channel() + launch { + channel.send(1) + channel.close(IllegalStateException("источник сломался")) + } + + val error = runCatching { + for (v in channel) println("получено: $v") + }.exceptionOrNull() + println("ошибка дошла: ${error?.message}") + // получено: 1 + // ошибка дошла: источник сломался +} +``` + +## produce: канал как результат корутины +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun CoroutineScope.produceSquares(n: Int): ReceiveChannel = produce { + for (i in 1..n) { + delay(10) + send(i * i) + } +} + +fun main() = runBlocking { + val squares = produceSquares(4) + println(squares.toList()) // [1, 4, 9, 16] +} +``` +`produce` закрывает канал автоматически при завершении тела - это его главное преимущество перед ручным `Channel()` + `launch`. Он помечен `@ObsoleteCoroutinesApi`, но остаётся самым аккуратным способом создать канал-источник. + +## Fan-out: несколько получателей +Канал распределяет элементы между получателями - каждый элемент достаётся **ровно одному**: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import java.util.concurrent.ConcurrentHashMap + +fun main() = runBlocking { + val tasks = Channel() + val handled = ConcurrentHashMap() + + repeat(3) { worker -> + launch { + for (task in tasks) { + delay(10) + handled[task] = worker + } + } + } + + repeat(9) { tasks.send(it) } + tasks.close() + delay(100) + + println("обработано задач: ${handled.size}") + println("каждая ровно раз: ${handled.keys.sorted() == (0..8).toList()}") + println("работали несколько воркеров: ${handled.values.toSet().size > 1}") + // обработано задач: 9 + // каждая ровно раз: true + // работали несколько воркеров: true +} +``` +Это главное отличие канала от `SharedFlow`: канал **распределяет**, `SharedFlow` **транслирует** всем. Пул воркеров без канала не сделать. + +## Fan-in: несколько отправителей +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun main() = runBlocking { + val results = Channel() + + launch { delay(10); results.send("от первого") } + launch { delay(20); results.send("от второго") } + launch { delay(30); results.send("от третьего") } + + repeat(3) { println(results.receive()) } + results.close() + // от первого + // от второго + // от третьего +} +``` + +## Конвейер +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun CoroutineScope.numbers(): ReceiveChannel = produce { + var i = 1 + while (i <= 5) send(i++) +} + +fun CoroutineScope.squares(input: ReceiveChannel): ReceiveChannel = produce { + for (n in input) send(n * n) +} + +fun CoroutineScope.evens(input: ReceiveChannel): ReceiveChannel = produce { + for (n in input) if (n % 2 == 0) send(n) +} + +fun main() = runBlocking { + val pipeline = evens(squares(numbers())) + println(pipeline.toList()) // [4, 16] + coroutineContext.cancelChildren() +} +``` +Каждая стадия - отдельная корутина, работающая параллельно с остальными. Для такой задачи обычно достаточно `Flow` с операторами, но каналы дают контроль над буферами каждой стадии. + +```mermaid +graph LR + A["produce: 1..5"] -->|"Channel"| B["squares"] + B -->|"Channel"| C["evens"] + C -->|"Channel"| D["collect"] + E["Каждая стадия —
своя корутина"] -.-> B +``` + +## Тикер и таймеры +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun main() = runBlocking { + val ticks = mutableListOf() + val job = launch { + var i = 0 + while (true) { + delay(20) + ticks += i++ + } + } + delay(70) + job.cancel() + println("тиков примерно 3: ${ticks.size in 2..4}") // тиков примерно 3: true +} +``` +Для периодических задач проще цикл с `delay`, чем `ticker` (который помечен устаревшим). Если нужен именно `Flow`, его даёт `flow { while (true) { delay(x); emit(Unit) } }`. + +## select: ждать первое из нескольких +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import kotlinx.coroutines.selects.select + +fun main() = runBlocking { + val fast = Channel() + val slow = Channel() + + launch { delay(10); fast.send("быстрый") } + launch { delay(50); slow.send("медленный") } + + val first = select { + fast.onReceive { "пришло из fast: $it" } + slow.onReceive { "пришло из slow: $it" } + } + println(first) + coroutineContext.cancelChildren() + // пришло из fast: быстрый +} +``` +`select` работает и с `Deferred` - это способ устроить гонку запросов: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.selects.select + +fun main() = runBlocking { + val mirror1 = async { delay(50); "зеркало 1" } + val mirror2 = async { delay(10); "зеркало 2" } + + val winner = select { + mirror1.onAwait { it } + mirror2.onAwait { it } + } + println("ответил первым: $winner") + coroutineContext.cancelChildren() + // ответил первым: зеркало 2 +} +``` +Важно: проигравший `Deferred` продолжает работу - его нужно отменить явно (здесь это делает `cancelChildren`). + +## Channel как мьютекс для состояния +Канал-«актор» - способ изолировать изменяемое состояние в одной корутине: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +sealed interface Command { + data class Increment(val by: Int) : Command + data class Read(val reply: CompletableDeferred) : Command +} + +fun main() = runBlocking { + val commands = Channel() + + launch { + var state = 0 // доступно только этой корутине + for (cmd in commands) { + when (cmd) { + is Command.Increment -> state += cmd.by + is Command.Read -> cmd.reply.complete(state) + } + } + } + + repeat(100) { commands.send(Command.Increment(1)) } + val reply = CompletableDeferred() + commands.send(Command.Read(reply)) + println("состояние без блокировок: ${reply.await()}") + commands.close() + // состояние без блокировок: 100 +} +``` +Синхронизации нет вообще: состояние изменяет одна корутина, остальные присылают сообщения. Подробнее: [Разделяемое состояние](Разделяемое%20состояние.md) + +## Channel против Flow +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + // Channel: горячий, значения расходятся по получателям + val channel = Channel(capacity = 3) + channel.send(1); channel.send(2) + channel.close() + println("канал раздал: ${channel.toList()}") + + // Flow: холодный, каждый подписчик получает всё + val flow = flowOf(1, 2) + println("поток повторил: ${flow.toList()} ${flow.toList()}") + // канал раздал: [1, 2] + // поток повторил: [1, 2] [1, 2] +} +``` +| | `Channel` | `Flow` | +|---|---|---| +| природа | горячий, объект-очередь | холодный, описание | +| значение получает | один получатель | каждый подписчик | +| существует без получателей | да, накапливает | нет, не запущен | +| нужно закрывать | да | нет | +| операторы | почти нет | десятки | +| типичное применение | пул воркеров, развязка корутин | данные, события, UI | + +Практическое правило: **начинайте с `Flow`**. Канал нужен, когда значения должны распределяться между получателями (пул воркеров), когда производитель и потребитель должны работать независимо, или когда нужна точная политика буферизации. Для «потока данных из репозитория в UI» канал - неправильный выбор: он одноразовый и легко теряет данные. + +Если канал уже есть, его можно превратить во `Flow`: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val channel = Channel(capacity = 2) + channel.send(1); channel.send(2) + channel.close() + + println(channel.consumeAsFlow().toList()) // [1, 2] +} +``` +`consumeAsFlow()` можно собрать только один раз - канал одноразовый. Повторный `collect` бросит `IllegalStateException`. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `BlockingQueue` | `Channel` | +> | `ArrayBlockingQueue(n)` | `Channel(capacity = n)` | +> | `LinkedBlockingQueue()` | `Channel(UNLIMITED)` | +> | `SynchronousQueue` | `Channel(RENDEZVOUS)` | +> | `queue.put(x)` (блокирует поток) | `channel.send(x)` (приостанавливает) | +> | `queue.take()` (блокирует поток) | `channel.receive()` (приостанавливает) | +> | «отравленная пилюля» как признак конца | `close()` | +> | `java.util.concurrent.Flow` | `Flow` | +> +> **`BlockingQueue` блокирует поток, канал - нет.** Это главное различие. Пул воркеров на `BlockingQueue` требует по потоку на воркера, и 1000 воркеров - это 1000 потоков. Канал держит их как корутины: +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.channels.* +> import java.util.concurrent.atomic.AtomicInteger +> +> fun main() = runBlocking { +> val tasks = Channel(capacity = 100) +> val done = AtomicInteger(0) +> repeat(1000) { // 1000 воркеров +> launch { for (t in tasks) { delay(1); done.incrementAndGet() } } +> } +> repeat(500) { tasks.send(it) } +> tasks.close() +> delay(200) +> println("обработано: ${done.get()}") // обработано: 500 +> } +> ``` +> В Java это 1000 потоков и около гигабайта стеков. +> +> **Признак конца данных.** `BlockingQueue` не умеет закрываться: чтобы сообщить воркерам «задач больше нет», в очередь кладут специальное значение - «отравленную пилюлю» (`POISON_PILL`), причём по одной на каждого воркера, иначе часть из них зависнет навсегда: +> ```java +> for (int i = 0; i < workers; i++) queue.put(POISON_PILL); +> ``` +> Это хрупкий паттерн: нужно знать точное число получателей, а сама пилюля ломает типизацию (обычно это `null`-объект или специальный экземпляр). `channel.close()` корректно завершает всех получателей, сколько бы их ни было, а `for (x in channel)` просто выходит из цикла. +> +> **Передача ошибки.** В Java исключение из производителя в очередь не положить - его оборачивают в элемент-обёртку (`Either`-подобный класс) или передают через отдельный механизм. `channel.close(exception)` доставляет исключение получателям, и оно выбрасывается в точке `receive`. +> +> **Таймауты и выбор из нескольких очередей.** `BlockingQueue` даёт `poll(timeout, unit)`, но ждать «первое значение из двух очередей» штатно невозможно - приходится опрашивать по очереди с короткими таймаутами. `select` в Kotlin решает это напрямую, причём без активного ожидания. +> +> **`java.util.concurrent.Flow` - это Reactive Streams, а не очередь.** Оно появилось в Java 9 как SPI для реактивных библиотек, с ручным управлением давлением через `Subscription.request(n)`. Ближайший аналог в Kotlin - `Flow`, а не `Channel`. Подробнее: [Flow](Flow.md) +> +> Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [JVM](../../Программирование%20на%20Java/JVM/JVM.md) + +> [!warning] Подводные камни +> **Незакрытый канал подвешивает получателя навсегда:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.channels.* +> +> fun main() = runBlocking { +> val channel = Channel() +> launch { +> channel.send(1) +> // close() забыли +> } +> val job = launch { +> for (v in channel) println("получено: $v") +> println("этого не будет") +> } +> delay(50) +> println("получатель всё ещё ждёт: ${job.isActive}") +> coroutineContext.cancelChildren() +> // получено: 1 +> // получатель всё ещё ждёт: true +> } +> ``` +> `for`-цикл по каналу ждёт `close()`. Используйте `produce`, который закрывает канал сам. +> +> **`receive()` на закрытом канале бросает исключение:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.channels.* +> +> fun main() = runBlocking { +> val channel = Channel() +> channel.close() +> val error = runCatching { channel.receive() }.exceptionOrNull() +> println(error is ClosedReceiveChannelException) // true +> } +> ``` +> Для безопасного чтения - `receiveCatching()` или `for`-цикл. +> +> **`send` на закрытом канале тоже бросает:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.channels.* +> +> fun main() = runBlocking { +> val channel = Channel() +> channel.close() +> val error = runCatching { channel.send(1) }.exceptionOrNull() +> println(error is ClosedSendChannelException) // true +> } +> ``` +> `trySend` вместо исключения вернёт неуспешный результат. +> +> **`Channel.UNLIMITED` - это `OutOfMemoryError` под нагрузкой.** Он снимает обратное давление: производитель быстрее потребителя будет накапливать элементы в памяти, пока процесс не упадёт. Безлимитный канал оправдан только при известном конечном числе элементов. +> +> **`trySend` молча теряет данные:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.channels.* +> +> fun main() = runBlocking { +> val channel = Channel(capacity = 2) +> val sent = (1..5).count { channel.trySend(it).isSuccess } +> println("отправлено из 5: $sent") // отправлено из 5: 2 +> } +> ``` +> Результат `trySend` обычно игнорируют - и три элемента исчезают без следа. Проверяйте `isSuccess` или используйте `send`. +> +> **Канал - одноразовый.** Повторный `consumeAsFlow().collect` или второй `toList()` после исчерпания бросит исключение либо вернёт пустой результат. Для многократного чтения нужен `Flow` или `SharedFlow`. +> +> **Канал раздаёт, а не транслирует.** Если нужно, чтобы **все** подписчики получили каждое событие, канал не подойдёт - элемент достаётся одному получателю. Нужен `SharedFlow`. Подробнее: [StateFlow и SharedFlow](StateFlow%20и%20SharedFlow.md) +> +> **`CONFLATED` теряет значения по определению.** Он хранит только последнее непрочитанное - для счётчиков и журналов это ошибка, для «актуального состояния» - норма. +> +> **Отмена корутины с `send` оставляет элемент непереданным:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.channels.* +> +> fun main() = runBlocking { +> val channel = Channel() +> val sender = launch { channel.send(1) } // ждёт получателя +> delay(20) +> sender.cancel() +> println("отправитель отменён: ${sender.isCancelled}") +> println("канал пуст: ${channel.tryReceive().isFailure}") +> // отправитель отменён: true +> // канал пуст: true +> } +> ``` +> Элемент не доставлен и не сохранён. Для надёжной доставки нужен буфер или подтверждение. +> +> **Проигравший в `select` продолжает работать.** `select` возвращает первый результат, но остальные `Deferred` и корутины живут дальше, расходуя ресурсы. Отменяйте их явно. +> +> **`select` с `onSend` и несколькими каналами не атомарен между итерациями.** В цикле состояние каналов между проверками меняется - на `select` нельзя строить транзакционную логику. +> +> **`produce` и `actor` помечены устаревшими** (`@ObsoleteCoroutinesApi`): их API может измениться. Для новых источников данных предпочтительнее `Flow`, для состояния - `Mutex` или `StateFlow`. +> +> **`isClosedForReceive`/`isEmpty` - гонка по определению.** Между проверкой и действием состояние изменится. Единственный корректный способ - `receiveCatching()` или `for`-цикл, обрабатывающие закрытие атомарно. + +Далее: [Разделяемое состояние](Разделяемое%20состояние.md) diff --git a/Kotlin/Корутины/Flow.md b/Kotlin/Корутины/Flow.md new file mode 100644 index 0000000..f116f54 --- /dev/null +++ b/Kotlin/Корутины/Flow.md @@ -0,0 +1,783 @@ +--- +title: Flow +description: Холодный асинхронный поток значений: построение, операторы, контекст, обработка ошибок и backpressure +tags: + - kotlin + - coroutines + - flow +--- + +`Flow` - асинхронная последовательность значений. Если `suspend`-функция возвращает одно значение, то `Flow` - много, причём каждое может приходить с задержкой. + +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun temperatures(): Flow = flow { + for (t in listOf(20, 22, 25)) { + delay(20) // внутри flow можно приостанавливаться + emit(t) + } +} + +fun main() = runBlocking { + temperatures().collect { println("температура: $it") } + // температура: 20 + // температура: 22 + // температура: 25 +} +``` +Сравните с `List`: список нужно вычислить целиком, прежде чем вернуть. `Flow` отдаёт значения по мере готовности. + +## Холодный поток +`Flow` ничего не делает, пока его не начнут собирать. Каждый вызов `collect` запускает тело заново. +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + var starts = 0 + val f = flow { + starts++ + emit(1) + emit(2) + } + + println("создан, запусков: $starts") // создан, запусков: 0 + f.collect { } + f.collect { } + println("после двух collect: $starts") // после двух collect: 2 +} +``` +Это главное свойство: `Flow` - **описание** вычисления, а не запущенный процесс. Горячие потоки, которые работают независимо от подписчиков, - это `StateFlow` и `SharedFlow`. Подробнее: [StateFlow и SharedFlow](StateFlow%20и%20SharedFlow.md) + +## Способы создать Flow +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flowOf(1, 2, 3).collect { print("$it ") } + println() // 1 2 3 + + listOf("a", "b").asFlow().collect { print("$it ") } + println() // a b + + (1..3).asFlow().collect { print("$it ") } + println() // 1 2 3 + + flow { + emit("из билдера") + emitAll(flowOf("и", "вложенного")) + }.collect { print("$it ") } + println() // из билдера и вложенного +} +``` +Для колбэчных источников есть `callbackFlow` - он же мост от слушателей к `Flow`: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* + +class Sensor { + private var listener: ((Int) -> Unit)? = null + fun subscribe(l: (Int) -> Unit) { listener = l } + fun unsubscribe() { listener = null; println("слушатель снят") } + fun fire(value: Int) = listener?.invoke(value) +} + +fun Sensor.readings(): Flow = callbackFlow { + subscribe { value -> trySend(value) } + awaitClose { unsubscribe() } // вызовется при отмене collect +} + +fun main() = runBlocking { + val sensor = Sensor() + val job = launch { + sensor.readings().collect { println("показание: $it") } + } + delay(20) + sensor.fire(42) + sensor.fire(43) + delay(20) + job.cancelAndJoin() + // показание: 42 + // показание: 43 + // слушатель снят +} +``` +`awaitClose` обязателен: без него `callbackFlow` бросит `IllegalStateException`, потому что иначе слушатель остался бы висеть. + +## Промежуточные операторы +Операторы ленивые: они только оборачивают поток, не запуская его. +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val result = (1..10).asFlow() + .filter { it % 2 == 0 } + .map { it * it } + .take(3) + .toList() + println(result) // [4, 16, 36] +} +``` +Главное отличие от операторов коллекций - внутри можно приостанавливаться: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +suspend fun enrich(id: Int): String { delay(10); return "запись $id" } + +fun main() = runBlocking { + flowOf(1, 2, 3) + .map { enrich(it) } // suspend внутри map + .collect { println(it) } + // запись 1 + // запись 2 + // запись 3 +} +``` + +| Оператор | Что делает | +|---|---| +| `map`, `filter` | как у коллекций, но с `suspend` внутри | +| `transform` | произвольное число `emit` на элемент | +| `take`, `drop`, `takeWhile` | ограничение по количеству | +| `onEach` | побочный эффект без изменения значений | +| `withIndex`, `runningFold`, `scan` | накопление | +| `distinctUntilChanged` | пропуск подряд идущих дублей | +| `debounce`, `sample` | фильтрация по времени | +| `flatMapConcat`, `flatMapMerge`, `flatMapLatest` | поток из потоков | +| `zip`, `combine` | объединение двух потоков | +| `onStart`, `onCompletion`, `catch` | обработка жизненного цикла | + +`transform` - самый общий: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flowOf(1, 2, 3) + .transform { value -> + emit("начало $value") + emit("конец $value") + } + .collect { print("$it; ") } + println() + // начало 1; конец 1; начало 2; конец 2; начало 3; конец 3; +} +``` + +## Терминальные операторы +Только они запускают поток, и все они `suspend`: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val f = flowOf(1, 2, 3, 4) + + println(f.toList()) // [1, 2, 3, 4] + println(f.first()) // 1 + println(f.firstOrNull { it > 10 }) // null + println(f.lastOrNull()) // 4 + println(f.count()) // 4 + println(f.reduce { a, b -> a + b }) // 10 + println(f.fold(100) { a, b -> a + b }) // 110 + println(f.toSet()) // [1, 2, 3, 4] + println(f.filter { it == 2 }.single()) // 2 +} +``` +`first()` отменяет поток сразу после первого значения - этого достаточно, чтобы остановить бесконечный источник: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + var emitted = 0 + val infinite = flow { + var i = 0 + while (true) { + emitted++ + emit(i++) + } + } + println(infinite.first()) // 0 + println("излучено значений: $emitted") // излучено значений: 1 +} +``` + +## Контекст и flowOn +По умолчанию `Flow` выполняется в контексте того, кто его собирает - это называется context preservation: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flow { + println("flow на: ${Thread.currentThread().name}") + emit(1) + }.collect { + println("collect на: ${Thread.currentThread().name}") + } + // flow на: main + // collect на: main +} +``` +Сменить контекст **производителя** можно только через `flowOn`: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flow { + val onPool = Thread.currentThread().name.contains("DefaultDispatcher") + println("flow в пуле: $onPool") + emit(1) + } + .flowOn(Dispatchers.IO) + .collect { + println("collect на: ${Thread.currentThread().name}") + } + // flow в пуле: true + // collect на: main +} +``` +`flowOn` действует на всё, что **выше** него по цепочке: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flowOf(1, 2) + .map { it * 10 } // в IO + .flowOn(Dispatchers.IO) + .map { it + 1 } // в main + .collect { print("$it ") } + println() // 11 21 +} +``` +Менять контекст внутри `flow { }` через `withContext` запрещено: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val broken = flow { + withContext(Dispatchers.IO) { emit(1) } // нарушение контракта + } + val error = runCatching { broken.collect { } }.exceptionOrNull() + println(error is IllegalStateException) // true + println(error?.message?.contains("Flow invariant is violated")) // true +} +``` +Причина запрета - предсказуемость: собирающий код должен быть уверен, что `collect` вызывается в его контексте (например, в UI-потоке). Для смены контекста есть только `flowOn`. + +## Обработка ошибок +`try/catch` работает, но правильнее оператор `catch`: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flow { + emit(1) + throw IllegalStateException("источник упал") + } + .catch { e -> println("перехвачено: ${e.message}") } + .collect { println("значение: $it") } + // значение: 1 + // перехвачено: источник упал +} +``` +`catch` ловит ошибки только **выше** себя - это называется прозрачность исключений: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val error = runCatching { + flowOf(1) + .catch { println("не сработает") } + .collect { throw IllegalStateException("ошибка в collect") } + }.exceptionOrNull() + println("ошибка collect прошла мимо catch: ${error?.message}") + // ошибка collect прошла мимо catch: ошибка в collect +} +``` +Чтобы ловить и ошибки потребителя, нужен `onEach` + `catch` в таком порядке: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flowOf(1, 2) + .onEach { if (it == 2) error("обработка упала на $it") } + .catch { e -> println("перехвачено: ${e.message}") } + .collect { println("обработано: $it") } + // обработано: 1 + // перехвачено: обработка упала на 2 +} +``` +`catch` может подменить поток значениями по умолчанию: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flow { error("сеть недоступна") } + .catch { emit("данные из кеша") } + .collect { println(it) } + // данные из кеша +} +``` + +## retry и повторные попытки +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + var attempt = 0 + flow { + attempt++ + if (attempt < 3) error("попытка $attempt не удалась") + emit("успех на попытке $attempt") + } + .retry(3) { e -> + println("ретрай после: ${e.message}") + delay(10) + true + } + .collect { println(it) } + // ретрай после: попытка 1 не удалась + // ретрай после: попытка 2 не удалась + // успех на попытке 3 +} +``` +`retryWhen` даёт доступ к номеру попытки - это нужно для экспоненциальной задержки: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + var calls = 0 + flow { + calls++ + if (calls < 3) error("сбой") + emit("готово") + } + .retryWhen { _, attempt -> + if (attempt >= 3) false + else { + delay(10L * (attempt + 1)) // 10, 20, 30 мс + println("попытка ${attempt + 1}") + true + } + } + .collect { println(it) } + // попытка 1 + // попытка 2 + // готово +} +``` + +## Жизненный цикл: onStart, onCompletion +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flowOf(1, 2) + .onStart { println("начали") } + .onEach { println("значение $it") } + .onCompletion { cause -> println("завершили, ошибка: $cause") } + .collect { } + // начали + // значение 1 + // значение 2 + // завершили, ошибка: null +} +``` +`onCompletion` вызывается всегда - при успехе, ошибке и отмене, причём получает причину: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flow { + emit(1) + error("упало") + } + .onCompletion { cause -> println("причина: ${cause?.message}") } + .catch { } + .collect { } + // причина: упало +} +``` +`onStart` может излучать значения - это способ показать «загрузка...» до первого реального элемента: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + flow { delay(20); emit("данные") } + .onStart { emit("загрузка...") } + .collect { println(it) } + // загрузка... + // данные +} +``` + +## buffer: производитель и потребитель параллельно +По умолчанию производитель ждёт потребителя: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.system.measureTimeMillis + +fun source() = flow { + repeat(3) { + delay(20) // производство + emit(it) + } +} + +fun main() = runBlocking { + val sequential = measureTimeMillis { + source().collect { delay(20) } // потребление + } + val buffered = measureTimeMillis { + source().buffer().collect { delay(20) } + } + println("без буфера около 120 мс: ${sequential >= 110}") + println("с буфером быстрее: ${buffered < sequential}") + // без буфера около 120 мс: true + // с буфером быстрее: true +} +``` +Без буфера шаги складываются: 3 × (20 + 20) = 120 мс. С буфером производство идёт параллельно с потреблением, и общее время приближается к максимуму из двух конвейеров. + +## conflate и collectLatest +Когда важны только свежие значения, промежуточные можно отбрасывать: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun fast() = flow { + repeat(5) { + delay(10) + emit(it) + } +} + +fun main() = runBlocking { + val all = mutableListOf() + fast().collect { delay(40); all += it } + + val conflated = mutableListOf() + fast().conflate().collect { delay(40); conflated += it } + + println("без conflate получено всё: ${all.size == 5}") + println("с conflate пропущены промежуточные: ${conflated.size < 5}") + println("последнее значение всегда доходит: ${conflated.last() == 4}") + // без conflate получено всё: true + // с conflate пропущены промежуточные: true + // последнее значение всегда доходит: true +} +``` +`collectLatest` работает иначе: он не пропускает значения, а **отменяет** обработку предыдущего: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val completed = mutableListOf() + flow { + repeat(3) { delay(10); emit(it) } + }.collectLatest { value -> + delay(25) // не успеет для первых двух + completed += value + } + println("доработал только последний: $completed") // доработал только последний: [2] +} +``` + +| Стратегия | Поведение при быстром производителе | +|---|---| +| по умолчанию | производитель ждёт потребителя | +| `buffer(n)` | очередь на `n` элементов, затем ожидание | +| `buffer(onBufferOverflow = DROP_OLDEST)` | выбрасывает старые | +| `conflate()` | хранит только последнее непрочитанное | +| `collectLatest` | отменяет обработку устаревшего значения | +| `debounce(ms)` | пропускает только после паузы | +| `sample(ms)` | берёт последнее раз в интервал | + +## Объединение потоков +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val letters = flowOf("a", "b", "c") + val numbers = flowOf(1, 2, 3) + + println(letters.zip(numbers) { l, n -> "$l$n" }.toList()) // [a1, b2, c3] + + val slow = flow { emit(1); delay(30); emit(2) } + val fast2 = flow { emit("x"); delay(10); emit("y") } + println(slow.combine(fast2) { a, b -> "$a$b" }.toList()) + // [1x, 1y, 2y] +} +``` +`zip` ждёт пару - элементов будет столько, сколько в коротком потоке. `combine` излучает при каждом изменении любого из потоков, используя последние известные значения другого. Для UI (например, «форма валидна, если валидны все поля») нужен `combine`. + +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val concatenated = flowOf(1, 2).flatMapConcat { n -> + flow { emit("$n-a"); delay(10); emit("$n-b") } + }.toList() + println(concatenated) // [1-a, 1-b, 2-a, 2-b] + + val latest = flowOf(1, 2, 3).flatMapLatest { n -> + flow { delay(20); emit("результат $n") } + }.toList() + println(latest) // [результат 3] +} +``` +`flatMapLatest` - основной оператор для поиска по вводу: новый запрос отменяет предыдущий. + +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +suspend fun search(query: String): List { + delay(20) + return listOf("результат для «$query»") +} + +fun main() = runBlocking { + val input = flow { + emit("к"); delay(5) + emit("ко"); delay(5) + emit("кот"); delay(100) // пользователь остановился + emit("кота") + } + + input + .debounce(30) // ждём паузу в наборе + .filter { it.length >= 2 } + .flatMapLatest { search(it).asFlow() } + .collect { println(it) } + // результат для «кот» + // результат для «кота» +} +``` +Это каноничный конвейер: `debounce` отсекает промежуточные нажатия, `flatMapLatest` отменяет устаревший запрос. + +```mermaid +graph LR + A["ввод: к, ко, кот"] --> B["debounce(30)"] + B --> C["только 'кот'
после паузы"] + C --> D["flatMapLatest"] + D --> E["запрос к API"] + E --> F["collect: результаты"] + D -.->|"пришёл новый ввод"| G["отмена
предыдущего запроса"] +``` + +## Flow как возвращаемый тип +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +class OrderRepository { + // не suspend: Flow холодный, вызов ничего не запускает + fun observeOrders(): Flow = flow { + repeat(3) { i -> + delay(10) + emit("заказ $i") + } + }.flowOn(Dispatchers.IO) // производитель всегда в IO +} + +fun main() = runBlocking { + OrderRepository().observeOrders().collect { println(it) } + // заказ 0 + // заказ 1 + // заказ 2 +} +``` +Правила проектирования: функция, возвращающая `Flow`, не должна быть `suspend` (создание потока мгновенно), а `flowOn` ставится внутри - вызывающий не обязан знать, где работает источник. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Stream` (синхронный, одноразовый) | `Sequence` | +> | `Flow` из Reactive Streams | `Flow` | +> | RxJava `Observable`/`Flowable` | `Flow` | +> | Project Reactor `Flux` | `Flow` | +> | `Subscriber.request(n)` | приостановка `emit` | +> | `subscribeOn`/`observeOn` | `flowOn` | +> | `onErrorResume` | `catch` | +> | `BlockingQueue` + поток | `Channel` | +> +> **`java.util.stream.Stream` - это не аналог.** `Stream` синхронный: `stream.map(f)` не может ждать ответа сети, а терминальная операция блокирует поток до конца обработки. Прямой аналог `Stream` в Kotlin - `Sequence`. `Flow` отличается тем, что внутри каждого оператора разрешена приостановка. Подробнее: [Sequence](../Коллекции/Sequence.md) +> +> **Reactive Streams решают ту же задачу, но сложнее.** RxJava и Reactor построены на коллбэках (`onNext`, `onError`, `onComplete`) и требуют явного управления давлением через `request(n)`. В `Flow` backpressure получается бесплатно: `emit` - это `suspend`-функция, поэтому если потребитель не готов, производитель просто приостанавливается: +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> var produced = 0 +> flow { +> repeat(100) { produced++; emit(it) } +> }.take(3).collect { } +> println("произведено ровно столько, сколько взяли: $produced") +> // произведено ровно столько, сколько взяли: 3 +> } +> ``` +> Никакого `request(n)` - producer сам останавливается на `emit`. +> +> **Стектрейсы и отладка.** В RxJava стектрейс ошибки состоит из кадров библиотеки, а место в бизнес-логике приходится искать по `onAssembly`-хукам. `Flow` - обычные `suspend`-функции, поэтому исключение приходит с кадрами вашего кода, а `try/catch`, `for` и `return` работают внутри операторов естественно. +> +> **Количество операторов.** В RxJava их несколько сотен (`flatMapIterable`, `concatMapDelayError`, `switchIfEmpty`...), потому что каждый сценарий требует отдельного оператора - внутри цепочки нельзя писать обычный код. В `Flow` операторов несколько десятков: остальное выражается через `transform` и обычные конструкции языка. +> +> **Отмена подписки.** В Rx нужно хранить `Disposable` и не забыть вызвать `dispose()` - классический источник утечек, для борьбы с которым придумали `CompositeDisposable`. `Flow` собирается внутри корутины, поэтому отмена области автоматически прекращает сбор: +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val job = launch { +> flow { +> try { +> repeat(100) { delay(10); emit(it) } +> } finally { println("источник закрыт") } +> }.collect { } +> } +> delay(30) +> job.cancelAndJoin() +> // источник закрыт +> } +> ``` +> +> **Context preservation - то, чего нет в Rx.** В RxJava `observeOn` можно поставить в любом месте, и понять, на каком потоке выполнится `subscribe`, можно только прочитав всю цепочку. `Flow` гарантирует, что `collect` всегда выполняется в контексте собирающего, а `withContext` внутри `flow { }` запрещён на уровне рантайма. +> +> Сравнить: [Многопоточность в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [Функции в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md) + +> [!warning] Подводные камни +> **`Flow` не запустится без терминального оператора:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> var side = 0 +> flowOf(1, 2, 3).onEach { side++ } // collect не вызван +> delay(20) +> println("побочных эффектов: $side") // побочных эффектов: 0 +> } +> ``` +> Компилятор предупреждает о неиспользованном результате, но не всегда - например, если значение присвоено переменной. +> +> **`withContext` внутри `flow { }` бросает исключение в рантайме:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val error = runCatching { +> flow { withContext(Dispatchers.IO) { emit(1) } }.collect { } +> }.exceptionOrNull() +> println(error?.message?.contains("Flow invariant is violated")) // true +> } +> ``` +> Это ошибка времени выполнения, а не компиляции - её легко пропустить в редкой ветке кода. Используйте `flowOn`. +> +> **`flowOn` действует только выше по цепочке.** `flow.map { a }.flowOn(IO).map { b }` выполнит `a` в IO, а `b` - в контексте сборщика. Порядок операторов меняет смысл. +> +> **`catch` не ловит ошибки `collect`:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val error = runCatching { +> flowOf(1).catch { println("не вызовется") }.collect { error("из collect") } +> }.exceptionOrNull() +> println("ошибка прошла мимо: ${error?.message}") // ошибка прошла мимо: из collect +> } +> ``` +> Перенесите логику в `onEach` перед `catch`. +> +> **`emit` из другой корутины запрещён:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val broken = flow { +> coroutineScope { +> launch { emit(1) } // emit в другой корутине +> } +> } +> val error = runCatching { broken.collect { } }.exceptionOrNull() +> println(error != null) // true +> } +> ``` +> Сообщение - про нарушение инварианта. Для излучения из нескольких источников нужен `channelFlow` или `callbackFlow`. +> +> **`callbackFlow` без `awaitClose` падает:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val broken = callbackFlow { +> trySend(1) +> // нет awaitClose +> } +> val error = runCatching { broken.collect { } }.exceptionOrNull() +> println(error is IllegalStateException) // true +> } +> ``` +> Это защита от утечки слушателя: без `awaitClose` библиотека не знает, когда отписываться. +> +> **`trySend` молча теряет значения при полном буфере.** У `callbackFlow` буфер по умолчанию 64 элемента; `trySend` возвращает `ChannelResult`, который обычно игнорируют. Если источник быстрее потребителя, часть событий исчезнет - проверяйте результат или задавайте `buffer(Channel.UNLIMITED)` осознанно. +> +> **Повторный `collect` запускает поток заново.** Если `Flow` делает сетевой запрос, два `collect` - это два запроса. Для разделения результата между подписчиками нужен `shareIn`/`stateIn`. Подробнее: [StateFlow и SharedFlow](StateFlow%20и%20SharedFlow.md) +> +> **`Flow` без буфера последователен, а не параллелен:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> import kotlin.system.measureTimeMillis +> +> fun main() = runBlocking { +> val t = measureTimeMillis { +> flowOf(1, 2, 3).map { delay(30); it }.collect { } +> } +> println("последовательно, около 90 мс: ${t >= 85}") // последовательно, около 90 мс: true +> } +> ``` +> `map` с `suspend` внутри обрабатывает элементы по одному. Для параллельной обработки нужен `flatMapMerge` с `concurrency` или `map { async { } }` поверх списка. +> +> **`conflate` и `collectLatest` теряют данные - это их цель.** Для UI-состояния это правильно, для обработки платежей или записи в журнал - нет. Убедитесь, что потеря промежуточных значений допустима. +> +> **`debounce` задерживает даже последнее значение.** Пользователь перестал печатать - результат появится только через указанный интервал. Слишком большое значение ощущается как лаг интерфейса. +> +> **Бесконечный `Flow` в `toList()` повиснет.** Терминальные операторы, собирающие всё (`toList`, `count`, `fold`), на бесконечном источнике не завершатся никогда. Нужен `take(n)` или `first()`. +> +> **`SharingStarted.Eagerly` в `stateIn` держит источник активным всегда** - типичная утечка при переносе холодного `Flow` в горячий. Подробнее: [StateFlow и SharedFlow](StateFlow%20и%20SharedFlow.md) +> +> **Исключение в `onCompletion` не перехватывается предыдущим `catch`.** `onCompletion` - не `finally`: он не может подавить ошибку, а своя ошибка внутри него уйдёт потребителю. +> +> **`first()` отменяет источник.** Для `callbackFlow` это значит, что слушатель будет снят сразу после первого значения - если вы ожидали продолжения подписки, её нет. + +Далее: [StateFlow и SharedFlow](StateFlow%20и%20SharedFlow.md) diff --git a/Kotlin/Корутины/StateFlow и SharedFlow.md b/Kotlin/Корутины/StateFlow и SharedFlow.md new file mode 100644 index 0000000..c730964 --- /dev/null +++ b/Kotlin/Корутины/StateFlow и SharedFlow.md @@ -0,0 +1,618 @@ +--- +title: StateFlow и SharedFlow +description: Горячие потоки: хранение состояния, события, replay, stateIn и shareIn, конфляция и выбор между ними +tags: + - kotlin + - coroutines + - flow +--- + +`StateFlow` и `SharedFlow` - **горячие** потоки: они существуют и работают независимо от подписчиков. Обычный `Flow` холодный - он запускается на каждый `collect` и умирает вместе с ним. + +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val state = MutableStateFlow(0) + + val job = launch { + state.collect { println("подписчик: $it") } + } + delay(20) + state.value = 1 + state.value = 2 + delay(20) + job.cancel() + println("текущее значение без подписчиков: ${state.value}") + // подписчик: 0 + // подписчик: 1 + // подписчик: 2 + // текущее значение без подписчиков: 2 +} +``` +Обратите внимание: подписчик сразу получил текущее значение `0`, а после отмены сбора состояние осталось доступным через `.value`. + +## StateFlow: состояние +`StateFlow` - это наблюдаемое значение. У него всегда есть текущее состояние, и оно всегда одно. + +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +class Counter { + private val _count = MutableStateFlow(0) + val count: StateFlow = _count.asStateFlow() // только чтение снаружи + + fun increment() { _count.value++ } + fun add(n: Int) = _count.update { it + n } // атомарно +} + +fun main() = runBlocking { + val counter = Counter() + val job = launch { counter.count.collect { println("счёт: $it") } } + + delay(20) + counter.increment() + delay(20) + counter.add(10) + delay(20) + job.cancel() + // счёт: 0 + // счёт: 1 + // счёт: 11 +} +``` +Пара «приватный `MutableStateFlow` + публичный `StateFlow`» - основной шаблон: изменять состояние может только владелец, читать - все. + +## StateFlow конфлюирует и дедуплицирует +Два свойства, которые нужно знать наизусть. + +**1. Одинаковые подряд значения не доходят** - `StateFlow` сравнивает через `equals`: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val state = MutableStateFlow("a") + val received = mutableListOf() + val job = launch { state.collect { received += it } } + + delay(20) + state.value = "a"; delay(20) // то же значение — не придёт + state.value = "b"; delay(20) + state.value = "b"; delay(20) // снова дубль + state.value = "a"; delay(20) + job.cancel() + println(received) // [a, b, a] +} +``` +Задержки между присваиваниями здесь не для красоты: без них подписчик не успел бы обработать значения и сработало бы второе свойство - конфляция (см. ниже), из-за которой он увидел бы только `a` и финальное значение. + +Это бесплатный `distinctUntilChanged`, но он же источник ошибок с изменяемыми объектами: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +class MutableBox(val items: MutableList) + +fun main() = runBlocking { + val box = MutableBox(mutableListOf(1)) + val state = MutableStateFlow(box) + var updates = 0 + val job = launch { state.collect { updates++ } } + + delay(20) + box.items.add(2) // изменили содержимое + state.value = box // тот же объект — equals true + delay(20) + job.cancel() + println("обновлений: $updates") // обновлений: 1 +} +``` +Подписчик не узнал об изменении. Поэтому состояние в `StateFlow` должно быть неизменяемым - `data class` с `val` и неизменяемыми коллекциями. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +**2. Медленный подписчик пропускает промежуточные значения:** +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val state = MutableStateFlow(0) + val received = mutableListOf() + val job = launch { + state.collect { delay(50); received += it } + } + delay(10) + repeat(10) { state.value = it + 1 } + delay(150) + job.cancel() + println("получено не всё: ${received.size < 10}") + println("последнее значение дошло: ${received.last() == 10}") + // получено не всё: true + // последнее значение дошло: true +} +``` +`StateFlow` - про актуальное состояние, а не про историю. Последнее значение дойдёт всегда, промежуточные - нет. + +## MutableStateFlow.update +Присваивание `value` не атомарно при конкуренции: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val unsafe = MutableStateFlow(0) + withContext(Dispatchers.Default) { + List(100) { launch { repeat(100) { unsafe.value = unsafe.value + 1 } } } + .forEach { it.join() } + } + println("потеряны обновления: ${unsafe.value < 10_000}") + + val safe = MutableStateFlow(0) + withContext(Dispatchers.Default) { + List(100) { launch { repeat(100) { safe.update { v -> v + 1 } } } } + .forEach { it.join() } + } + println("update атомарен: ${safe.value == 10_000}") + // потеряны обновления: true + // update атомарен: true +} +``` +`update` выполняет CAS-цикл, поэтому при конкурентных изменениях нужен именно он. Подробнее: [Разделяемое состояние](Разделяемое%20состояние.md) + +## SharedFlow: события +`SharedFlow` не хранит «текущее значение» - он транслирует события всем подписчикам. +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val events = MutableSharedFlow() + + val a = launch { events.collect { println("A: $it") } } + val b = launch { events.collect { println("B: $it") } } + delay(20) // дать подписчикам стартовать + + events.emit("событие 1") + delay(20) + a.cancel(); b.cancel() + // A: событие 1 + // B: событие 1 +} +``` +По умолчанию `replay = 0`: подписчик получает только то, что произошло **после** подписки. Событие, отправленное без подписчиков, исчезает: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val events = MutableSharedFlow() + events.emit("в пустоту") // подписчиков нет + + val received = mutableListOf() + val job = launch { events.collect { received += it } } + delay(20) + events.emit("услышано") + delay(20) + job.cancel() + println(received) // [услышано] +} +``` + +## replay +`replay` задаёт, сколько последних событий получит новый подписчик: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val events = MutableSharedFlow(replay = 2) + events.emit(1) + events.emit(2) + events.emit(3) + + val received = mutableListOf() + val job = launch { events.collect { received += it } } + delay(20) + job.cancel() + println(received) // [2, 3] +} +``` +`StateFlow` эквивалентен `SharedFlow(replay = 1)` с дедупликацией и обязательным начальным значением. + +## Буфер и стратегия переполнения +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val dropping = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 2, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val received = mutableListOf() + val job = launch { dropping.collect { delay(50); received += it } } + delay(20) + + repeat(10) { dropping.emit(it) } // emit не приостанавливается + delay(200) + job.cancel() + println("часть событий отброшена: ${received.size < 10}") // часть событий отброшена: true +} +``` +| Параметр | Смысл | +|---|---| +| `replay` | сколько событий повторить новому подписчику | +| `extraBufferCapacity` | буфер сверх `replay` | +| `onBufferOverflow` | `SUSPEND` (по умолчанию), `DROP_OLDEST`, `DROP_LATEST` | + +Важно: `DROP_OLDEST`/`DROP_LATEST` работают только при ненулевом буфере (`replay + extraBufferCapacity > 0`). + +`tryEmit` - неприостанавливающаяся отправка: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val noBuffer = MutableSharedFlow() + println("без буфера и подписчиков: ${noBuffer.tryEmit(1)}") // без буфера и подписчиков: true + + val withSubscriber = MutableSharedFlow() + val job = launch { withSubscriber.collect { delay(1000) } } + delay(20) + println("подписчик занят, буфера нет: ${withSubscriber.tryEmit(2)}") // подписчик занят, буфера нет: false + job.cancel() +} +``` +Тонкость, которая удивляет: без подписчиков `tryEmit` возвращает `true`, хотя событие никто не получил - отправка «удалась», получателей просто не было. + +## stateIn и shareIn +Превращают холодный `Flow` в горячий: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + var starts = 0 + val cold = flow { + starts++ + repeat(3) { delay(20); emit(it) } + } + + val hot = cold.stateIn( + scope = this, + started = SharingStarted.Eagerly, + initialValue = -1, + ) + delay(30) + + val a = launch { hot.take(1).collect { println("A видит: $it") } } + val b = launch { hot.take(1).collect { println("B видит: $it") } } + a.join(); b.join() + println("источник запущен раз: $starts") + // A видит: 0 + // B видит: 0 + // источник запущен раз: 1 +} +``` +Без `stateIn` два подписчика запустили бы источник дважды - два сетевых запроса вместо одного. + +`SharingStarted` определяет жизненный цикл источника: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + var active = 0 + val source = flow { + active++ + try { + repeat(100) { delay(10); emit(it) } + } finally { active-- } + } + + val shared = source.shareIn( + scope = this, + started = SharingStarted.WhileSubscribed(), + replay = 1, + ) + + val job = launch { shared.collect { } } + delay(30) + println("источник активен при подписчике: ${active == 1}") + job.cancelAndJoin() + delay(30) + println("источник остановлен без подписчиков: ${active == 0}") + coroutineContext.cancelChildren() // иначе runBlocking ждёт корутину shareIn + // источник активен при подписчике: true + // источник остановлен без подписчиков: true +} +``` +| `SharingStarted` | Когда источник активен | +|---|---| +| `Eagerly` | сразу и до отмены scope | +| `Lazily` | с первого подписчика, затем навсегда | +| `WhileSubscribed()` | только пока есть подписчики | +| `WhileSubscribed(stopTimeoutMillis = 5000)` | плюс пауза перед остановкой | + +`WhileSubscribed(5000)` - стандартный выбор для Android: при повороте экрана подписчик пересоздаётся за миллисекунды, и таймаут не даёт зря перезапускать запрос. + +## Как выбрать +```mermaid +graph TD + A["Нужен поток значений"] --> B{"Один подписчик,
работа по запросу?"} + B -->|"да"| C["обычный Flow
холодный"] + B -->|"нет, нужно делить"| D{"Есть текущее
значение?"} + D -->|"да, это состояние"| E["StateFlow
initialValue обязателен"] + D -->|"нет, это события"| F{"Нужна история
для новых подписчиков?"} + F -->|"нет"| G["SharedFlow
replay = 0"] + F -->|"да, n последних"| H["SharedFlow
replay = n"] +``` + +| | `StateFlow` | `SharedFlow` | +|---|---|---| +| начальное значение | обязательно | нет | +| `.value` | есть | нет | +| дедупликация | да, по `equals` | нет | +| `replay` | всегда 1 | настраивается | +| потеря промежуточных | да, конфляция | зависит от буфера | +| типичное применение | состояние экрана, настройки | навигация, снекбары, ошибки | + +Практическое правило: состояние (что сейчас на экране) - `StateFlow`; события (что произошло один раз) - `SharedFlow`. Показ сообщения об ошибке через `StateFlow` приводит к повторному показу при пересоздании подписчика. + +## Состояние экрана целиком +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +data class ScreenState( + val loading: Boolean = false, + val items: List = emptyList(), + val error: String? = null, +) + +class ViewModel(private val scope: CoroutineScope) { + private val _state = MutableStateFlow(ScreenState()) + val state: StateFlow = _state.asStateFlow() + + fun load() { + scope.launch { + _state.update { it.copy(loading = true, error = null) } + delay(20) + _state.update { it.copy(loading = false, items = listOf("первый", "второй")) } + } + } +} + +fun main() = runBlocking { + val vm = ViewModel(this) + val job = launch { vm.state.collect { println(it) } } + delay(10) + vm.load() + delay(60) + job.cancel() + // ScreenState(loading=false, items=[], error=null) + // ScreenState(loading=true, items=[], error=null) + // ScreenState(loading=false, items=[первый, второй], error=null) +} +``` +Один `StateFlow` на всё состояние лучше трёх отдельных: подписчик видит согласованный снимок, а не промежуточные комбинации полей. `copy` + `update` делают изменение атомарным. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +## Производные состояния +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val query = MutableStateFlow("") + val items = MutableStateFlow(listOf("кот", "кофе", "книга")) + + val filtered = combine(query, items) { q, list -> + list.filter { it.startsWith(q) } + }.stateIn(this, SharingStarted.Eagerly, emptyList()) + + delay(20) // Eagerly запускает корутину, но не мгновенно + println(filtered.value) // [кот, кофе, книга] + query.value = "ко" + delay(20) + println(filtered.value) // [кот, кофе] + + coroutineContext.cancelChildren() +} +``` +`combine` + `stateIn` - способ получить вычисляемое состояние из нескольких источников. Важно вызвать `cancelChildren()` или ограничить scope: `stateIn` с `Eagerly` запускает корутину, которая живёт до отмены области. + +## subscriptionCount +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +fun main() = runBlocking { + val events = MutableSharedFlow() + println("подписчиков: ${events.subscriptionCount.value}") // подписчиков: 0 + + val job = launch { events.collect { } } + delay(20) + println("после подписки: ${events.subscriptionCount.value}") // после подписки: 1 + + job.cancelAndJoin() + println("после отмены: ${events.subscriptionCount.value}") // после отмены: 0 +} +``` +Это `StateFlow`, поэтому за числом подписчиков можно наблюдать - именно так `WhileSubscribed` понимает, когда останавливать источник. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Observable` + `Observer` (паттерн) | `StateFlow` | +> | `PropertyChangeListener` | `StateFlow` | +> | RxJava `BehaviorSubject` | `StateFlow` | +> | RxJava `PublishSubject` | `SharedFlow(replay = 0)` | +> | RxJava `ReplaySubject(n)` | `SharedFlow(replay = n)` | +> | `AtomicReference` + слушатели | `MutableStateFlow` | +> | `CopyOnWriteArrayList` | встроенная многоадресность | +> +> **Паттерн «наблюдатель» вручную.** В Java его пишут заново в каждом проекте: список слушателей, синхронизация при добавлении и удалении, защита от `ConcurrentModificationException` при оповещении, снятие слушателей при закрытии компонента. Классическая реализация: +> ```java +> private final List> listeners = new CopyOnWriteArrayList<>(); +> private volatile int value; +> +> public void setValue(int v) { +> this.value = v; +> for (var l : listeners) l.accept(v); +> } +> public void addListener(Consumer l) { listeners.add(l); } +> public void removeListener(Consumer l) { listeners.remove(l); } +> ``` +> Здесь уже есть проблемы: оповещение идёт в потоке вызывающего, медленный слушатель задерживает остальных, забытый `removeListener` - утечка. `MutableStateFlow` решает всё это за одну строку, а отписка происходит автоматически при отмене корутины-подписчика. +> +> **`BehaviorSubject` против `StateFlow`.** Прямой аналог, но с двумя отличиями. `BehaviorSubject` не дедуплицирует одинаковые значения - в Rx для этого добавляют `distinctUntilChanged()` руками. И `Subject` можно «завершить» через `onComplete`/`onError`, после чего он мёртв; `StateFlow` не завершается никогда - ошибки передают внутри состояния (поле `error`), что для UI удобнее: экран всегда в каком-то состоянии. +> +> **Утечки подписок.** В Rx подписка возвращает `Disposable`, который нужно хранить и вызывать `dispose()`; для борьбы с забывчивостью придумали `CompositeDisposable` и `AutoDispose`. В Kotlin подписчик - корутина, поэтому отмена scope отписывает всех: +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val state = MutableStateFlow(0) +> val scope = CoroutineScope(Job()) +> scope.launch { state.collect { } } +> delay(20) +> println("подписчиков: ${state.subscriptionCount.value}") +> scope.cancel() +> delay(20) +> println("после cancel: ${state.subscriptionCount.value}") +> // подписчиков: 1 +> // после cancel: 0 +> } +> ``` +> +> **`volatile` поле против `StateFlow`.** `volatile int value` даёт видимость между потоками, но не даёт ни оповещения, ни атомарного обновления (`value++` всё равно гонка - нужен `AtomicInteger`). `MutableStateFlow` совмещает три роли: потокобезопасное хранилище, атомарный `update` и многоадресная рассылка. +> +> **Нет аналога `WhileSubscribed`.** В Java управление жизненным циклом источника при появлении и исчезновении слушателей пишут вручную: счётчик подписчиков, запуск потока при первом, остановка при последнем, таймаут на повторное подключение. `shareIn(started = WhileSubscribed(5000))` - это одна строка. +> +> Сравнить: [Многопоточность в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [Паттерны проектирования](../Функциональный%20Kotlin/Паттерны%20проектирования.md) + +> [!warning] Подводные камни +> **`StateFlow` не доставит одинаковое значение дважды:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val showError = MutableStateFlow("ошибка сети") +> var shown = 0 +> val job = launch { showError.collect { shown++ } } +> delay(20) +> showError.value = "ошибка сети" // то же самое — не придёт +> delay(20) +> job.cancel() +> println("показов: $shown") // показов: 1 +> } +> ``` +> Для повторяющихся событий (две одинаковые ошибки подряд) нужен `SharedFlow`. +> +> **Изменяемое состояние внутри `StateFlow` ломает оповещение.** Если вы положили `MutableList` и меняете его содержимое, `equals` вернёт `true` и подписчики ничего не узнают. Храните неизменяемые `data class` и обновляйте через `copy`. +> +> **`state.value = state.value + 1` - гонка:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val state = MutableStateFlow(0) +> withContext(Dispatchers.Default) { +> List(50) { launch { repeat(200) { state.value = state.value + 1 } } } +> .forEach { it.join() } +> } +> println("обновления потеряны: ${state.value < 10_000}") // обновления потеряны: true +> } +> ``` +> Используйте `update { }`. +> +> **`SharedFlow` без подписчиков теряет события.** `emit` при `replay = 0` и нулевом буфере просто завершится, не доставив ничего. Для «событий, которые нельзя потерять» нужен `replay ≥ 1` или `Channel`. Подробнее: [Channels](Channels.md) +> +> **`emit` в `SharedFlow` с `SUSPEND` приостанавливается на медленном подписчике:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> import kotlin.system.measureTimeMillis +> +> fun main() = runBlocking { +> val events = MutableSharedFlow() +> val job = launch { events.collect { delay(100) } } +> delay(20) +> val t = measureTimeMillis { events.emit(1); events.emit(2) } +> println("emit ждал подписчика: ${t >= 90}") // emit ждал подписчика: true +> job.cancel() +> } +> ``` +> Один зависший подписчик блокирует отправку всем. Задавайте `extraBufferCapacity` и стратегию переполнения. +> +> **`tryEmit` возвращает `true` без подписчиков**, хотя событие никто не получил. Проверка `if (tryEmit(x))` не означает «доставлено». +> +> **`DROP_OLDEST` без буфера не работает.** При `replay = 0` и `extraBufferCapacity = 0` стратегия переполнения игнорируется, потому что буфера нет - `emit` будет приостанавливаться. +> +> **`SharingStarted.Eagerly` держит источник навсегда:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> var active = 0 +> val source = flow { +> active++ +> try { repeat(1000) { delay(10); emit(it) } } finally { active-- } +> } +> val hot = source.stateIn(this, SharingStarted.Eagerly, -1) +> delay(30) +> println("работает без единого подписчика: ${active == 1}") +> coroutineContext.cancelChildren() +> // работает без единого подписчика: true +> } +> ``` +> Если источник - опрос сервера, он будет работать и жечь батарею, пока жив scope. Для подписок UI нужен `WhileSubscribed()`. +> +> **`stateIn`/`shareIn` требуют scope, который кто-то отменит.** Передача `GlobalScope` или scope, живущего весь процесс, превращает их в утечку. +> +> **`StateFlow.value` - снимок, а не транзакция:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val a = MutableStateFlow(1) +> val b = MutableStateFlow(10) +> // между чтениями значения могли измениться +> val sum = a.value + b.value +> println("несогласованный снимок возможен: $sum") // несогласованный снимок возможен: 11 +> } +> ``` +> Два связанных значения нужно держать в одном `StateFlow` как единый объект состояния. +> +> **`collect` на `StateFlow` никогда не завершается.** Это бесконечный поток: `toList()` на нём повиснет, `onCompletion` не вызовется сам. Нужен `take(n)`, `first()` или отмена корутины. +> +> **Подписчик, созданный до `emit`, может его пропустить:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.flow.* +> +> fun main() = runBlocking { +> val events = MutableSharedFlow() +> launch { events.collect { println("не увидит: $it") } } +> events.emit(1) // launch ещё не успел подписаться +> delay(20) +> coroutineContext.cancelChildren() +> // (ничего не напечатано) +> } +> ``` +> `launch` возвращается до начала тела, поэтому подписка ещё не оформлена. В тестах для этого используют `events.subscriptionCount.first { it > 0 }`, в продакшене - `replay ≥ 1`. +> +> **`asStateFlow()` не копирует состояние** - это обёртка только для чтения над тем же объектом. Если внешний код получит доступ к исходному `MutableStateFlow`, он сможет его менять. +> +> **Ошибка в подписчике отменяет его подписку, а не поток.** Исключение в теле `collect` уходит в корутину-подписчик и может уронить её scope, при этом `StateFlow` продолжит жить. Оборачивайте тело `collect` в `try/catch`, если ошибки ожидаемы. + +Далее: [Channels](Channels.md) diff --git a/Kotlin/Корутины/Suspend-функции.md b/Kotlin/Корутины/Suspend-функции.md new file mode 100644 index 0000000..99e9675 --- /dev/null +++ b/Kotlin/Корутины/Suspend-функции.md @@ -0,0 +1,395 @@ +--- +title: Suspend-функции +description: Модификатор suspend, Continuation, машина состояний и мост к колбэчным API +tags: + - kotlin + - coroutines +--- + +`suspend` означает: эта функция может приостановиться, поэтому вызывать её разрешено только оттуда, где приостановка возможна. + +```kotlin +import kotlinx.coroutines.* + +suspend fun fetchData(): String { + delay(100) // точка приостановки + return "данные" +} + +fun main() = runBlocking { + println(fetchData()) // данные +} +``` +Вне корутины вызов не скомпилируется: `fun main() { fetchData() }` даёт «Suspend function can only be called within coroutine body». + +## Что добавляет компилятор +`suspend fun fetchData(): String` превращается в метод с дополнительным параметром: +```kotlin +// примерно так это выглядит с точки зрения Java: +// Object fetchData(Continuation continuation) +``` +`Continuation` - это «что делать дальше»: объект с методом `resumeWith`, хранящий сохранённое состояние функции. +```kotlin +import kotlin.coroutines.* + +fun main() { + val continuation = object : Continuation { + override val context = EmptyCoroutineContext + override fun resumeWith(result: Result) { + println("получено: ${result.getOrNull()}") + } + } + continuation.resumeWith(Result.success("значение")) // получено: значение +} +``` +Отсюда и название CPS - continuation-passing style. Компилятор делает это преобразование автоматически, поэтому в исходнике остаётся обычный последовательный код. + +## Машина состояний +Каждая точка приостановки превращается в метку состояния: +```kotlin +import kotlinx.coroutines.* + +suspend fun threeSteps(): Int { + println("шаг 1") // состояние 0 + delay(10) + println("шаг 2") // состояние 1 + delay(10) + println("шаг 3") // состояние 2 + return 42 +} + +fun main() = runBlocking { + println(threeSteps()) + // шаг 1 + // шаг 2 + // шаг 3 + // 42 +} +``` +В байт-коде это один метод с `switch` по полю `label`: при каждом возобновлении он входит в себя заново и прыгает к нужной метке. Локальные переменные, живущие между приостановками, становятся полями объекта-продолжения. + +Практические следствия: +- приостановка стоит дешевле переключения потока, но дороже обычного вызова +- глубоко вложенные `suspend`-вызовы создают по объекту продолжения на кадр +- стектрейс восстанавливается библиотекой, а не JVM - поэтому он может быть короче ожидаемого + +## Функция, которая не приостанавливается +`suspend` не обязывает приостанавливаться: +```kotlin +import kotlinx.coroutines.* + +suspend fun maybeSlow(cached: String?): String { + cached?.let { return it } // быстрый путь: без приостановки + delay(100) + return "загружено" +} + +fun main() = runBlocking { + println(maybeSlow("из кеша")) // из кеша + println(maybeSlow(null)) // загружено +} +``` +Если приостановки не произошло, функция возвращает значение напрямую - без накладных расходов. Это важно для «горячих» путей: `suspend` не означает «всегда медленно». + +Компилятор предупреждает о `suspend` без точек приостановки: +```kotlin +suspend fun noSuspension(): Int = 42 // warning: redundant 'suspend' modifier + +fun main() { + println("нужен вызов из корутины или runBlocking") +} +``` +Такой модификатор стоит убрать - он заставляет вызывающих быть `suspend` без причины. + +## suspend-лямбды и типы +```kotlin +import kotlinx.coroutines.* + +suspend fun retry(times: Int, block: suspend () -> String): String { + repeat(times - 1) { attempt -> + try { + return block() + } catch (e: Exception) { + println("попытка ${attempt + 1}: ${e.message}") + delay(10) + } + } + return block() +} + +fun main() = runBlocking { + var counter = 0 + val result = retry(3) { + counter++ + delay(5) + if (counter < 3) error("не готово") else "успех" + } + println(result) + // попытка 1: не готово + // попытка 2: не готово + // успех +} +``` +Тип `suspend () -> String` - функциональный тип, внутри которого можно приостанавливаться. Без `suspend` в типе параметра `delay` внутри лямбды не скомпилируется. + +Для лямбды с получателем аналогично: `suspend CoroutineScope.() -> Unit` - именно так объявлены `launch` и `runBlocking`. + +## Приостановка без блокировки: suspendCancellableCoroutine +Это мост от колбэчного API к `suspend`-функции - основной способ интегрировать существующие библиотеки. +```kotlin +import kotlinx.coroutines.* +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +// колбэчный API, который нужно обернуть +fun legacyLoad(onSuccess: (String) -> Unit, onError: (Throwable) -> Unit) { + Thread { + Thread.sleep(50) + onSuccess("результат из колбэка") + }.start() +} + +suspend fun load(): String = suspendCancellableCoroutine { cont -> + legacyLoad( + onSuccess = { cont.resume(it) }, + onError = { cont.resumeWithException(it) }, + ) +} + +fun main() = runBlocking { + println(load()) // результат из колбэка +} +``` +Правила: `resume` или `resumeWithException` нужно вызвать **ровно один раз**. Повторный вызов бросит `IllegalStateException`. + +Отмену нужно поддержать явно: +```kotlin +import kotlinx.coroutines.* +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.resume + +suspend fun awaitFuture(future: CompletableFuture): String = + suspendCancellableCoroutine { cont -> + future.whenComplete { value, error -> + if (error != null) cont.cancel(error) else cont.resume(value) + } + cont.invokeOnCancellation { future.cancel(true) } // освобождаем ресурс + } + +fun main() = runBlocking { + val future = CompletableFuture.supplyAsync { + Thread.sleep(30) + "из future" + } + println(awaitFuture(future)) // из future +} +``` +`invokeOnCancellation` - то, что отличает корректную обёртку от утечки: при отмене корутины нижележащий запрос тоже нужно отменить. Подробнее: [Отмена и таймауты](Отмена%20и%20таймауты.md) + +## Последовательность и параллельность +`suspend`-функции по умолчанию выполняются последовательно: +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +suspend fun step(name: String): String { delay(100); return name } + +fun main() = runBlocking { + val sequential = measureTimeMillis { + val a = step("a") + val b = step("b") + println("$a$b") // ab + } + println("последовательно около 200 мс: ${sequential >= 200}") // последовательно около 200 мс: true + + val parallel = measureTimeMillis { + coroutineScope { + val a = async { step("a") } + val b = async { step("b") } + println("${a.await()}${b.await()}") // ab + } + } + println("параллельно около 100 мс: ${parallel < 180}") // параллельно около 100 мс: true +} +``` +Это сознательное решение: последовательность - разумное поведение по умолчанию, параллельность требуется явно запросить через `async`. Подробнее: [Билдеры корутин](Билдеры%20корутин.md) + +## Правила проектирования suspend-API +| Правило | Почему | +|---|---| +| `suspend fun` не должна блокировать поток | иначе теряется весь смысл | +| смена диспетчера - внутри функции, а не у вызывающего | вызывающий не должен знать, I/O это или CPU | +| функция должна быть безопасна для отмены | иначе отмена не работает или течёт | +| возвращать значение, а не `Deferred` | параллельность - дело вызывающего | +| `suspend` + возврат `Flow` обычно излишни | `Flow` сам по себе холодный | + +Правило про диспетчер - самое практичное: +```kotlin +import kotlinx.coroutines.* + +// хорошо: вызывающему не нужно думать о потоках +suspend fun readConfig(): String = withContext(Dispatchers.IO) { + "содержимое файла" // здесь был бы блокирующий File.readText +} + +fun main() = runBlocking { + println(readConfig()) // содержимое файла +} +``` +Такая функция называется main-safe: её можно вызвать с любого потока, включая UI. + +## suspend и наследование +```kotlin +import kotlinx.coroutines.* + +interface Repository { + suspend fun find(id: Int): String? +} + +class RemoteRepository : Repository { + override suspend fun find(id: Int): String? { + delay(10) + return if (id == 1) "найдено" else null + } +} + +class CachedRepository : Repository { + private val cache = mapOf(1 to "из кеша") + override suspend fun find(id: Int): String? = cache[id] // без приостановки +} + +fun main() = runBlocking { + println(RemoteRepository().find(1)) // найдено + println(CachedRepository().find(1)) // из кеша + println(CachedRepository().find(2)) // null +} +``` +`suspend` в интерфейсе - правильный способ объявить операцию, которая **может** быть асинхронной. Реализация решает, приостанавливаться ли. Обратное невозможно: не-suspend метод интерфейса нельзя переопределить как `suspend`. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `CompletableFuture f()` | `suspend fun f(): T` | +> | `void f(Callback cb)` | `suspend fun f(): T` | +> | `f().thenApply(x -> ...)` | `val x = f()` | +> | `CompletionStage` в сигнатуре | `suspend` в модификаторе | +> | блокирующий `f()` + пул потоков | `suspend` + `withContext(IO)` | +> | нет аналога | `Continuation` | +> +> **Асинхронность в типе возврата против модификатора.** В Java асинхронная функция возвращает обёртку: `CompletableFuture`. Это заражает тип: `List>` приходится разворачивать через `allOf`, а вложенные вызовы дают `CompletableFuture>` и `thenCompose` вместо `thenApply`. В Kotlin тип возврата остаётся обычным (`User`), а асинхронность выражается модификатором: +> ```kotlin +> import kotlinx.coroutines.* +> +> suspend fun loadOne(id: Int): String { delay(10); return "u$id" } +> +> suspend fun loadMany(ids: List): List = coroutineScope { +> ids.map { async { loadOne(it) } }.awaitAll() +> } +> +> fun main() = runBlocking { println(loadMany(listOf(1, 2, 3))) } // [u1, u2, u3] +> ``` +> В Java это `CompletableFuture.allOf(...)` с ручной сборкой результатов и приведением типов. +> +> **`Continuation` - это обобщённый коллбэк.** В сущности `suspend fun f(): T` компилируется в `f(Continuation)`, то есть в тот же колбэчный стиль, который в Java пишут руками. Разница в том, что преобразование делает компилятор, поэтому исходник остаётся линейным, а в Java программист пишет лямбды сам и получает «callback hell» при вложенности. +> +> **Из Java `suspend`-функцию вызвать неудобно.** Она видна как метод с лишним параметром `Continuation`, возвращающий `Object`. Для интеграции есть готовые мосты: `future { }` из `kotlinx-coroutines-jdk8` возвращает `CompletableFuture`, а `runBlocking` даёт синхронный вызов. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) +> +> **Виртуальные потоки убирают необходимость в модификаторе.** В Java 21 блокирующий `loadOne(id)` на виртуальном потоке масштабируется без изменения сигнатуры - это главный плюс Loom: существующий код работает как есть. Цена - компилятор не знает, где функция блокируется, поэтому нет ни структурированной отмены по дереву, ни проверки «не блокируй UI-поток» на этапе компиляции. +> +> Сравнить: [Многопоточность в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [Функции в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md) + +> [!warning] Подводные камни +> **Блокировка внутри `suspend` сводит корутины на нет:** +> ```kotlin +> import kotlinx.coroutines.* +> +> // плохо: функция объявлена suspend, но блокирует поток +> suspend fun badRead(): String { +> Thread.sleep(50) +> return "заблокировал поток" +> } +> +> // хорошо: блокирующий код в подходящем диспетчере +> suspend fun goodRead(): String = withContext(Dispatchers.IO) { +> Thread.sleep(50) +> "поток из IO-пула" +> } +> +> fun main() = runBlocking { +> println(badRead()) +> println(goodRead()) +> // заблокировал поток +> // поток из IO-пула +> } +> ``` +> Компилятор это не поймает: `suspend` не запрещает блокировку. Ошибка проявится как «приложение зависает под нагрузкой». +> +> **Повторный `resume` бросает исключение:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlin.coroutines.resume +> +> suspend fun broken(): String = suspendCancellableCoroutine { cont -> +> cont.resume("первый") +> // cont.resume("второй") // IllegalStateException: Already resumed +> } +> +> fun main() = runBlocking { println(broken()) } // первый +> ``` +> Колбэчные API, которые могут вызвать коллбэк дважды (например, при ретраях внутри), требуют защиты флагом или `isActive`-проверки. +> +> **Обёртка без `invokeOnCancellation` течёт.** При отмене корутины нижележащий запрос продолжит работу: соединение останется открытым, таймер - запущенным, файл - читаемым. Всегда освобождайте ресурс в `invokeOnCancellation`. +> +> **`suspendCoroutine` без `Cancellable` игнорирует отмену:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch { +> try { +> withTimeout(30) { delay(1000) } +> } catch (e: TimeoutCancellationException) { +> println("таймаут сработал: ${e::class.simpleName}") +> } +> } +> job.join() +> } +> ``` +> `delay` отменяем, поэтому таймаут работает. А вот `suspendCoroutine { }` (без `Cancellable`) приостановится навсегда и таймаут его не прервёт - используйте `suspendCancellableCoroutine`. +> +> **`suspend` поднимается по всему стеку вызовов.** Добавить приостановку в глубоко вложенную функцию означает помечать `suspend` всю цепочку вверх. Если наверху - не-suspend интерфейс (например, `Comparator` или `equals`), сделать это невозможно, и придётся либо менять архитектуру, либо блокировать через `runBlocking`. +> +> **`suspend` нельзя использовать в конструкторе, геттере свойства и `init`:** +> ```kotlin +> import kotlinx.coroutines.* +> +> class Service { +> // suspend init — невозможно +> // val value: String get() = loadValue() // геттер не может быть suspend +> +> suspend fun loadValue(): String { delay(10); return "значение" } +> } +> +> fun main() = runBlocking { println(Service().loadValue()) } // значение +> ``` +> Для асинхронной инициализации используют отдельную `suspend fun init()` или `lazy`-обёртку через `Deferred`. +> +> **Нельзя переопределить обычный метод как `suspend`.** Если интерфейс объявлен в Java (`Runnable`, `Callable`, `Comparator`), асинхронность туда не поместится - только `runBlocking` внутри, что блокирует поток. +> +> **Стектрейсы обрезаны.** Исключение из корутины содержит кадры только текущей точки возобновления, а не всю логическую цепочку. Для отладки включают `-Dkotlinx.coroutines.debug` или добавляют `kotlinx-coroutines-debug`, которые восстанавливают историю приостановок. +> +> **Рекурсивная `suspend`-функция создаёт объект продолжения на каждый кадр:** +> ```kotlin +> import kotlinx.coroutines.* +> +> suspend fun countDown(n: Int): Int = if (n <= 0) 0 else countDown(n - 1) + 1 +> +> fun main() = runBlocking { +> println(countDown(1000)) // 1000 +> } +> ``` +> Глубина ограничена не стеком JVM, а памятью, но на десятках тысяч кадров это заметная аллокация. Хвостовая рекурсия (`tailrec`) с `suspend` не сочетается. +> +> **`suspend` без приостановок - лишний.** Он заставляет всех вызывающих быть `suspend`, не давая ничего взамен. Компилятор предупреждает; предупреждение стоит слушать. + +Далее: [Структурированная конкурентность](Структурированная%20конкурентность.md) diff --git a/Kotlin/Корутины/Билдеры корутин.md b/Kotlin/Корутины/Билдеры корутин.md new file mode 100644 index 0000000..f0a9f8f --- /dev/null +++ b/Kotlin/Корутины/Билдеры корутин.md @@ -0,0 +1,417 @@ +--- +title: Билдеры корутин +description: launch, async, runBlocking, withContext, produce и actor - что выбрать и чем они различаются +tags: + - kotlin + - coroutines +--- + +Билдер - функция, которая создаёт корутину. Их немного, и выбор между ними определяется двумя вопросами: нужен ли результат и нужна ли новая корутина вообще. + +| Билдер | Возвращает | Блокирует поток | Нужен результат | +|---|---|---|---| +| `launch` | `Job` | нет | нет | +| `async` | `Deferred` | нет | да | +| `runBlocking` | `T` | да | да | +| `withContext` | `T` | нет | да, смена контекста | +| `produce` | `ReceiveChannel` | нет | поток значений | +| `actor` | `SendChannel` | нет | обработка сообщений | + +## launch: запустить и не ждать результата +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { + delay(50) + println("работа выполнена") + } + println("launch вернулся сразу: ${job.isActive}") + job.join() + println("дождались") + // launch вернулся сразу: true + // работа выполнена + // дождались +} +``` +`launch` возвращает `Job` - дескриптор для `join()`, `cancel()` и проверки состояния. Результат тела отбрасывается: тип всегда `Job`, даже если блок что-то вычислил. + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { 42 } // значение 42 теряется + job.join() + println(job::class.simpleName != null) // true +} +``` + +## async: запустить и получить результат +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +suspend fun loadPrice(id: Int): Int { delay(100); return id * 10 } + +fun main() = runBlocking { + val time = measureTimeMillis { + val a = async { loadPrice(1) } + val b = async { loadPrice(2) } + println("сумма: ${a.await() + b.await()}") // сумма: 30 + } + println("параллельно: ${time < 180}") // параллельно: true +} +``` +`Deferred` - это `Job` с результатом: у него есть и `join()`, и `await()`. Вызов `await()` приостанавливает корутину до готовности значения. + +`await` можно вызывать повторно - результат кешируется: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + var calls = 0 + val d = async { calls++; delay(20); "значение" } + println(d.await()) // значение + println(d.await()) // значение + println("тело выполнилось раз: $calls") // тело выполнилось раз: 1 +} +``` + +Ленивый запуск - тело не начнётся до `await()` или `start()`: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val lazy = async(start = CoroutineStart.LAZY) { + println("тело стартовало") + "результат" + } + println("создан, но не запущен") + delay(20) + println(lazy.await()) + // создан, но не запущен + // тело стартовало + // результат +} +``` +Внимание: ленивый `async`, у которого так и не вызвали `await()`, никогда не выполнится и заблокирует завершение области до её отмены. + +## runBlocking: мост из синхронного кода +```kotlin +import kotlinx.coroutines.* + +suspend fun compute(): Int { delay(20); return 42 } + +fun main() { + val result = runBlocking { compute() } // обычная функция вызывает suspend + println(result) // 42 +} +``` +`runBlocking` блокирует текущий поток до завершения корутины и всех её детей. Это единственный билдер, который **не** требует scope - он его создаёт. + +Применимость: +| Место | Уместен ли `runBlocking` | +|---|---| +| функция `main` | да | +| тесты (если нет `runTest`) | да | +| CLI-утилита | да | +| обработчик HTTP-запроса | нет, фреймворк даёт scope | +| Android UI-поток | категорически нет | +| внутри другой корутины | нет, это блокировка потока | + +## withContext: сменить контекст без новой корутины +```kotlin +import kotlinx.coroutines.* + +suspend fun readFile(): String = withContext(Dispatchers.IO) { + Thread.sleep(20) // здесь блокирующий вызов допустим + "содержимое" +} + +fun main() = runBlocking { + println(readFile()) // содержимое +} +``` +`withContext` не создаёт новую корутину - он переключает контекст текущей и возвращает значение блока. Это главный инструмент для «main-safe» функций. + +Разница с `async { }.await()` существенна: +```kotlin +import kotlinx.coroutines.* + +suspend fun viaWithContext(): Int = withContext(Dispatchers.Default) { 1 } + +suspend fun viaAsync(): Int = coroutineScope { + async(Dispatchers.Default) { 1 }.await() // лишняя корутина +} + +fun main() = runBlocking { + println("${viaWithContext()} ${viaAsync()}") // 1 1 +} +``` +Результат одинаков, но `async().await()` создаёт объект `Deferred` и корутину зря. Правило: если результат нужен **сразу**, это `withContext`; `async` оправдан только когда между запуском и `await` есть другая работа. + +## Параллельная декомпозиция +```kotlin +import kotlinx.coroutines.* + +data class Dashboard(val user: String, val orders: List, val balance: Int) + +suspend fun fetchUser(): String { delay(30); return "Иван" } +suspend fun fetchOrders(): List { delay(40); return listOf("заказ 1") } +suspend fun fetchBalance(): Int { delay(20); return 1000 } + +suspend fun loadDashboard(): Dashboard = coroutineScope { + val user = async { fetchUser() } + val orders = async { fetchOrders() } + val balance = async { fetchBalance() } + Dashboard(user.await(), orders.await(), balance.await()) +} + +fun main() = runBlocking { + println(loadDashboard()) + // Dashboard(user=Иван, orders=[заказ 1], balance=1000) +} +``` +Это каноничный шаблон: `coroutineScope` + несколько `async` + сборка результата. Время работы - максимум из трёх, а не сумма; при ошибке любого запроса остальные отменяются. + +## CoroutineStart +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + launch(start = CoroutineStart.DEFAULT) { println("3: обычный, после текущего кода") } + launch(start = CoroutineStart.UNDISPATCHED) { println("1: сразу, до первой приостановки") } + val lazy = launch(start = CoroutineStart.LAZY) { println("4: только после start()") } + println("2: тело main") + lazy.start() + delay(20) + // 1: сразу, до первой приостановки + // 2: тело main + // 3: обычный, после текущего кода + // 4: только после start() +} +``` +| Значение | Поведение | +|---|---| +| `DEFAULT` | запланировать запуск согласно диспетчеру | +| `LAZY` | не запускать до `start()`, `join()` или `await()` | +| `ATOMIC` | запустить, игнорируя отмену до первой точки приостановки | +| `UNDISPATCHED` | выполнять в текущем потоке до первой приостановки | + +`UNDISPATCHED` используют, когда важно выполнить начало тела немедленно (например, зарегистрировать подписку до возврата из функции). Остальные значения нужны редко. + +## produce и actor +`produce` - билдер, возвращающий канал с результатами: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +fun CoroutineScope.numbers(): ReceiveChannel = produce { + for (i in 1..3) { + delay(10) + send(i) + } +} + +fun main() = runBlocking { + val channel = numbers() + for (value in channel) print("$value ") + println() // 1 2 3 +} +``` +`actor` - обратное: корутина, принимающая сообщения: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* + +sealed interface Msg { + data class Add(val value: Int) : Msg + data class GetSum(val response: CompletableDeferred) : Msg +} + +fun CoroutineScope.counter() = actor { + var sum = 0 + for (msg in channel) { + when (msg) { + is Msg.Add -> sum += msg.value + is Msg.GetSum -> msg.response.complete(sum) + } + } +} + +fun main() = runBlocking { + val c = counter() + c.send(Msg.Add(10)) + c.send(Msg.Add(5)) + val response = CompletableDeferred() + c.send(Msg.GetSum(response)) + println(response.await()) // 15 + c.close() +} +``` +Оба помечены `@ObsoleteCoroutinesApi` - их планируют заменить. Для потоков значений используйте [Flow](Flow.md), для разделяемого состояния - [Mutex или StateFlow](Разделяемое%20состояние.md). + +## Как выбирать +```mermaid +graph TD + A["Нужна корутина"] --> B{"Вы в suspend-функции
или в scope?"} + B -->|"нет, обычный код"| C["runBlocking"] + B -->|"да"| D{"Нужен результат?"} + D -->|"нет, fire-and-forget"| E["launch"] + D -->|"да"| F{"Нужен прямо сейчас?"} + F -->|"да, просто другой поток"| G["withContext"] + F -->|"нет, сначала другая работа"| H["async + await"] +``` + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `executor.execute(task)` | `launch` | +> | `executor.submit(task)` -> `Future` | `async` -> `Deferred` | +> | `future.get()` (блокирует) | `deferred.await()` (приостанавливает) | +> | `CompletableFuture.supplyAsync` | `async` | +> | `future.get()` в `main` | `runBlocking` | +> | `CompletableFuture.allOf` | `awaitAll` | +> | явная передача пула в каждый вызов | контекст наследуется | +> +> **`await()` приостанавливает, `get()` блокирует.** Это ключевая разница. `Future.get()` занимает поток до готовности результата, поэтому в пуле из 8 потоков 8 ожидающих задач полностью его парализуют - классический deadlock при вложенных `get()`. `Deferred.await()` освобождает поток, поэтому миллион ожидающих корутин живут в нескольких потоках. +> +> **Контекст наследуется, а не передаётся.** В Java пул приходится протаскивать в каждый вызов: `supplyAsync(task, executor)`, и забытый второй аргумент молча отправит задачу в `ForkJoinPool.commonPool`. В Kotlin дочерняя корутина наследует контекст родителя: +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> withContext(Dispatchers.IO) { +> launch { +> val name = Thread.currentThread().name +> val onIo = name.contains("DefaultDispatcher") +> println("ребёнок унаследовал IO: $onIo") +> } +> } +> // ребёнок унаследовал IO: true +> } +> ``` +> +> **`CompletableFuture` комбинируется методами, корутины - кодом.** Java предлагает `thenCombine`, `allOf`, `anyOf`, `thenCompose` - по методу на каждый случай. В Kotlin сбор результатов пишется обычным кодом (`listOf(a.await(), b.await())`), поэтому `if`, циклы и `try/catch` работают там естественно. +> +> **Нет аналога `runBlocking` в Java, потому что он не нужен:** `future.get()` и так блокирует. Обратная ситуация - в Kotlin `runBlocking` нужен именно как явная граница между блокирующим и приостанавливаемым миром, и эта явность полезна: видно, где поток встаёт. +> +> **`launch` против `submit` в обработке ошибок.** Исключение в `submit` попадает в `Future` и теряется, если не вызвать `get()`. Исключение в `launch` уходит родителю и обязательно будет обработано - либо родителем, либо `CoroutineExceptionHandler`, либо уронит приложение. Терять ошибки молча корутины не позволяют. Подробнее: [Исключения в корутинах](Исключения%20в%20корутинах.md) +> +> Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md) + +> [!warning] Подводные камни +> **`runBlocking` внутри корутины блокирует поток диспетчера:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> withContext(Dispatchers.Default.limitedParallelism(1)) { +> launch { +> runBlocking { delay(50) } // держит единственный поток +> println("первая закончила") +> } +> launch { println("вторая ждала освобождения потока") } +> } +> // первая закончила +> // вторая ждала освобождения потока +> } +> ``` +> На ограниченном диспетчере это прямой путь к deadlock. Внутри корутины нужен `withContext`, а не `runBlocking`. +> +> **`async` без `await` проглатывает исключение до конца области:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val result = runCatching { +> coroutineScope { +> async { throw IllegalStateException("ошибка в async") } +> delay(20) +> "тело завершилось" +> } +> } +> println(result.exceptionOrNull()?.message) // ошибка в async +> } +> ``` +> Ошибка не исчезла - она отменила область при завершении. Но заметить её в месте возникновения невозможно, а если `async` висит в `SupervisorJob`, она потеряется совсем. +> +> **Ленивый `async` без `await` подвешивает область навсегда:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch { +> async(start = CoroutineStart.LAZY) { "никогда не выполнится" } +> println("тело launch закончилось") +> } +> val finished = withTimeoutOrNull(200) { job.join(); true } ?: false +> println("launch завершился: $finished") +> job.cancel() +> // тело launch закончилось +> // launch завершился: false +> } +> ``` +> Ленивый `Deferred` остаётся незавершённым ребёнком, а родитель обязан дождаться всех детей - поэтому `launch` не завершится никогда, даже после выхода из тела. Без `job.cancel()` программа повиснет. Это тихая утечка: ни ошибки, ни предупреждения. +> +> **`async` последовательно - это не параллельно:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlin.system.measureTimeMillis +> +> suspend fun slow(): Int { delay(100); return 1 } +> +> fun main() = runBlocking { +> val wrong = measureTimeMillis { +> val a = async { slow() }.await() // await сразу — ждём здесь же +> val b = async { slow() }.await() +> println(a + b) // 2 +> } +> println("последовательно: ${wrong >= 200}") // последовательно: true +> } +> ``` +> `await()` сразу после `async` убивает параллельность. Запускайте все `async`, потом ждите. +> +> **`launch` теряет результат молча.** Тип `Job` не содержит значения, поэтому `launch { computeSomething() }` компилируется и ничего не делает с результатом. Компилятор не предупредит. +> +> **`withContext` на каждый мелкий вызов стоит дорого:** +> ```kotlin +> import kotlinx.coroutines.* +> +> suspend fun badLoop(): Int = withContext(Dispatchers.Default) { +> var sum = 0 +> repeat(1000) { sum += it } // одно переключение — правильно +> sum +> } +> +> fun main() = runBlocking { +> println(badLoop()) // 499500 +> } +> ``` +> Переключение контекста - это передача задачи в другой пул с синхронизацией. Выносите `withContext` наружу цикла, а не внутрь. +> +> **`runBlocking` в Android UI-потоке вызывает ANR.** Это самая частая серьёзная ошибка: UI замирает до конца корутины. В Android используйте `lifecycleScope`/`viewModelScope`. +> +> **`produce` и `actor` устарели.** Они помечены `@ObsoleteCoroutinesApi`, их API может измениться. Для новых проектов - `Flow` и `Channel` напрямую, либо `Mutex` для состояния. +> +> **`CoroutineStart.ATOMIC` игнорирует отмену:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> @OptIn(ExperimentalCoroutinesApi::class) +> val job = launch(start = CoroutineStart.ATOMIC) { +> println("тело всё равно начнётся") +> delay(100) +> println("а это уже нет") +> } +> job.cancel() +> job.join() +> // тело всё равно начнётся +> } +> ``` +> Начало тела выполнится даже после `cancel()`. Это нужно для гарантированного освобождения ресурсов, но легко превращается в сюрприз. +> +> **Возврат `Deferred` из публичной функции ломает структурированность** - вызывающий может не вызвать `await()`, и ошибка потеряется. Возвращайте значение из `suspend`-функции. + +Далее: [Диспетчеры и контекст](Диспетчеры%20и%20контекст.md) diff --git a/Kotlin/Корутины/Диспетчеры и контекст.md b/Kotlin/Корутины/Диспетчеры и контекст.md new file mode 100644 index 0000000..59234fe --- /dev/null +++ b/Kotlin/Корутины/Диспетчеры и контекст.md @@ -0,0 +1,404 @@ +--- +title: Диспетчеры и контекст +description: CoroutineContext как набор элементов, выбор между Default, IO и Main, limitedParallelism и наследование контекста +tags: + - kotlin + - coroutines +--- + +`CoroutineContext` - неизменяемый набор элементов, описывающий, как и где выполняется корутина. Диспетчер - один из этих элементов, отвечающий за поток. + +```kotlin +import kotlinx.coroutines.* +import kotlin.coroutines.ContinuationInterceptor + +fun main() = runBlocking { + println(coroutineContext[ContinuationInterceptor] != null) // true + println(coroutineContext[Job] != null) // true + println(coroutineContext[CoroutineName]) // null — имени не задали + + withContext(CoroutineName("загрузка")) { + println(coroutineContext[CoroutineName]?.name) // загрузка + } +} +``` +Элементы складываются оператором `+`, а извлекаются по ключу как из `Map`. Ключ диспетчера называется `ContinuationInterceptor` - это более общее понятие «перехватчик продолжений», частным случаем которого является диспетчер. Ключ `CoroutineDispatcher` тоже существует, но помечен `@ExperimentalStdlibApi`. + +## Из чего состоит контекст +| Элемент | Зачем | +|---|---| +| `Job` | жизненный цикл, иерархия, отмена | +| `CoroutineDispatcher` | на каком потоке выполнять | +| `CoroutineName` | имя для отладки и логов | +| `CoroutineExceptionHandler` | что делать с необработанной ошибкой | + +```kotlin +import kotlinx.coroutines.* +import kotlin.coroutines.ContinuationInterceptor + +fun main() = runBlocking { + val context = Dispatchers.IO + CoroutineName("задача") + SupervisorJob() + + withContext(context) { + println(coroutineContext[CoroutineName]?.name) // задача + println(coroutineContext[ContinuationInterceptor] == Dispatchers.IO) // true + } +} +``` +Сложение не коммутативно по смыслу: при конфликте элементов одного типа побеждает **правый**. +```kotlin +import kotlinx.coroutines.* +import kotlin.coroutines.CoroutineContext + +fun main() { + val base: CoroutineContext = Dispatchers.IO + CoroutineName("первое") + val combined = base + CoroutineName("второе") + println(combined[CoroutineName]?.name) // второе +} +``` +Для двух диспетчеров подряд (`Dispatchers.IO + Dispatchers.Default`) компилятор выдаёт предупреждение о бессмысленности выражения - правило то же, побеждает правый, но писать так незачем. + +## Стандартные диспетчеры +| Диспетчер | Потоков | Для чего | +|---|---|---| +| `Dispatchers.Default` | по числу ядер (минимум 2) | вычисления, обработка данных | +| `Dispatchers.IO` | до 64 (растёт по требованию) | блокирующий I/O: файлы, JDBC, legacy-HTTP | +| `Dispatchers.Main` | один, платформенный | UI (Android, Swing, JavaFX) | +| `Dispatchers.Unconfined` | любой, текущий | редкие случаи, не для продакшена | + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + println("runBlocking: ${Thread.currentThread().name}") + + withContext(Dispatchers.Default) { + println("Default: ${Thread.currentThread().name.contains("DefaultDispatcher")}") + } + withContext(Dispatchers.IO) { + println("IO: ${Thread.currentThread().name.contains("DefaultDispatcher")}") + } + // runBlocking: main + // Default: true + // IO: true +} +``` +Обратите внимание: и `Default`, и `IO` показывают `DefaultDispatcher-worker-N` - они **делят один пул потоков**. `IO` - это тот же пул с другим лимитом параллелизма, поэтому переключение между `Default` и `IO` дешёвое и не требует смены потока. + +## Default против IO +Выбор определяется одним вопросом: код считает или ждёт? + +```kotlin +import kotlinx.coroutines.* + +// CPU-bound: больше потоков, чем ядер, только вредит +suspend fun compute(): Long = withContext(Dispatchers.Default) { + (1..1_000_000).sumOf { it.toLong() } +} + +// блокирующий I/O: поток простаивает, поэтому их нужно много +suspend fun readBlocking(): String = withContext(Dispatchers.IO) { + Thread.sleep(20) // имитация JDBC или File.readText + "прочитано" +} + +fun main() = runBlocking { + println(compute()) // 500000500000 + println(readBlocking()) // прочитано +} +``` +Логика лимитов: на `Default` потоков столько же, сколько ядер, потому что больше вычислять всё равно некому - лишние потоки только добавят переключений контекста. На `IO` потоки в основном спят в ожидании ответа, поэтому их держат до 64. + +Если API уже `suspend` и не блокирует (Ktor-клиент, R2DBC, `kotlinx-io`), диспетчер менять не нужно вообще - это лишнее переключение. + +## limitedParallelism +Ограничивает число одновременных корутин, не создавая новых потоков: +```kotlin +import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicInteger + +fun main() = runBlocking { + val limited = Dispatchers.IO.limitedParallelism(2) + val running = AtomicInteger(0) + val maxRunning = AtomicInteger(0) + + withContext(limited) { + repeat(10) { + launch { + val now = running.incrementAndGet() + maxRunning.updateAndGet { m -> maxOf(m, now) } + Thread.sleep(20) // занимает поток + running.decrementAndGet() + } + } + } + println("одновременно выполнялось не больше 2: ${maxRunning.get() <= 2}") + // одновременно выполнялось не больше 2: true +} +``` +Это замена старому `newFixedThreadPoolContext`: вместо отдельного пула используется общий, а ограничивается только параллелизм. Типичное применение - не перегрузить базу данных или внешний API. + +Важная тонкость: лимит считает корутины, **занимающие поток**, а не существующие. Приостановленная корутина слот освобождает: +```kotlin +import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicInteger + +fun main() = runBlocking { + val limited = Dispatchers.IO.limitedParallelism(2) + val inFlight = AtomicInteger(0) + val maxInFlight = AtomicInteger(0) + + withContext(limited) { + repeat(10) { + launch { + val now = inFlight.incrementAndGet() + maxInFlight.updateAndGet { m -> maxOf(m, now) } + delay(20) // слот освобождён + inFlight.decrementAndGet() + } + } + } + println("одновременно в полёте больше 2: ${maxInFlight.get() > 2}") + // одновременно в полёте больше 2: true +} +``` +Поэтому `limitedParallelism` - не семафор на число запросов. Если нужно ограничить именно количество одновременных обращений к внешнему API (включая время ожидания ответа), нужен `Semaphore` из `kotlinx.coroutines.sync`. Подробнее: [Разделяемое состояние](Разделяемое%20состояние.md) + +## Dispatchers.Main +```kotlin +// Android или Swing — требует соответствующей зависимости +// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0") +// +// viewModelScope.launch { // уже на Main +// val data = withContext(Dispatchers.IO) { repository.load() } +// textView.text = data // снова на Main +// } +``` +Главный шаблон UI-программирования: корутина стартует на `Main`, тяжёлую работу уводит в `withContext`, результат обрабатывает снова на `Main` - без коллбэков и `runOnUiThread`. Без платформенной зависимости `Dispatchers.Main` бросит `IllegalStateException` при первом обращении. + +## Unconfined +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + withContext(Dispatchers.Unconfined) { + println("до delay: ${Thread.currentThread().name}") + delay(10) + println("после delay на потоке таймера: ${Thread.currentThread().name.contains("DefaultExecutor")}") + } + // до delay: main + // после delay на потоке таймера: true +} +``` +`Unconfined` не привязывает корутину к потоку: до первой приостановки она идёт в текущем, а после возобновляется в том потоке, который её разбудил. Это ломает любые предположения о потоках, поэтому в продакшене не используется - только в тестах и специальных случаях. + +## Наследование контекста +Дочерняя корутина наследует контекст родителя, заменяя только `Job`: +```kotlin +import kotlinx.coroutines.* +import kotlin.coroutines.ContinuationInterceptor + +fun main() = runBlocking { + withContext(Dispatchers.IO + CoroutineName("родитель")) { + launch { + println("имя: ${coroutineContext[CoroutineName]?.name}") // имя: родитель + val inherited = coroutineContext[ContinuationInterceptor] == Dispatchers.IO + println("диспетчер унаследован: $inherited") // диспетчер унаследован: true + } + launch(CoroutineName("ребёнок")) { + println("переопределено: ${coroutineContext[CoroutineName]?.name}") // переопределено: ребёнок + } + } +} +``` +`Job` ребёнка всегда новый и становится дочерним по отношению к `Job` родителя - именно так строится дерево. Подробнее: [Структурированная конкурентность](Структурированная%20конкурентность.md) + +```mermaid +graph TD + A["Контекст родителя:
Job#1 + Dispatchers.IO + Name"] --> B["launch { }"] + B --> C["Контекст ребёнка:
Job#2 (parent = Job#1)
+ Dispatchers.IO + Name"] + A --> D["launch(Dispatchers.Default) { }"] + D --> E["Контекст ребёнка:
Job#3 (parent = Job#1)
+ Dispatchers.Default + Name"] +``` + +## Свои элементы контекста +Элемент контекста - способ передать данные через всё дерево корутин: +```kotlin +import kotlinx.coroutines.* +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +class RequestId(val value: String) : AbstractCoroutineContextElement(RequestId) { + companion object Key : CoroutineContext.Key +} + +suspend fun logWithId(message: String) { + val id = coroutineContext[RequestId]?.value ?: "нет id" + println("[$id] $message") +} + +fun main() = runBlocking { + withContext(RequestId("req-42")) { + logWithId("начало обработки") + launch { logWithId("из дочерней корутины") } + } + logWithId("вне контекста") + // [req-42] начало обработки + // [req-42] из дочерней корутины + // [нет id] вне контекста +} +``` +Это корутинный аналог `ThreadLocal`, но работающий правильно при переключении потоков. Применяется для трассировки запросов, идентификаторов пользователя, локали. + +## ThreadLocal и корутины +```kotlin +import kotlinx.coroutines.* + +val threadLocal = ThreadLocal() + +fun main() = runBlocking { + threadLocal.set("главный") + println("до: ${threadLocal.get()}") + + withContext(Dispatchers.Default) { + println("на другом потоке: ${threadLocal.get()}") // null — другой поток + } + + withContext(Dispatchers.Default + threadLocal.asContextElement("перенесённый")) { + println("через asContextElement: ${threadLocal.get()}") // перенесённый + } + // до: главный + // на другом потоке: null + // через asContextElement: перенесённый +} +``` +`ThreadLocal` привязан к потоку, а корутина потоки меняет - поэтому напрямую он не работает. `asContextElement()` переносит значение при каждом переключении. Это важно для MDC в логировании и Spring Security. + +## Проверка потока в отладке +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking(CoroutineName("корень")) { + println(coroutineContext[CoroutineName]?.name) // корень + launch(CoroutineName("дочерняя")) { + println(coroutineContext[CoroutineName]?.name) // дочерняя + }.join() +} +``` +Запуск с `-Dkotlinx.coroutines.debug` добавляет к имени потока идентификатор корутины (`main @корень#1`), что сильно упрощает чтение логов. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `ExecutorService` передаётся аргументом | диспетчер наследуется из контекста | +> | `Executors.newFixedThreadPool(n)` | `Dispatchers.Default` / `limitedParallelism(n)` | +> | `Executors.newCachedThreadPool()` | `Dispatchers.IO` | +> | `SwingUtilities.invokeLater`, `runOnUiThread` | `Dispatchers.Main` | +> | `ThreadLocal` | элемент контекста | +> | `MDC` в логировании | `MDCContext` или свой элемент | +> | пул = потоки | диспетчер = политика, потоки общие | +> +> **Пул потоков против диспетчера.** В Java каждый `ExecutorService` владеет своими потоками: четыре пула по 10 потоков - это 40 реальных потоков, даже если 35 из них простаивают. `Dispatchers.Default` и `Dispatchers.IO` делят один пул, а `limitedParallelism` ограничивает только число одновременных задач. Поэтому в Kotlin «ограничить параллелизм до 2 для работы с БД» не стоит двух простаивающих потоков. +> +> **Контекст наследуется, пул - нет.** В Java пул нужно передавать в каждый асинхронный вызов, и забытый аргумент молча уводит задачу в `ForkJoinPool.commonPool`: +> ```java +> CompletableFuture.supplyAsync(this::load); // commonPool! +> CompletableFuture.supplyAsync(this::load, myExecutor); // правильно +> ``` +> Эти две строки отличаются одним аргументом и дают совершенно разное поведение под нагрузкой. В Kotlin дочерняя корутина получает диспетчер родителя автоматически, а переопределение - явное и видимое. +> +> **`ThreadLocal` в асинхронном коде в Java не работает так же** - и там это обходят сложнее. При передаче задачи в пул `ThreadLocal` теряется; Spring решает это через `TaskDecorator` и копирование `SecurityContext`/MDC в каждом месте. В Kotlin элемент контекста путешествует вместе с корутиной по всему дереву, а `asContextElement()` переносит и настоящий `ThreadLocal`, когда он нужен библиотеке. +> +> **Переключение потока явное и дешёвое.** В Java чтобы вернуться в UI-поток, нужен `invokeLater` или `Handler.post` с коллбэком, который разрывает линейность кода. `withContext(Dispatchers.Main)` - это обычный блок, после которого выполнение продолжается на нужном потоке: +> ```kotlin +> import kotlinx.coroutines.* +> +> suspend fun loadAndRender(): String { +> val data = withContext(Dispatchers.IO) { Thread.sleep(10); "данные" } +> return "отрисовано: $data" // снова на исходном диспетчере +> } +> +> fun main() = runBlocking { println(loadAndRender()) } // отрисовано: данные +> ``` +> +> Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [JVM](../../Программирование%20на%20Java/JVM/JVM.md) + +> [!warning] Подводные камни +> **Блокирующий вызов на `Dispatchers.Default` исчерпывает пул:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlin.system.measureTimeMillis +> +> fun main() = runBlocking { +> val t = measureTimeMillis { +> withContext(Dispatchers.Default.limitedParallelism(2)) { +> List(6) { launch { Thread.sleep(50) } }.forEach { it.join() } +> } +> } +> println("сериализовалось вместо параллели: ${t >= 150}") // сериализовалось вместо параллели: true +> } +> ``` +> Шесть задач по 50 мс на двух потоках заняли 150 мс вместо 50. На реальном `Default` (число ядер) блокирующий код точно так же съедает все потоки, и вся программа встаёт - включая несвязанные корутины. +> +> **`Dispatchers.IO` не делает блокирующий код неблокирующим.** Он лишь даёт больше потоков. 64 одновременных JDBC-запроса всё равно упрутся в размер пула соединений, а 1000 корутин на `IO` встанут в очередь. +> +> **`Dispatchers.Main` падает без платформенной зависимости:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() { +> val error = runCatching { runBlocking { withContext(Dispatchers.Main) { } } } +> .exceptionOrNull() +> println(error != null) // true +> } +> ``` +> Сообщение - «Module with the Main dispatcher is missing». Нужен `kotlinx-coroutines-android`, `-javafx` или `-swing`. +> +> **`newSingleThreadContext` создаёт настоящий поток, который нужно закрывать:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> @OptIn(DelicateCoroutinesApi::class) +> val dispatcher = newSingleThreadContext("мой-поток") +> try { +> withContext(dispatcher) { +> println(Thread.currentThread().name) // мой-поток +> } +> } finally { +> dispatcher.close() // без этого поток живёт до конца процесса +> } +> } +> ``` +> API помечено `@DelicateCoroutinesApi` именно из-за этой утечки. Почти всегда вместо него нужен `limitedParallelism(1)`. +> +> **`Unconfined` возобновляется в неизвестном потоке.** Код после `delay` окажется на потоке таймера, после `Channel.receive` - на потоке отправителя. Любая работа с UI или `ThreadLocal` при этом ломается непредсказуемо. +> +> **`withContext` внутри цикла - лишние переключения:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlin.system.measureTimeMillis +> +> fun main() = runBlocking { +> val inside = measureTimeMillis { +> repeat(10_000) { withContext(Dispatchers.Default) { it } } +> } +> val outside = measureTimeMillis { +> withContext(Dispatchers.Default) { repeat(10_000) { } } +> } +> println("снаружи быстрее: ${outside < inside}") // снаружи быстрее: true +> } +> ``` +> +> **Передача `Job` в `withContext` рвёт структурированность.** `withContext(Dispatchers.IO + Job())` создаёт корутину без родителя - отмена сверху до неё не дойдёт. Передавайте только диспетчер и имя. +> +> **Контекст неизменяем, `+` создаёт новый.** `context + CoroutineName("x")` не меняет исходный объект; забытое присваивание результата - тихая ошибка. +> +> **`ThreadLocal` без `asContextElement` теряется при любом переключении**, в том числе при возобновлении после `delay` на другом потоке. Для MDC в логах это значит, что идентификатор запроса исчезнет из части записей - используйте `MDCContext` из `kotlinx-coroutines-slf4j`. +> +> **Число потоков `IO` настраивается только через системное свойство** (`kotlinx.coroutines.io.parallelism`) и влияет на весь процесс. Для ограничения конкретного места используйте `limitedParallelism`. +> +> **Переключение диспетчера для неблокирующего API - чистая потеря.** Если библиотека уже `suspend` и не блокирует, `withContext(Dispatchers.IO)` вокруг неё добавляет переключение контекста и ничего не даёт. + +Далее: [Отмена и таймауты](Отмена%20и%20таймауты.md) diff --git a/Kotlin/Корутины/Исключения в корутинах.md b/Kotlin/Корутины/Исключения в корутинах.md new file mode 100644 index 0000000..31e4abd --- /dev/null +++ b/Kotlin/Корутины/Исключения в корутинах.md @@ -0,0 +1,529 @@ +--- +title: Исключения в корутинах +description: Как ошибки идут по дереву, разница launch и async, SupervisorJob, CoroutineExceptionHandler и CancellationException +tags: + - kotlin + - coroutines +--- + +Обработка ошибок в корутинах отличается от обычной именно потому, что корутина - не вызов функции: исключение возникает не там, где его ждёт `try`, а внутри другой корутины. + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val result = runCatching { + coroutineScope { + launch { throw IllegalStateException("ошибка внутри") } + } + } + println(result.exceptionOrNull()?.message) // ошибка внутри +} +``` +Ошибка из `launch` не выбрасывается в месте вызова `launch` - она идёт **вверх по дереву** и проявляется на границе области. + +## Два механизма: launch и async +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + // launch: ошибка идёт родителю сразу + val fromLaunch = runCatching { + coroutineScope { launch { error("из launch") } } + }.exceptionOrNull()?.message + + // async: ошибка хранится в Deferred и выбрасывается при await + val fromAsync = runCatching { + coroutineScope { + val d = async { error("из async") } + d.await() + } + }.exceptionOrNull()?.message + + println("$fromLaunch | $fromAsync") // из launch | из async +} +``` +Разница в том, **когда** вы увидите ошибку: + +| | `launch` | `async` | +|---|---|---| +| куда идёт ошибка | сразу родителю | сохраняется в `Deferred` | +| где ловить | `try` вокруг области, обработчик | `try` вокруг `await()` | +| если не обработать | падает область | при `coroutineScope` - тоже падает область | +| если `async` висит в supervisor | - | ошибка потеряется совсем | + +Ключевой момент: `async` в обычной области **всё равно** передаёт ошибку родителю, даже без `await()`. Исключение в этом случае попадает в родителя при завершении области. + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val result = runCatching { + coroutineScope { + async { error("await не вызывали") } + delay(20) + "тело дошло до конца" + } + } + println(result.exceptionOrNull()?.message) // await не вызывали +} +``` +А вот в supervisor-области непрочитанная ошибка `async` исчезает: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + supervisorScope { + async { error("никто не узнает") } // await не вызван + delay(20) + println("ни падения, ни лога") + } + // ни падения, ни лога +} +``` +Это самый тихий способ потерять ошибку в корутинах. + +## Как ошибка идёт по дереву +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val result = runCatching { + coroutineScope { + launch { // уровень 1 + launch { // уровень 2 + launch { // уровень 3 + throw IllegalStateException("с третьего уровня") + } + } + } + launch { + try { delay(1000) } finally { println("соседнюю ветку отменили") } + } + } + } + println("дошло до корня: ${result.exceptionOrNull()?.message}") + // соседнюю ветку отменили + // дошло до корня: с третьего уровня +} +``` +Механика: корутина, получившая ошибку, отменяет себя, передаёт исключение родителю, тот отменяет **всех остальных детей** и повторяет процедуру выше. Промежуточные уровни не могут «не пропустить» ошибку - её нужно ловить `try/catch` внутри самой корутины. + +```mermaid +graph BT + D["Ребёнок уровня 3
throw"] -->|"ошибка наверх"| C["Ребёнок уровня 2"] + C -->|"ошибка наверх"| B["Ребёнок уровня 1"] + B -->|"ошибка наверх"| A["Корень области"] + A -->|"отменить"| E["Соседняя ветка"] + A --> F{"есть обработчик?"} + F -->|"да"| G["CoroutineExceptionHandler"] + F -->|"нет"| H["Thread.uncaughtExceptionHandler
падение"] +``` + +## Где try/catch работает +Внутри самой корутины - как обычно: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + launch { + try { + error("локальная ошибка") + } catch (e: IllegalStateException) { + println("обработали на месте: ${e.message}") + } + }.join() + println("родитель не затронут") + // обработали на месте: локальная ошибка + // родитель не затронут +} +``` +А вокруг `launch` - нет: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch(SupervisorJob()) { // отдельный Job, чтобы не уронить main + try { + launch { error("из вложенного launch") } + } catch (e: Exception) { + println("этого не будет") + } + } + job.join() + println("родитель выжил: ${job.isCancelled}") + // родитель выжил: true +} +``` +`launch` возвращается мгновенно, до того как тело начало выполняться - ловить там нечего. + +## SupervisorJob +Супервизор меняет направление: ошибка ребёнка не идёт вверх. +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val handler = CoroutineExceptionHandler { _, e -> + println("обработчик: ${e.message}") + } + val scope = CoroutineScope(SupervisorJob() + handler) + + scope.launch { error("первая задача упала") } + delay(20) + scope.launch { println("вторая работает как ни в чём не бывало") } + delay(20) + println("scope жив: ${scope.isActive}") + scope.cancel() + // обработчик: первая задача упала + // вторая работает как ни в чём не бывало + // scope жив: true +} +``` +Сравните с обычным `Job`: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val handler = CoroutineExceptionHandler { _, e -> println("обработчик: ${e.message}") } + val scope = CoroutineScope(Job() + handler) + + scope.launch { error("первая задача упала") } + delay(20) + val second = scope.launch { println("не запустится") } + delay(20) + println("scope мёртв: ${!scope.isActive}, вторая отменена: ${second.isCancelled}") + // обработчик: первая задача упала + // scope мёртв: true, вторая отменена: true +} +``` +Правило: долгоживущий scope компонента - `SupervisorJob`; группа задач внутри одной операции - обычный `Job` через `coroutineScope`. + +## supervisorScope против coroutineScope +```kotlin +import kotlinx.coroutines.* + +suspend fun loadAllOrFail(): String = coroutineScope { + val a = async { delay(20); "A" } + val b = async { error("B упал"); "B" } + "${a.await()}${b.await()}" +} + +suspend fun loadWhatCan(): List = supervisorScope { + val a = async { delay(20); "A" } + val b = async { error("B упал") } + listOf( + runCatching { a.await() }.getOrElse { "?" }, + runCatching { b.await() }.getOrElse { "?" }, + ) +} + +fun main() = runBlocking { + println(runCatching { loadAllOrFail() }.exceptionOrNull()?.message) // B упал + println(loadWhatCan()) // [A, ?] +} +``` +Первый вариант - «нужны все данные», второй - «покажем что есть». В supervisor-области каждый `await()` нужно обрабатывать отдельно, иначе ошибка улетит в обработчик по умолчанию. + +## CoroutineExceptionHandler +Это «последняя линия» - обработчик необработанных ошибок, аналог `Thread.UncaughtExceptionHandler`. +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val handler = CoroutineExceptionHandler { context, e -> + println("необработанная ошибка в ${context[CoroutineName]?.name}: ${e.message}") + } + val scope = CoroutineScope(SupervisorJob() + handler) + + scope.launch(CoroutineName("загрузчик")) { error("сеть недоступна") } + delay(20) + scope.cancel() + // необработанная ошибка в загрузчик: сеть недоступна +} +``` +Три правила, которые не очевидны: + +**1. Обработчик работает только в корневой корутине.** В дочерней он игнорируется: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val handler = CoroutineExceptionHandler { _, e -> println("вызван: ${e.message}") } + val scope = CoroutineScope(SupervisorJob()) + + scope.launch { + launch(handler) { error("ошибка в ребёнке") } // handler проигнорирован + } + delay(50) + scope.cancel() + // Exception in thread ... IllegalStateException: ошибка в ребёнке +} +``` +Ошибка ушла наверх, а наверху обработчика нет. Ставить `handler` нужно в контекст scope или корневого билдера. + +**2. `async` обработчик не использует** - его ошибка принадлежит `Deferred`: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val handler = CoroutineExceptionHandler { _, e -> println("не будет вызван") } + val scope = CoroutineScope(SupervisorJob() + handler) + + val d = scope.async { error("ошибка в async") } + delay(20) + println("ошибка ждёт в Deferred: ${runCatching { d.await() }.isFailure}") + scope.cancel() + // ошибка ждёт в Deferred: true +} +``` + +**3. `runBlocking` обработчик не использует** - он выбрасывает исключение наружу, в вызывающий поток. + +## CancellationException - не ошибка +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { + launch { + delay(1000) + }.cancel() // отмена ребёнка + delay(20) + println("родитель продолжает работу") + } + job.join() + println("родитель завершился нормально: ${!job.isCancelled}") + // родитель продолжает работу + // родитель завершился нормально: true +} +``` +`CancellationException`, выброшенное корутиной, трактуется как «я завершилась по запросу» и наверх не передаётся. Именно поэтому `withTimeout` внутри одной корутины не роняет соседей. Подробнее: [Отмена и таймауты](Отмена%20и%20таймауты.md) + +Обратная сторона: `CancellationException` нельзя использовать как обычное исключение - оно будет молча проигнорировано: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch(SupervisorJob()) { + throw CancellationException("это не ошибка, а отмена") + } + job.join() + println("отменён, а не упал: ${job.isCancelled}") // отменён, а не упал: true +} +``` + +## Агрегация ошибок +Родитель получает **первое** исключение. Ошибки, возникшие уже во время отмены - например, в блоке `finally` соседней корутины - не теряются, а попадают в `suppressed`: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val error = runCatching { + coroutineScope { + launch { delay(10); throw IllegalStateException("первая") } + launch { + try { delay(1000) } finally { throw IllegalArgumentException("из finally") } + } + } + }.exceptionOrNull() + + println("основная: ${error?.message}") + println("подавленных: ${error?.suppressed?.size}") + println("подавленная: ${error?.suppressed?.firstOrNull()?.message}") + // основная: первая + // подавленных: 1 + // подавленная: из finally +} +``` +Важно, чего здесь **нет**: если бы вторая корутина просто бросила исключение через 30 мс, подавленной ошибки не появилось бы - её отменили бы на 10-й миллисекунде, и она никогда не дошла бы до `throw`. Fail-fast означает именно это: вторая ошибка обычно не возникает, потому что работу остановили. В `suppressed` попадают только сбои очистки. + +Практический вывод для логирования: печатать нужно полный стектрейс (`printStackTrace()` или `logger.error(msg, e)`), а не `e.message` - иначе сбои в `finally` останутся невидимыми. + +## Повторные попытки +```kotlin +import kotlinx.coroutines.* + +suspend fun retry(times: Int, delayMs: Long, block: suspend () -> T): T { + var last: Throwable? = null + repeat(times) { attempt -> + try { + return block() + } catch (e: CancellationException) { + throw e // отмену не ретраим! + } catch (e: Exception) { + last = e + println("попытка ${attempt + 1} не удалась: ${e.message}") + delay(delayMs) + } + } + throw last ?: IllegalStateException("нет попыток") +} + +fun main() = runBlocking { + var attempts = 0 + val result = retry(3, 10) { + attempts++ + if (attempts < 3) error("сеть недоступна") else "данные получены" + } + println(result) + // попытка 1 не удалась: сеть недоступна + // попытка 2 не удалась: сеть недоступна + // данные получены +} +``` +Строка `catch (e: CancellationException) { throw e }` обязательна в любом обобщённом обработчике ошибок в корутинах - без неё функция станет неотменяемой. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | исключение в `submit` → в `Future` | ошибка `launch` → родителю | +> | `Future.get()` → `ExecutionException` | `await()` → исходное исключение | +> | `CompletableFuture.exceptionally` | `try/catch` вокруг `await` | +> | `Thread.UncaughtExceptionHandler` | `CoroutineExceptionHandler` | +> | `InterruptedException` (проверяемое) | `CancellationException` (непроверяемое) | +> | ошибка задачи не влияет на пул | ошибка отменяет область | +> | нет аналога | `SupervisorJob` | +> +> **Ошибку в Java легко потерять молча.** `executor.submit(task)` складывает исключение в `Future`, и если `get()` никто не вызовет, об ошибке не узнает никто - ни логов, ни падения: +> ```java +> executor.submit(() -> { throw new RuntimeException("исчезнет"); }); +> // тишина +> ``` +> Корутины этого не допускают: ошибка `launch` обязательно дойдёт до родителя, а если никто не обработал - до `CoroutineExceptionHandler` или `Thread.uncaughtExceptionHandler`. Исключение одно - непрочитанный `async` в supervisor-области, и это считается ошибкой программиста. +> +> **`get()` оборачивает исключение, `await()` - нет.** В Java настоящая причина лежит внутри `ExecutionException`, поэтому обработка выглядит так: +> ```java +> try { +> future.get(); +> } catch (ExecutionException e) { +> if (e.getCause() instanceof IOException io) { /* ... */ } +> } +> ``` +> В Kotlin `await()` бросает то же исключение, что было выброшено внутри, поэтому `catch (e: IOException)` работает напрямую: +> ```kotlin +> import kotlinx.coroutines.* +> import java.io.IOException +> +> fun main() = runBlocking { +> val caught = try { +> coroutineScope { async { throw IOException("диск") }.await() } +> } catch (e: IOException) { +> "поймали ${e::class.simpleName}: ${e.message}" +> } +> println(caught) // поймали IOException: диск +> } +> ``` +> +> **`exceptionally`/`handle` против `try/catch`.** В Java обработка ошибок в асинхронной цепочке - отдельный метод с лямбдой, и правила распространения ошибки по цепочке приходится помнить (`exceptionally` ловит, `thenApply` пропускает, `whenComplete` не меняет результат). В Kotlin работает обычный `try/catch`, с теми же правилами, что в синхронном коде. +> +> **Отмена пула против отмены области.** В Java ошибка в одной задаче никак не влияет на остальные - пул продолжает работу. Это и плюс (изоляция), и минус (бессмысленное продолжение работы, часть которой уже провалилась). Kotlin даёт выбор явно: `coroutineScope` - fail-fast, `supervisorScope` - изоляция как в Java. +> +> **`CancellationException` непроверяемое, `InterruptedException` - нет.** В Java прерывание приходится обрабатывать в каждом блокирующем вызове, и типичная ошибка - проглотить его, не восстановив флаг. В Kotlin отмену не нужно объявлять, но проглотить её так же легко через `catch (e: Exception)`. +> +> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md), [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md) + +> [!warning] Подводные камни +> **`try/catch` вокруг `launch` ничего не ловит:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch(SupervisorJob()) { +> try { +> launch { error("внутри") } +> } catch (e: Exception) { +> println("не напечатается") +> } +> delay(50) +> } +> job.join() +> println("ошибка ушла наверх, а не в catch") +> // ошибка ушла наверх, а не в catch +> } +> ``` +> `launch` возвращается до выполнения тела. Ловить нужно **внутри** лямбды или вокруг `coroutineScope`. +> +> **`catch (e: Exception)` перехватывает отмену:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch { +> try { +> delay(1000) +> } catch (e: Exception) { +> println("отмена попала в общий catch: ${e is CancellationException}") +> } +> println("и работа продолжилась") +> } +> delay(20) +> job.cancelAndJoin() +> // отмена попала в общий catch: true +> // и работа продолжилась +> } +> ``` +> Всегда ставьте `catch (e: CancellationException) { throw e }` первым. +> +> **`runCatching` в корутинах опасен** по той же причине - он ловит `Throwable`, включая `CancellationException`. В `suspend`-коде это делает блок неотменяемым, и `cancel()` перестаёт работать. Используйте явный `try/catch` с пробросом отмены. +> +> **`CoroutineExceptionHandler` в дочерней корутине игнорируется.** Он учитывается только у корневой корутины области. Передавать его нужно при создании scope или в корневой `launch`. +> +> **`CoroutineExceptionHandler` не работает с `async`** - его ошибка принадлежит `Deferred` и ждёт `await()`. Если `await()` не вызван в supervisor-области, ошибка исчезает навсегда. +> +> **`SupervisorJob`, переданный в `launch`, не делает детей независимыми:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch(SupervisorJob()) { // supervisor — для ЭТОЙ корутины +> launch { delay(10); error("первый ребёнок") } +> launch { +> try { delay(1000) } finally { println("второго тоже отменили") } +> } +> } +> job.join() +> // второго тоже отменили +> } +> ``` +> `SupervisorJob` в контексте билдера становится родителем самой корутины, но внутри её тела работает обычная область. Нужен `supervisorScope { }`. +> +> **`supervisorScope` не защищает код своего тела:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val caught = runCatching { +> supervisorScope { +> launch { delay(1000) } +> error("ошибка в теле блока") +> } +> }.exceptionOrNull()?.message +> println(caught) // ошибка в теле блока +> } +> ``` +> Supervisor меняет поведение только для детей. +> +> **Ошибка внутри `CoroutineExceptionHandler` роняет процесс.** Обработчик - последняя линия, выше его некуда. Логирование внутри него должно быть защищено от собственных сбоев. +> +> **`finally` может не выполниться при падении JVM.** Если необработанная ошибка дошла до `Thread.uncaughtExceptionHandler` и процесс завершается, часть блоков очистки в других корутинах может не успеть. +> +> **Подавленные исключения легко не заметить.** При нескольких одновременных ошибках `e.message` покажет только первую. В логах нужен полный `printStackTrace()` или `e.suppressed`. +> +> **Проброс через несколько уровней нельзя перехватить в середине.** Промежуточный `launch` не видит ошибку ребёнка как исключение - он просто отменяется. Чтобы обработать её на этом уровне, нужен `try/catch` вокруг `coroutineScope` внутри промежуточной корутины. +> +> **`CancellationException` как бизнес-ошибка не работает:** +> ```kotlin +> import kotlinx.coroutines.* +> +> class ValidationError(message: String) : CancellationException(message) +> +> fun main() = runBlocking { +> val job = launch(SupervisorJob()) { throw ValidationError("неверные данные") } +> job.join() +> println("ошибка проигнорирована: отменён=${job.isCancelled}") +> // ошибка проигнорирована: отменён=true +> } +> ``` +> Наследование от `CancellationException` превращает исключение в «тихую отмену»: ни обработчика, ни падения. Для бизнес-ошибок наследуйтесь от `Exception`. + +Далее: [Flow](Flow.md) diff --git a/Kotlin/Корутины/Корутины против потоков.md b/Kotlin/Корутины/Корутины против потоков.md new file mode 100644 index 0000000..a64178f --- /dev/null +++ b/Kotlin/Корутины/Корутины против потоков.md @@ -0,0 +1,344 @@ +--- +title: Корутины против потоков +description: Реальная стоимость потока и корутины, сравнение с виртуальными потоками Java и случаи, когда нужны именно потоки +tags: + - kotlin + - coroutines +--- + +Поток - ресурс ОС: стек в памяти, запись в планировщике ядра, переключение через системный вызов. Корутина - объект в куче, переключение которого - обычный вызов метода. + +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +fun main() = runBlocking { + val coroutines = measureTimeMillis { + List(100_000) { launch { delay(100) } }.forEach { it.join() } + } + println("100 000 корутин: уложились в секунду: ${coroutines < 1000}") + // 100 000 корутин: уложились в секунду: true +} +``` +Сто тысяч потоков создать нельзя - JVM упадёт с `OutOfMemoryError` ещё на нескольких десятках тысяч. + +## Стоимость в цифрах +| Характеристика | Поток ОС | Корутина | +|---|---|---| +| память | около 1 МБ стека (`-Xss`) | десятки-сотни байт | +| создание | системный вызов, ~50-100 мкс | аллокация объекта, ~100 нс | +| переключение | через ядро, 1-10 мкс | вызов метода, ~10 нс | +| разумный максимум | тысячи | миллионы | +| планировщик | ядро ОС | библиотека, в пользовательском коде | + +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +fun main() = runBlocking { + val threadTime = measureTimeMillis { + List(1000) { Thread { Thread.sleep(10) } }.onEach { it.start() }.forEach { it.join() } + } + val coroutineTime = measureTimeMillis { + List(1000) { launch { delay(10) } }.forEach { it.join() } + } + println("1000 потоков: ${threadTime} мс, 1000 корутин: ${coroutineTime} мс") + println("корутины быстрее: ${coroutineTime < threadTime}") + // корутины быстрее: true +} +``` +Разница здесь не в самом ожидании (оба спят по 10 мс), а в накладных расходах на создание тысячи объектов ядра. + +## Где именно экономия +Корутины не делают работу быстрее - они устраняют простой потоков. + +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +fun main() = runBlocking { + // блокировка: 4 задачи на 1 потоке = 4 × 100 мс + val blocking = measureTimeMillis { + withContext(Dispatchers.Default.limitedParallelism(1)) { + List(4) { launch { Thread.sleep(100) } }.forEach { it.join() } + } + } + + // приостановка: 4 задачи на 1 потоке = 100 мс + val suspending = measureTimeMillis { + withContext(Dispatchers.Default.limitedParallelism(1)) { + List(4) { launch { delay(100) } }.forEach { it.join() } + } + } + + println("блокировка около 400 мс: ${blocking >= 380}") + println("приостановка около 100 мс: ${suspending < 200}") + // блокировка около 400 мс: true + // приостановка около 100 мс: true +} +``` +Тот же один поток, та же работа - разница в четыре раза. Именно это окупает корутины на сервере, обслуживающем много одновременных запросов: поток не стоит в ожидании ответа базы, а берёт следующий запрос. + +## Корутины не помогают при счёте +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +fun heavy(): Long = (1..20_000_000L).sum() + +fun main() = runBlocking { + val sequential = measureTimeMillis { repeat(4) { heavy() } } + + val parallel = measureTimeMillis { + withContext(Dispatchers.Default) { + List(4) { async { heavy() } }.awaitAll() + } + } + + val oneThread = measureTimeMillis { + withContext(Dispatchers.Default.limitedParallelism(1)) { + List(4) { async { heavy() } }.awaitAll() + } + } + + println("параллельно на ядрах быстрее: ${parallel < sequential}") + println("на одном потоке выигрыша нет: ${oneThread >= parallel}") + // параллельно на ядрах быстрее: true + // на одном потоке выигрыша нет: true +} +``` +Вывод: для вычислений выигрыш даёт только параллелизм по ядрам, а не количество корутин. Тысяча корутин на четырёх ядрах считает ровно столько же, сколько четыре. Корутины здесь удобны как способ организации кода, а не как ускорение. + +## Что происходит при блокировке +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val cores = Runtime.getRuntime().availableProcessors() + println("ядер в системе: ${cores > 0}") + + val finished = withTimeoutOrNull(300) { + withContext(Dispatchers.Default) { + // занимаем все потоки Default блокирующими вызовами + List(cores) { launch { Thread.sleep(500) } } + launch { println("эта корутина не получит поток вовремя") } + } + true + } + println("диспетчер оказался парализован: ${finished == null}") + // ядер в системе: true + // диспетчер оказался парализован: true +} +``` +Это главный практический риск корутин: один блокирующий вызов в неправильном диспетчере останавливает несвязанные задачи. Поток, заблокированный на `Thread.sleep` или JDBC, не может выполнять другие корутины. Подробнее: [Диспетчеры и контекст](Диспетчеры%20и%20контекст.md) + +## Виртуальные потоки Java +Project Loom решает ту же задачу иначе: делает блокировку дешёвой. + +```kotlin +fun main() { + println("JVM: ${Runtime.version().feature()}") + // Java 21+: Thread.ofVirtual().start { ... } — виртуальный поток, + // который при блокирующем вызове освобождает несущий поток ОС. + // Thread.sleep(1000) на нём стоит почти столько же, сколько delay(1000) в корутине. +} +``` +Сравнение подходов: + +| | Корутины | Виртуальные потоки | +|---|---|---| +| где видно асинхронность | в типе: `suspend` | нигде, код обычный | +| существующий блокирующий код | нужно переписать | работает как есть | +| отмена | по дереву, встроена | `interrupt`, вручную | +| структурированность | обязательна | `StructuredTaskScope`, опционально | +| потоки данных | `Flow` | нет аналога, нужен Reactive Streams | +| платформы | JVM, Android, Native, JS, Wasm | только JVM 21+ | +| `ThreadLocal` | нужен `asContextElement` | работает (но дорог при миллионах) | +| готовность экосистемы | библиотеки с `suspend` API | любая блокирующая библиотека | + +**Главный плюс Loom** - обратная совместимость: миллионы строк блокирующего кода и библиотек (JDBC, Apache HttpClient, старые ORM) внезапно масштабируются без изменений. + +**Главный плюс корутин** - асинхронность видна компилятору. Из `suspend` в сигнатуре вырастают структурированная конкурентность, отмена по дереву, `Flow` с backpressure и проверка «это нельзя вызвать из UI-потока» на этапе компиляции. С виртуальными потоками компилятор не знает, где функция заблокируется, поэтому такие гарантии недоступны. + +```kotlin +import kotlinx.coroutines.* + +// в сигнатуре видно, что функция может приостановиться +suspend fun loadUser(id: Int): String { + delay(10) + return "пользователь $id" +} + +// а здесь не видно ничего: может считать 1 нс, может ждать сеть 10 с +fun loadUserBlocking(id: Int): String { + Thread.sleep(10) + return "пользователь $id" +} + +fun main() = runBlocking { + println(loadUser(1)) + println(loadUserBlocking(2)) + // пользователь 1 + // пользователь 2 +} +``` +Второй вариант нельзя безопасно вызвать из UI-потока, и ни компилятор, ни тип этого не подскажут. + +Для Android вопрос не стоит: виртуальных потоков там нет, корутины - стандарт. Для серверного Kotlin на JVM 21+ оба подхода рабочие, и выбор определяется экосистемой: Ktor и Exposed дают `suspend` API, Spring MVC и JDBC - блокирующий. + +```mermaid +graph TD + A["Нужна конкурентность"] --> B{"Платформа?"} + B -->|"Android, Native, JS"| C["корутины"] + B -->|"JVM 21+"| D{"Код и библиотеки?"} + D -->|"блокирующие: JDBC,
Spring MVC"| E["виртуальные потоки
или корутины + IO"] + D -->|"suspend API: Ktor,
R2DBC"| C + A --> F{"Нужны потоки данных,
отмена по дереву?"} + F -->|"да"| C +``` + +## Когда нужны именно потоки +Корутины не универсальны. + +**1. Блокирующий код, который нельзя переписать.** JDBC, `java.io`, нативные библиотеки через JNI. Их место - `Dispatchers.IO` или отдельный пул: +```kotlin +import kotlinx.coroutines.* + +suspend fun queryDatabase(): String = withContext(Dispatchers.IO) { + Thread.sleep(20) // здесь мог быть JDBC + "строка из БД" +} + +fun main() = runBlocking { + println(queryDatabase()) // строка из БД +} +``` + +**2. Код, требующий привязки к потоку.** Некоторые библиотеки используют `ThreadLocal` или требуют вызовов из одного и того же потока (OpenGL, часть UI-тулкитов, нативные SDK): +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + @OptIn(DelicateCoroutinesApi::class) + val dedicated = newSingleThreadContext("нативный-поток") + try { + withContext(dedicated) { + println("всегда один и тот же поток: ${Thread.currentThread().name}") + } + } finally { + dedicated.close() + } + // всегда один и тот же поток: нативный-поток +} +``` + +**3. Приоритеты и реальное время.** `Thread.setPriority`, потоки реального времени, привязка к ядру - у корутин этого нет вообще. + +**4. Бесконечный цикл без точек приостановки.** Корутина, которая никогда не приостанавливается, занимает поток диспетчера полностью, и её нельзя отменить. Для такой задачи нужен выделенный поток. + +## Измерение: сколько живёт корутина +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val before = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() } + + val jobs = List(200_000) { launch { delay(5000) } } + + val after = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() } + val perCoroutine = (after - before) / 200_000 + + println("200 000 корутин создано: ${jobs.size}") + println("байт на корутину (порядок величины): ${perCoroutine in 1..5000}") + jobs.forEach { it.cancel() } + // 200 000 корутин создано: 200000 + // байт на корутину (порядок величины): true +} +``` +Измерение грубое (GC работает параллельно), но порядок виден: сотни байт против мегабайта у потока. Именно поэтому «корутина на каждый запрос» или «корутина на каждое соединение» - нормальная архитектура, а «поток на каждый запрос» перестаёт работать на нескольких тысячах. + +## Выбор на практике +| Задача | Решение | +|---|---| +| много одновременных сетевых запросов | корутины | +| обработка HTTP-запросов на сервере | корутины или виртуальные потоки | +| асинхронность в Android | корутины, других вариантов нет | +| параллельные вычисления | `Dispatchers.Default`, корутин по числу ядер | +| JDBC и блокирующие драйверы | корутины + `Dispatchers.IO`, либо виртуальные потоки | +| нативная библиотека с привязкой к потоку | выделенный поток | +| задача с приоритетом ОС | поток | +| поток событий с backpressure | `Flow` | + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `new Thread()` | `launch` | +> | `ExecutorService` | `CoroutineScope` + диспетчер | +> | `Thread.sleep` (блокирует) | `delay` (приостанавливает) | +> | `Thread.ofVirtual()` (Java 21+) | корутины | +> | `StructuredTaskScope` (Java 21+) | `coroutineScope` | +> | `ThreadLocal` | элемент контекста корутины | +> | «поток на запрос» не масштабируется | «корутина на запрос» - норма | +> +> **Модель «поток на запрос» и её предел.** Классический Java-сервер выделяет поток на каждое соединение. При 10 000 одновременных соединений это 10 000 потоков, около 10 ГБ стеков и планировщик ядра, занятый переключениями. Именно отсюда выросли NIO, Netty и реактивное программирование - с их колбэками и нечитаемыми стектрейсами. Корутины дают масштабируемость NIO, сохраняя линейный код: +> ```kotlin +> import kotlinx.coroutines.* +> +> suspend fun handleRequest(id: Int): String { +> delay(10) // здесь мог быть запрос к БД +> return "ответ $id" +> } +> +> fun main() = runBlocking { +> val responses = List(10_000) { async { handleRequest(it) } }.awaitAll() +> println("обработано запросов: ${responses.size}") // обработано запросов: 10000 +> } +> ``` +> Десять тысяч «обработчиков запросов» на несколько потоков - в Java до Loom это требовало полностью асинхронного стека. +> +> **Виртуальные потоки - ответ Java на ту же проблему.** `Thread.ofVirtual().start { }` создаёт поток, который при блокирующем вызове освобождает несущий поток ОС. По стоимости это сравнимо с корутиной, а главное преимущество - существующий блокирующий код работает без изменений. Цена - отсутствие информации об асинхронности в типах, из-за чего нет ни отмены по дереву, ни `Flow`, ни проверок на этапе компиляции. Loom добирает часть этого отдельными API (`StructuredTaskScope`), но они опциональны и появились на 8 лет позже корутин. +> +> **`CompletableFuture` - промежуточное решение, которое проиграло оба раза.** Он даёт неблокирующий код, но ценой разрыва линейности: лямбды, протаскивание переменных через замыкания, неудобные `if` и циклы. Корутины и виртуальные потоки решают задачу так, что код остаётся последовательным - просто разными способами. +> +> **`ThreadLocal` стоит дорого при миллионе потоков.** Это ограничение Loom, о котором говорят меньше: `ThreadLocal` на миллион виртуальных потоков - это миллион копий значения. Для корутин элемент контекста наследуется деревом и копий не создаёт. +> +> **Отладка: стектрейсы.** Поток даёт полный стектрейс бесплатно - это заметный плюс и потоков, и виртуальных потоков. У корутины стектрейс содержит кадры только от последней точки возобновления, а историю приостановок восстанавливает библиотека `kotlinx-coroutines-debug` или флаг `-Dkotlinx.coroutines.debug`. Без них отладка асинхронного кода труднее, чем у потоков. +> +> Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [JVM](../../Программирование%20на%20Java/JVM/JVM.md) + +> [!warning] Подводные камни +> **Блокирующий вызов отменяет всю выгоду:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlin.system.measureTimeMillis +> +> fun main() = runBlocking { +> val t = measureTimeMillis { +> withContext(Dispatchers.Default.limitedParallelism(2)) { +> List(10) { launch { Thread.sleep(50) } }.forEach { it.join() } +> } +> } +> println("10 задач по 50 мс на 2 потоках: ${t >= 240}") // 10 задач по 50 мс на 2 потоках: true +> } +> ``` +> Корутины превратились в обычный пул потоков с очередью. Весь смысл - в `delay` вместо `Thread.sleep`, то есть в приостановке вместо блокировки. +> +> **«Корутин больше - значит быстрее» неверно для вычислений.** На четырёх ядрах сорок счётных корутин работают не быстрее четырёх, а переключений становится больше. Число параллельных вычислительных задач должно соответствовать числу ядер. +> +> **Стектрейсы хуже, чем у потоков.** Исключение из корутины содержит кадры от точки возобновления, а не всю логическую цепочку вызовов. Это реальная цена корутин, которую частично компенсирует `kotlinx-coroutines-debug`. +> +> **Корутина без точек приостановки не отменяется и занимает поток целиком.** Бесконечный счётный цикл в корутине хуже, чем в потоке: поток можно хотя бы прервать средствами ОС, а корутину - нет. +> +> **`ThreadLocal` не работает так, как ожидается.** Любая библиотека, полагающаяся на `ThreadLocal` (MDC в логах, `SecurityContext` в Spring, транзакции Hibernate), в корутинах требует `asContextElement` или специальной интеграции. С виртуальными потоками такой проблемы нет. +> +> **Профилировщики и инструменты знают о потоках, а не о корутинах.** В дампе потоков вы увидите `DefaultDispatcher-worker-1`, а не свои корутины; счётчики JMX считают потоки. Для корутин нужны отдельные инструменты - `kotlinx-coroutines-debug` или поддержка в IDE. +> +> **Переход на корутины не бесплатен архитектурно.** `suspend` поднимается по всему стеку вызовов до точки входа. Если наверху - не-suspend интерфейс (`Comparator`, `equals`, Java-колбэк), приостановку туда не поместить, и придётся либо менять архитектуру, либо блокировать поток через `runBlocking`. +> +> **Смешивание корутин и блокирующего кода даёт худшее из двух миров.** Половина кода на `suspend`, половина блокирующая, `runBlocking` в середине - такая система и не масштабируется, и сложна в отладке. Границу между блокирующим и приостанавливаемым кодом нужно держать явной и единственной. +> +> **Корутины не отменяют гонок и deadlock'ов.** Разделяемое изменяемое состояние требует синхронизации точно так же, как в потоках, а два `Mutex` в разном порядке дают взаимную блокировку. Подробнее: [Разделяемое состояние](Разделяемое%20состояние.md) +> +> **Измерения на микробенчмарках обманчивы.** JIT-компиляция, прогрев и GC искажают результаты в десятки раз. Цифры в этой заметке - порядки величин, а не точные значения; для реальных измерений нужен JMH. + +Далее: [Взаимодействие с Java](../Взаимодействие%20с%20Java/Взаимодействие%20с%20Java.md) diff --git a/Kotlin/Корутины/Корутины.md b/Kotlin/Корутины/Корутины.md new file mode 100644 index 0000000..6d266c4 --- /dev/null +++ b/Kotlin/Корутины/Корутины.md @@ -0,0 +1,277 @@ +--- +title: Корутины +description: Что такое приостанавливаемое вычисление, зачем оно нужно и как устроен раздел +tags: + - kotlin + - coroutines +--- + +Корутина - это вычисление, которое можно **приостановить** и продолжить позже, не блокируя поток. Это главный инструмент Kotlin для асинхронного кода: вместо коллбэков и `CompletableFuture` вы пишете последовательный код, а компилятор превращает его в машину состояний. + +```kotlin +import kotlinx.coroutines.* + +suspend fun loadUser(id: Int): String { + delay(100) // приостановка, а не блокировка + return "пользователь $id" +} + +suspend fun loadOrders(userId: Int): List { + delay(100) + return listOf("заказ 1", "заказ 2") +} + +fun main() = runBlocking { + val user = loadUser(1) + val orders = loadOrders(1) + println("$user: $orders") // пользователь 1: [заказ 1, заказ 2] +} +``` +Код читается как последовательный, но на `delay` поток освобождается и может выполнять другие корутины. Это и есть вся идея. + +## Почему не потоки +Поток ОС стоит около мегабайта стека и переключается через ядро. Корутина - это объект в куче, её «переключение» - обычный вызов метода. + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val jobs = List(50_000) { + launch { delay(1000) } + } + println("запущено корутин: ${jobs.size}") // запущено корутин: 50000 + jobs.forEach { it.join() } + println("все завершились") // все завершились +} +``` +Пятьдесят тысяч потоков повалили бы JVM с `OutOfMemoryError`; пятьдесят тысяч корутин спокойно живут в нескольких потоках. Подробнее: [Корутины против потоков](Корутины%20против%20потоков.md) + +## Приостановка вместо блокировки +Разница принципиальная. Блокировка занимает поток, ничего не делая: +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +fun main() = runBlocking { + val blocking = measureTimeMillis { + withContext(Dispatchers.Default) { + List(4) { launch { Thread.sleep(500) } }.forEach { it.join() } + } + } + + val suspending = measureTimeMillis { + List(4) { launch { delay(500) } }.forEach { it.join() } + } + + println("блокировка дольше: ${blocking > suspending}") // блокировка дольше: true + println("приостановка около 500 мс: ${suspending < 900}") // приостановка около 500 мс: true +} +``` +`Thread.sleep` держит поток; `delay` возвращает его в пул. Из-за этого четыре `delay(500)` на одном потоке занимают 500 мс, а четыре `Thread.sleep(500)` - столько, сколько позволит число потоков. + +## Как это работает под капотом +Компилятор превращает `suspend`-функцию в машину состояний с объектом-продолжением (`Continuation`). Каждая точка приостановки - это метка, куда вернётся выполнение. + +```mermaid +graph LR + A["вызов suspend-функции"] --> B["выполнение до delay"] + B --> C{"нужна приостановка?"} + C -->|"нет, результат готов"| G["вернуть значение"] + C -->|"да"| D["сохранить состояние
в Continuation"] + D --> E["вернуть COROUTINE_SUSPENDED
поток свободен"] + E --> F["внешнее событие:
таймер, I/O, другая корутина"] + F --> H["resume: восстановить состояние"] + H --> B +``` + +Практическое следствие: приостановка возможна **только** в `suspend`-функции, потому что только у неё есть скрытый параметр `Continuation`. Подробнее: [Suspend-функции](Suspend-функции.md) + +## Параллелизм +Последовательный код легко превратить в параллельный: +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +suspend fun fetchA(): Int { delay(200); return 1 } +suspend fun fetchB(): Int { delay(200); return 2 } + +fun main() = runBlocking { + val sequential = measureTimeMillis { + val sum = fetchA() + fetchB() + println("последовательно: $sum") // последовательно: 3 + } + + val parallel = measureTimeMillis { + val a = async { fetchA() } + val b = async { fetchB() } + println("параллельно: ${a.await() + b.await()}") // параллельно: 3 + } + + println("ускорение примерно вдвое: ${sequential > parallel + 100}") // ускорение примерно вдвое: true +} +``` +Разница - в `async`: он запускает корутину и сразу возвращает `Deferred`, а `await` ждёт результат. Подробнее: [Билдеры корутин](Билдеры%20корутин.md) + +## Структурированная конкурентность +Главное архитектурное решение библиотеки: у каждой корутины есть родитель, и родитель не завершится раньше детей. + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + println("начало") + coroutineScope { + launch { delay(100); println("ребёнок 1") } + launch { delay(50); println("ребёнок 2") } + } + println("конец — после всех детей") + // начало + // ребёнок 2 + // ребёнок 1 + // конец — после всех детей +} +``` +Из этого бесплатно получаются: ожидание завершения, распространение отмены вниз и ошибок вверх, отсутствие «потерянных» задач. Это то, чего в Java приходится добиваться вручную. Подробнее: [Структурированная конкурентность](Структурированная%20конкурентность.md) + +## Карта раздела +| Заметка | О чём | +|---|---| +| [Suspend-функции](Suspend-функции.md) | `suspend`, `Continuation`, машина состояний | +| [Структурированная конкурентность](Структурированная%20конкурентность.md) | `CoroutineScope`, иерархия, жизненный цикл | +| [Билдеры корутин](Билдеры%20корутин.md) | `launch`, `async`, `runBlocking`, `withContext` | +| [Диспетчеры и контекст](Диспетчеры%20и%20контекст.md) | на каком потоке выполняется код | +| [Отмена и таймауты](Отмена%20и%20таймауты.md) | кооперативная отмена, `withTimeout`, `NonCancellable` | +| [Исключения в корутинах](Исключения%20в%20корутинах.md) | распространение ошибок, `SupervisorJob`, обработчики | +| [Flow](Flow.md) | асинхронный поток значений, операторы, backpressure | +| [StateFlow и SharedFlow](StateFlow%20и%20SharedFlow.md) | горячие потоки и состояние | +| [Channels](Channels.md) | передача значений между корутинами | +| [Разделяемое состояние](Разделяемое%20состояние.md) | `Mutex`, атомарные типы, конфайнмент | +| [Корутины против потоков](Корутины%20против%20потоков.md) | цифры, стоимость, когда нужны потоки | + +## Подключение +Корутины - не часть stdlib, это отдельная библиотека: +```kotlin +// build.gradle.kts +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") +} +``` +В языке есть только `suspend`, `Continuation` и `suspendCoroutine` - минимальная основа. `launch`, `async`, `Flow`, диспетчеры и каналы живут в `kotlinx.coroutines`. Подробнее: [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Thread`, `ExecutorService` | корутины на пуле потоков | +> | `CompletableFuture` | `Deferred` или просто `suspend fun` | +> | `.thenApply().thenCompose()` | последовательный код | +> | `CountDownLatch`, `Future.get()` | `join()`, `await()` | +> | виртуальные потоки (Java 21+) | корутины | +> | `Flow` из Reactive Streams | `kotlinx.coroutines.Flow` | +> | отмена через `Future.cancel` и флаги | отмена по иерархии | +> +> **Главное отличие - асинхронный код выглядит последовательным.** В Java до виртуальных потоков асинхронность означала коллбэки или цепочки `CompletableFuture`: +> ```java +> loadUser(id) +> .thenCompose(user -> loadOrders(user.getId())) +> .thenApply(orders -> format(orders)) +> .exceptionally(e -> "ошибка: " + e.getMessage()); +> ``` +> Каждый шаг - лямбда, переменные приходится протаскивать через замыкания, а `try/catch`, циклы и `if` в такой цепочке выражаются неудобно. В Kotlin то же самое пишется обычным кодом: +> ```kotlin +> import kotlinx.coroutines.* +> +> suspend fun loadUser2(id: Int): String { delay(10); return "user$id" } +> suspend fun loadOrders2(user: String): List { delay(10); return listOf("$user-заказ") } +> +> suspend fun report(id: Int): String = try { +> val user = loadUser2(id) +> val orders = loadOrders2(user) +> orders.joinToString() +> } catch (e: Exception) { +> "ошибка: ${e.message}" +> } +> +> fun main() = runBlocking { println(report(1)) } // user1-заказ +> ``` +> Работают обычные `try/catch`, `for`, `return` и локальные переменные. +> +> **Виртуальные потоки (Java 21) решают ту же задачу иначе.** Project Loom делает блокировку дешёвой: виртуальный поток при блокирующем вызове освобождает несущий поток ОС, поэтому старый блокирующий код внезапно масштабируется. Разница в подходе: Loom прячет асинхронность от программиста (`Thread.sleep` просто становится дешёвым), корутины делают её видимой в типах (`suspend` в сигнатуре). Плюс Loom - работает весь существующий код; плюс корутин - компилятор знает, где функция может приостановиться, и из этого вырастают структурированная конкурентность, `Flow` и отмена. Корутины также работают на Android и в Kotlin/Native, где Loom недоступен. +> +> **Отмена в Java не распространяется.** `Future.cancel(true)` ставит флаг прерывания одному потоку; задачи, которые он породил, продолжат работу. В Kotlin отмена идёт по дереву корутин автоматически. Для Java это пришлось бы строить руками поверх `ExecutorService`. Подробнее: [Отмена и таймауты](Отмена%20и%20таймауты.md) +> +> **Реактивные библиотеки закрывают часть задач.** RxJava и Project Reactor дают потоки данных и операторы, то есть примерный аналог `Flow`. Но у них свой стиль (всё - цепочка операторов), высокий порог входа и плохие стектрейсы. `Flow` при этом - обычные `suspend`-функции внутри, поэтому в нём можно писать `for` и `try`. +> +> Сравнить: [Многопоточность в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [JVM](../../Программирование%20на%20Java/JVM/JVM.md) + +> [!warning] Подводные камни +> **Корутины не ускоряют вычисления на CPU.** Они экономят потоки при ожидании (сеть, диск, таймеры). Если код считает, а не ждёт, выгоды от корутин нет - там нужен параллелизм по числу ядер: +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val result = withContext(Dispatchers.Default) { // пул по числу ядер +> (1..4).map { async { (1..1_000_000).sumOf { x -> x.toLong() } } }.awaitAll() +> } +> println(result.size) // 4 +> } +> ``` +> На `Dispatchers.IO` такой код только создаст лишние потоки. Подробнее: [Диспетчеры и контекст](Диспетчеры%20и%20контекст.md) +> +> **Блокирующий вызов внутри корутины блокирует поток целиком:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlin.system.measureTimeMillis +> +> fun main() = runBlocking { +> val t = measureTimeMillis { +> withContext(Dispatchers.Default.limitedParallelism(1)) { +> List(3) { launch { Thread.sleep(100) } }.forEach { it.join() } +> } +> } +> println("последовательно, не 100 мс: ${t >= 300}") // последовательно, не 100 мс: true +> } +> ``` +> `Thread.sleep`, `JDBC`, старый `HttpURLConnection`, `File.readText` - всё это блокирует. Такие вызовы нужно оборачивать в `withContext(Dispatchers.IO)`. +> +> **`runBlocking` блокирует вызывающий поток** - это мост из обычного кода в корутины, а не способ запускать их в продакшене. В Android и серверных фреймворках у вас уже есть scope; `runBlocking` уместен в `main`, тестах и утилитах. +> +> **Корутина без scope теряется.** `GlobalScope.launch` не привязан ни к чему: его никто не отменит и никто не узнает об ошибке. Это главный антипаттерн в корутинах. Подробнее: [Структурированная конкурентность](Структурированная%20конкурентность.md) +> +> **Исключение в `launch` не ловится снаружи:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> try { +> launch { throw IllegalStateException("внутри launch") } +> } catch (e: Exception) { +> println("не поймается здесь") +> } +> delay(50) +> println("а это выполнится") +> } +> ``` +> Этот код напечатает `а это выполнится`, а затем уронит программу: `launch` передаёт ошибку родителю, а не выбрасывает её в месте вызова. Подробнее: [Исключения в корутинах](Исключения%20в%20корутинах.md) +> +> **Отмена кооперативная.** Корутина, которая не вызывает `suspend`-функций и не проверяет `isActive`, не отменяется: +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch(Dispatchers.Default) { +> var i = 0 +> while (i < 100_000_000) i++ // не проверяет отмену +> println("досчитал несмотря на отмену") +> } +> delay(20) +> job.cancelAndJoin() +> println("cancel вернулся") +> } +> ``` +> Цикл без точек приостановки игнорирует `cancel`. +> +> **`suspend` в сигнатуре - это заразно.** Вызвать `suspend`-функцию можно только из другой `suspend`-функции или из билдера. Поэтому `suspend` поднимается вверх по стеку до точки входа, и добавить его в середину существующего синхронного кода не получится без переделки всей цепочки вызовов. +> +> **Версия библиотеки имеет значение.** API корутин развивается: `limitedParallelism`, `Flow`-операторы, `SupervisorJob` поведение менялись между версиями. Код из статей 2019 года может не компилироваться или вести себя иначе. + +Далее: [Suspend-функции](Suspend-функции.md) diff --git a/Kotlin/Корутины/Отмена и таймауты.md b/Kotlin/Корутины/Отмена и таймауты.md new file mode 100644 index 0000000..0a7ca33 --- /dev/null +++ b/Kotlin/Корутины/Отмена и таймауты.md @@ -0,0 +1,525 @@ +--- +title: Отмена и таймауты +description: Кооперативная отмена, CancellationException, withTimeout, NonCancellable и освобождение ресурсов +tags: + - kotlin + - coroutines +--- + +Отмена корутин **кооперативная**: библиотека не может остановить код насильно, она лишь сообщает корутине, что её просят завершиться. Код должен это проверять - сам или через `suspend`-функции. + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { + repeat(100) { i -> + delay(10) + println("шаг $i") + } + } + delay(35) + job.cancelAndJoin() + println("отменено") + // шаг 0 + // шаг 1 + // шаг 2 + // отменено +} +``` +Все `suspend`-функции из `kotlinx.coroutines` (`delay`, `join`, `await`, `withContext`) проверяют отмену и бросают `CancellationException`. + +## Как работает отмена +`cancel()` переводит `Job` в состояние Cancelling и бросает `CancellationException` в точке приостановки: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { + try { + delay(1000) + } catch (e: CancellationException) { + println("поймали: ${e::class.simpleName}") + throw e // обязательно пробросить! + } + } + delay(20) + job.cancelAndJoin() + println("состояние: отменён=${job.isCancelled}, завершён=${job.isCompleted}") + // поймали: JobCancellationException + // состояние: отменён=true, завершён=true +} +``` +`CancellationException` - особое исключение: оно не считается ошибкой и не приводит к падению родителя. Подробнее: [Исключения в корутинах](Исключения%20в%20корутинах.md) + +## Код без точек приостановки не отменяется +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +fun main() = runBlocking { + val t = measureTimeMillis { + val job = launch(Dispatchers.Default) { + var sum = 0L + repeat(200_000_000) { sum += it } // ни одной точки приостановки + println("досчитал несмотря на отмену: ${sum > 0}") + } + delay(20) + job.cancelAndJoin() + } + println("cancel ждал окончания цикла: ${t > 100}") + // досчитал несмотря на отмену: true + // cancel ждал окончания цикла: true +} +``` +Два способа сделать такой код отменяемым: + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + // 1. проверять isActive + val job1 = launch(Dispatchers.Default) { + var i = 0 + while (isActive && i < 200_000_000) i++ + println("вышел по isActive: ${i < 200_000_000}") + } + delay(20) + job1.cancelAndJoin() + + // 2. вызывать ensureActive() — бросит CancellationException + val job2 = launch(Dispatchers.Default) { + try { + repeat(200_000_000) { + if (it % 100_000 == 0) ensureActive() + } + } catch (e: CancellationException) { + println("вышел по ensureActive") + throw e + } + } + delay(20) + job2.cancelAndJoin() + // вышел по isActive: true + // вышел по ensureActive +} +``` +Есть и третий вариант - `yield()`: он и проверяет отмену, и даёт другим корутинам шанс выполниться. + +| Способ | Бросает исключение | Когда применять | +|---|---|---| +| `isActive` | нет, возвращает `Boolean` | условие цикла | +| `ensureActive()` | да, `CancellationException` | внутри тела, быстрый выход | +| `yield()` | да | ещё и уступает поток другим | + +## Освобождение ресурсов +`finally` работает как обычно: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { + try { + println("ресурс открыт") + delay(1000) + } finally { + println("ресурс закрыт") + } + } + delay(20) + job.cancelAndJoin() + println("готово") + // ресурс открыт + // ресурс закрыт + // готово +} +``` +Но внутри `finally` отменённой корутины **нельзя приостанавливаться**: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { + try { + delay(1000) + } finally { + val failed = runCatching { delay(50) }.isFailure + println("delay в finally упал: $failed") // delay в finally упал: true + } + } + delay(20) + job.cancelAndJoin() +} +``` +Корутина уже отменена, поэтому любая приостановка немедленно бросает `CancellationException`. Если в очистке нужна приостановка - оберните её в `NonCancellable`: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { + try { + delay(1000) + } finally { + withContext(NonCancellable) { + delay(20) + println("асинхронная очистка выполнена") + } + } + } + delay(20) + job.cancelAndJoin() + println("готово") + // асинхронная очистка выполнена + // готово +} +``` +`NonCancellable` нужен именно и только для этого. Использовать его, чтобы «защитить» обычную работу от отмены - ошибка: такая корутина станет неостановимой. + +Для `Closeable`-ресурсов проще `use`: +```kotlin +import kotlinx.coroutines.* + +class Connection : AutoCloseable { + fun query() = "результат" + override fun close() = println("соединение закрыто") +} + +fun main() = runBlocking { + val job = launch { + Connection().use { conn -> + println(conn.query()) + delay(1000) + } + } + delay(20) + job.cancelAndJoin() + // результат + // соединение закрыто +} +``` + +## withTimeout +```kotlin +import kotlinx.coroutines.* + +suspend fun slowOperation(): String { + delay(1000) + return "готово" +} + +fun main() = runBlocking { + try { + withTimeout(50) { slowOperation() } + } catch (e: TimeoutCancellationException) { + println("не успели: ${e.message}") + } + // не успели: Timed out waiting for 50 ms +} +``` +`withTimeoutOrNull` вместо исключения возвращает `null` - обычно это удобнее: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val fast = withTimeoutOrNull(100) { delay(20); "успели" } + val slow = withTimeoutOrNull(20) { delay(100); "не успели" } + + println(fast) // успели + println(slow) // null +} +``` +`TimeoutCancellationException` - подкласс `CancellationException`, поэтому он отменяет только свой блок, а не всю область. + +## Таймаут и ресурсы +Опасное место: таймаут может сработать между получением ресурса и входом в `try`. +```kotlin +import kotlinx.coroutines.* + +class Resource2 { + var closed = false + fun close() { closed = true } +} + +fun main() = runBlocking { + var resource: Resource2? = null + withTimeoutOrNull(30) { + resource = Resource2() // создали + try { + delay(1000) // здесь таймаут + } finally { + resource?.close() // закрыли + } + } + println("ресурс закрыт: ${resource?.closed}") // ресурс закрыт: true +} +``` +Правило: открывайте ресурс **внутри** блока и закрывайте в `finally` или через `use`. Открытие снаружи с закрытием внутри - источник утечек. + +## Отмена распространяется вниз +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val parent = launch { + launch { + try { delay(1000) } finally { println("ребёнок 1 завершён") } + } + launch { + try { delay(1000) } finally { println("ребёнок 2 завершён") } + } + try { delay(1000) } finally { println("родитель завершён") } + } + delay(20) + parent.cancelAndJoin() + println("всё дерево остановлено") + // ребёнок 1 завершён + // ребёнок 2 завершён + // родитель завершён + // всё дерево остановлено +} +``` +Это бесплатное следствие структурированной конкурентности: отменив одну корутину, вы останавливаете всё поддерево. Подробнее: [Структурированная конкурентность](Структурированная%20конкурентность.md) + +```mermaid +sequenceDiagram + participant В as Вызывающий + participant Р as Родитель + participant Д as Дети + В->>Р: cancel() + Р->>Р: состояние Cancelling + Р->>Д: отменить всех детей + Д->>Д: CancellationException
в точке приостановки + Д->>Д: выполнить finally + Д-->>Р: ребёнок Cancelled + Р->>Р: выполнить finally + Р-->>В: состояние Cancelled, join() вернулся +``` + +## cancel против cancelAndJoin +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job1 = launch { try { delay(1000) } finally { println("1: очистка") } } + job1.cancel() // не ждём завершения + println("после cancel: завершён=${job1.isCompleted}") + + val job2 = launch { try { delay(1000) } finally { println("2: очистка") } } + job2.cancelAndJoin() // ждём + println("после cancelAndJoin: завершён=${job2.isCompleted}") + // после cancel: завершён=false + // 1: очистка + // 2: очистка + // после cancelAndJoin: завершён=true +} +``` +`cancel()` только отправляет сигнал; `cancelAndJoin()` дожидается, пока корутина действительно остановится и выполнит очистку. Для корректного освобождения ресурсов нужен второй вариант. + +## Отмена и async +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val deferred = async { delay(1000); "результат" } + delay(20) + deferred.cancel() + + val error = runCatching { deferred.await() }.exceptionOrNull() + println("await после cancel бросил: ${error is CancellationException}") // await после cancel бросил: true +} +``` +Отменённый `Deferred` при `await()` бросает `CancellationException`, а не возвращает `null`. Для «результат или ничего» используйте `withTimeoutOrNull`. + +## Таймаут на весь scope +```kotlin +import kotlinx.coroutines.* + +suspend fun loadWithDeadline(): List? = withTimeoutOrNull(60) { + coroutineScope { + val a = async { delay(20); "быстрый" } + val b = async { delay(1000); "медленный" } + listOf(a.await(), b.await()) + } +} + +fun main() = runBlocking { + println(loadWithDeadline()) // null — медленный не успел, отменились оба +} +``` +Таймаут поверх `coroutineScope` даёт дедлайн на всю группу задач: не успел один - отменяются все. Это правильное поведение для запроса с общим SLA. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Thread.interrupt()` | `job.cancel()` | +> | `Thread.isInterrupted()` | `isActive` | +> | `InterruptedException` | `CancellationException` | +> | `Future.cancel(true)` - одна задача | отмена всего поддерева | +> | `ExecutorService.shutdownNow()` | `scope.cancel()` | +> | `future.get(50, MILLISECONDS)` | `withTimeout(50)` | +> | `ScheduledExecutorService` для дедлайна | `withTimeout` | +> | флаг `volatile boolean stopped` | `isActive` | +> +> **Прерывание в Java тоже кооперативное, но работает хуже.** `Thread.interrupt()` ставит флаг и прерывает только блокирующие вызовы, которые умеют его замечать (`sleep`, `wait`, `join`, часть NIO). Проблемы знакомые: `InterruptedException` - проверяемое исключение, которое приходится ловить в каждом блокирующем вызове, а при перехвате легко потерять флаг: +> ```java +> try { +> Thread.sleep(1000); +> } catch (InterruptedException e) { +> // забыли Thread.currentThread().interrupt() — прерывание потеряно +> } +> ``` +> В Kotlin `CancellationException` непроверяемое, поэтому его не нужно ловить везде, а `catch (e: Exception)` без проброса ломает отмену ровно так же - разница в том, что IDE и детекторы это подсказывают. +> +> **Отмена по дереву против отмены по задаче.** `Future.cancel(true)` прерывает один поток. Если задача породила подзадачи через другой `submit`, они продолжат работу, и отследить их можно только вручную - списком `Future`. В Kotlin `cancel()` на родителе гарантированно останавливает всё поддерево, включая задачи, запущенные тремя уровнями ниже. +> +> **Таймаут не требует отдельного планировщика.** В Java `future.get(50, MILLISECONDS)` прерывает **ожидание**, но не саму задачу - она продолжит выполняться в пуле. Чтобы остановить и её, нужен `cancel(true)` плюс поддержка прерывания в коде задачи. `withTimeout` отменяет сам блок: +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> var finished = false +> withTimeoutOrNull(30) { +> try { delay(1000); finished = true } finally { println("задача остановлена") } +> } +> println("тело доработало: $finished") +> // задача остановлена +> // тело доработало: false +> } +> ``` +> +> **`shutdownNow()` против `scope.cancel()`.** `shutdownNow` возвращает список незапущенных задач и прерывает потоки работающих - результат зависит от того, умеет ли код реагировать на прерывание. `scope.cancel()` отменяет корутины в известных точках приостановки, а `join()` даёт дождаться завершения очистки. +> +> Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **Перехват `CancellationException` без проброса ломает отмену:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch { +> repeat(5) { +> try { +> delay(20) +> } catch (e: CancellationException) { +> println("проглотили отмену на шаге $it") +> // нет throw — цикл продолжается! +> } +> } +> println("корутина доработала до конца") +> } +> delay(30) +> job.cancelAndJoin() +> // проглотили отмену на шаге 1 +> // проглотили отмену на шаге 2 +> // проглотили отмену на шаге 3 +> // проглотили отмену на шаге 4 +> // корутина доработала до конца +> } +> ``` +> Корутина стала неотменяемой. Та же беда возникает от `catch (e: Exception)` - `CancellationException` в него попадает. +> +> **`try/catch (Exception)` вокруг `suspend`-вызова перехватывает отмену:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch { +> try { +> delay(1000) +> } catch (e: Exception) { +> println("перехвачено как обычная ошибка: ${e is CancellationException}") +> } +> println("продолжили работу после отмены") +> } +> delay(20) +> job.cancelAndJoin() +> // перехвачено как обычная ошибка: true +> // продолжили работу после отмены +> } +> ``` +> Правильно: сначала `catch (e: CancellationException) { throw e }`, затем `catch (e: Exception)`. +> +> **`runCatching` глотает `CancellationException`** по той же причине - он ловит `Throwable`. В корутинном коде это регулярная проблема: `runCatching { suspendCall() }` делает блок неотменяемым. Для `suspend`-кода нужен явный `try/catch` с пробросом отмены. +> +> **Приостановка в `finally` отменённой корутины не работает:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val job = launch { +> try { +> delay(1000) +> } finally { +> val ok = runCatching { delay(50); "очистка" }.isSuccess +> println("приостановка в finally удалась: $ok") // приостановка в finally удалась: false +> } +> } +> delay(20) +> job.cancelAndJoin() +> } +> ``` +> Нужен `withContext(NonCancellable)`. +> +> **`NonCancellable` вокруг обычной работы делает корутину неостановимой:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlin.system.measureTimeMillis +> +> fun main() = runBlocking { +> val t = measureTimeMillis { +> val job = launch { +> withContext(NonCancellable) { +> delay(200) // отмена проигнорирована +> println("доработал несмотря на cancel") +> } +> } +> delay(20) +> job.cancelAndJoin() +> } +> println("cancel ждал 200 мс: ${t >= 190}") +> // доработал несмотря на cancel +> // cancel ждал 200 мс: true +> } +> ``` +> `NonCancellable` оправдан только для короткой очистки в `finally`. +> +> **`cancel()` без `join()` не ждёт очистки.** Если после `cancel()` сразу закрыть ресурс или выйти из процесса, `finally` может не успеть выполниться. Используйте `cancelAndJoin()`. +> +> **Чистый вычислительный цикл игнорирует отмену.** Любой `while`/`repeat` без `suspend`-вызовов нужно дополнять `isActive` или `ensureActive()` - иначе корутина отработает до конца, а `cancelAndJoin()` будет её ждать. +> +> **`withTimeout` бросает исключение, которое легко не заметить:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val result = runCatching { withTimeout(20) { delay(100); "значение" } } +> println("упало с таймаутом: ${result.exceptionOrNull() is TimeoutCancellationException}") +> // упало с таймаутом: true +> } +> ``` +> `TimeoutCancellationException` - подкласс `CancellationException`, поэтому обычный `catch (e: CancellationException) { throw e }` его пробросит, и таймаут «не сработает» там, где вы ждали обработки. Используйте `withTimeoutOrNull` или ловите `TimeoutCancellationException` до отмены. +> +> **Ресурс, открытый до `withTimeout`, может утечь:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> var opened = 0 +> var closed = 0 +> withTimeoutOrNull(30) { +> opened++ // открыли внутри — хорошо +> try { delay(1000) } finally { closed++ } +> } +> println("открыто $opened, закрыто $closed") // открыто 1, закрыто 1 +> } +> ``` +> Если бы `opened++` стоял перед `withTimeoutOrNull`, а `closed++` внутри - при срабатывании таймаута до входа в блок ресурс остался бы открытым. +> +> **Отменённый scope нельзя переиспользовать** - `launch` в нём сразу возвращает отменённый `Job`. Для перезапуска создавайте новый scope. +> +> **`await()` на отменённом `Deferred` бросает исключение, а не возвращает `null`.** Код, ожидающий `null` при отмене, получит падение. +> +> **Отмена не мгновенна.** Между `cancel()` и фактическим завершением проходит время: корутина должна дойти до точки приостановки и выполнить `finally`. Проверка `isCompleted` сразу после `cancel()` обычно даёт `false`. + +Далее: [Исключения в корутинах](Исключения%20в%20корутинах.md) diff --git a/Kotlin/Корутины/Разделяемое состояние.md b/Kotlin/Корутины/Разделяемое состояние.md new file mode 100644 index 0000000..2257ba4 --- /dev/null +++ b/Kotlin/Корутины/Разделяемое состояние.md @@ -0,0 +1,516 @@ +--- +title: Разделяемое состояние +description: Mutex, Semaphore, атомарные типы, конфайнмент потока и подход «не разделять состояние вообще» +tags: + - kotlin + - coroutines +--- + +Корутины не отменяют гонок. Если несколько корутин меняют одну переменную, нужна синхронизация - причём такая, которая приостанавливает, а не блокирует поток. + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + var counter = 0 + withContext(Dispatchers.Default) { + List(100) { launch { repeat(1000) { counter++ } } }.forEach { it.join() } + } + println("потеряно инкрементов: ${counter < 100_000}") // потеряно инкрементов: true +} +``` +`counter++` - это чтение, увеличение и запись: три операции, между которыми может вклиниться другая корутина. На одном потоке проблемы не было бы, но `Dispatchers.Default` использует все ядра. + +## Способы, по возрастанию цены +| Подход | Стоимость | Когда применять | +|---|---|---| +| не разделять состояние | нулевая | всегда, когда возможно | +| неизменяемые данные | нулевая | состояние целиком заменяется | +| атомарные типы | очень низкая | счётчики, флаги, одна переменная | +| `MutableStateFlow.update` | низкая | наблюдаемое состояние | +| конфайнмент в один поток | низкая | группа связанных переменных | +| `Mutex` | средняя | составная операция, вызов `suspend` внутри | +| `Semaphore` | средняя | ограничение числа одновременных операций | + +Порядок не случаен: первые два варианта убирают проблему, остальные её решают. + +## Атомарные типы +```kotlin +import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +fun main() = runBlocking { + val counter = AtomicInteger(0) + withContext(Dispatchers.Default) { + List(100) { launch { repeat(1000) { counter.incrementAndGet() } } }.forEach { it.join() } + } + println("точный результат: ${counter.get()}") // точный результат: 100000 + + val ref = AtomicReference(listOf()) + withContext(Dispatchers.Default) { + List(10) { i -> launch { ref.updateAndGet { it + "элемент $i" } } }.forEach { it.join() } + } + println("добавлено без потерь: ${ref.get().size}") // добавлено без потерь: 10 +} +``` +Атомарные типы работают через CAS (compare-and-set) - никаких блокировок, только повтор при конфликте. Это самый дешёвый способ, но он покрывает только операции с **одной** переменной. + +## Mutex +`Mutex` - взаимное исключение для корутин. В отличие от `synchronized`, он приостанавливает, а не блокирует поток. +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +fun main() = runBlocking { + val mutex = Mutex() + var counter = 0 + + withContext(Dispatchers.Default) { + List(100) { launch { repeat(1000) { mutex.withLock { counter++ } } } } + .forEach { it.join() } + } + println("результат точный: ${counter == 100_000}") // результат точный: true +} +``` +Главное преимущество - внутри `withLock` можно вызывать `suspend`-функции: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class Cache { + private val mutex = Mutex() + private val data = mutableMapOf() + + suspend fun get(id: Int): String = mutex.withLock { + data[id] ?: load(id).also { data[id] = it } + } + + private suspend fun load(id: Int): String { + delay(10) // suspend внутри критической секции + return "значение $id" + } +} + +fun main() = runBlocking { + val cache = Cache() + val results = List(5) { async { cache.get(1) } }.awaitAll() + println("все получили одно значение: ${results.toSet().size == 1}") // все получили одно значение: true +} +``` +С `synchronized` так нельзя: `suspend`-вызов внутри `synchronized`-блока не компилируется, потому что приостановка освободила бы поток, а монитор остался бы захваченным. + +## Mutex не реентерабелен +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +fun main() = runBlocking { + val mutex = Mutex() + + val deadlocked = withTimeoutOrNull(100) { + mutex.withLock { + mutex.withLock { // вторичный захват тем же владельцем + "недостижимо" + } + } + } + println("повторный захват повис: ${deadlocked == null}") // повторный захват повис: true +} +``` +Это осознанное решение: реентерабельность в корутинах определить корректно нельзя - «тот же владелец» для корутины понятие нечёткое, ведь она может перемещаться между потоками. Практический вывод: функция под `withLock` не должна вызывать другую функцию, которая берёт тот же мьютекс. Приватные методы, вызываемые из критической секции, не должны блокироваться сами. + +## Semaphore +Ограничивает число одновременных операций: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.util.concurrent.atomic.AtomicInteger + +fun main() = runBlocking { + val semaphore = Semaphore(permits = 3) + val inFlight = AtomicInteger(0) + val maxInFlight = AtomicInteger(0) + + withContext(Dispatchers.Default) { + List(20) { + launch { + semaphore.withPermit { + val now = inFlight.incrementAndGet() + maxInFlight.updateAndGet { m -> maxOf(m, now) } + delay(20) // ожидание ответа API + inFlight.decrementAndGet() + } + } + }.forEach { it.join() } + } + println("одновременно не больше 3: ${maxInFlight.get() <= 3}") // одновременно не больше 3: true +} +``` +Важное отличие от `limitedParallelism`: семафор считает операции **в полёте**, включая приостановленные. Именно он нужен, чтобы не превысить лимит внешнего API. Подробнее: [Диспетчеры и контекст](Диспетчеры%20и%20контекст.md) + +## Конфайнмент в один поток +Если состояние меняется только в одном потоке, синхронизация не нужна вовсе: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val single = Dispatchers.Default.limitedParallelism(1) + var counter = 0 + + withContext(Dispatchers.Default) { + List(100) { + launch { + repeat(1000) { + withContext(single) { counter++ } // все изменения в одном потоке + } + } + }.forEach { it.join() } + } + println("точный результат: ${counter == 100_000}") // точный результат: true +} +``` +Работает, но дорого: 100 000 переключений контекста. Правильная версия конфайнмента - **грубая**, когда весь блок работы уходит в один поток: +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +fun main() = runBlocking { + val single = Dispatchers.Default.limitedParallelism(1) + var counter = 0 + + val coarse = measureTimeMillis { + withContext(single) { // переключение один раз + repeat(100_000) { counter++ } + } + } + println("результат: $counter") + println("грубый конфайнмент быстр: ${coarse < 500}") + // результат: 100000 + // грубый конфайнмент быстр: true +} +``` + +## Актор: состояние внутри корутины +Самый чистый подход - состояние не разделяется вообще, им владеет одна корутина: +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel + +sealed interface AccountCommand { + data class Deposit(val amount: Int) : AccountCommand + data class Withdraw(val amount: Int, val ok: CompletableDeferred) : AccountCommand + data class Balance(val reply: CompletableDeferred) : AccountCommand +} + +fun main() = runBlocking { + val commands = Channel() + + launch { + var balance = 0 // никто больше не видит эту переменную + for (cmd in commands) { + when (cmd) { + is AccountCommand.Deposit -> balance += cmd.amount + is AccountCommand.Withdraw -> { + val enough = balance >= cmd.amount + if (enough) balance -= cmd.amount + cmd.ok.complete(enough) + } + is AccountCommand.Balance -> cmd.reply.complete(balance) + } + } + } + + repeat(100) { commands.send(AccountCommand.Deposit(10)) } + + val ok = CompletableDeferred() + commands.send(AccountCommand.Withdraw(500, ok)) + println("списание прошло: ${ok.await()}") + + val tooMuch = CompletableDeferred() + commands.send(AccountCommand.Withdraw(10_000, tooMuch)) + println("превышение отклонено: ${!tooMuch.await()}") + + val balance = CompletableDeferred() + commands.send(AccountCommand.Balance(balance)) + println("баланс: ${balance.await()}") + commands.close() + // списание прошло: true + // превышение отклонено: true + // баланс: 500 +} +``` +Проверка «хватает ли денег» и списание происходят атомарно без всякой блокировки - просто потому, что их выполняет одна корутина. Подробнее: [Channels](Channels.md) + +## StateFlow для наблюдаемого состояния +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +fun main() = runBlocking { + val state = MutableStateFlow(0) + withContext(Dispatchers.Default) { + List(100) { launch { repeat(1000) { state.update { v -> v + 1 } } } } + .forEach { it.join() } + } + println("результат точный: ${state.value == 100_000}") // результат точный: true +} +``` +`update` - это CAS-цикл, поэтому он и атомарен, и даёт оповещение подписчикам. Если состояние нужно наблюдать, это лучший вариант. Подробнее: [StateFlow и SharedFlow](StateFlow%20и%20SharedFlow.md) + +## Как выбрать +```mermaid +graph TD + A["Состояние меняют
несколько корутин"] --> B{"Можно вообще
не разделять?"} + B -->|"да"| C["локальная переменная
или актор"] + B -->|"нет"| D{"За состоянием
наблюдают?"} + D -->|"да"| E["MutableStateFlow
+ update"] + D -->|"нет"| F{"Одна переменная?"} + F -->|"да"| G["AtomicInteger / AtomicReference"] + F -->|"нет, составная операция"| H{"Нужен suspend
внутри секции?"} + H -->|"да"| I["Mutex.withLock"] + H -->|"нет"| J["Mutex или
конфайнмент в поток"] +``` + +## Неизменяемость убирает проблему +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +data class Stats(val requests: Int = 0, val errors: Int = 0) { + val successRate: Double + get() = if (requests == 0) 0.0 else (requests - errors).toDouble() / requests +} + +fun main() = runBlocking { + val stats = MutableStateFlow(Stats()) + + withContext(Dispatchers.Default) { + List(100) { i -> + launch { + stats.update { it.copy(requests = it.requests + 1, errors = it.errors + i % 10 / 9) } + } + }.forEach { it.join() } + } + val final = stats.value + println("запросов: ${final.requests}, ошибок: ${final.errors}") + println("согласованный снимок: ${final.requests == 100}") + // запросов: 100, ошибок: 10 + // согласованный снимок: true +} +``` +Два связанных поля обновляются одним атомарным `copy`, поэтому несогласованного состояния («запросов 5, ошибок 7») не возникает никогда. Это главный довод за неизменяемые `data class` в качестве состояния. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +## Коллекции +```kotlin +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap + +fun main() = runBlocking { + // обычный MutableList не потокобезопасен + val unsafe = mutableListOf() + withContext(Dispatchers.Default) { + List(100) { i -> launch { repeat(100) { unsafe.add(i) } } }.forEach { it.join() } + } + println("размер может быть неверным: ${unsafe.size <= 10_000}") + + // вариант 1: Mutex + val mutex = Mutex() + val guarded = mutableListOf() + withContext(Dispatchers.Default) { + List(100) { i -> launch { repeat(100) { mutex.withLock { guarded.add(i) } } } } + .forEach { it.join() } + } + println("с Mutex точно: ${guarded.size == 10_000}") + + // вариант 2: потокобезопасная коллекция из JDK + val concurrent = ConcurrentHashMap() + withContext(Dispatchers.Default) { + List(100) { i -> launch { concurrent[i] = i } }.forEach { it.join() } + } + println("ConcurrentHashMap точно: ${concurrent.size == 100}") + // размер может быть неверным: true + // с Mutex точно: true + // ConcurrentHashMap точно: true +} +``` +В Kotlin нет своих потокобезопасных коллекций для JVM - используются `java.util.concurrent`. Но учтите: `ConcurrentHashMap` безопасен для отдельных операций, а составные («прочитать, изменить, записать») всё равно требуют `compute`/`merge` или мьютекса. Подробнее: [Изменяемость](../Коллекции/Изменяемость.md) + +## Что не нужно делать +```kotlin +import kotlinx.coroutines.* +import kotlin.system.measureTimeMillis + +fun main() = runBlocking { + var counter = 0 + val lock = Any() + + val time = measureTimeMillis { + withContext(Dispatchers.Default) { + List(100) { launch { repeat(1000) { synchronized(lock) { counter++ } } } } + .forEach { it.join() } + } + } + println("результат верный: ${counter == 100_000}") + println("но поток блокировался: ${time >= 0}") + // результат верный: true + // но поток блокировался: true +} +``` +`synchronized` работает и даёт правильный результат, но блокирует поток диспетчера: пока корутина ждёт монитор, поток не может выполнять другие корутины. На ограниченном диспетчере это прямой путь к deadlock, а `suspend`-вызов внутри `synchronized` вообще не скомпилируется. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `synchronized` (блокирует поток) | `Mutex.withLock` (приостанавливает) | +> | `ReentrantLock` | `Mutex` (не реентерабелен) | +> | `Semaphore` из `j.u.c.` (блокирует) | `kotlinx.coroutines.sync.Semaphore` | +> | `AtomicInteger` | он же, или `MutableStateFlow` | +> | `volatile` | `@Volatile` или `StateFlow` | +> | `ThreadLocal` для конфайнмента | элемент контекста корутины | +> | `ConcurrentHashMap` | он же | +> | нет аналога | актор на `Channel` | +> +> **`synchronized` занимает поток, `Mutex` - нет.** Это принципиальное различие. В пуле из 8 потоков восемь корутин, ждущих монитор, парализуют весь диспетчер - включая корутины, которым этот монитор не нужен. `Mutex` приостанавливает корутину и освобождает поток: +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.sync.Mutex +> import kotlinx.coroutines.sync.withLock +> +> fun main() = runBlocking { +> val mutex = Mutex() +> val single = Dispatchers.Default.limitedParallelism(1) +> var progress = "" +> withContext(single) { +> launch { mutex.withLock { delay(30); progress += "A" } } +> launch { mutex.withLock { progress += "B" } } +> launch { progress += "C" } // не ждёт мьютекс +> } +> println("порядок выполнения: $progress") +> // порядок выполнения: CAB +> } +> ``` +> На одном потоке корутина `C` выполнилась первой, пока `A` держала мьютекс и ждала на `delay`. С `synchronized` и `Thread.sleep` поток был бы занят и `C` стояла бы в очереди. +> +> **`suspend` внутри `synchronized` не компилируется.** Это не ограничение, а защита: приостановка освободила бы поток, а монитор остался бы захваченным - потенциальный deadlock, который JVM не заметит. `Mutex` снимает запрет, потому что владение не привязано к потоку. +> +> **`ReentrantLock` реентерабелен, `Mutex` - нет.** В Java повторный захват тем же потоком разрешён, что делает рекурсивные вызовы под локом безопасными. В корутинах понятие «тот же владелец» не определено: корутина мигрирует между потоками, а `withLock` в дочерней корутине - это уже другой владелец. Поэтому `Mutex` принципиально не реентерабелен, и рекурсию под локом приходится исключать архитектурно. +> +> **`volatile` против `StateFlow`.** `@Volatile` в Kotlin есть и работает так же: гарантирует видимость записи между потоками, но не атомарность составных операций. `MutableStateFlow` даёт и видимость, и атомарный `update`, и оповещение - три вещи вместо одной. +> +> **Актор не имеет аналога в Java.** Паттерн «состояние внутри потока, снаружи только сообщения» в Java требует `BlockingQueue` + выделенный поток + «отравленную пилюлю» для остановки + ручную реализацию ответов (обычно через `CompletableFuture` в сообщении). В Kotlin это `Channel` + `launch` + `CompletableDeferred` - примерно двадцать строк и никаких потоков. +> +> **Проблемы те же.** Корутины не отменяют ни гонок, ни deadlock'ов, ни инверсии приоритетов. Два мьютекса, захваченные в разном порядке, дают взаимную блокировку точно так же, как два `synchronized`: +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.sync.Mutex +> import kotlinx.coroutines.sync.withLock +> +> fun main() = runBlocking { +> val a = Mutex() +> val b = Mutex() +> val result = withTimeoutOrNull(100) { +> coroutineScope { +> launch { a.withLock { delay(20); b.withLock { } } } +> launch { b.withLock { delay(20); a.withLock { } } } +> } +> } +> println("классический deadlock: ${result == null}") // классический deadlock: true +> coroutineContext.cancelChildren() +> } +> ``` +> Разница только в том, что корутинный deadlock можно разорвать таймаутом, а поток, стоящий на мониторе, прервать нельзя. +> +> Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [HashMap в Java](../../Программирование%20на%20Java/Коллекции/HashMap.md), [JVM](../../Программирование%20на%20Java/JVM/JVM.md) + +> [!warning] Подводные камни +> **`Mutex` не реентерабелен - повторный захват это deadlock:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.sync.Mutex +> import kotlinx.coroutines.sync.withLock +> +> class Service { +> private val mutex = Mutex() +> suspend fun outer() = mutex.withLock { inner() } +> suspend fun inner() = mutex.withLock { "никогда" } // тот же мьютекс +> } +> +> fun main() = runBlocking { +> val result = withTimeoutOrNull(100) { Service().outer() } +> println("повисло: ${result == null}") // повисло: true +> } +> ``` +> Публичный метод, берущий лок, не должен вызывать другой публичный метод того же класса. Выносите логику в приватные методы без блокировки. +> +> **`synchronized` в корутине блокирует поток диспетчера.** Результат будет верным, но пропускная способность упадёт, а на `limitedParallelism(1)` возможен deadlock. Используйте `Mutex`. +> +> **`suspend` внутри `synchronized` не компилируется** - и это хорошо. Попытка обойти запрет через `runBlocking` внутри `synchronized` создаёт именно тот deadlock, от которого защищает компилятор. +> +> **Отмена внутри `withLock` освобождает лок, но работа может остаться незавершённой:** +> ```kotlin +> import kotlinx.coroutines.* +> import kotlinx.coroutines.sync.Mutex +> import kotlinx.coroutines.sync.withLock +> +> fun main() = runBlocking { +> val mutex = Mutex() +> var step = 0 +> val job = launch { +> mutex.withLock { +> step = 1 +> delay(1000) // здесь отмена +> step = 2 // не выполнится +> } +> } +> delay(20) +> job.cancelAndJoin() +> println("лок свободен: ${!mutex.isLocked}, состояние на середине: ${step == 1}") +> // лок свободен: true, состояние на середине: true +> } +> ``` +> Критическая секция прервалась на середине, оставив несогласованное состояние. Либо делайте секцию атомарной (одна операция `copy`), либо восстанавливайте инвариант в `finally`. +> +> **`AtomicInteger` защищает одну переменную, а не инвариант:** +> ```kotlin +> import kotlinx.coroutines.* +> import java.util.concurrent.atomic.AtomicInteger +> +> fun main() = runBlocking { +> val total = AtomicInteger(0) +> val errors = AtomicInteger(0) +> withContext(Dispatchers.Default) { +> List(100) { launch { total.incrementAndGet(); errors.incrementAndGet() } } +> .forEach { it.join() } +> } +> println("каждый атомарен: ${total.get()} ${errors.get()}") +> // каждый атомарен: 100 100 +> } +> ``` +> Итог верный, но в любой момент между двумя инкрементами снимок несогласован: читатель может увидеть `total = 50, errors = 49`. Для связанных значений нужен один объект состояния. +> +> **`ConcurrentHashMap` не делает составные операции атомарными.** `if (!map.containsKey(k)) map[k] = v` - гонка. Нужны `computeIfAbsent`, `putIfAbsent` или `merge`. +> +> **Обычный `mutableListOf` в корутинах небезопасен**, даже если кажется, что обращения редки. `ArrayList.add` при конкурентном вызове может потерять элемент или бросить `ArrayIndexOutOfBoundsException` из-за состояния расширения массива. +> +> **Мелкозернистый конфайнмент убивает производительность.** `withContext(single) { counter++ }` внутри цикла - это переключение контекста на каждую операцию. Выносите весь блок работы в один `withContext`. +> +> **Два мьютекса в разном порядке - deadlock**, как и в Java. Правило то же: фиксированный порядок захвата или единый лок. +> +> **`Semaphore` с утечкой разрешений исчерпывается.** Ручная пара `acquire()`/`release()` без `try/finally` при исключении потеряет разрешение навсегда. Используйте `withPermit`. +> +> **`limitedParallelism(1)` - не замена `Mutex`.** Он гарантирует один поток для **своих** задач, но состояние может меняться и из других контекстов. Конфайнмент работает только если **все** обращения идут через этот диспетчер. +> +> **`@Volatile` не делает `++` атомарным.** Он даёт только видимость записи. `@Volatile var counter = 0` с `counter++` из нескольких корутин теряет инкременты точно так же, как обычное поле. +> +> **Актор сериализует все операции.** Если команд много, одна корутина станет узким местом - её очередь будет расти. Для высокой нагрузки нужны шардирование по ключу или атомарные типы. + +Далее: [Корутины против потоков](Корутины%20против%20потоков.md) diff --git a/Kotlin/Корутины/Структурированная конкурентность.md b/Kotlin/Корутины/Структурированная конкурентность.md new file mode 100644 index 0000000..45e0c11 --- /dev/null +++ b/Kotlin/Корутины/Структурированная конкурентность.md @@ -0,0 +1,478 @@ +--- +title: Структурированная конкурентность +description: CoroutineScope, иерархия Job, coroutineScope и supervisorScope, жизненный цикл и GlobalScope +tags: + - kotlin + - coroutines +--- + +Структурированная конкурентность - главное архитектурное решение корутин: каждая корутина принадлежит области видимости (scope) и имеет родителя. Из этого следуют три гарантии: + +1. родитель не завершится, пока не завершатся дети +2. отмена родителя отменяет всех детей +3. ошибка ребёнка доходит до родителя + +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + println("начало") + coroutineScope { + launch { delay(100); println("медленный ребёнок") } + launch { delay(20); println("быстрый ребёнок") } + } + println("конец — только после всех детей") + // начало + // быстрый ребёнок + // медленный ребёнок + // конец — только после всех детей +} +``` +Без этого свойства асинхронный код неизбежно порождает «потерянные» задачи: никто не знает, сколько их работает и когда они закончатся. + +## CoroutineScope +Scope - это объект с одним свойством `coroutineContext`, в котором есть `Job`. Билдеры (`launch`, `async`) - расширения на `CoroutineScope`, поэтому без scope их просто не вызвать. + +```kotlin +import kotlinx.coroutines.* + +class DataLoader : CoroutineScope { + private val job = SupervisorJob() + override val coroutineContext = job + Dispatchers.Default + + fun load(id: Int) = launch { + delay(20L * id) // разные задержки — порядок предсказуем + println("загружено $id") + } + + fun close() { + job.cancel() // отменяет всё, что запущено этим scope + } +} + +fun main() = runBlocking { + val loader = DataLoader() + loader.load(1) + loader.load(2) + delay(80) + loader.close() + println("загрузчик закрыт") + // загружено 1 + // загружено 2 + // загрузчик закрыт +} +``` +Такой класс владеет своими корутинами: `close()` гарантированно останавливает всё. Это основной способ связать корутины с жизненным циклом компонента. + +Чаще используют не наследование, а поле - так интерфейс класса не загрязняется методами scope: +```kotlin +import kotlinx.coroutines.* + +class Service2 { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + fun start() { + scope.launch { + delay(20) + println("работа выполнена") + } + } + + fun stop() = scope.cancel() +} + +fun main() = runBlocking { + val s = Service2() + s.start() + delay(50) + s.stop() + println("остановлено") + // работа выполнена + // остановлено +} +``` +Функция `CoroutineScope(context)` - фабрика, а не конструктор интерфейса. Обратите внимание: в `SupervisorJob()` - именно скобки, иначе получится ссылка на конструктор. + +## coroutineScope: вложенная область +`coroutineScope { }` - `suspend`-функция, которая создаёт дочернюю область и ждёт её завершения: +```kotlin +import kotlinx.coroutines.* + +suspend fun loadAll(): List = coroutineScope { + val a = async { delay(50); "первый" } + val b = async { delay(30); "второй" } + listOf(a.await(), b.await()) +} + +fun main() = runBlocking { + println(loadAll()) // [первый, второй] +} +``` +Это стандартный способ сделать `suspend`-функцию, которая внутри работает параллельно: снаружи она выглядит как обычная функция, возвращающая значение, и гарантированно не оставляет фоновых задач после возврата. + +Ошибка внутри отменяет остальных детей: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + try { + coroutineScope { + launch { + try { + delay(1000) + println("не напечатается") + } finally { + println("ребёнка 1 отменили") + } + } + launch { + delay(20) + throw IllegalStateException("ошибка во втором") + } + } + } catch (e: IllegalStateException) { + println("поймано: ${e.message}") + } + // ребёнка 1 отменили + // поймано: ошибка во втором +} +``` +Это свойство «всё или ничего»: если часть параллельной работы провалилась, продолжать остальную обычно бессмысленно. + +## supervisorScope: независимые дети +Когда дети не должны зависеть друг от друга, нужен `supervisorScope`: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + supervisorScope { + launch { + delay(20) + throw IllegalStateException("первый упал") + } + launch { + delay(50) + println("второй выжил") + } + } + println("область завершилась") + // Exception in thread ... IllegalStateException: первый упал + // второй выжил + // область завершилась +} +``` +Ошибка первого не затронула второго. Обратите внимание: исключение всё равно печатается - его нужно обработать, иначе оно уйдёт в обработчик по умолчанию. Подробнее: [Исключения в корутинах](Исключения%20в%20корутинах.md) + +| | `coroutineScope` | `supervisorScope` | +|---|---|---| +| ошибка ребёнка | отменяет остальных детей и область | не затрагивает остальных | +| ошибка пробрасывается наверх | да, из блока | нет, идёт в `CoroutineExceptionHandler` | +| отмена сверху | отменяет всех детей | отменяет всех детей | +| когда применять | части одной задачи | независимые подзадачи | + +## Иерархия Job +`Job` - дескриптор корутины: её состояние и связи с родителем и детьми. +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val parent = launch { + val child1 = launch { delay(1000) } + val child2 = launch { delay(1000) } + println("детей у родителя: ${coroutineContext[Job]!!.children.count()}") + child1.cancel() + println("child1 отменён: ${child1.isCancelled}") + println("child2 активен: ${child2.isActive}") + child2.cancel() + } + parent.join() + println("родитель завершён: ${parent.isCompleted}") + // детей у родителя: 2 + // child1 отменён: true + // child2 активен: true + // родитель завершён: true +} +``` + +Состояния `Job` и переходы между ними: + +```mermaid +stateDiagram-v2 + [*] --> New: "launch(start = LAZY)" + [*] --> Active: "launch / async" + New --> Active: "start() / join()" + Active --> Completing: "тело завершилось" + Completing --> Completed: "все дети завершились" + Active --> Cancelling: "cancel() / ошибка" + Completing --> Cancelling: "cancel() / ошибка ребёнка" + Cancelling --> Cancelled: "все дети отменены" + Completed --> [*] + Cancelled --> [*] +``` + +Флаги состояния: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val job = launch { delay(1000) } + println("${job.isActive} ${job.isCompleted} ${job.isCancelled}") // true false false + + job.cancel() + job.join() + println("${job.isActive} ${job.isCompleted} ${job.isCancelled}") // false true true +} +``` +Важная тонкость: у отменённой корутины `isCompleted` тоже `true` - завершение включает и успешное, и отменённое. Различать их нужно через `isCancelled`. + +## Job как часть контекста +`Job` можно передать явно, и тогда корутина станет ребёнком именно его: +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + val group = Job() + + launch(group) { delay(1000); println("не напечатается 1") } + launch(group) { delay(1000); println("не напечатается 2") } + val independent = launch { delay(30); println("независимая выполнилась") } + + delay(10) + group.cancel() // отменяет только своих детей + independent.join() + println("готово") + // независимая выполнилась + // готово +} +``` +Так делают «группы» задач, отменяемые вместе. Но осторожно: при явном `Job` корутина перестаёт быть ребёнком `runBlocking`, и тот не будет её ждать. + +## GlobalScope: почему его не надо +```kotlin +import kotlinx.coroutines.* + +fun main() = runBlocking { + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch { + delay(1000) + println("этого вы не увидите") + } + delay(20) + println("main завершился, не дождавшись") + // main завершился, не дождавшись +} +``` +Корутина в `GlobalScope` живёт до конца процесса, её никто не отменит и никто не узнает об ошибке. Аннотация `@DelicateCoroutinesApi` поставлена специально - API помечено как опасное. + +Правильные альтернативы: +| Вместо `GlobalScope` | Используйте | +|---|---| +| фоновая задача в сервисе | свой `CoroutineScope` с явным `cancel()` | +| задача на время запроса | `coroutineScope` внутри обработчика | +| задача в Android ViewModel | `viewModelScope` | +| задача в Android UI | `lifecycleScope` | +| задача в тесте | `runTest` | +| утилита или `main` | `runBlocking` | + +Единственный оправданный случай - задача, которая действительно должна жить весь процесс (например, периодическая отправка метрик), и даже тогда лучше завести явный `applicationScope`. + +## Утечка через внешний launch +```kotlin +import kotlinx.coroutines.* + +// плохо: функция возвращается, а работа продолжается неизвестно где +fun CoroutineScope.badLoad(): Unit { + launch { delay(100); println("ещё работаю") } +} + +// хорошо: функция suspend и не оставляет хвостов +suspend fun goodLoad(): String = coroutineScope { + val d = async { delay(20); "результат" } + d.await() +} + +fun main() = runBlocking { + println(goodLoad()) // результат + badLoad() + println("badLoad вернулся, но задача ещё жива") + delay(150) + // результат + // badLoad вернулся, но задача ещё жива + // ещё работаю +} +``` +Правило: публичная функция, выполняющая асинхронную работу, должна быть `suspend` и возвращать результат. Расширение на `CoroutineScope`, запускающее `launch`, оправдано только там, где «запустить и забыть» - это и есть контракт. + +## Ожидание нескольких результатов +```kotlin +import kotlinx.coroutines.* + +suspend fun loadPage(n: Int): String { delay(20L * n); return "страница $n" } + +fun main() = runBlocking { + val pages = coroutineScope { + (1..3).map { async { loadPage(it) } }.awaitAll() + } + println(pages) // [страница 1, страница 2, страница 3] + + val results = coroutineScope { + (1..3).map { async { loadPage(it) } }.map { it.await() } + } + println(results) // [страница 1, страница 2, страница 3] +} +``` +`awaitAll` отличается от `map { it.await() }` поведением при ошибке: `awaitAll` падает сразу, как только упал любой из `Deferred`, а последовательные `await` дождутся своей очереди. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `ExecutorService` + `shutdown()` | `CoroutineScope` + `cancel()` | +> | `CountDownLatch`, `invokeAll` | `coroutineScope`, `awaitAll` | +> | `Future.cancel` по одной задаче | отмена по дереву | +> | `StructuredTaskScope` (Java 21+) | `coroutineScope` | +> | ошибка в `Runnable` теряется | ошибка идёт родителю | +> | задачи не знают о родителе | иерархия `Job` | +> +> **В Java задачи плоские.** `ExecutorService` - это пул: вы отправляете задачу и получаете `Future`. Если задача запустит ещё три, пул о них ничего не узнает, `shutdown()` их не дождётся, а `Future.cancel` на родителе их не остановит. Отслеживать такие связи приходится вручную - списками `Future`, `CountDownLatch` и флагами. +> +> **`StructuredTaskScope` в Java 21 - прямой аналог `coroutineScope`.** Он появился в том же Project Loom именно потому, что проблема была признана фундаментальной: +> ```java +> try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { +> var user = scope.fork(() -> loadUser(id)); +> var orders = scope.fork(() -> loadOrders(id)); +> scope.join().throwIfFailed(); +> return new Result(user.get(), orders.get()); +> } +> ``` +> Сравните с Kotlin: +> ```kotlin +> import kotlinx.coroutines.* +> +> suspend fun loadUser3(id: Int): String { delay(10); return "u$id" } +> suspend fun loadOrders3(id: Int): List { delay(10); return listOf("o$id") } +> +> suspend fun result(id: Int): String = coroutineScope { +> val user = async { loadUser3(id) } +> val orders = async { loadOrders3(id) } +> "${user.await()} ${orders.await()}" +> } +> +> fun main() = runBlocking { println(result(1)) } // u1 [o1] +> ``` +> Идея одна и та же: блок не завершится, пока не завершатся подзадачи. `ShutdownOnFailure` соответствует `coroutineScope`, `ShutdownOnSuccess` - гонке через `select`. Отличие в том, что в Kotlin это единственный способ работы, а в Java - новый опциональный API поверх существующих плоских пулов. +> +> **Ошибка в `Runnable` исчезает.** В `executor.submit(task)` исключение складывается в `Future`, и если `get()` никто не вызовет, об ошибке не узнает никто. В `executor.execute(task)` оно уйдёт в `UncaughtExceptionHandler` потока. Корутины гарантируют, что ошибка дойдёт до родителя и, если не обработана, до обработчика области. +> +> **Завершение пула против отмены области.** `shutdown()` ждёт текущих задач, `shutdownNow()` прерывает потоки, а прерывание работает только на блокирующих вызовах. `scope.cancel()` отменяет всё дерево кооперативно и предсказуемо. Подробнее: [Отмена и таймауты](Отмена%20и%20таймауты.md) +> +> Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md) + +> [!warning] Подводные камни +> **`GlobalScope` - утечка по определению.** Задача не привязана ни к чему, не отменяется при закрытии компонента и может переживать его. В Android это классическая причина обращения к уничтоженному экрану. +> +> **`CoroutineScope(...)` без `cancel()` - та же утечка:** +> ```kotlin +> import kotlinx.coroutines.* +> +> class Leaky { +> private val scope = CoroutineScope(Dispatchers.Default) +> fun start() { scope.launch { delay(10_000) } } +> // нет метода stop — корутина живёт до конца процесса +> } +> +> fun main() = runBlocking { +> Leaky().start() +> println("объект больше не нужен, а корутина работает") +> delay(20) +> // объект больше не нужен, а корутина работает +> } +> ``` +> Если класс создаёт scope, у него обязан быть метод освобождения. +> +> **`Job()` вместо `SupervisorJob()` в собственном scope роняет весь scope при первой ошибке:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val scope = CoroutineScope(Job()) +> scope.launch { throw IllegalStateException("первая задача") } +> delay(20) +> val second = scope.launch { println("не запустится") } +> delay(20) +> println("scope отменён: ${!scope.isActive}, вторая отменена: ${second.isCancelled}") +> // scope отменён: true, вторая отменена: true +> } +> ``` +> Для долгоживущего scope почти всегда нужен `SupervisorJob`, иначе одна ошибка выводит из строя весь компонент. +> +> **Передача `Job` в билдер разрывает связь с родителем:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> launch(Job()) { // свой Job — runBlocking НЕ ждёт +> delay(200) +> println("не напечатается") +> } +> println("main вышел") +> // main вышел +> } +> ``` +> `launch(Job())` выглядит безобидно, но делает корутину сиротой. Чтобы просто сменить диспетчер, передавайте только диспетчер. +> +> **`supervisorScope` не защищает от ошибки в своём теле:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> try { +> supervisorScope { +> launch { delay(1000) } +> throw IllegalStateException("ошибка в теле, не в ребёнке") +> } +> } catch (e: IllegalStateException) { +> println("поймано: ${e.message}") // поймано: ошибка в теле, не в ребёнке +> } +> } +> ``` +> Supervisor меняет поведение только для **детей**; код самого блока ведёт себя обычно и отменяет область. +> +> **`coroutineScope` внутри `launch` не делает дочерние ошибки локальными.** `coroutineScope` пробрасывает исключение наверх; если наверху `launch`, оно уйдёт родителю и может уронить всю область. Локализовать нужно через `try/catch` вокруг `coroutineScope` или через `supervisorScope`. +> +> **Наследование `CoroutineScope` классом публикует `launch` в API:** +> ```kotlin +> import kotlinx.coroutines.* +> +> class Exposed : CoroutineScope { +> override val coroutineContext = SupervisorJob() + Dispatchers.Default +> } +> +> fun main() = runBlocking { +> val e = Exposed() +> e.launch { println("любой может запустить корутину в чужом scope") } +> delay(20) +> e.cancel() +> // любой может запустить корутину в чужом scope +> } +> ``` +> Внешний код получает возможность запускать задачи в вашем scope. Поле `private val scope` предпочтительнее наследования. +> +> **`cancel()` на scope делает его непригодным навсегда:** +> ```kotlin +> import kotlinx.coroutines.* +> +> fun main() = runBlocking { +> val scope = CoroutineScope(SupervisorJob()) +> scope.cancel() +> val job = scope.launch { println("не выполнится") } +> delay(20) +> println("корутина сразу отменена: ${job.isCancelled}") // корутина сразу отменена: true +> } +> ``` +> Отменённый scope не «перезапускается» - нужно создавать новый. В Android это причина ошибок при повторном использовании ViewModel-scope. +> +> **`awaitAll` и `map { await() }` ведут себя по-разному при ошибке.** `awaitAll` падает при первой ошибке любого элемента; последовательные `await()` сначала дождутся предыдущих. Для fail-fast нужен `awaitAll`. +> +> **Возврат `Deferred` из публичной функции ломает структурированность.** Вызывающий может не вызвать `await()`, и ошибка потеряется, либо вызвать его после закрытия scope. Возвращайте готовое значение из `suspend`-функции. + +Далее: [Билдеры корутин](Билдеры%20корутин.md) diff --git a/Kotlin/ООП/Абстрактные классы.md b/Kotlin/ООП/Абстрактные классы.md new file mode 100644 index 0000000..a84ca4b --- /dev/null +++ b/Kotlin/ООП/Абстрактные классы.md @@ -0,0 +1,306 @@ +--- +title: Абстрактные классы +description: abstract против open, шаблонный метод и выбор между абстрактным классом и интерфейсом +tags: + - kotlin + - oop +--- + +Абстрактный класс нельзя создать - только унаследовать. + +```kotlin +abstract class Shape4 { + abstract val area: Double // без реализации + abstract fun name(): String + + fun describe() = "${name()}: площадь %.2f".format(java.util.Locale.US, area) +} + +class Circle3(val radius: Double) : Shape4() { + override val area get() = Math.PI * radius * radius + override fun name() = "круг" +} + +class Square3(val side: Double) : Shape4() { + override val area get() = side * side + override fun name() = "квадрат" +} + +fun main() { + val shapes = listOf(Circle3(1.0), Square3(2.0)) + shapes.forEach { println(it.describe()) } + // круг: площадь 3.14 + // квадрат: площадь 4.00 + // println(Shape4()) // ошибка: нельзя создать абстрактный класс +} +``` + +## abstract и open +`abstract` подразумевает `open` - писать его не нужно: +```kotlin +abstract class Base32 { + abstract fun required() // open не нужен, реализации нет + open fun optional() = "по умолчанию" // есть реализация, но переопределяема + fun fixed() = "не переопределить" // final +} + +class Impl6 : Base32() { + override fun required() = Unit + override fun optional() = "своё" +} + +fun main() { + val i = Impl6() + println("${i.optional()} / ${i.fixed()}") // своё / не переопределить +} +``` + +| Модификатор | Реализация | Нужно переопределять | +|---|---|---| +| `abstract` | нет | обязательно | +| `open` | есть | можно | +| нет модификатора | есть | нельзя | + +Абстрактный класс может не иметь ни одного абстрактного члена - тогда он просто запрещает создание экземпляра: +```kotlin +abstract class Utils { + fun helper() = "общая логика" +} + +class Concrete : Utils() + +fun main() { println(Concrete().helper()) } // общая логика +``` + +## Абстрактный класс может иметь состояние +Это главное отличие от интерфейса: +```kotlin +abstract class Repository2(protected val tableName: String) { + private val cache = mutableMapOf() // состояние + + protected abstract fun loadFromDb(id: Int): String? + + fun find(id: Int): String? = cache.getOrPut(id) { + loadFromDb(id) ?: return null + } + + fun cacheSize() = cache.size +} + +class UserRepository2 : Repository2("users") { + override fun loadFromDb(id: Int) = if (id == 1) "Иван" else null +} + +fun main() { + val repo = UserRepository2() + println(repo.find(1)) // Иван + println(repo.find(1)) // Иван — из кеша + println(repo.cacheSize()) // 1 + println(repo.find(99)) // null +} +``` +Конструктор, приватные поля, инициализация - всё доступно. У интерфейса этого нет. + +## Шаблонный метод +Самое частое применение: алгоритм зафиксирован в базовом классе, шаги переопределяются. +```kotlin +abstract class Importer { + fun import(data: String): String { + val parsed = parse(data) + val valid = parsed.filter { validate(it) } + return "загружено ${valid.size} из ${parsed.size}: $valid" + } + + protected abstract fun parse(data: String): List + protected open fun validate(item: String) = item.isNotBlank() +} + +class CsvImporter : Importer() { + override fun parse(data: String) = data.split(",").map { it.trim() } +} + +class StrictCsvImporter : Importer() { + override fun parse(data: String) = data.split(",").map { it.trim() } + override fun validate(item: String) = item.length >= 3 +} + +fun main() { + println(CsvImporter().import("a, b, , c")) + // загружено 3 из 4: [a, b, c] + println(StrictCsvImporter().import("abc, b, defg")) + // загружено 2 из 3: [abc, defg] +} +``` +`import` не `open` - порядок шагов менять нельзя. `parse` обязателен, `validate` имеет разумное поведение по умолчанию. + +## protected для членов, нужных подклассам +```kotlin +abstract class Connection { + protected var isOpen = false + private set + + protected abstract fun doConnect(): Boolean + + fun connect(): String { + isOpen = doConnect() + return if (isOpen) "подключено" else "ошибка" + } +} + +class TcpConnection(private val host: String) : Connection() { + override fun doConnect() = host.isNotBlank() + fun status() = if (isOpen) "$host: активно" else "$host: закрыто" +} + +fun main() { + val c = TcpConnection("example.com") + println(c.connect()) // подключено + println(c.status()) // example.com: активно +} +``` + +## Абстрактный класс или интерфейс +```kotlin +interface Drawable { fun draw(): String } // контракт + +abstract class Widget(val id: String) : Drawable { // + состояние и общая логика + protected var visible = true + fun hide() { visible = false } + override fun draw() = if (visible) render() else "" + protected abstract fun render(): String +} + +class Button(id: String, val label: String) : Widget(id) { + override fun render() = "[$label]" +} + +fun main() { + val b = Button("b1", "ОК") + println(b.draw()) // [ОК] + b.hide() + println("'${b.draw()}'") // '' +} +``` + +| | Интерфейс | Абстрактный класс | +|---|---|---| +| сколько можно реализовать | много | один | +| состояние (свойство с полем) | нет | да | +| конструктор | нет | да | +| `init` | нет | да | +| `private`-члены | да (с реализацией) | да | +| `protected`-члены | **нет** | да | +| видимость членов | только `public` или `private` | любая | + +Правило выбора: +- **интерфейс** - когда описываете возможность («умеет рисоваться», «сравним»), особенно если её могут захотеть добавить к существующим классам +- **абстрактный класс** - когда у наследников общее состояние или нужен шаблонный метод с `protected`-шагами + +На практике часто сочетают: публичный интерфейс для клиентов и абстрактный класс как база для реализаций. + +Подробнее: [Интерфейсы](Интерфейсы.md) + +## Абстрактный класс и sealed +```kotlin +sealed class Result4 { + abstract val isSuccess: Boolean +} + +class Ok2(val data: String) : Result4() { + override val isSuccess = true +} + +class Fail(val error: String) : Result4() { + override val isSuccess = false +} + +fun handle(r: Result4) = when (r) { // else не нужен + is Ok2 -> "данные: ${r.data}" + is Fail -> "ошибка: ${r.error}" +} + +fun main() { + println(handle(Ok2("готово"))) // данные: готово + println(Fail("сбой").isSuccess) // false +} +``` +`sealed` класс абстрактен по определению. Разница: `abstract` допускает наследников где угодно, `sealed` - только в своём модуле и пакете, зато даёт исчерпывающий `when`. Подробнее: [Sealed class и interface](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `abstract class A` | то же | +> | `abstract void f();` | `abstract fun f()` | +> | поля `protected` | свойства `protected` | +> | абстрактное поле невозможно | `abstract val` | +> | `default`-методы в интерфейсе (Java 8+) | реализация в интерфейсе без ключевого слова | +> +> **`abstract val` - то, чего в Java нет.** Java позволяет объявить абстрактным только метод, поэтому «абстрактное свойство» выражается парой геттер/сеттер: +> ```java +> abstract class Shape { +> abstract double getArea(); // абстрактный геттер +> } +> ``` +> В Kotlin `abstract val area: Double` оставляет наследнику выбор: хранимое свойство, геттер или параметр конструктора. Клиент разницы не видит. +> +> **`abstract` не требует `open`**, потому что отсутствие реализации уже означает необходимость переопределения. В Java `final abstract` - ошибка компиляции по той же причине. +> +> **Доля абстрактных классов в Kotlin ниже.** Часть их задач закрывают другие средства: `sealed` для закрытых иерархий, делегирование для переиспользования кода, функции-расширения для добавления поведения без наследования. +> +> Сравнить: [Абстрактные классы в Java](../../Программирование%20на%20Java/ООП/Абстрактные%20классы.md) + +> [!warning] Подводные камни +> **Вызов абстрактного метода из `init` - гарантированная ловушка:** +> ```kotlin +> abstract class Base33 { +> init { println("из init: ${describe()}") } +> abstract fun describe(): String +> } +> +> class Derived32 : Base33() { +> private val name: String = "Иван" +> override fun describe() = "name=$name" +> } +> +> fun main() { Derived32() } // из init: name=null +> ``` +> Подкласс ещё не инициализирован, поэтому non-null свойство оказывается `null`. Подробнее: [Инициализация](Инициализация.md) +> +> **Абстрактный класс расходует единственный слот наследования.** Класс, унаследованный от абстрактного, больше ни от чего наследоваться не сможет. Поэтому публичный API лучше начинать с интерфейса. +> +> **`protected abstract` - часть контракта с наследниками**, и менять его так же больно, как публичный API: изменение сигнатуры сломает все подклассы. +> +> **Абстрактный класс с конструктором усложняет тестирование.** Чтобы проверить логику шаблонного метода, нужен наследник; с интерфейсом достаточно лямбды или объекта-выражения. +> +> **`abstract` и `private` несовместимы** - приватный член невозможно переопределить: +> ```kotlin +> abstract class Base34 { +> // private abstract fun f() // ошибка +> protected abstract fun f(): String +> fun run3() = f() +> } +> +> class Impl7 : Base34() { override fun f() = "готово" } +> +> fun main() { println(Impl7().run3()) } // готово +> ``` +> +> **Абстрактное свойство легко переопределить несогласованно:** +> ```kotlin +> abstract class Base35 { abstract val id: Int } +> +> class Unstable : Base35() { +> override val id: Int get() = (0..100).random() // разное при каждом чтении +> } +> +> fun main() { +> val u = Unstable() +> println(u.id == u.id) // чаще false +> } +> ``` +> Контракт «свойство стабильно» компилятором не проверяется; при использовании в `equals` или как ключа это даст тяжёлые для отладки ошибки. +> +> **Глубокие абстрактные иерархии трудны для чтения.** Если шаблонный метод вызывает абстрактные шаги, которые вызывают другие шаблонные методы, поведение собирается из четырёх файлов. + +Дополнительно: [Интерфейсы](Интерфейсы.md) +Далее: [Интерфейсы](Интерфейсы.md) diff --git a/Kotlin/ООП/Вложенные и внутренние классы.md b/Kotlin/ООП/Вложенные и внутренние классы.md new file mode 100644 index 0000000..883bdd3 --- /dev/null +++ b/Kotlin/ООП/Вложенные и внутренние классы.md @@ -0,0 +1,284 @@ +--- +title: Вложенные и внутренние классы +description: Вложенный класс по умолчанию, inner для ссылки на внешний объект и утечки памяти +tags: + - kotlin + - oop +--- + +Класс внутри класса по умолчанию **не** имеет ссылки на внешний объект. Чтобы её получить, нужен `inner`. + +```kotlin +class Outer5 { + private val secret = "скрыто" + + class Nested2 { + fun describe() = "вложенный, внешний объект недоступен" + // fun bad() = secret // ошибка: нет доступа + } + + inner class Inner5 { + fun describe() = "внутренний, вижу: $secret" + } +} + +fun main() { + println(Outer5.Nested2().describe()) // внешний объект не нужен + println(Outer5().Inner5().describe()) // нужен экземпляр Outer5 +} +``` + +| | вложенный (по умолчанию) | `inner` | +|---|---|---| +| ссылка на внешний объект | нет | есть | +| доступ к членам внешнего класса | только к членам `companion object` | ко всем, включая `private` | +| как создать | `Outer.Nested()` | `outer.Inner()` | +| аналог в Java | `static class` | обычный вложенный класс | +| риск утечки памяти | нет | есть | + +Обратите внимание на создание: `Outer5().Inner5()` - сначала внешний объект, затем внутренний на нём. + +## Зачем вложенные классы +Группировка по смыслу: класс нужен только в контексте внешнего и не имеет смысла отдельно. +```kotlin +class HttpClient2 { + class Request2(val url: String, val method: String = "GET") + class Response(val code: Int, val body: String) + + fun send(request: Request2): Response = + Response(200, "ответ на ${request.method} ${request.url}") +} + +fun main() { + val client = HttpClient2() + val response = client.send(HttpClient2.Request2("http://x.ru")) + println("${response.code}: ${response.body}") // 200: ответ на GET http://x.ru +} +``` +Имя `HttpClient2.Request2` само документирует принадлежность, и пакет не засоряется. + +Частый случай - варианты sealed-иерархии: +```kotlin +sealed interface State3 { + data object Loading3 : State3 + data class Ready(val data: String) : State3 + data class Error3(val message: String) : State3 +} + +fun render(s: State3) = when (s) { + State3.Loading3 -> "загрузка" + is State3.Ready -> "готово: ${s.data}" + is State3.Error3 -> "ошибка: ${s.message}" +} + +fun main() { + println(render(State3.Ready("данные"))) // готово: данные + println(render(State3.Loading3)) // загрузка +} +``` +Подробнее: [Sealed class и interface](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +## Зачем inner +Когда внутренний объект логически часть конкретного экземпляра и должен видеть его состояние. +```kotlin +class Table(private val name: String) { + private val rows = mutableListOf() + + inner class Row(private val data: String) { + fun insert(): String { + rows.add(data) // меняем состояние внешнего + return "в таблицу $name добавлено '$data', всего ${rows.size}" + } + } + + fun newRow(data: String) = Row(data) + fun count() = rows.size +} + +fun main() { + val t = Table("users") + println(t.newRow("Иван").insert()) // в таблицу users добавлено 'Иван', всего 1 + println(t.newRow("Пётр").insert()) // в таблицу users добавлено 'Пётр', всего 2 + println(t.count()) // 2 +} +``` + +## this в inner-классе +При совпадении имён нужна квалификация метками: +```kotlin +class Outer6(val name: String = "внешний") { + inner class Inner6(val name: String = "внутренний") { + fun describe() = "${this.name} / ${this@Outer6.name}" + } +} + +fun main() { + println(Outer6().Inner6().describe()) // внутренний / внешний +} +``` +`this@Outer6` - ссылка на внешний объект. Без метки `this` относится к ближайшему классу. + +То же для `super`: +```kotlin +open class Base36 { open fun f() = "база" } + +class Outer7 : Base36() { + override fun f() = "внешний" + + inner class Inner7 { + fun both() = "${f()} / ${super@Outer7.f()}" + } +} + +fun main() { println(Outer7().Inner7().both()) } // внешний / база +``` + +## Локальные классы +Класс можно объявить внутри функции: +```kotlin +fun process(items: List): String { + val prefix = ">> " + + class Formatter(val value: Int) { + fun format() = "$prefix$value" // захватывает prefix + } + + return items.map { Formatter(it).format() }.joinToString() +} + +fun main() { + println(process(listOf(1, 2))) // >> 1, >> 2 +} +``` +Локальный класс видит переменные функции, включая изменяемые. Нужен редко: обычно хватает [объекта-выражения](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) или лямбды. + +## Вложенный интерфейс и object +```kotlin +class Service17 { + fun interface Listener { // интерфейс всегда "вложенный" + fun onEvent(name: String): String + } + + object Defaults { // object не может быть inner + const val TIMEOUT = 30 + } + + companion object { + fun create() = Service17() + } + + fun notify2(l: Listener) = l.onEvent("старт") +} + +fun main() { + val s = Service17.create() + println(s.notify2 { "получено: $it" }) // получено: старт + println(Service17.Defaults.TIMEOUT) // 30 +} +``` +Интерфейсы, `object`, `enum` и `data class` не могут быть `inner` - у них нет смысла быть привязанными к экземпляру. + +## Вложенность и дженерики +`inner`-класс видит параметры типа внешнего: +```kotlin +class Box3(private val value: T) { + inner class Wrapper { // T доступен + fun get(): T = value + fun describe() = "обёртка над ${value}" + } + + class Helper4(val other: U) // вложенный: свой параметр типа +} + +fun main() { + val b = Box3("данные") + println(b.Wrapper().get()) // данные + println(b.Wrapper().describe()) // обёртка над данные + println(Box3.Helper4(42).other) // 42 +} +``` +Вложенный класс параметров внешнего не видит - ему нужны свои. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `static class Nested` | `class Nested` (по умолчанию) | +> | `class Inner` (без static) | `inner class Inner` | +> | `outer.new Inner()` | `outer.Inner()` | +> | `Outer.this.field` | `this@Outer.field` | +> | `Outer.super.method()` | `super@Outer.method()` | +> | анонимный класс | объект-выражение | +> +> **Значения по умолчанию перевёрнуты - и это важно.** В Java вложенный класс по умолчанию держит ссылку на внешний объект, и чтобы этого избежать, нужно написать `static`. Разработчики об этом забывают, из-за чего возникают утечки памяти: внутренний объект, переживший внешний, не даёт собрать его сборщику мусора. +> +> В Kotlin по умолчанию ссылки нет - её нужно запросить словом `inner`. Безопасный вариант стал умолчанием, а опасный требует явного решения. Это особенно заметно в Android, где утечки активити через нестатические внутренние классы и анонимные обработчики были массовой проблемой. +> +> **`Outer.this` против `this@Outer`.** Синтаксис меток в Kotlin единообразен: та же `@`-нотация используется для `return@forEach` и `this@apply` в scope-функциях. +> +> Сравнить: [Вложенные классы в Java](../../Программирование%20на%20Java/ООП/Вложенные%20классы.md) + +> [!warning] Подводные камни +> **`inner` держит внешний объект в памяти - это утечка.** Если внутренний объект переживает внешний (попал в долгоживущую коллекцию, кеш или обработчик), внешний не будет собран: +> ```kotlin +> class Activity2(val heavyData: ByteArray = ByteArray(1024)) { +> inner class Handler3 { +> fun handle() = "обработка, внешний объект жив" +> } +> } +> +> val globalHandlers = mutableListOf() +> +> fun main() { +> globalHandlers.add(Activity2().Handler3()) +> println(globalHandlers.first().handle()) +> println("Activity2 не может быть собран: он держится ссылкой из Handler3") +> } +> ``` +> Правило: `inner` только когда нужен доступ к состоянию внешнего объекта и время жизни внутреннего не превышает внешнего. +> +> **Вложенный класс не видит членов экземпляра внешнего класса** - частая ошибка при переносе кода из Java, где поведение обратное: +> ```kotlin +> class Outer8 { +> private val value = 1 +> class Nested3 { +> // fun bad() = value // ошибка +> fun ok() = "нужен inner или параметр" +> } +> } +> +> fun main() { println(Outer8.Nested3().ok()) } +> ``` +> +> **Создание `inner`-класса требует экземпляра внешнего:** +> ```kotlin +> class Outer9 { +> inner class Inner8 +> class Nested4 +> } +> +> fun main() { +> println(Outer9.Nested4()::class.simpleName) // Nested4 +> println(Outer9().Inner8()::class.simpleName) // Inner8 +> // Outer9.Inner8() // ошибка +> } +> ``` +> +> **`data class` не может быть `inner`:** +> ```kotlin +> class Outer10 { +> data class Point5(val x: Int) // вложенный — ок +> // inner data class Bad(val x: Int) // ошибка +> } +> +> fun main() { println(Outer10.Point5(1)) } // Point5(x=1) +> ``` +> Сгенерированные `equals` и `copy` не учитывали бы ссылку на внешний объект, что дало бы противоречивое поведение. +> +> **Глубокая вложенность ухудшает читаемость.** `Outer.Middle.Inner.Deep` в сигнатурах - сигнал, что классы стоит вынести на верхний уровень или в отдельный файл. +> +> **Вложенный класс попадает в публичный API под составным именем.** Переименование внешнего класса меняет полное имя вложенного - для библиотеки это ломающее изменение. +> +> **Локальный класс в функции захватывает переменные** и продлевает их жизнь, как и лямбда. Если объект локального класса возвращается из функции, захваченные значения остаются в памяти. + +Дополнительно: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) +Далее: [Полиморфизм](Полиморфизм.md) diff --git a/Kotlin/ООП/Делегирование.md b/Kotlin/ООП/Делегирование.md new file mode 100644 index 0000000..51f21a6 --- /dev/null +++ b/Kotlin/ООП/Делегирование.md @@ -0,0 +1,372 @@ +--- +title: Делегирование +description: Делегирование интерфейсов через by, делегаты свойств и границы автоматического перенаправления +tags: + - kotlin + - oop +--- + +Делегирование - передача работы другому объекту. Kotlin поддерживает его на уровне языка словом `by`, поэтому композиция требует не больше кода, чем наследование. + +## Делегирование интерфейса +```kotlin +interface Logger5 { + fun log(msg: String): String + fun warn(msg: String): String +} + +class ConsoleLogger3 : Logger5 { + override fun log(msg: String) = "[LOG] $msg" + override fun warn(msg: String) = "[WARN] $msg" +} + +class Service15(logger: Logger5) : Logger5 by logger { + fun work() = log("начинаю работу") +} + +fun main() { + val s = Service15(ConsoleLogger3()) + println(s.work()) // [LOG] начинаю работу + println(s.warn("внимание")) // [WARN] внимание — метод перенаправлен +} +``` +`Service15` реализует `Logger5`, но код методов не пишет: компилятор генерирует перенаправление на `logger`. Унаследованной реализации при этом нет - есть ссылка на другой объект. + +## Переопределение части методов +Делегировать можно всё, кроме того, что нужно изменить: +```kotlin +interface Repository3 { + fun find(id: Int): String? + fun save(id: Int, value: String): String +} + +class DbRepository : Repository3 { + private val data = mutableMapOf() + override fun find(id: Int) = data[id] + override fun save(id: Int, value: String): String { + data[id] = value + return "сохранено" + } +} + +class CachingRepository(private val origin: Repository3) : Repository3 by origin { + private val cache = mutableMapOf() + + override fun find(id: Int): String? = // только этот метод свой + cache.getOrPut(id) { origin.find(id) ?: return null } +} + +fun main() { + val repo = CachingRepository(DbRepository()) + println(repo.save(1, "Иван")) // сохранено — делегировано + println(repo.find(1)) // Иван — через кеш + println(repo.find(1)) // Иван — уже из кеша + println(repo.find(99)) // null +} +``` +Это декоратор в три строки. На наследовании он потребовал бы `open`-класса и был бы связан с его внутренностями. + +## Делегирование нескольких интерфейсов +```kotlin +interface Reader { fun read(): String } +interface Writer2 { fun write(data: String): String } + +class FileReader2 : Reader { override fun read() = "данные из файла" } +class FileWriter2 : Writer2 { override fun write(data: String) = "записано: $data" } + +class ReadWriteFile( + reader: Reader, + writer: Writer2, +) : Reader by reader, Writer2 by writer + +fun main() { + val f = ReadWriteFile(FileReader2(), FileWriter2()) + println(f.read()) // данные из файла + println(f.write("текст")) // записано: текст +} +``` +Это даёт эффект множественного наследования реализации - но без его проблем, потому что каждый интерфейс обслуживает отдельный объект. + +## Расширение коллекции +Классическая задача, которую нельзя безопасно решить наследованием: +```kotlin +class CountingList3(private val inner: MutableList = mutableListOf()) + : MutableList by inner { + + var addedCount = 0 + private set + + override fun add(element: T): Boolean { + addedCount++ + return inner.add(element) + } + + override fun addAll(elements: Collection): Boolean { + addedCount += elements.size + return inner.addAll(elements) + } +} + +fun main() { + val list = CountingList3() + list.add(1) + list.addAll(listOf(2, 3)) + println("${list.size} элементов, добавлений: ${list.addedCount}") // 3 элементов, добавлений: 3 + println(list.toList()) // [1, 2, 3] +} +``` +При наследовании от `ArrayList` счётчик мог бы удвоиться, если `addAll` внутри вызывает `add`. С делегированием внутренние вызовы `inner` остаются внутри `inner` и наших переопределений не касаются. + +Обратите внимание на `list.toList()`: сам `println(list)` вывел бы `CountingList3@5fa7e7ff`, потому что `toString` - член `Any`, а члены `Any` делегирование не перенаправляет. + +Эта же особенность - источник главной ловушки делегирования, см. подводные камни. + +## Делегаты свойств +Тот же `by` для свойства передаёт работу объекту с методами `getValue` и `setValue`. + +### Готовые делегаты +```kotlin +import kotlin.properties.Delegates + +class Form3 { + val config: String by lazy { "вычислено один раз" } + + var name: String by Delegates.observable("") { _, old, new -> + println("name: '$old' -> '$new'") + } + + var age: Int by Delegates.vetoable(0) { _, _, new -> new in 0..150 } + + var port: Int by Delegates.notNull() +} + +fun main() { + val f = Form3() + println(f.config) // вычислено один раз + f.name = "Иван" // name: '' -> 'Иван' + f.age = 200 // отклонено + println(f.age) // 0 + f.port = 8080 + println(f.port) // 8080 +} +``` +Подробнее: [Свойства](Свойства.md) + +### Делегирование свойства в Map +```kotlin +class Settings(private val map: Map) { + val host: String by map + val port: Int by map +} + +fun main() { + val s = Settings(mapOf("host" to "localhost", "port" to 8080)) + println("${s.host}:${s.port}") // localhost:8080 +} +``` +Имя свойства становится ключом. Для `var` нужен `MutableMap`. + +### Свой делегат +Нужны операторы `getValue` и (для `var`) `setValue`: +```kotlin +import kotlin.reflect.KProperty + +class NonBlank(private var value: String = "") { + operator fun getValue(thisRef: Any?, property: KProperty<*>) = value + + operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) { + require(newValue.isNotBlank()) { "${property.name} не может быть пустым" } + value = newValue.trim() + } +} + +class User6 { + var name: String by NonBlank() +} + +fun main() { + val u = User6() + u.name = " Иван " + println("'${u.name}'") // 'Иван' + try { + u.name = " " + } catch (e: IllegalArgumentException) { + println(e.message) // name не может быть пустым + } +} +``` +`property` даёт имя и метаданные свойства - удобно для сообщений об ошибках и для чтения из конфигурации по имени. + +Для делегата только на чтение достаточно реализовать `ReadOnlyProperty`: +```kotlin +import kotlin.properties.ReadOnlyProperty + +fun envVar(name: String) = ReadOnlyProperty { _, _ -> + System.getenv(name) ?: "<не задано>" +} + +class Config7 { + val home: String by envVar("HOME") + val missing: String by envVar("НЕТ_ТАКОЙ_ПЕРЕМЕННОЙ") +} + +fun main() { + println(Config7().home.isNotEmpty()) // true + println(Config7().missing) // <не задано> +} +``` + +### provideDelegate +Позволяет проверить свойство при создании делегата - например, что имя есть в конфигурации: +```kotlin +import kotlin.reflect.KProperty + +class Required(private val source: Map) { + operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): Required { + require(property.name in source) { "нет ключа '${property.name}'" } + return this + } + + operator fun getValue(thisRef: Any?, property: KProperty<*>) = source[property.name]!! +} + +fun main() { + val source = mapOf("host" to "localhost") + + class Ok3 { val host: String by Required(source) } + println(Ok3().host) // localhost + + try { + class Bad4 { val port: String by Required(source) } + Bad4() + } catch (e: IllegalArgumentException) { + println(e.message) // нет ключа 'port' + } +} +``` +Проверка происходит при создании объекта, а не при первом чтении - ошибка обнаруживается раньше. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | все методы перенаправлять вручную | `by` генерирует их | +> | декоратор = класс с полем и 10 методами | `class D(x: I) : I by x` | +> | `Supplier` + `volatile` для ленивости | `by lazy` | +> | `PropertyChangeSupport` | `Delegates.observable` | +> | нет аналога делегатов свойств | `by` с `getValue`/`setValue` | +> +> **Делегирование интерфейса - главная экономия.** В Java декоратор над интерфейсом из десяти методов требует десять методов-перенаправлений, даже если менять нужно один. Любой новый метод в интерфейсе нужно добавить во все декораторы. Kotlin генерирует перенаправление сам: +> ```java +> class CachingRepository implements Repository { +> private final Repository origin; +> CachingRepository(Repository origin) { this.origin = origin; } +> public String save(int id, String v) { return origin.save(id, v); } // шум +> public String find(int id) { /* своя логика */ } +> } +> ``` +> +> **Делегатов свойств в Java нет совсем.** Ленивое поле требует ручной двойной проверки блокировки, наблюдаемое - `PropertyChangeSupport` и явных вызовов. В Kotlin это `by lazy` и `by Delegates.observable`. +> +> **Паттерн Декоратор перестаёт быть паттерном** - он становится однострочной конструкцией языка, поэтому его применяют там, где в Java предпочли бы наследование «чтобы не писать много кода». +> +> Сравнить: [Полиморфизм подтипов в Java](../../Программирование%20на%20Java/ООП/Полиморфизм/Полиморфизм%20подтипов.md), [Абстрактные классы](../../Программирование%20на%20Java/ООП/Абстрактные%20классы.md) + +> [!warning] Подводные камни +> **Делегат не знает о переопределениях - это главная ловушка.** Если один метод делегата вызывает другой, он вызовет **свою** версию, а не вашу: +> ```kotlin +> interface Sender { +> fun send(msg: String): String +> fun sendAll(msgs: List): String +> } +> +> class BasicSender : Sender { +> override fun send(msg: String) = "отправлено: $msg" +> override fun sendAll(msgs: List) = msgs.joinToString { send(it) } +> } +> +> class LoggingSender(private val inner: Sender) : Sender by inner { +> var logged = 0 +> private set +> +> override fun send(msg: String): String { +> logged++ +> return inner.send(msg) +> } +> } +> +> fun main() { +> val s = LoggingSender(BasicSender()) +> s.sendAll(listOf("a", "b")) +> println("залогировано: ${s.logged}") // 0 — наш send не вызвался! +> } +> ``` +> `sendAll` делегирован и внутри вызывает `send` объекта `inner`. Чтобы счётчик работал, нужно переопределить и `sendAll`. Это обратная сторона изоляции: наследование здесь сработало бы «как ожидается», но стало бы хрупким. +> +> **Члены `Any` не делегируются.** `toString`, `equals` и `hashCode` остаются реализациями по умолчанию, то есть по ссылке: +> ```kotlin +> class Wrapped(private val inner: MutableList = mutableListOf()) : MutableList by inner +> +> fun main() { +> val a = Wrapped().apply { add(1) } +> val b = Wrapped().apply { add(1) } +> println(a.toString()) // Wrapped@4554617c, а не [1] +> println(a == b) // false, хотя содержимое одинаково +> println(a == listOf(1)) // false +> } +> ``` +> Обёртка над коллекцией, попавшая в `Set` или в сравнение, поведёт себя не как коллекция. Эти три метода нужно переопределять вручную. Подробнее: [equals, hashCode и toString](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/equals,%20hashCode%20и%20toString.md) +> +> **Делегат фиксируется при создании** и не может быть заменён: +> ```kotlin +> fun interface Source { fun get(): String } +> +> class Service16(private var source: Source) : Source by source { +> fun replace(newSource: Source) { source = newSource } // не влияет на by! +> } +> +> fun main() { +> val s = Service16 { "первый" } +> s.replace { "второй" } +> println(s.get()) // первый — by хранит исходную ссылку +> } +> ``` +> `by` копирует ссылку в скрытое поле при создании объекта. Для подменяемого делегата нужно перенаправлять вручную. +> +> **Делегирование нельзя сделать `by this`** или сослаться на собственное свойство в заголовке - объект ещё не создан. +> +> **Из делегированных методов нет доступа к `this` обёртки.** Делегат не знает, что его кто-то оборачивает, - он не может вызвать методы обёртки. +> +> **`by lazy` на `var` невозможен** - только `val`. +> +> **Делегат свойства - объект на каждый экземпляр:** +> ```kotlin +> class Heavy { +> val a: String by lazy { "a" } +> val b: String by lazy { "b" } // ещё один объект Lazy +> } +> +> fun main() { println(Heavy().a + Heavy().b) } // ab +> ``` +> Для класса с десятками ленивых свойств, создаваемого миллионами экземпляров, это заметный расход памяти. +> +> **Делегирование в `Map` падает в рантайме при отсутствии ключа:** +> ```kotlin +> class Settings2(map: Map) { +> val host: String by map +> } +> +> fun main() { +> val s = Settings2(emptyMap()) +> try { +> println(s.host) +> } catch (e: NoSuchElementException) { +> println("ключ отсутствует") // ключ отсутствует +> } +> } +> ``` +> Проверить это заранее помогает `provideDelegate`. +> +> **Делегирование `MutableList` раскрывает полный изменяемый API.** Обёртка, делегирующая `MutableList`, позволяет внешнему коду добавлять элементы в обход вашей логики - через `iterator().remove()`, `subList()` и другие пути. + +Дополнительно: [Свойства](Свойства.md) +Далее: [Вложенные и внутренние классы](Вложенные%20и%20внутренние%20классы.md) diff --git a/Kotlin/ООП/Инициализация.md b/Kotlin/ООП/Инициализация.md new file mode 100644 index 0000000..31eb143 --- /dev/null +++ b/Kotlin/ООП/Инициализация.md @@ -0,0 +1,320 @@ +--- +title: Инициализация +description: Порядок выполнения init и инициализаторов, инициализация при наследовании и ловушка open-метода +tags: + - kotlin + - oop +--- + +Инициализаторы свойств и блоки `init` выполняются **в порядке их объявления** в теле класса - как единая последовательность. + +```kotlin +class Order4 { + val a = println("1: свойство a").let { "a" } + init { println("2: первый init") } + val b = println("3: свойство b").let { "b" } + init { println("4: второй init") } + + constructor() { println("5: тело конструктора") } +} + +fun main() { Order4() } +``` +Вывод: `1`, `2`, `3`, `4`, `5`. Тело вторичного конструктора идёт **последним**, после всех инициализаторов. + +Блоков `init` может быть несколько; их используют, когда инициализация не сводится к одному выражению. + +## Параметры конструктора доступны сразу +```kotlin +class User3(name: String, age: Int) { + val displayName = name.uppercase() + val category: String + + init { + category = when { + age < 18 -> "несовершеннолетний" + age < 65 -> "взрослый" + else -> "пенсионер" + } + } +} + +fun main() { + val u = User3("иван", 20) + println("${u.displayName}: ${u.category}") // ИВАН: взрослый +} +``` + +## Порядок при наследовании +Базовый класс инициализируется **полностью** до начала инициализации подкласса: +```kotlin +open class Base4 { + val baseField = println("1: свойство Base").let { "base" } + init { println("2: init Base") } +} + +class Derived4 : Base4() { + val derivedField = println("3: свойство Derived").let { "derived" } + init { println("4: init Derived") } +} + +fun main() { Derived4() } +``` +Вывод: `1`, `2`, `3`, `4`. Внутри каждого класса - строго порядок объявления; базовый класс целиком раньше подкласса. Переставьте `init` выше свойства - и номера пойдут как `2`, `1`, `4`, `3`. + +Аргументы для конструктора базового класса вычисляются **до** всего остального: +```kotlin +open class Base5(val value: String) { + init { println("2: init Base, value=$value") } +} + +class Derived5(input: String) : Base5(input.uppercase().also { println("1: аргумент=$it") }) { + init { println("3: init Derived") } +} + +fun main() { Derived5("иван") } +``` + +## Ловушка: open-метод в конструкторе +Это главная ошибка инициализации. Вызов `open`-метода из конструктора базового класса попадёт в переопределённую версию - **до** того, как подкласс инициализирован. + +```kotlin +open class Base6 { + init { show() } // опасно + open fun show() = println("Base") +} + +class Derived6 : Base6() { + val name: String = "Иван" + override fun show() = println("Derived, name='$name'") +} + +fun main() { Derived6() } +``` +Вывод: `Derived, name='null'`. Свойство `name` объявлено non-null, но в этот момент ещё не присвоено - и мы наблюдаем `null` в типе `String`. + +Если свойство подкласса имеет примитивный тип, вместо `null` будет `0` - и это ещё труднее заметить, потому что исключения не возникает: +```kotlin +open class Base7 { + init { println("размер при инициализации: ${size()}") } + open fun size() = 0 +} + +class Derived7 : Base7() { + private val count: Int = 3 + override fun size() = count +} + +fun main() { + val d = Derived7() + println("размер после: ${d.size()}") +} +``` +Вывод: `0`, затем `3`. Ошибки нет, просто неверное значение. + +А вот если переопределённый метод обращается к объекту, результат - `NullPointerException` в коде, где ни одного nullable-типа не видно: +```kotlin +open class Base7b { + init { println("размер: ${size()}") } + open fun size() = 0 +} + +class Derived7b : Base7b() { + private val items = listOf(1, 2, 3) + override fun size() = items.size // items ещё null +} + +fun main() { + try { + Derived7b() + } catch (e: NullPointerException) { + println("NPE: ${e.message}") + // NPE: Cannot invoke "java.util.List.size()" because "this.items" is null + } +} +``` +Та же проблема существует в Java; разница в том, что Kotlin позволяет её избежать, закрывая классы по умолчанию. + +Как не попадать: +- не вызывать `open`-члены из конструкторов и `init` +- делать такие методы `private` или `final` +- если поведение подкласса нужно при создании - передавать его как параметр конструктора + +```kotlin +open class Base8(private val label: String) { + init { println("метка: $label") } // параметр уже готов +} + +class Derived8 : Base8("Derived") { + val name = "Иван" +} + +fun main() { Derived8() } // метка: Derived +``` + +## Порядок объявления имеет значение +Свойство нельзя использовать до его объявления: +```kotlin +class Bad3 { + // val b = a + 1 // ошибка: variable 'a' must be initialized + val a = 1 + val b = a + 1 +} + +fun main() { println(Bad3().b) } // 2 +``` +Компилятор ловит это для прямых обращений. Но через метод проверка обходится: +```kotlin +class Sneaky { + val b = compute() // вызывается до инициализации a + val a = 10 + private fun compute() = a * 2 + + override fun toString() = "a=$a b=$b" +} + +fun main() { println(Sneaky()) } // a=10 b=0 +``` +`b` равно `0`, потому что `a` на момент вызова ещё не присвоено. Ошибки компиляции нет - компилятор не отслеживает порядок через вызовы методов. + +## Ленивая инициализация как решение +Если порядок становится проблемой, `by lazy` снимает её: значение вычисляется при первом обращении, когда объект уже собран. +```kotlin +class Fixed { + val b: Int by lazy { a * 2 } // вычислится после создания + val a = 10 + + override fun toString() = "a=$a b=$b" +} + +fun main() { println(Fixed()) } // a=10 b=20 +``` +Подробнее: [Свойства](Свойства.md) + +## Исключение в инициализации +```kotlin +class Validated(val age: Int) { + init { + require(age >= 0) { "возраст отрицательный: $age" } + println("объект создан") + } +} + +fun main() { + try { + Validated(-1) + } catch (e: IllegalArgumentException) { + println("не создан: ${e.message}") // не создан: возраст отрицательный: -1 + } + println(Validated(20).age) // объект создан, 20 +} +``` +Ссылка на объект не вернётся, но побочные эффекты, выполненные до исключения, останутся. Поэтому в `init` держат только проверки и присваивания - без регистрации в реестрах, открытия файлов и запуска потоков. + +## Инициализация object и companion object +```kotlin +object Registry2 { + init { println("инициализация Registry") } + val items = mutableListOf() +} + +class Service9 { + companion object { + init { println("инициализация companion") } + val created = 0 + } +} + +fun main() { + println("старт") + println(Registry2.items.size) // инициализация Registry, затем 0 + println(Service9.created) // инициализация companion, затем 0 +} +``` +Оба инициализируются лениво - при первом обращении, потокобезопасно средствами JVM. `companion object` инициализируется при первом обращении к нему или при первом создании экземпляра класса. + +Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | блок `{ }` в теле класса | `init { }` | +> | `static { }` | `init` в `companion object` | +> | присваивание в конструкторе | инициализатор свойства | +> | `super(args)` первой строкой | `: Base(args)` в заголовке | +> | нет проверки порядка | ошибка при прямом обращении вперёд | +> +> **Порядок инициализации одинаков**: базовый класс, потом подкласс; внутри класса - по порядку объявления. Проблема вызова переопределяемого метода из конструктора тоже одинакова. +> +> **Kotlin частично защищает от неинициализированных свойств.** Прямое обращение к свойству до его объявления - ошибка компиляции, тогда как Java молча даёт значение по умолчанию: +> ```java +> class Sneaky { +> int b = a + 1; // компилируется, b = 1, потому что a == 0 +> int a = 10; +> } +> ``` +> Через вызов метода обход возможен в обоих языках. +> +> **Главная защита - закрытые классы.** Ловушка с `open`-методом в конструкторе требует, чтобы класс был наследуемым. Поскольку в Kotlin класс закрыт по умолчанию, большинство классов в эту ловушку попасть не может в принципе. +> +> Сравнить: [Блоки инициализации в Java](../../Программирование%20на%20Java/ООП/Блоки%20инициализации.md), [Конструкторы](../../Программирование%20на%20Java/ООП/Конструкторы.md) + +> [!warning] Подводные камни +> **Вызов `open`-метода из `init` видит неинициализированный подкласс** - значение будет `null` даже у non-null свойства: +> ```kotlin +> open class Base9 { +> init { check() } +> open fun check() {} +> } +> +> class Derived9 : Base9() { +> private val name: String = "Иван" +> override fun check() { +> @Suppress("SENSELESS_COMPARISON") +> println("name инициализирован: " + (name != null)) +> } +> } +> +> fun main() { Derived9() } // name инициализирован: false +> ``` +> Это единственный штатный способ увидеть `null` в переменной типа `String`. +> +> **Порядок объявления свойств меняет поведение**, если между ними есть зависимость через методы. Перестановка двух строк может дать `0` вместо правильного значения - без ошибки компиляции. +> +> **Тело вторичного конструктора выполняется последним**, уже после всех `init`. Код, который ожидает обратного порядка, получит неожиданный результат. +> +> **`lateinit` не инициализируется автоматически** - обращение до присваивания даёт `UninitializedPropertyAccessException`. +> +> **Круговая зависимость между `object` даёт `null` или `0` без ошибки:** +> ```kotlin +> object First { val value: String = Second.value + "-first" } +> object Second { val value: String = "second" } +> +> fun main() { println(First.value) } // second-first +> ``` +> Если поменять зависимость местами, получится `null-second`: один из объектов будет читать поле другого во время его инициализации. +> +> **Исключение в `init` оставляет побочные эффекты.** Частая ошибка - регистрация `this` в глобальном реестре до валидации: объект создан не будет, а ссылка на него останется. +> +> **Утечка `this` из конструктора.** Передача `this` куда-либо в `init` открывает доступ к недосозданному объекту: +> ```kotlin +> class Leaky { +> val items = mutableListOf() +> init { register(this) } // объект ещё не готов +> val name: String = "готов" +> +> companion object { +> val all = mutableListOf() +> fun register(l: Leaky) = all.add(l) +> } +> } +> +> fun main() { +> Leaky() +> println("в реестре: ${Leaky.all.size}") // в реестре: 1 +> } +> ``` +> Другой поток, получивший такую ссылку, увидит `name` как `null`. + +Дополнительно: [Классы и конструкторы](Классы%20и%20конструкторы.md) +Далее: [Модификаторы видимости](Модификаторы%20видимости.md) diff --git a/Kotlin/ООП/Интерфейсы.md b/Kotlin/ООП/Интерфейсы.md new file mode 100644 index 0000000..175cb7f --- /dev/null +++ b/Kotlin/ООП/Интерфейсы.md @@ -0,0 +1,332 @@ +--- +title: Интерфейсы +description: Реализация по умолчанию, свойства без состояния, ромбовидное наследование и fun interface +tags: + - kotlin + - oop +--- + +Интерфейс описывает контракт. В Kotlin он может содержать и реализацию. + +```kotlin +interface Drawable2 { + fun draw(): String // только объявление + fun show() = "рисую: ${draw()}" // реализация по умолчанию +} + +class Circle4 : Drawable2 { + override fun draw() = "круг" +} + +fun main() { + println(Circle4().show()) // рисую: круг +} +``` +Скобок после имени интерфейса в списке супертипов нет - конструктора у него не бывает. + +## Свойства в интерфейсе +Свойство объявить можно, но **хранить значение - нет**: +```kotlin +interface Named2 { + val name: String // абстрактное + val upper: String get() = name.uppercase() // через геттер — можно + // val cached: String = "x" // ошибка: поля в интерфейсе запрещены +} + +class User5(override val name: String) : Named2 + +class Admin2 : Named2 { + override val name get() = "админ" // геттер вместо поля +} + +fun main() { + println(User5("иван").upper) // ИВАН + println(Admin2().name) // админ +} +``` +Реализовать свойство можно параметром конструктора, хранимым свойством или геттером - интерфейсу это безразлично. + +## Множественная реализация +```kotlin +interface Readable2 { fun read(): String } +interface Writable { fun write(data: String): String } +interface Closeable2 { fun close() = "закрыто" } + +class File2(private val name: String) : Readable2, Writable, Closeable2 { + override fun read() = "чтение $name" + override fun write(data: String) = "запись '$data' в $name" +} + +fun main() { + val f = File2("a.txt") + println(f.read()) // чтение a.txt + println(f.write("текст")) // запись 'текст' в a.txt + println(f.close()) // закрыто +} +``` +Ограничений на число интерфейсов нет - в отличие от классов. + +## Ромбовидное наследование +Если два интерфейса дают реализацию одного метода, компилятор требует разрешить конфликт: +```kotlin +interface A7 { fun f() = "A" } +interface B7 { fun f() = "B" } + +class C7 : A7, B7 { + override fun f() = "${super.f()}${super.f()}" +} + +fun main() { println(C7().f()) } // AB +``` +Без `override` класс не скомпилируется: `class C7 : A7, B7` даст ошибку `inherits multiple interface methods`. + +Требование строгое: `override` нужен, даже если реализацию даёт только **один** из интерфейсов: +```kotlin +interface A8 { fun f(): String } // абстрактный +interface B8 { fun f() = "B" } // с реализацией + +// class C8 : A8, B8 // ошибка: inherits multiple interface methods +class C8 : A8, B8 { + override fun f() = super.f() // выбираем явно +} + +fun main() { println(C8().f()) } // B +``` +А вот реализация из **класса** абстрактный метод интерфейса покрывает - здесь писать `override` не нужно: +```kotlin +interface Named3 { fun name(): String } + +abstract class Base3 : Named3 { + override fun name() = "из класса" +} + +class Impl10 : Base3(), Named3 // реализация унаследована от Base3 + +fun main() { println(Impl10().name()) } // из класса +``` + +Для свойств правило то же: +```kotlin +interface A9 { val name: String get() = "A" } +interface B9 { val name: String get() = "B" } + +class C9 : A9, B9 { + override val name get() = super.name + super.name +} + +fun main() { println(C9().name) } // AB +``` + +## Наследование интерфейсов +```kotlin +interface Entity2 { val id: Int } + +interface Timestamped : Entity2 { + val createdAt: Long + fun age(now: Long) = now - createdAt +} + +class Record2(override val id: Int, override val createdAt: Long) : Timestamped + +fun main() { + val r = Record2(1, 1000) + println("${r.id} ${r.age(1500)}") // 1 500 +} +``` +Интерфейс может наследовать несколько интерфейсов - ограничения на один супертип здесь нет. + +## private-члены +```kotlin +interface Validator2 { + fun validate(value: String): Boolean + + private fun isBlank2(value: String) = value.trim().isEmpty() + + fun check(value: String) = when { + isBlank2(value) -> "пусто" + validate(value) -> "ок" + else -> "неверно" + } +} + +class EmailValidator : Validator2 { + override fun validate(value: String) = "@" in value +} + +fun main() { + val v = EmailValidator() + println(v.check(" ")) // пусто + println(v.check("a@b.ru")) // ок + println(v.check("нет")) // неверно +} +``` +`private` в интерфейсе - способ вынести вспомогательную логику, не включая её в контракт. `protected` в интерфейсе **запрещён**: все члены либо публичны, либо приватны. + +## fun interface - SAM +Интерфейс с единственным абстрактным методом можно пометить `fun`, и тогда вместо объекта допустима лямбда: +```kotlin +fun interface Transformer { + fun transform(input: String): String +} + +fun apply3(value: String, t: Transformer) = t.transform(value) + +fun main() { + println(apply3("abc", Transformer { it.uppercase() })) // ABC + println(apply3("abc") { it.reversed() }) // cba + + val obj = object : Transformer { // можно и так + override fun transform(input: String) = "[$input]" + } + println(apply3("abc", obj)) // [abc] +} +``` +Для своего кода обычно лучше функциональный тип `(String) -> String` - он не требует объявления интерфейса. `fun interface` нужен, когда: +- интерфейсу нужны дополнительные методы с реализацией +- API должен быть удобным из Java +- имя контракта важно для читаемости + +```kotlin +fun interface Predicate2 { + fun test(value: T): Boolean + fun negate(): Predicate2 = Predicate2 { !test(it) } // метод по умолчанию +} + +fun main() { + val positive = Predicate2 { it > 0 } + println(positive.test(5)) // true + println(positive.negate().test(5)) // false +} +``` + +## Интерфейсы в делегировании +```kotlin +interface Logger4 { fun log(msg: String): String } + +class ConsoleLogger2 : Logger4 { + override fun log(msg: String) = "[LOG] $msg" +} + +class Service14(logger: Logger4) : Logger4 by logger { + fun work() = log("работаю") +} + +fun main() { + println(Service14(ConsoleLogger2()).work()) // [LOG] работаю +} +``` +Подробнее: [Делегирование](Делегирование.md) + +## Интерфейс как граница модуля +```kotlin +interface PaymentGateway { + fun charge(amount: Long): String +} + +internal class StripeGateway : PaymentGateway { + override fun charge(amount: Long) = "Stripe: списано $amount" +} + +internal class TestGateway : PaymentGateway { + override fun charge(amount: Long) = "Тест: якобы списано $amount" +} + +fun createGateway(test: Boolean): PaymentGateway = + if (test) TestGateway() else StripeGateway() + +fun main() { + println(createGateway(test = true).charge(100)) // Тест: якобы списано 100 + println(createGateway(test = false).charge(100)) // Stripe: списано 100 +} +``` +Это основное практическое применение: клиент зависит от интерфейса, реализации скрыты за `internal` и подменяемы. Подробнее: [Модификаторы видимости](Модификаторы%20видимости.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `interface A { }` | то же | +> | `default void f() { }` | `fun f() { }` - слово не нужно | +> | `static` методы в интерфейсе | `companion object` в интерфейсе | +> | `private` методы (Java 9+) | то же | +> | поля `public static final` | только `const` в `companion object` | +> | `A.super.f()` | `super.f()` | +> | `@FunctionalInterface` | `fun interface` | +> | нет свойств | `val`/`var` в интерфейсе | +> +> **Реализация по умолчанию не требует ключевого слова.** В Java нужно `default`, потому что до версии 8 интерфейсы реализации не имели и слово понадобилось для совместимости. В Kotlin такого ограничения не было. +> +> **Свойства в интерфейсе** - то, чего в Java нет. Java-интерфейс объявляет геттер как метод, и реализация обязана быть методом: +> ```java +> interface Named { String getName(); } +> ``` +> Kotlin позволяет реализовать `val name: String` параметром конструктора - короче и без ручного геттера. +> +> **Константы в интерфейсе работают иначе.** В Java поле интерфейса неявно `public static final`, что породило антипаттерн «интерфейс-константа». В Kotlin поля в интерфейсе запрещены; константы живут в `companion object`: +> ```kotlin +> interface Config6 { +> companion object { const val MAX = 100 } +> } +> +> fun main() { println(Config6.MAX) } // 100 +> ``` +> +> **Реализация по умолчанию и совместимость с Java.** По умолчанию Kotlin компилирует её в статический метод плюс `DefaultImpls`-класс, а не в Java `default`-метод. Java-классу, реализующему такой интерфейс, придётся реализовать метод самому. Флаг `-Xjvm-default=all` меняет это поведение. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) +> +> Сравнить: [Интерфейсы в Java](../../Программирование%20на%20Java/ООП/Интерфейсы.md), [Полиморфизм подтипов](../../Программирование%20на%20Java/ООП/Полиморфизм/Полиморфизм%20подтипов.md) + +> [!warning] Подводные камни +> **Свойство в интерфейсе не хранит значение.** Попытка задать значение - ошибка: +> ```kotlin +> interface Counter2 { +> // var count: Int = 0 // ошибка: property initializers are not allowed +> var count: Int // только объявление +> } +> +> class Impl8 : Counter2 { override var count = 0 } +> +> fun main() { +> val c = Impl8() +> c.count++ +> println(c.count) // 1 +> } +> ``` +> +> **Геттер в интерфейсе вычисляется у каждой реализации заново** - это не кеш: +> ```kotlin +> interface Sized { +> val items: List +> val total: Int get() = items.sum().also { println("считаю") } +> } +> +> class Impl9 : Sized { override val items = listOf(1, 2) } +> +> fun main() { +> val i = Impl9() +> println(i.total) +> println(i.total) // "считаю" дважды +> } +> ``` +> Кешировать можно только в классе-реализации через `by lazy`. +> +> **Ромбовидный конфликт не решается автоматически** - нужен явный `override` с `super`. Это строгость, а не недостаток: язык не выбирает реализацию за вас. +> +> **`protected` в интерфейсе запрещён.** Если нужна защищённая логика для наследников, нужен абстрактный класс. Подробнее: [Абстрактные классы](Абстрактные%20классы.md) +> +> **Добавление метода в интерфейс - ломающее изменение** для всех реализаций, если у него нет реализации по умолчанию. Для публичного API это означает, что интерфейс стоит проектировать узким. +> +> **`fun interface` позволяет только один абстрактный метод.** Добавив второй, вы сломаете все вызовы с лямбдой: +> ```kotlin +> fun interface Handler2 { fun handle(x: Int): String } +> +> fun main() { +> val h = Handler2 { "получено $it" } +> println(h.handle(1)) // получено 1 +> } +> ``` +> +> **Интерфейс с реализацией по умолчанию - уже не чистый контракт.** Изменение такой реализации меняет поведение всех классов, которые её не переопределили, - то есть это наследование со всеми его рисками. +> +> **Из Java реализация по умолчанию не видна** без `-Xjvm-default=all`. Java-код будет требовать реализовать метод, который в Kotlin считается необязательным. + +Дополнительно: [Абстрактные классы](Абстрактные%20классы.md) +Далее: [Делегирование](Делегирование.md) diff --git a/Kotlin/ООП/Классы и конструкторы.md b/Kotlin/ООП/Классы и конструкторы.md new file mode 100644 index 0000000..cefbac6 --- /dev/null +++ b/Kotlin/ООП/Классы и конструкторы.md @@ -0,0 +1,326 @@ +--- +title: Классы и конструкторы +description: Основной конструктор, параметры-свойства, вторичные конструкторы и фабрики +tags: + - kotlin + - oop +--- + +Минимальный класс - одна строка: +```kotlin +class Empty + +fun main() { + val e = Empty() // new не нужен + println(e::class.simpleName) // Empty +} +``` +Оператора `new` в Kotlin нет: конструктор вызывается как функция. + +## Основной конструктор +Объявляется в заголовке класса: +```kotlin +class User(name: String, age: Int) { + val displayName = name.uppercase() // параметры доступны в теле + val isAdult = age >= 18 +} + +fun main() { + val u = User("иван", 20) + println("${u.displayName} ${u.isAdult}") // ИВАН true +} +``` +Параметры `name` и `age` - **только параметры**: они видны в инициализаторах свойств и в блоках `init`, но не становятся членами класса. + +```kotlin +class User(name: String) { + val displayName = name.uppercase() + // fun show() = name // ошибка: name недоступен в методах +} + +fun main() { println(User("иван").displayName) } // ИВАН +``` + +## Параметры-свойства +Добавьте `val` или `var` - параметр станет свойством: +```kotlin +class User(val name: String, var age: Int) { + fun show() = "$name, $age" // теперь доступны везде +} + +fun main() { + val u = User("Иван", 20) + u.age = 21 + println(u.show()) // Иван, 21 +} +``` +Это самая частая форма: одна строка вместо поля, конструктора, геттера и сеттера. + +Можно смешивать: часть параметров сделать свойствами, часть оставить аргументами: +```kotlin +class User(val name: String, password: String) { + private val hash = password.hashCode() // пароль не хранится как есть + + fun check(input: String) = input.hashCode() == hash +} + +fun main() { + val u = User("Иван", "secret") + println(u.check("secret")) // true + println(u.check("wrong")) // false +} +``` + +## Значения по умолчанию +```kotlin +class Server( + val host: String = "localhost", + val port: Int = 8080, + val secure: Boolean = false, +) + +fun main() { + println(Server().port) // 8080 + println(Server("example.com", 443, true).host) // example.com + println(Server(port = 9090).port) // 9090 — именованный аргумент +} +``` +Это заменяет телескопические конструкторы и во многих случаях - паттерн Builder. Подробнее: [Функции](../Синтаксис%20и%20типы/Работа%20с%20данными/Функции.md) + +Висячая запятая после последнего параметра разрешена - упрощает правки и diff. + +## Модификаторы и аннотации +Если у конструктора есть модификатор видимости или аннотация, слово `constructor` обязательно: +```kotlin +class Service private constructor(val id: Int) { + companion object { + fun create(id: Int) = if (id > 0) Service(id) else null + } +} + +fun main() { + println(Service.create(1)?.id) // 1 + println(Service.create(-1)) // null + // Service(1) // ошибка: конструктор приватный +} +``` +Приватный конструктор + фабрика в компаньоне - способ контролировать создание: валидация, кеширование, возврат `null` вместо исключения. + +## Вторичные конструкторы +Объявляются в теле класса словом `constructor`: +```kotlin +class Point(val x: Int, val y: Int) { + constructor(value: Int) : this(value, value) // точка на диагонали + constructor() : this(0, 0) + + override fun toString() = "($x, $y)" +} + +fun main() { + println(Point(1, 2)) // (1, 2) + println(Point(5)) // (5, 5) + println(Point()) // (0, 0) +} +``` +Вторичный конструктор **обязан** делегировать основному через `this(...)` - напрямую инициализировать свойства он не может. + +Класс без основного конструктора может иметь только вторичные: +```kotlin +class Config4 { + val source: String + + constructor(file: java.io.File) { + source = "файл ${file.name}" + } + + constructor(url: String, timeout: Int) { + source = "$url (таймаут $timeout)" + } +} + +fun main() { + println(Config4("http://x.ru", 30).source) // http://x.ru (таймаут 30) +} +``` +Здесь делегировать некому, поэтому свойство инициализируется в каждом конструкторе. Форма редкая - обычно хватает основного конструктора со значениями по умолчанию. + +### Когда нужны вторичные +Почти никогда. Значения по умолчанию покрывают большинство случаев: +```kotlin +// вместо трёх конструкторов +class Point(val x: Int = 0, val y: Int = x) + +fun main() { + println("${Point().x} ${Point(5).y} ${Point(1, 2).y}") // 0 5 2 +} +``` +Вторичный конструктор оправдан, когда нужно **преобразовать** аргументы другого типа или выполнить работу перед делегированием: +```kotlin +class Duration2(val millis: Long) { + constructor(seconds: Int) : this(seconds * 1000L) + constructor(text: String) : this(text.removeSuffix("s").toLong() * 1000) +} + +fun main() { + println(Duration2(2).millis) // 2000 + println(Duration2("3s").millis) // 3000 +} +``` +Но и здесь фабричные функции в компаньоне обычно читаются лучше - у них есть имена: +```kotlin +class Duration3 private constructor(val millis: Long) { + companion object { + fun ofSeconds(s: Int) = Duration3(s * 1000L) + fun parse(text: String) = Duration3(text.removeSuffix("s").toLong() * 1000) + } +} + +fun main() { + println(Duration3.ofSeconds(2).millis) // 2000 + println(Duration3.parse("3s").millis) // 3000 +} +``` +Два конструктора с одинаковыми типами параметров объявить нельзя, а фабрик с разными именами - сколько угодно. + +## Валидация в конструкторе +```kotlin +class Person(val name: String, val age: Int) { + init { + require(name.isNotBlank()) { "имя не может быть пустым" } + require(age in 0..150) { "недопустимый возраст: $age" } + } +} + +fun main() { + println(Person("Иван", 20).name) // Иван + try { + Person("", 20) + } catch (e: IllegalArgumentException) { + println(e.message) // имя не может быть пустым + } +} +``` +Блок `init` выполняется при создании объекта. Подробнее о порядке: [Инициализация](Инициализация.md) + +## Создание объекта +```kotlin +class User(val name: String) + +fun main() { + val u = User("Иван") // просто вызов + val list = List(3) { User("u$it") } // в лямбде + val byRef = ::User // ссылка на конструктор + println("${u.name} ${list.size} ${byRef("Пётр").name}") // Иван 3 Пётр +} +``` +`::User` - ссылка на конструктор, её можно передать как функцию. Подробнее: [Лямбды и функциональные типы](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `new User("Иван")` | `User("Иван")` | +> | конструктор = метод с телом | основной конструктор в заголовке | +> | поле + присваивание в конструкторе | `val` в параметрах конструктора | +> | телескопические конструкторы | значения по умолчанию | +> | Builder для многих параметров | именованные аргументы | +> | `this(...)` для делегирования | то же | +> | `private` конструктор + `static` фабрика | `private constructor` + `companion object` | +> +> **Главная экономия - параметры-свойства.** В Java класс из четырёх полей занимает 15+ строк: +> ```java +> public class User { +> private final String name; +> private final int age; +> public User(String name, int age) { +> this.name = name; +> this.age = age; +> } +> public String getName() { return name; } +> public int getAge() { return age; } +> } +> ``` +> В Kotlin это `class User(val name: String, val age: Int)`. +> +> **Builder обычно не нужен.** В Java паттерн Builder решает проблему конструктора с десятком опциональных параметров. Именованные аргументы и значения по умолчанию дают то же самое без дополнительного класса: +> ```kotlin +> class Request( +> val url: String, +> val method: String = "GET", +> val timeout: Int = 30, +> val retries: Int = 0, +> ) +> +> fun main() { +> println(Request("http://x.ru", timeout = 60).timeout) // 60 +> } +> ``` +> Builder остаётся полезен, когда объект собирается по частям в разных местах кода. +> +> Сравнить: [Конструкторы в Java](../../Программирование%20на%20Java/ООП/Конструкторы.md), [Builder](../../Программирование%20на%20Java/Паттерны%20проектирования/Builder.md) + +> [!warning] Подводные камни +> **Параметр без `val`/`var` недоступен в методах** - только в инициализаторах и `init`. Ошибка «unresolved reference» в методе обычно означает забытый `val`. +> +> **`val` в конструкторе делает свойство частью публичного API.** Если значение нужно только внутри, пишите `private val`: +> ```kotlin +> class Service4(private val apiKey: String) { +> fun call() = "запрос с ключом длины ${apiKey.length}" +> } +> +> fun main() { +> val s = Service4("secret") +> println(s.call()) // запрос с ключом длины 6 +> // println(s.apiKey) // ошибка: приватное свойство +> } +> ``` +> +> **Значения по умолчанию не видны из Java.** Без `@JvmOverloads` Java-код обязан передать все аргументы: +> ```kotlin +> class Server2 @JvmOverloads constructor( +> val host: String = "localhost", +> val port: Int = 8080, +> ) +> +> fun main() { println(Server2().port) } // 8080 +> ``` +> +> **Вторичный конструктор не может объявлять свойства:** +> ```kotlin +> class Point2(val x: Int) { +> // constructor(val y: Int) : this(y) // ошибка: val запрещён здесь +> constructor(y: Long) : this(y.toInt()) +> } +> +> fun main() { println(Point2(5L).x) } // 5 +> ``` +> +> **Порядок инициализации не совпадает с порядком конструкторов.** Блоки `init` и инициализаторы свойств выполняются **до** тела вторичного конструктора: +> ```kotlin +> class Order2 { +> val a = "свойство".also { println("1: $it") } +> init { println("2: init") } +> +> constructor() { println("3: тело вторичного") } +> } +> +> fun main() { Order2() } +> ``` +> Вывод: `1`, `2`, `3`. Подробнее: [Инициализация](Инициализация.md) +> +> **Два конструктора с одинаковой сигнатурой невозможны**, даже если смысл разный: +> ```kotlin +> class Range3 private constructor(val from: Int, val to: Int) { +> companion object { +> fun ofBounds(from: Int, to: Int) = Range3(from, to) +> fun ofStart(start: Int, length: Int) = Range3(start, start + length) +> } +> } +> +> fun main() { println(Range3.ofStart(1, 5).to) } // 6 +> ``` +> Фабричные функции решают это именами. +> +> **Исключение в конструкторе оставляет объект недосозданным.** Если в `init` что-то упало, ссылка на объект не вернётся - но уже выполненные побочные эффекты (запись в реестр, открытый файл) останутся. Поэтому в конструкторе делают только валидацию и присваивание. + +Дополнительно: [Свойства](Свойства.md) +Далее: [Свойства](Свойства.md) diff --git a/Kotlin/ООП/Модификаторы видимости.md b/Kotlin/ООП/Модификаторы видимости.md new file mode 100644 index 0000000..4937cd1 --- /dev/null +++ b/Kotlin/ООП/Модификаторы видимости.md @@ -0,0 +1,290 @@ +--- +title: Модификаторы видимости +description: public, internal, protected, private и чем internal отличается от package-private +tags: + - kotlin + - oop +--- + +Четыре модификатора. По умолчанию - `public`. + +| Модификатор | Где виден | +|---|---| +| `public` | везде (по умолчанию) | +| `internal` | в пределах модуля | +| `protected` | в классе и его подклассах | +| `private` | в классе (или в файле, если член верхнего уровня) | + +## На верхнем уровне +```kotlin +public val a = 1 // везде; слово public избыточно +internal val b = 2 // в своём модуле +private val c = 3 // только в этом файле + +private fun helper() = c * 2 + +fun main() { println("$a $b ${helper()}") } // 1 2 6 +``` +Для членов верхнего уровня `private` означает «виден в файле» - аналога «виден в пакете» у Kotlin нет. `protected` на верхнем уровне применить нельзя. + +## В классе +```kotlin +open class Service10 { + val publicField = 1 + internal val internalField = 2 + protected val protectedField = 3 + private val privateField = 4 + + fun show() = "$publicField $internalField $protectedField $privateField" +} + +class Child2 : Service10() { + fun access() = "$publicField $internalField $protectedField" + // privateField недоступен +} + +fun main() { + val s = Service10() + println(s.show()) // 1 2 3 4 + println(Child2().access()) // 1 2 3 + // println(s.protectedField) // ошибка: protected +} +``` + +`protected` в Kotlin **не** даёт видимости в пакете - только в классе и подклассах. Это строже, чем в Java. + +## private в классе и вложенные классы +```kotlin +class Outer2 { + private val secret = "скрыто" + + class Nested { + // fun bad() = secret // ошибка: нет доступа к членам Outer + fun ok() = "вложенный класс" + } + + inner class Inner2 { + fun read() = secret // inner видит внешний класс + } +} + +fun main() { + println(Outer2.Nested().ok()) // вложенный класс + println(Outer2().Inner2().read()) // скрыто +} +``` +Подробнее: [Вложенные и внутренние классы](Вложенные%20и%20внутренние%20классы.md) + +## internal - видимость в модуле +Модуль - это единица компиляции: модуль Gradle или Maven, набор файлов одной задачи `kotlinc`. +```kotlin +internal class Repository { + internal fun load() = "данные" +} + +internal fun helper2() = Repository().load() + +fun main() { println(helper2()) } // данные +``` +`internal` - основной инструмент для библиотек: публичный API помечается `public`, всё остальное - `internal`, и клиент библиотеки не видит внутренностей. + +Типичный приём - публичный интерфейс и внутренняя реализация: +```kotlin +interface UserRepository { + fun find(id: Int): String? +} + +internal class UserRepositoryImpl : UserRepository { + override fun find(id: Int) = if (id == 1) "Иван" else null +} + +fun createRepository(): UserRepository = UserRepositoryImpl() + +fun main() { + println(createRepository().find(1)) // Иван +} +``` +Снаружи модуля видны только `UserRepository` и `createRepository` - реализацию заменить можно без ломающих изменений. + +## Видимость конструктора +```kotlin +class Service11 private constructor(val id: Int) { + companion object { + fun create(id: Int) = if (id > 0) Service11(id) else null + } +} + +fun main() { + println(Service11.create(5)?.id) // 5 + println(Service11.create(0)) // null +} +``` +Слово `constructor` обязательно, когда у конструктора есть модификатор. Подробнее: [Классы и конструкторы](Классы%20и%20конструкторы.md) + +## Видимость сеттера +Геттер всегда имеет видимость самого свойства, а сеттер можно ограничить: +```kotlin +class Session2 { + var token: String = "" + private set + + var attempts: Int = 0 + internal set + + fun login(t: String) { token = t; attempts++ } +} + +fun main() { + val s = Session2() + s.login("abc") + println("${s.token} ${s.attempts}") // abc 1 + // s.token = "x" // ошибка +} +``` + +## Правила согласованности +Публичный член не может раскрывать тип с меньшей видимостью: +```kotlin +internal class Internal2 + +class Public2 { + // fun bad(): Internal2 = Internal2() // ошибка: public раскрывает internal + internal fun ok(): Internal2 = Internal2() + private fun alsoOk(): Internal2 = Internal2() +} + +fun main() { println(Public2().ok()::class.simpleName) } // Internal2 +``` +Компилятор проверяет это и для параметров, и для типов результата, и для супертипов. + +## Видимость и переопределение +Переопределённый член не может сужать видимость: +```kotlin +open class Base10 { + open internal fun f() = "base" + protected open fun g() = "g" +} + +class Derived10 : Base10() { + override fun f() = "derived" // internal сохранена + public override fun g() = "расширена" // protected → public можно +} + +fun main() { + println(Derived10().f()) // derived + println(Derived10().g()) // расширена — теперь доступен снаружи +} +``` +Расширять видимость разрешено, сужать - нет: это нарушило бы контракт базового типа. + +`protected`-член можно сделать `public` в подклассе - приём для постепенного раскрытия API. + +## Что видно из Java +Это важное практическое различие: + +| Kotlin | В байт-коде | Доступно из Java | +|---|---|---| +| `public` | `public` | да | +| `internal` | `public` + искажённое имя | **да** | +| `protected` | `protected` | да, в подклассе и в пакете | +| `private` (член класса) | `private` | нет | +| `private` (верхний уровень) | `private` в файловом классе | нет | + +```kotlin +class Service12 { + internal fun internalMethod() = "в байт-коде это public" + private fun privateMethod() = "действительно private" + + fun show() = "${internalMethod()} / ${privateMethod()}" +} + +fun main() { println(Service12().show()) } +``` +`internal` существует только на уровне компилятора Kotlin. В байт-коде член становится `public`, а к его имени добавляется суффикс с именем модуля - чтобы случайный вызов из Java выглядел странно, но он возможен. Полагаться на `internal` как на защиту от Java-кода нельзя. + +Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `public` | `public` | +> | нет модификатора = package-private | **нет аналога** | +> | `protected` = подклассы + пакет | `protected` = только подклассы | +> | `private` | `private` | +> | нет аналога | `internal` = модуль | +> | модули JPMS (Java 9+) | `internal` работает всегда | +> +> **По умолчанию `public`, а не package-private.** Это ловушка при переходе: Java-разработчик не пишет модификатор, ожидая видимость в пакете, и случайно публикует API. +> +> **Package-private в Kotlin нет совсем.** Замена - `internal` (шире: весь модуль) или `private` на верхнем уровне (уже: один файл). Пакет в Kotlin не является границей инкапсуляции, потому что пакеты легко расширить извне - любой может объявить класс в вашем пакете. +> +> **`protected` строже.** В Java `protected`-член виден всему пакету, что часто неожиданно. В Kotlin - только подклассам. +> +> **`internal` решает задачу, для которой в Java нужны JPMS.** Модульная система Java появилась в 9 и применяется редко; `internal` работает в любом проекте с модулями Gradle и не требует настройки. +> +> Сравнить: [Модификаторы в Java](../../Программирование%20на%20Java/ООП/Инкапсуляция/Модификаторы.md), [Инкапсуляция](../../Программирование%20на%20Java/ООП/Инкапсуляция/Инкапсуляция.md) + +> [!warning] Подводные камни +> **Забыли модификатор - получили публичный API.** Всё, что не помечено, видно всем и становится обязательством на поддержку. +> +> **`internal` не защищает от Java-кода** - в байт-коде это `public`. Единственная настоящая защита - `private` или отдельный модуль. +> +> **`private` на верхнем уровне - это «виден в файле», а не «в пакете»:** +> ```kotlin +> private val fileLocal = "виден только здесь" +> private class Helper3 { fun work() = fileLocal } +> +> fun main() { println(Helper3().work()) } +> ``` +> Другой файл того же пакета доступа не получит. +> +> **`private` член класса виден во всём классе, включая вложенные объявления:** +> ```kotlin +> class Outer3 { +> private val secret = "скрыто" +> +> inner class Inner3 { +> fun read() = secret // доступ есть +> } +> +> companion object { +> fun describe() = "компаньон не видит secret экземпляра" +> } +> } +> +> fun main() { +> println(Outer3().Inner3().read()) // скрыто +> println(Outer3.describe()) +> } +> ``` +> +> **`protected` недоступен в расширениях** - даже внутри того же класса: +> ```kotlin +> open class Base11 { +> protected val value = 42 +> fun show() = value +> } +> +> // fun Base11.bad() = value // ошибка: protected недоступен в расширении +> +> fun main() { println(Base11().show()) } // 42 +> ``` +> +> **`private` конструктор не защищает `data class` от `copy`** в версиях до Kotlin 2.1: клиент мог обойти фабрику через `copy()`. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) +> +> **Расширение видимости при переопределении легко сделать случайно.** `public override fun` у `protected`-метода публикует то, что автор базового класса прятал. +> +> **`internal`-классы в публичных сигнатурах - ошибка компиляции**, но `internal`-тип внутри дженерика может просочиться: +> ```kotlin +> internal class Impl2 +> +> class Api2 { +> internal val items = mutableListOf() +> val count: Int get() = items.size // публично только число +> } +> +> fun main() { println(Api2().count) } // 0 +> ``` + +Дополнительно: [ООП](ООП.md) +Далее: [Наследование](Наследование.md) diff --git a/Kotlin/ООП/Наследование.md b/Kotlin/ООП/Наследование.md new file mode 100644 index 0000000..85c980b --- /dev/null +++ b/Kotlin/ООП/Наследование.md @@ -0,0 +1,307 @@ +--- +title: Наследование +description: open, вызов конструктора базового класса, наследование свойств и выбор в пользу композиции +tags: + - kotlin + - oop +--- + +Класс закрыт для наследования, пока не объявлен `open`. + +```kotlin +open class Animal(val name: String) { + open fun sound() = "..." + fun describe() = "$name говорит ${sound()}" // final: переопределить нельзя +} + +class Dog(name: String) : Animal(name) { + override fun sound() = "Гав" +} + +fun main() { + println(Dog("Рекс").describe()) // Рекс говорит Гав +} +``` +`describe()` не `open`, но вызывает `sound()`, который переопределён - это и есть полиморфизм: шаблон поведения в базовом классе, детали в подклассе. + +## Вызов конструктора базового класса +Указывается в заголовке: +```kotlin +open class Base12(val id: Int) + +class Derived12(id: Int, val name: String) : Base12(id) + +fun main() { + val d = Derived12(1, "Иван") + println("${d.id} ${d.name}") // 1 Иван +} +``` +Аргументы можно вычислять: +```kotlin +open class Shape2(val area: Double) + +class Circle2(val radius: Double) : Shape2(Math.PI * radius * radius) + +fun main() { + println("%.2f".format(java.util.Locale.US, Circle2(1.0).area)) // 3.14 +} +``` + +Если у подкласса нет основного конструктора, базовый вызывается из каждого вторичного через `super`: +```kotlin +open class Base13(val source: String) + +class Derived13 : Base13 { + constructor(file: java.io.File) : super("файл ${file.name}") + constructor(url: String, port: Int) : super("$url:$port") +} + +fun main() { + println(Derived13("http://x.ru", 80).source) // http://x.ru:80 +} +``` + +Скобки в заголовке обязательны, даже если конструктор без параметров: +```kotlin +open class Base14 +class Derived14 : Base14() // скобки = вызов конструктора + +interface Marker2 +class Impl3 : Marker2 // у интерфейса скобок нет + +fun main() { println(Derived14()::class.simpleName) } // Derived14 +``` +Наличие скобок различает класс и интерфейс в списке супертипов. + +## Что можно наследовать +```kotlin +open class Base15 { + open val version: Int = 1 + open var state: String = "новый" + open fun run2() = "база" + val fixed = "не переопределить" // не open +} + +class Derived15 : Base15() { + override val version = 2 + override var state: String = "изменённый" + override fun run2() = "подкласс" +} + +fun main() { + val d: Base15 = Derived15() + println("${d.version} ${d.state} ${d.run2()} ${d.fixed}") + // 2 изменённый подкласс не переопределить +} +``` +`open` нужен каждому члену отдельно: открытый класс не делает своих членов открытыми. + +### val можно переопределить как var +```kotlin +open class Base16 { + open val value: Int = 1 +} + +class Derived16 : Base16() { + override var value: Int = 2 // добавили сеттер +} + +fun main() { + val d = Derived16() + d.value = 5 + println(d.value) // 5 +} +``` +Обратное запрещено: `var` нельзя сузить до `val` - это сломало бы код, который присваивает значение. + +### Переопределение свойства геттером +```kotlin +open class Base17 { + open val size: Int = 0 +} + +class Derived17(private val items: List) : Base17() { + override val size: Int get() = items.size // вычисляемое вместо хранимого +} + +fun main() { + println(Derived17(listOf(1, 2, 3)).size) // 3 +} +``` + +## Свойство в конструкторе подкласса +```kotlin +open class Base18 { + open val name: String = "базовое" +} + +class Derived18(override val name: String) : Base18() + +fun main() { + println(Derived18("своё").name) // своё +} +``` +`override` прямо в параметре конструктора - короткая и частая форма. + +## Абстрактный член не требует open +```kotlin +abstract class Shape3 { + abstract val area: Double // open подразумевается + abstract fun describe(): String + open fun show() = "фигура: ${describe()}" +} + +class Square2(val side: Double) : Shape3() { + override val area get() = side * side + override fun describe() = "квадрат со стороной $side" +} + +fun main() { + println(Square2(2.0).show()) // фигура: квадрат со стороной 2.0 +} +``` +Подробнее: [Абстрактные классы](Абстрактные%20классы.md) + +## Одиночное наследование, множественные интерфейсы +```kotlin +open class Entity(val id: Int) +interface Serializable2 { fun serialize(): String } +interface Validatable { fun validate(): Boolean } + +class User4(id: Int, val name: String) : Entity(id), Serializable2, Validatable { + override fun serialize() = """{"id":$id,"name":"$name"}""" + override fun validate() = name.isNotBlank() +} + +fun main() { + val u = User4(1, "Иван") + println(u.serialize()) // {"id":1,"name":"Иван"} + println(u.validate()) // true +} +``` +Класс в списке супертипов идёт первым - это соглашение, а не требование компилятора. + +## Что наследовать нельзя +```kotlin +class Final2 // закрыт по умолчанию +data class Point3(val x: Int) // data class не может быть open +@JvmInline value class Id2(val value: Int) // value class тоже +object Singleton2 // object +enum class Status2 { OK } // enum +sealed class Limited // только в своём модуле и пакете + +fun main() { println("перечислены закрытые формы") } +``` +Для `enum` есть исключение: отдельная константа может переопределить метод, образуя анонимный подкласс. Подробнее: [Enum class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Enum%20class.md) + +## Композиция вместо наследования +Наследование связывает подкласс с реализацией базового класса. Изменение базового класса может сломать подкласс, даже если публичный контракт не менялся - это проблема хрупкого базового класса. + +Классический пример - подсчёт добавленных элементов: +```kotlin +// плохо: наследование от реализации +class CountingList : ArrayList() { + var added = 0 + override fun add(element: T): Boolean { + added++ + return super.add(element) + } + override fun addAll(elements: Collection): Boolean { + added += elements.size + return super.addAll(elements) // внутри может вызвать add()! + } +} + +fun main() { + val list = CountingList() + list.addAll(listOf(1, 2, 3)) + println("добавлено: ${list.added}, реально: ${list.size}") +} +``` +На текущем JDK выведется `добавлено: 3, реально: 3` - потому что `ArrayList.addAll` копирует массив напрямую и `add` не вызывает. Но это не гарантия: реализация, которая реализует `addAll` через цикл с `add`, удвоит счётчик. Корректность класса зависит от внутренностей базового, а они не входят в его контракт и могут измениться между версиями JDK. + +Композиция такой зависимости не создаёт: +```kotlin +class CountingList2 { + private val items = mutableListOf() + var added = 0 + private set + + fun add(element: T) { added++; items.add(element) } + fun addAll(elements: Collection) { elements.forEach { add(it) } } + val size: Int get() = items.size +} + +fun main() { + val list = CountingList2() + list.addAll(listOf(1, 2, 3)) + println("добавлено: ${list.added}, реально: ${list.size}") // 3, 3 +} +``` +Kotlin делает композицию такой же короткой, как наследование, - через делегирование `by`. Подробнее: [Делегирование](Делегирование.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `class A extends B` | `class A : B()` | +> | `implements I` | та же запись через запятую | +> | класс открыт, `final` закрывает | закрыт, `open` открывает | +> | метод виртуален по умолчанию | финален по умолчанию | +> | `super(args)` первой строкой тела | `: Base(args)` в заголовке | +> | `@Override` опциональна | `override` обязательно | +> | нельзя переопределить поле | свойство переопределяется | +> +> **Закрытость по умолчанию - осознанный выбор.** Проектировать класс под наследование дорого: нужно документировать, какие методы вызывают какие, и поддерживать это между версиями. Java по умолчанию делает всё наследуемым, из-за чего библиотечные классы либо получают `final`, либо навсегда остаются связанными своими внутренностями. +> +> **Переопределение свойств** в Java невозможно - поля скрываются, а не переопределяются: +> ```java +> class Base { int value = 1; } +> class Derived extends Base { int value = 2; } // сокрытие, не переопределение +> // ((Base) new Derived()).value == 1 +> ``` +> Обращение по ссылке базового типа даст поле базового класса. В Kotlin свойство - это методы доступа, поэтому `override` работает полиморфно и такой ловушки нет. +> +> Сравнить: [Наследование в Java](../../Программирование%20на%20Java/ООП/Наследование/Наследование.md), [Сокрытие](../../Программирование%20на%20Java/ООП/Наследование/Сокрытие.md) + +> [!warning] Подводные камни +> **`open` у класса не открывает его члены** - каждый метод и свойство нужно помечать отдельно. +> +> **Переопределённый метод остаётся `open`.** Чтобы закрыть ветку иерархии, нужен `final override`: +> ```kotlin +> open class A4 { open fun f() = "A" } +> open class B4 : A4() { final override fun f() = "B" } +> // class C4 : B4() { override fun f() = "C" } // ошибка: f закрыт +> +> fun main() { println(B4().f()) } // B +> ``` +> +> **Вызов `open`-метода из конструктора видит неинициализированный подкласс:** +> ```kotlin +> open class Base19 { +> init { println("из init: ${describe()}") } +> open fun describe() = "база" +> } +> +> class Derived19 : Base19() { +> private val name: String = "Иван" +> override fun describe() = "подкласс, name=$name" +> } +> +> fun main() { Derived19() } // из init: подкласс, name=null +> ``` +> Подробнее: [Инициализация](Инициализация.md) +> +> **Скобки обязательны при наследовании класса**: `: Base()`, а не `: Base`. Без скобок компилятор ищет интерфейс. +> +> **`protected` в Kotlin не даёт доступа из пакета** - только из подклассов. Код, перенесённый из Java, может перестать компилироваться. +> +> **`var` нельзя переопределить как `val`** - только наоборот. +> +> **Наследование от классов стандартной библиотеки хрупко.** `ArrayList`, `HashMap` и подобные не документируют, какие методы вызывают друг друга внутри. Используйте делегирование. +> +> **Глубокие иерархии трудно читать.** Чтобы понять поведение класса в иерархии из пяти уровней, нужно просмотреть все пять. Практическое правило: наследование - для «является» и когда базовый класс спроектирован под него; во всех остальных случаях - композиция. +> +> **Нельзя наследоваться от `data class`**, поэтому иерархии носителей данных строят на `sealed`-интерфейсах с `data class`-реализациями. + +Дополнительно: [Переопределение](Переопределение.md) +Далее: [Переопределение](Переопределение.md) diff --git a/Kotlin/ООП/ООП.md b/Kotlin/ООП/ООП.md new file mode 100644 index 0000000..cec3c4a --- /dev/null +++ b/Kotlin/ООП/ООП.md @@ -0,0 +1,197 @@ +--- +title: ООП +description: Обзор объектной модели Kotlin: что закрыто по умолчанию, чего нет совсем и почему +tags: + - kotlin + - oop +--- + +Объектная модель Kotlin устроена так же, как в Java - один суперкласс, множество интерфейсов, виртуальные вызовы. Отличия в значениях по умолчанию и в том, что из Java убрано. + +## Главное отличие: всё закрыто по умолчанию +```kotlin +class User(val name: String) { + fun greet() = "Привет, $name" +} + +// class Admin : User("админ") // ошибка: User закрыт для наследования +``` +Класс нельзя унаследовать, пока он не объявлен `open`. Метод нельзя переопределить, пока он не `open`. Это противоположность Java, где `final` нужно писать явно. + +```kotlin +open class User(val name: String) { + open fun greet() = "Привет, $name" + fun id() = name.hashCode() // переопределить нельзя +} + +class Admin(name: String) : User(name) { + override fun greet() = "Админ $name" + // override fun id() = 0 // ошибка: id не open +} + +fun main() { + val u: User = Admin("Иван") + println(u.greet()) // Админ Иван — виртуальный вызов +} +``` + +Причина - проблема хрупкого базового класса. Автор класса, который не проектировал его под наследование, не может гарантировать, что подкласс не нарушит инварианты. `open` - это явное обещание поддерживать наследование как часть контракта. + +## Карта раздела +```mermaid +graph TD + A["Классы и конструкторы"] --> B["Свойства"] + B --> C["Инициализация"] + C --> D["Модификаторы видимости"] + D --> E["Наследование"] + E --> F["Переопределение"] + F --> G["Абстрактные классы"] + G --> H["Интерфейсы"] + H --> I["Делегирование"] + I --> J["Вложенные классы"] + J --> K["Полиморфизм"] +``` + +| Заметка | О чём | +|---|---| +| [Классы и конструкторы](Классы%20и%20конструкторы.md) | основной и вторичные конструкторы, параметры-свойства | +| [Свойства](Свойства.md) | геттеры, сеттеры, backing field, `lateinit`, делегаты | +| [Инициализация](Инициализация.md) | порядок выполнения `init`, ловушка вызова open-метода | +| [Модификаторы видимости](Модификаторы%20видимости.md) | `public`, `internal`, `protected`, `private` | +| [Наследование](Наследование.md) | `open`, вызов конструктора базового класса | +| [Переопределение](Переопределение.md) | `override`, `final`, `super`, ковариантность результата | +| [Абстрактные классы](Абстрактные%20классы.md) | `abstract` против `open`, выбор с интерфейсом | +| [Интерфейсы](Интерфейсы.md) | реализация по умолчанию, свойства, ромбовидное наследование | +| [Делегирование](Делегирование.md) | `by` для интерфейсов и свойств | +| [Вложенные и внутренние классы](Вложенные%20и%20внутренние%20классы.md) | `nested` по умолчанию, `inner` для ссылки на внешний | +| [Полиморфизм](Полиморфизм.md) | подтипы, перегрузка, дженерики | + +## Чего в Kotlin нет +| Из Java | Что вместо | +|---|---| +| `static` | [companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md), функции верхнего уровня | +| поля (`field`) в публичном API | [свойства](Свойства.md) с геттером и сеттером | +| checked exceptions | все исключения непроверяемые, см. [Система исключений](../Исключения/Система%20исключений.md) | +| `package-private` (без модификатора) | `internal` - видимость в пределах модуля | +| анонимные классы | [объект-выражение](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) | +| примитивы и обёртки как разные типы | один `Int`, упаковка по необходимости | +| `wait`/`notify` в `Object` | [корутины](../Корутины/Корутины.md) и примитивы JDK | + +## Классы на любой вкус +Kotlin предлагает специализированные формы класса вместо одного универсального: + +```kotlin +class Service // обычный +data class Point(val x: Int, val y: Int) // носитель данных +enum class Status { OK, FAIL } // фиксированный набор значений +sealed interface Result3 // фиксированный набор типов +object Registry // синглтон +@JvmInline value class UserId(val value: Int) // обёртка без аллокации +abstract class Shape // частичная реализация +interface Named // контракт +annotation class Marker // аннотация +fun interface Handler { fun handle(x: Int) } // SAM-интерфейс +``` +Правильный выбор формы снимает большую часть ручной работы: `data class` избавляет от написания `equals`, `sealed` - от `else` в `when`, `object` - от реализации синглтона. + +Подробнее о первых пяти: [Базовые типы ООП](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md) + +## Свойства вместо полей +В Kotlin нет публичных полей - только свойства. Снаружи обращение выглядит как к полю, но всегда идёт через методы доступа: +```kotlin +class Rect(val width: Int, val height: Int) { + val area: Int get() = width * height // вычисляется при каждом чтении + + var scale: Double = 1.0 + set(value) { + require(value > 0) { "масштаб должен быть положительным" } + field = value + } +} + +fun main() { + val r = Rect(2, 3) + println(r.area) // 6 — похоже на поле, но это вызов геттера + r.scale = 2.0 + println(r.scale) // 2.0 +} +``` +Практическое следствие: заменить хранимое свойство на вычисляемое можно без изменения кода клиентов. В Java для этого пришлось бы менять `obj.field` на `obj.getField()` везде. + +Подробнее: [Свойства](Свойства.md) + +## Композиция удобнее наследования +Kotlin делает композицию такой же короткой, как наследование, - через делегирование: +```kotlin +interface Logger2 { fun log(msg: String) } + +class ConsoleLogger : Logger2 { + override fun log(msg: String) = println("[LOG] $msg") +} + +class Service2(logger: Logger2) : Logger2 by logger // делегирует всё + +fun main() { + Service2(ConsoleLogger()).log("работает") // [LOG] работает +} +``` +`Service2` реализует `Logger2`, не наследуя реализацию: методы просто перенаправляются. Подробнее: [Делегирование](Делегирование.md) + +## Что осталось от Java без изменений +- одиночное наследование классов, множественная реализация интерфейсов +- динамическая диспетчеризация: вызов идёт по фактическому типу объекта +- `Any` в корне иерархии (аналог `Object`) +- стирание типов в дженериках +- модель памяти и сборка мусора JVM + +То есть байт-код, который порождает Kotlin, - обычные JVM-классы. Любая Java-библиотека работает с Kotlin-классами, и наоборот. Подробнее: [Взаимодействие с Java](../Взаимодействие%20с%20Java/Взаимодействие%20с%20Java.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | класс открыт, `final` закрывает | класс закрыт, `open` открывает | +> | метод виртуален по умолчанию | метод финален по умолчанию | +> | `@Override` опциональна | `override` обязательно | +> | поля + геттеры/сеттеры | свойства | +> | `static` | `companion object` | +> | нет модификатора = package-private | нет модификатора = `public` | +> | нет аналога | `internal` (видимость в модуле) | +> | checked exceptions | их нет | +> | делегирование вручную | `by` | +> +> **Обязательный `override`** закрывает два класса ошибок Java: опечатка в имени метода создаёт новый метод вместо переопределения, а добавление метода в базовый класс может случайно «переопределиться» существующим методом подкласса. В Kotlin и то и другое - ошибка компиляции. +> +> **Отсутствие модификатора означает `public`**, а не package-private. Это ловушка при переходе: Java-разработчик по привычке не пишет модификатор, рассчитывая на видимость в пакете, и получает публичный API. +> +> Сравнить: [ООП в Java](../../Программирование%20на%20Java/ООП/ООП.md), [Инкапсуляция](../../Программирование%20на%20Java/ООП/Инкапсуляция/Инкапсуляция.md) + +> [!warning] Подводные камни +> **`open` не наследуется транзитивно для метода.** Переопределённый метод остаётся открытым, пока его явно не закроют: +> ```kotlin +> open class A3 { open fun f() = "A" } +> open class B3 : A3() { override fun f() = "B" } // всё ещё open! +> class C3 : B3() { override fun f() = "C" } // можно +> +> open class D3 : A3() { final override fun f() = "D" } // так закрывают +> +> fun main() { +> println(listOf(A3(), B3(), C3(), D3()).joinToString { it.f() }) // A, B, C, D +> } +> ``` +> +> **Публичный `val` - часть API навсегда.** Превратить его в `var` можно, а убрать или переименовать - ломающее изменение, потому что клиенты обращаются к нему как к полю. +> +> **`internal` не защищает от Java.** В байт-коде такой член становится `public` с искажённым именем, поэтому Java-код из другого модуля может его вызвать: +> ```kotlin +> class Service3 { +> internal fun secret() = "виден из Java" +> } +> +> fun main() { println(Service3().secret()) } +> ``` +> +> **Отсутствие checked exceptions означает, что сигнатура не говорит об ошибках.** Функция может бросить что угодно, и компилятор об этом не предупредит. Способы это компенсировать - в [Result и runCatching](../Исключения/Result%20и%20runCatching.md). +> +> **Наследование от `data class` невозможно**, потому что он не может быть `open`. Иерархии строят на `sealed`-типах или на интерфейсах. + +Далее: [Классы и конструкторы](Классы%20и%20конструкторы.md) diff --git a/Kotlin/ООП/Переопределение.md b/Kotlin/ООП/Переопределение.md new file mode 100644 index 0000000..eef1068 --- /dev/null +++ b/Kotlin/ООП/Переопределение.md @@ -0,0 +1,341 @@ +--- +title: Переопределение +description: override и final, вызов super, конфликт реализаций и ковариантность результата +tags: + - kotlin + - oop +--- + +`override` обязательно. Без него член не переопределяет, а конфликтует с родительским. + +```kotlin +open class Animal2 { + open fun sound() = "..." +} + +class Cat : Animal2() { + override fun sound() = "Мяу" + // fun sound() = "Мяу" // ошибка: 'sound' hides member and needs 'override' +} + +fun main() { + val a: Animal2 = Cat() + println(a.sound()) // Мяу — вызов по фактическому типу +} +``` + +Это закрывает два класса ошибок: опечатка в имени создала бы новый метод, а добавление метода в базовый класс случайно «переопределилось» бы существующим методом подкласса. В Kotlin и то и другое - ошибка компиляции. + +## final override +Переопределённый член остаётся открытым для дальнейшего переопределения. Закрыть ветку - `final`: +```kotlin +open class A5 { open fun f() = "A" } +open class B5 : A5() { final override fun f() = "B" } +// class C5 : B5() { override fun f() = "C" } // ошибка: f закрыт + +fun main() { println(B5().f()) } // B +``` + +## super +```kotlin +open class Logger3 { + open fun log(msg: String) = "[базовый] $msg" +} + +class TimestampLogger : Logger3() { + override fun log(msg: String) = super.log("[время] $msg") +} + +fun main() { + println(TimestampLogger().log("привет")) // [базовый] [время] привет +} +``` +`super` работает и для свойств - через их геттеры: +```kotlin +open class Base20 { + open val items: List get() = listOf(1, 2) +} + +class Derived20 : Base20() { + override val items: List get() = super.items + 3 +} + +fun main() { println(Derived20().items) } // [1, 2, 3] +``` + +Из вложенного класса к `super` внешнего обращаются через `super@Outer`: +```kotlin +open class Base21 { + open fun name() = "база" +} + +class Outer4 : Base21() { + override fun name() = "внешний" + + inner class Inner4 { + fun bothNames() = "${name()} / ${super@Outer4.name()}" + } +} + +fun main() { println(Outer4().Inner4().bothNames()) } // внешний / база +``` + +## Конфликт реализаций +Если два супертипа дают реализацию одного метода, компилятор требует разрешить конфликт явно: +```kotlin +interface A6 { fun f() = "A" } +interface B6 { fun f() = "B" } + +class C6 : A6, B6 { + override fun f() = super.f() + super.f() // указываем источник +} + +fun main() { println(C6().f()) } // AB +``` +Без `override` такой класс не скомпилируется - Kotlin не выбирает реализацию за вас. + +То же с классом и интерфейсом: +```kotlin +open class Base22 { open fun f() = "класс" } +interface Mixin { fun f() = "интерфейс" } + +class Impl4 : Base22(), Mixin { + override fun f() = "${super.f()} + ${super.f()}" +} + +fun main() { println(Impl4().f()) } // класс + интерфейс +``` +Подробнее: [Интерфейсы](Интерфейсы.md) + +## Что можно переопределять +| Член | Условие | +|---|---| +| метод | `open` или `abstract` | +| `val` | `open` или `abstract`; можно расширить до `var` | +| `var` | `open` или `abstract`; сузить до `val` нельзя | +| свойство в конструкторе | `override val` в параметре | + +```kotlin +abstract class Base23 { + abstract val size: Int + abstract var label: String + abstract fun compute(): Int +} + +class Impl5(override var label: String, private val items: List) : Base23() { + override val size get() = items.size + override fun compute() = items.sum() +} + +fun main() { + val i = Impl5("данные", listOf(1, 2, 3)) + println("${i.label} ${i.size} ${i.compute()}") // данные 3 6 +} +``` + +## Ковариантность результата +Переопределение может сузить тип результата: +```kotlin +open class Animal3 +class Dog2 : Animal3() + +open class Shelter { + open fun adopt(): Animal3 = Animal3() +} + +class DogShelter : Shelter() { + override fun adopt(): Dog2 = Dog2() // Dog2 — подтип Animal3 +} + +fun main() { + val s: Shelter = DogShelter() + println(s.adopt()::class.simpleName) // Dog2 +} +``` +Это безопасно: клиент ожидает `Animal3`, а получает `Dog2`, который им является. + +Параметры сузить нельзя - это сломало бы контракт: +```kotlin +open class Base24 { + open fun process(x: Any) = "any: $x" +} + +class Derived24 : Base24() { + override fun process(x: Any) = "переопределено: $x" + fun process(x: String) = "перегрузка: $x" // это перегрузка, не override +} + +fun main() { + val d = Derived24() + println(d.process("текст")) // перегрузка: текст + println((d as Base24).process("текст")) // переопределено: текст +} +``` +Вызов зависит от статического типа ссылки - классическая ловушка смешения перегрузки и переопределения. + +## Nullability при переопределении +```kotlin +open class Base25 { + open fun find(id: Int): String? = null +} + +class Derived25 : Base25() { + override fun find(id: Int): String = "найдено $id" // сузили String? до String +} + +fun main() { + val b: Base25 = Derived25() + println(b.find(1)) // найдено 1 +} +``` +Результат можно сделать non-null - это сужение. А параметр, наоборот, можно расширить до nullable: +```kotlin +open class Base26 { + open fun handle(x: String) = "строка" +} + +class Derived26 : Base26() { + override fun handle(x: String) = "подкласс: $x" +} + +fun main() { println(Derived26().handle("a")) } // подкласс: a +``` + +## Переопределение equals, hashCode, toString +Члены `Any` открыты всегда: +```kotlin +class Point4(val x: Int, val y: Int) { + override fun equals(other: Any?) = other is Point4 && other.x == x && other.y == y + override fun hashCode() = 31 * x + y + override fun toString() = "($x, $y)" +} + +fun main() { + println(Point4(1, 2)) // (1, 2) + println(Point4(1, 2) == Point4(1, 2)) // true +} +``` +Подробнее: [equals, hashCode и toString](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/equals,%20hashCode%20и%20toString.md) + +## Переопределение и расширения +Функция-расширение **не** переопределяется - она разрешается статически, по типу ссылки: +```kotlin +open class Base27 +class Derived27 : Base27() + +fun Base27.describe() = "база" +fun Derived27.describe() = "подкласс" + +fun main() { + val d: Base27 = Derived27() + println(d.describe()) // база — по типу переменной! + println(Derived27().describe()) // подкласс +} +``` +Это принципиальное отличие от методов. Если нужен полиморфизм - нужен метод класса. Подробнее: [Функции-расширения](../Функциональный%20Kotlin/Функции-расширения.md) + +Расширение не может скрыть метод с той же сигнатурой: метод всегда побеждает. +```kotlin +class Service13 { + fun run2() = "метод" +} + +fun Service13.run2() = "расширение" // никогда не вызовется + +fun main() { println(Service13().run2()) } // метод +``` + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `@Override` опциональна | `override` обязательно | +> | метод виртуален, `final` закрывает | нужен `open`, иначе финален | +> | переопределённый метод остаётся виртуальным | то же, закрывается `final override` | +> | `super.method()` | то же | +> | `A.super.method()` при конфликте | `super.method()` | +> | поля скрываются | свойства переопределяются | +> | ковариантный результат (Java 5+) | то же | +> +> **Обязательный `override`** - главное практическое улучшение. В Java `@Override` необязательна, поэтому опечатка в имени тихо создаёт новый метод, который никогда не вызовется. +> +> **Конфликт реализаций интерфейсов** решается похоже, но синтаксис другой: `A.super.f()` в Java против `super.f()` в Kotlin. Требование явно разрешать конфликт одинаково строгое в обоих языках. +> +> **Сокрытие полей** - проблема Java, которой в Kotlin нет. Поле подкласса с тем же именем скрывает поле базового класса, и какое из них увидит код, зависит от типа ссылки: +> ```java +> class Base { int value = 1; } +> class Derived extends Base { int value = 2; } +> Base b = new Derived(); +> // b.value == 1, ((Derived) b).value == 2 +> ``` +> В Kotlin свойства - это методы доступа, поэтому `override` работает полиморфно. +> +> **Статические методы в Java не переопределяются, а скрываются.** В Kotlin статики нет; члены `companion object` тоже не переопределяются, но путаницы меньше, потому что компаньон не наследуется. +> +> Сравнить: [Переопределение в Java](../../Программирование%20на%20Java/ООП/Наследование/Переопределение.md), [Сокрытие](../../Программирование%20на%20Java/ООП/Наследование/Сокрытие.md), [Ad-hoc полиморфизм](../../Программирование%20на%20Java/ООП/Полиморфизм/Ad-hoc%20(перегрузка).md) + +> [!warning] Подводные камни +> **Расширение не переопределяется** - выбор идёт по статическому типу. Это самая частая ошибка ожиданий: +> ```kotlin +> open class Base28 +> class Derived28 : Base28() +> +> fun Base28.name() = "база" +> fun Derived28.name() = "подкласс" +> +> fun main() { +> val list: List = listOf(Derived28()) +> println(list[0].name()) // база — не "подкласс" +> } +> ``` +> +> **Перегрузка вместо переопределения компилируется молча:** +> ```kotlin +> open class Base29 { open fun f(x: Any) = "any" } +> class Derived29 : Base29() { +> override fun f(x: Any) = "override" +> fun f(x: Int) = "overload" +> } +> +> fun main() { +> val d = Derived29() +> println(d.f(1)) // overload — точнее по типу +> println((d as Base29).f(1)) // override — по типу ссылки +> } +> ``` +> +> **Вызов `open`-метода из конструктора попадает в подкласс до его инициализации.** Подробнее: [Инициализация](Инициализация.md) +> +> **`super` в лямбде внутри метода работает, а в объекте-выражении - относится к нему:** +> ```kotlin +> open class Base30 { open fun f() = "база" } +> +> class Derived30 : Base30() { +> override fun f() = "подкласс" +> fun test(): String { +> val fromLambda = listOf(1).map { super.f() }.first() // база +> return fromLambda +> } +> } +> +> fun main() { println(Derived30().test()) } // база +> ``` +> +> **Переопределение `equals` без `hashCode` ломает хеш-коллекции.** Компилятор не предупреждает. +> +> **Сужение видимости при переопределении запрещено**, а расширение - разрешено и легко делается случайно: `public override` у `protected`-метода публикует внутреннюю деталь. +> +> **Делегат свойства не наследуется.** Переопределяя `val x by lazy { }`, подкласс должен задать своё хранилище - делегат базового класса не переиспользуется: +> ```kotlin +> open class Base31 { +> open val value: String by lazy { "база" } +> } +> +> class Derived31 : Base31() { +> override val value: String by lazy { "подкласс" } // свой делегат +> } +> +> fun main() { println(Derived31().value) } // подкласс +> ``` + +Дополнительно: [Полиморфизм](Полиморфизм.md) +Далее: [Абстрактные классы](Абстрактные%20классы.md) diff --git a/Kotlin/ООП/Полиморфизм.md b/Kotlin/ООП/Полиморфизм.md new file mode 100644 index 0000000..0e31745 --- /dev/null +++ b/Kotlin/ООП/Полиморфизм.md @@ -0,0 +1,325 @@ +--- +title: Полиморфизм +description: Полиморфизм подтипов, перегрузка, параметрический полиморфизм и что разрешается статически +tags: + - kotlin + - oop +--- + +Полиморфизм - возможность работать с разными типами через один интерфейс. В Kotlin четыре формы: + +| Форма | Механизм | Когда выбирается реализация | +|---|---|---| +| подтипов | `override` | в рантайме, по фактическому типу | +| ad-hoc (перегрузка) | одно имя, разные параметры | при компиляции | +| параметрический | дженерики | при компиляции (типы стираются) | +| через `when` по sealed | сопоставление типов | в рантайме, но проверяется компилятором | + +## Полиморфизм подтипов +```kotlin +interface Shape5 { + fun area(): Double + fun name(): String +} + +class Circle5(val r: Double) : Shape5 { + override fun area() = Math.PI * r * r + override fun name() = "круг" +} + +class Square4(val side: Double) : Shape5 { + override fun area() = side * side + override fun name() = "квадрат" +} + +fun describe(s: Shape5) = "${s.name()}: %.2f".format(java.util.Locale.US, s.area()) + +fun main() { + val shapes: List = listOf(Circle5(1.0), Square4(2.0)) + shapes.forEach { println(describe(it)) } + // круг: 3.14 + // квадрат: 4.00 + println(shapes.sumOf { it.area() } > 7) // true +} +``` +Функция `describe` написана один раз и работает с любым `Shape5`, включая те, которые появятся позже. Выбор реализации происходит в рантайме по фактическому типу объекта - это динамическая диспетчеризация. + +## Перегрузка +Одно имя, разные параметры. Выбор - при компиляции, по статическим типам аргументов: +```kotlin +fun format2(value: Int) = "целое: $value" +fun format2(value: Double) = "дробное: $value" +fun format2(value: String) = "строка: '$value'" +fun format2(value: Any?) = "что-то: $value" + +fun main() { + println(format2(1)) // целое: 1 + println(format2(1.0)) // дробное: 1.0 + println(format2("текст")) // строка: 'текст' + println(format2(listOf(1))) // что-то: [1] + + val x: Any = 1 // статический тип Any! + println(format2(x)) // что-то: 1 — не "целое: 1" +} +``` +Последняя строка показывает суть: компилятор видит тип переменной, а не объекта. Это основное отличие от полиморфизма подтипов. + +В Kotlin перегрузка нужна реже - её заменяют значения по умолчанию: +```kotlin +fun connect(host: String, port: Int = 8080, secure: Boolean = false) = + "$host:$port secure=$secure" + +fun main() { + println(connect("localhost")) // localhost:8080 secure=false + println(connect("localhost", secure = true)) // localhost:8080 secure=true +} +``` +Подробнее: [Функции](../Синтаксис%20и%20типы/Работа%20с%20данными/Функции.md) + +## Параметрический полиморфизм +Один код для любого типа: +```kotlin +fun firstOrDefault(items: List, default: T): T = + if (items.isEmpty()) default else items[0] + +class Box4(private val value: T) { + fun get(): T = value + fun map(f: (T) -> R): Box4 = Box4(f(value)) +} + +fun main() { + println(firstOrDefault(listOf(1, 2), 0)) // 1 + println(firstOrDefault(emptyList(), "нет")) // нет + println(Box4(5).map { it * 2 }.get()) // 10 +} +``` +Ограничение параметра типа позволяет вызывать методы: +```kotlin +fun > maxOf2(a: T, b: T): T = if (a > b) a else b + +fun main() { + println(maxOf2(3, 7)) // 7 + println(maxOf2("abc", "abd")) // abd +} +``` +Подробнее: [Параметрический полиморфизм](../Дженерики/Параметрический%20полиморфизм.md) + +## Полиморфизм через sealed и when +```kotlin +sealed interface Payment { + data class Card(val number: String) : Payment + data class Cash(val amount: Long) : Payment + data object Invoice : Payment +} + +fun process2(p: Payment): String = when (p) { + is Payment.Card -> "карта ****${p.number.takeLast(4)}" + is Payment.Cash -> "наличные ${p.amount}" + Payment.Invoice -> "счёт" +} + +fun main() { + println(process2(Payment.Card("1234567890123456"))) // карта ****3456 + println(process2(Payment.Cash(500))) // наличные 500 + println(process2(Payment.Invoice)) // счёт +} +``` +Это альтернатива полиморфизму подтипов с обратным распределением ответственности: + +| | метод в классе | `when` по sealed | +|---|---|---| +| добавить новый тип | правок в одном месте | компилятор найдёт все `when` | +| добавить новую операцию | правка во всех классах | один новый `when` | +| логика операции | размазана по классам | собрана в одном месте | + +Выбор зависит от того, что меняется чаще. Набор типов стабилен, а операций много - `sealed` + `when`. Операции стабильны, а типы добавляются - методы в классах. + +Подробнее: [Sealed class и interface](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +## Что разрешается статически +Это главный источник ошибок ожиданий. Статически (по типу ссылки, а не объекта) выбираются: + +**Функции-расширения:** +```kotlin +open class Base37 +class Derived33 : Base37() + +fun Base37.describe() = "база" +fun Derived33.describe() = "подкласс" + +fun main() { + val d: Base37 = Derived33() + println(d.describe()) // база — по типу переменной! + println(Derived33().describe()) // подкласс +} +``` + +**Перегруженные функции:** +```kotlin +open class Animal4 +class Dog3 : Animal4() + +fun feed(a: Animal4) = "корм для животного" +fun feed(d: Dog3) = "корм для собаки" + +fun main() { + val a: Animal4 = Dog3() + println(feed(a)) // корм для животного + println(feed(Dog3())) // корм для собаки +} +``` + +**Члены `companion object`** - они не переопределяются: +```kotlin +open class Base38 { + companion object { fun create() = "из Base" } +} + +class Derived34 : Base38() { + companion object { fun create() = "из Derived" } +} + +fun main() { + println(Base38.create()) // из Base + println(Derived34.create()) // из Derived — выбор по имени класса +} +``` + +Динамически выбираются только **члены класса**, помеченные `open`/`abstract` и переопределённые через `override`. + +## Приведение и проверка типа +```kotlin +fun handle(x: Any): String = when (x) { + is String -> "строка длины ${x.length}" // умное приведение + is Int -> "число ${x + 1}" + is List<*> -> "список из ${x.size}" + else -> "неизвестно: ${x::class.simpleName}" +} + +fun main() { + println(handle("abc")) // строка длины 3 + println(handle(5)) // число 6 + println(handle(listOf(1, 2))) // список из 2 + println(handle(1.0)) // неизвестно: Double +} +``` +Длинная цепочка `is` часто означает, что иерархия просит `sealed`-типа или полиморфного метода. Подробнее: [Приведение типов](../Синтаксис%20и%20типы/Работа%20с%20данными/Приведение%20типов.md) + +## Вариантность +Полиморфизм распространяется на дженерики через `out` и `in`: +```kotlin +open class Animal5(val name: String) +class Dog4(name: String) : Animal5(name) + +fun printAll(animals: List) = animals.joinToString { it.name } + +fun main() { + val dogs: List = listOf(Dog4("Рекс"), Dog4("Бобик")) + println(printAll(dogs)) // Рекс, Бобик — List подходит как List +} +``` +Это работает, потому что `List` ковариантен. С `MutableList` так нельзя - он инвариантен. Подробнее: [Вариантность](../Дженерики/Вариантность.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | метод виртуален по умолчанию | нужен `open` | +> | `@Override` опциональна | `override` обязательно | +> | перегрузка для опциональных параметров | значения по умолчанию | +> | `instanceof` + каст | `is` + умное приведение | +> | `switch` с pattern matching (Java 21) | `when` с `is` | +> | Visitor для операций над иерархией | `sealed` + `when` | +> | статические методы скрываются | члены компаньона не наследуются | +> +> **Механика полиморфизма подтипов идентична** - это та же виртуальная диспетчеризация JVM. Отличается значение по умолчанию: в Kotlin метод финален, пока не помечен `open`. +> +> **Visitor почти не нужен.** В Java для добавления операций к закрытой иерархии без правки классов применяют паттерн Visitor: интерфейс с методом на каждый тип, метод `accept` в каждом классе. `sealed` + `when` даёт ту же проверку полноты компилятором, но без дополнительного кода: +> ```kotlin +> sealed interface Expr2 { +> data class Num2(val value: Int) : Expr2 +> data class Add2(val l: Expr2, val r: Expr2) : Expr2 +> } +> +> fun eval(e: Expr2): Int = when (e) { +> is Expr2.Num2 -> e.value +> is Expr2.Add2 -> eval(e.l) + eval(e.r) +> } +> +> fun show(e: Expr2): String = when (e) { // новая операция — один when +> is Expr2.Num2 -> "${e.value}" +> is Expr2.Add2 -> "(${show(e.l)} + ${show(e.r)})" +> } +> +> fun main() { +> val expr = Expr2.Add2(Expr2.Num2(1), Expr2.Num2(2)) +> println("${show(expr)} = ${eval(expr)}") // (1 + 2) = 3 +> } +> ``` +> +> **Умное приведение убирает двойную работу.** В Java после `instanceof` нужен явный каст (до версии 16, где появился pattern matching). +> +> Сравнить: [Полиморфизм в Java](../../Программирование%20на%20Java/ООП/Полиморфизм/Полиморфизм.md), [Полиморфизм подтипов](../../Программирование%20на%20Java/ООП/Полиморфизм/Полиморфизм%20подтипов.md), [Ad-hoc полиморфизм](../../Программирование%20на%20Java/ООП/Полиморфизм/Ad-hoc%20(перегрузка).md) + +> [!warning] Подводные камни +> **Расширения не полиморфны** - выбор по статическому типу. Это самая частая ошибка: +> ```kotlin +> open class Base39 +> class Derived35 : Base39() +> +> fun Base39.name() = "база" +> fun Derived35.name() = "подкласс" +> +> fun main() { +> println(listOf(Derived35()).first().name()) // база +> } +> ``` +> Нужен полиморфизм - нужен член класса. +> +> **Перегрузка выбирается по типу ссылки, а не объекта.** Код ведёт себя по-разному в зависимости от того, как объявлена переменная. +> +> **Смешение перегрузки и переопределения даёт неочевидный результат:** +> ```kotlin +> open class Base40 { open fun f(x: Any) = "any" } +> class Derived36 : Base40() { +> override fun f(x: Any) = "override" +> fun f(x: Int) = "overload" +> } +> +> fun main() { +> val d = Derived36() +> println(d.f(1)) // overload +> println((d as Base40).f(1)) // override +> } +> ``` +> +> **Вызов `open`-метода из конструктора попадает в подкласс до его инициализации.** Подробнее: [Инициализация](Инициализация.md) +> +> **Стирание типов ломает перегрузку по дженерикам:** +> ```kotlin +> // fun show(items: List) = "строки" +> // fun show(items: List) = "числа" // ошибка: та же JVM-сигнатура +> +> @JvmName("showStrings") +> fun show2(items: List) = "строки: ${items.size}" +> fun show2(items: List) = "числа: ${items.size}" +> +> fun main() { println(show2(listOf("a"))) } // строки: 1 +> ``` +> Обойти можно через `@JvmName`, но обычно это признак, что нужен один дженерик-метод. +> +> **Длинная цепочка `is` вместо полиморфизма.** Если `when (x) { is A -> ...; is B -> ... }` повторяется в нескольких местах, логику стоит перенести в методы типов или закрыть иерархию через `sealed`, чтобы компилятор контролировал полноту. +> +> **Члены `companion object` не переопределяются.** Попытка «переопределить» фабрику в подклассе создаёт независимый компаньон; вызов `Derived.create()` работает только потому, что имя разрешается статически. +> +> **Массивы инвариантны, а `List` ковариантен** - в отличие от Java, где массивы ковариантны и могут бросить `ArrayStoreException`: +> ```kotlin +> fun main() { +> val strings: List = listOf("a") +> val anys: List = strings // ok +> println(anys.size) // 1 +> // val arr: Array = arrayOf("a") // ошибка компиляции +> } +> ``` + +Дополнительно: [Переопределение](Переопределение.md) +Далее: [Система исключений](../Исключения/Система%20исключений.md) diff --git a/Kotlin/ООП/Свойства.md b/Kotlin/ООП/Свойства.md new file mode 100644 index 0000000..476c1e4 --- /dev/null +++ b/Kotlin/ООП/Свойства.md @@ -0,0 +1,433 @@ +--- +title: Свойства +description: Геттеры и сеттеры, backing field, вычисляемые свойства, lateinit, lazy и делегаты +tags: + - kotlin + - oop +--- + +Свойство - это поле плюс методы доступа. Снаружи выглядит как поле, но обращение всегда идёт через геттер и сеттер. + +```kotlin +class User { + val id: Int = 1 // только чтение: геттер + var name: String = "Иван" // чтение и запись: геттер + сеттер +} + +fun main() { + val u = User() + println(u.id) // вызов геттера + u.name = "Пётр" // вызов сеттера + println(u.name) // Пётр +} +``` + +Полная форма объявления: +```kotlin +class Rect { + var width: Int = 0 + get() = field + set(value) { field = value } +} + +fun main() { + val r = Rect() + r.width = 5 + println(r.width) // 5 +} +``` +Такой код писать не нужно - это то, что компилятор генерирует сам. Писать вручную стоит только нестандартное поведение. + +## field - backing field +Внутри геттера и сеттера доступен `field` - само хранилище. Прямое обращение к свойству по имени привело бы к бесконечной рекурсии. +```kotlin +class Counter { + var value: Int = 0 + set(newValue) { + println("было $field, стало $newValue") + field = newValue // так + // value = newValue // бесконечная рекурсия! + } +} + +fun main() { + val c = Counter() + c.value = 5 // было 0, стало 5 + c.value = 7 // было 5, стало 7 + println(c.value) // 7 +} +``` + +`field` появляется только если он реально нужен. У вычисляемого свойства хранилища нет: +```kotlin +class Rect2(val width: Int, val height: Int) { + val area: Int get() = width * height // поля нет, считается каждый раз +} + +fun main() { + println(Rect2(2, 3).area) // 6 +} +``` +Это важно для памяти: свойство-геттер не занимает места в объекте. + +## Валидация в сеттере +```kotlin +class Account { + var balance: Long = 0 + set(value) { + require(value >= 0) { "баланс не может быть отрицательным: $value" } + field = value + } +} + +fun main() { + val a = Account() + a.balance = 100 + println(a.balance) // 100 + try { + a.balance = -1 + } catch (e: IllegalArgumentException) { + println(e.message) // баланс не может быть отрицательным: -1 + } +} +``` + +## Видимость сеттера +Частый приём - читать можно всем, менять только внутри: +```kotlin +class Session { + var token: String = "" + private set // геттер public, сеттер private + + fun login(t: String) { token = t } +} + +fun main() { + val s = Session() + s.login("abc") + println(s.token) // abc + // s.token = "x" // ошибка: сеттер приватный +} +``` +Это аккуратнее, чем приватное поле плюс отдельный публичный геттер. + +## Вычисляемые свойства или функции +```kotlin +class Order3(val items: List) { + val total: Int get() = items.sum() // свойство + fun computeTotal() = items.sum() // функция +} + +fun main() { + val o = Order3(listOf(1, 2, 3)) + println("${o.total} ${o.computeTotal()}") // 6 6 +} +``` +Соглашение: свойство, если это **характеристика** объекта, вычисляется дешёво и не бросает исключений. Функция - если выполняется работа: запрос, вычисление сложнее O(1), обращение к сети. + +```kotlin +class Data2(val rows: List) { + val size: Int get() = rows.size // дешёво — свойство + fun parse(): List = rows.map { it.length } // работа — функция +} + +fun main() { + val d = Data2(listOf("ab", "cde")) + println("${d.size} ${d.parse()}") // 2 [2, 3] +} +``` + +## Инициализация и порядок +Свойство должно быть инициализировано при создании объекта: +```kotlin +class Service5 { + val a: Int = 1 // сразу + val b: Int // в init + init { b = a + 1 } + val c: Int get() = a + b // геттер, инициализации не требует +} + +fun main() { + val s = Service5() + println("${s.a} ${s.b} ${s.c}") // 1 2 3 +} +``` +Подробнее: [Инициализация](Инициализация.md) + +## lateinit +Когда значение появится позже, но хочется non-null тип: +```kotlin +class Service6 { + lateinit var client: String + + fun setup() { client = "готов" } + fun use() = if (::client.isInitialized) client else "не настроен" +} + +fun main() { + val s = Service6() + println(s.use()) // не настроен + s.setup() + println(s.use()) // готов + println(s.client) // готов +} +``` +Ограничения `lateinit`: +- только `var` +- только non-null **ссылочный** тип: для `Int`, `Double` и других примитивов нельзя +- нельзя задать свой геттер или сеттер +- проверка `::x.isInitialized` доступна только внутри класса + +Обращение до инициализации даёт понятную ошибку: +```kotlin +class Service7 { lateinit var name: String } + +fun main() { + try { + println(Service7().name) + } catch (e: UninitializedPropertyAccessException) { + println(e.message) // lateinit property name has not been initialized + } +} +``` +`lateinit` нужен для внедрения зависимостей и жизненных циклов фреймворков (Android, Spring). В остальных случаях лучше nullable-тип или `lazy`. + +## lazy +Вычисляется один раз при первом обращении: +```kotlin +class Config5 { + val heavy: String by lazy { + println("вычисляем...") + "результат" + } +} + +fun main() { + val c = Config5() + println("создан") // создан + println(c.heavy) // вычисляем... затем результат + println(c.heavy) // результат — повторно не считается +} +``` +`lazy` работает только с `val`. По умолчанию потокобезопасен (синхронизация при первом доступе); если объект гарантированно в одном потоке, можно ослабить: +```kotlin +val value: Int by lazy(LazyThreadSafetyMode.NONE) { 42 } + +fun main() { println(value) } // 42 +``` + +| | `lateinit var` | `by lazy` | +|---|---|---| +| изменяемость | `var` | `val` | +| кто задаёт значение | внешний код | сама лямбда | +| примитивы | нельзя | можно | +| потокобезопасность | нет | есть по умолчанию | +| доступ до инициализации | исключение | вычислит на месте | + +## observable и vetoable +Готовые делегаты для реакции на изменение: +```kotlin +import kotlin.properties.Delegates + +class Form { + var name: String by Delegates.observable("") { _, old, new -> + println("name: '$old' -> '$new'") + } + + var age: Int by Delegates.vetoable(0) { _, _, new -> + new in 0..150 // false отменяет присваивание + } +} + +fun main() { + val f = Form() + f.name = "Иван" // name: '' -> 'Иван' + f.age = 20 + f.age = 500 // отклонено + println(f.age) // 20 +} +``` + +`Delegates.notNull()` - альтернатива `lateinit` для примитивов: +```kotlin +import kotlin.properties.Delegates + +class Service8 { + var port: Int by Delegates.notNull() +} + +fun main() { + val s = Service8() + s.port = 8080 + println(s.port) // 8080 +} +``` + +## Свойства в Map +Делегирование в `Map` - удобно для разбора динамических данных: +```kotlin +class User2(map: Map) { + val name: String by map + val age: Int by map +} + +fun main() { + val u = User2(mapOf("name" to "Иван", "age" to 20)) + println("${u.name} ${u.age}") // Иван 20 +} +``` + +## Свой делегат +```kotlin +import kotlin.reflect.KProperty + +class Trimmed { + private var value = "" + operator fun getValue(thisRef: Any?, property: KProperty<*>) = value + operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) { + value = newValue.trim() + } +} + +class Form2 { + var login: String by Trimmed() +} + +fun main() { + val f = Form2() + f.login = " иван " + println("'${f.login}'") // 'иван' +} +``` +Подробнее: [Делегирование](Делегирование.md) + +## Свойства верхнего уровня и расширения +```kotlin +val appName = "MyApp" // свойство файла +const val VERSION = "1.0" // константа компиляции + +val String.firstWord: String // свойство-расширение + get() = substringBefore(" ") + +fun main() { + println("$appName $VERSION") // MyApp 1.0 + println("привет мир".firstWord) // привет +} +``` +У свойства-расширения не может быть `field` - только геттер. Подробнее: [Функции-расширения](../Функциональный%20Kotlin/Функции-расширения.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | поле + `getX()` + `setX()` | `var x` | +> | `obj.getName()` | `obj.name` | +> | `obj.setName("x")` | `obj.name = "x"` | +> | `private final` поле | `val` | +> | геттер с вычислением | `val x get() = ...` | +> | Lombok `@Getter`/`@Setter` | не нужны | +> | `Supplier` + кеш для ленивости | `by lazy` | +> | `Optional` поле | `T?` | +> +> **Свойства - не синтаксический сахар над геттерами, а единица абстракции.** Переход от хранимого свойства к вычисляемому не меняет код клиентов: +> ```kotlin +> class Rect3(val w: Int, val h: Int) { +> val area: Int = w * h // было: хранится +> } +> +> class Rect4(val w: Int, val h: Int) { +> val area: Int get() = w * h // стало: считается. Клиенты не изменились +> } +> +> fun main() { println("${Rect3(2,3).area} ${Rect4(2,3).area}") } // 6 6 +> ``` +> В Java переход от `obj.field` к `obj.getField()` - ломающее изменение, поэтому там поля сразу прячут за геттерами «на всякий случай». В Kotlin эта защита встроена. +> +> **Java-геттеры видны как свойства.** Метод `getName()` в Java-классе доступен из Kotlin как `obj.name`: +> ```kotlin +> fun main() { +> val sb = StringBuilder("abc") +> println(sb.length) // вместо sb.length() — синтетическое свойство +> } +> ``` +> +> **`by lazy` заменяет двойную проверку блокировки.** Классический ленивый синглтон в Java требует `volatile` и синхронизации; в Kotlin это одна строка. +> +> Сравнить: [Инкапсуляция в Java](../../Программирование%20на%20Java/ООП/Инкапсуляция/Инкапсуляция.md) + +> [!warning] Подводные камни +> **Обращение к свойству внутри его сеттера - бесконечная рекурсия:** +> ```kotlin +> class Bad2 { +> var x: Int = 0 +> set(value) { field = value } // верно +> // set(value) { x = value } // StackOverflowError +> } +> +> fun main() { Bad2().x = 1; println("ok") } +> ``` +> +> **Вычисляемое свойство выполняется при каждом чтении:** +> ```kotlin +> class Slow(val items: List) { +> val max: Int get() = items.also { println("считаем") }.max() +> } +> +> fun main() { +> val s = Slow(listOf(1, 2)) +> println(s.max) +> println(s.max) // "считаем" выведется дважды +> } +> ``` +> Если вычисление дорогое, нужен `by lazy` или хранимое свойство. +> +> **`val` не означает неизменяемость объекта** - только то, что ссылку нельзя переприсвоить: +> ```kotlin +> class Box2 { val items = mutableListOf(1) } +> +> fun main() { +> val b = Box2() +> b.items.add(2) // разрешено +> println(b.items) // [1, 2] +> } +> ``` +> +> **`val` с геттером может возвращать разное:** +> ```kotlin +> class Clock { val now: Long get() = System.nanoTime() } +> +> fun main() { +> val c = Clock() +> println(c.now != c.now) // true — два разных значения +> } +> ``` +> Поэтому умное приведение для таких свойств не работает. Подробнее: [Умные приведения](../Синтаксис%20и%20типы/Null-безопасность/Умные%20приведения.md) +> +> **`lateinit` не работает с примитивами и с `val`.** Для `Int` используйте `Delegates.notNull()` или значение-маркер. +> +> **`lateinit` скрывает незаданную зависимость до рантайма.** Чем раньше объект получает всё необходимое (лучше - в конструкторе), тем меньше шансов на `UninitializedPropertyAccessException`. +> +> **`by lazy` с исключением внутри повторит попытку:** +> ```kotlin +> class Risky { +> var attempts = 0 +> val value: String by lazy { +> attempts++ +> if (attempts < 2) error("первая попытка падает") +> "успех на попытке $attempts" +> } +> } +> +> fun main() { +> val r = Risky() +> runCatching { r.value } +> println(r.value) // успех на попытке 2 +> } +> ``` +> Результат не кешируется, пока лямбда не завершится успешно. +> +> **Свойство-расширение не имеет поля** - состояние к чужому классу добавить нельзя, только вычисление. +> +> **Публичный `var` в API позволяет внешнему коду менять состояние в любой момент.** Часто правильнее `var` с `private set` и явным методом изменения. + +Дополнительно: [Делегирование](Делегирование.md) +Далее: [Инициализация](Инициализация.md) diff --git a/Kotlin/Рефлексия/Аннотации.md b/Kotlin/Рефлексия/Аннотации.md new file mode 100644 index 0000000..14c41c5 --- /dev/null +++ b/Kotlin/Рефлексия/Аннотации.md @@ -0,0 +1,653 @@ +--- +title: Аннотации +description: Объявление аннотаций, цели, retention, use-site targets, чтение через рефлексию, аннотации интеропа +tags: + - kotlin + - annotations + - reflection +--- + +Аннотация - метаданные, прикреплённые к объявлению. Сама по себе она ничего не делает: смысл ей придаёт тот, кто её читает - рефлексия в рантайме, процессор аннотаций в компиляции или сам компилятор. + +```kotlin +import kotlin.reflect.full.findAnnotation + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Table(val name: String, val schema: String = "public") + +@Table("users") +class User(val id: Int, val name: String) + +fun main() { + val table = User::class.findAnnotation() + println("таблица=${table?.name}, схема=${table?.schema}") +} +``` +``` +таблица=users, схема=public +``` + +## Объявление +```kotlin +annotation class Simple + +annotation class WithParams( + val name: String, + val priority: Int = 0, + val tags: Array = [], +) + +fun main() { + println("объявлены") +} +``` +``` +объявлены +``` +Правила для аннотаций жёстче, чем для обычных классов: +- нет тела, нет функций и состояния - только параметры конструктора; +- все параметры - `val`; +- типы параметров ограничены: примитивы, `String`, `KClass`, `enum`, другая аннотация, массивы перечисленного; +- нельзя `null` в качестве значения по умолчанию - сам тип не может быть nullable. + +## Цели - @Target +```kotlin +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.VALUE_PARAMETER, +) +annotation class Marker + +@Marker +class Annotated(@Marker val prop: Int) { + @Marker + fun method() = Unit +} + +fun main() { + println("применено к четырём местам") +} +``` +``` +применено к четырём местам +``` +| Цель | Куда | +|---|---| +| `CLASS` | класс, интерфейс, `object`, аннотация | +| `FUNCTION` | функция, не конструктор | +| `PROPERTY` | свойство (только Kotlin-код) | +| `FIELD` | backing field свойства | +| `VALUE_PARAMETER` | параметр функции или конструктора | +| `CONSTRUCTOR` | конструктор | +| `PROPERTY_GETTER`, `PROPERTY_SETTER` | аксессоры | +| `LOCAL_VARIABLE` | локальная переменная | +| `TYPE` | использование типа: `List<@Marker String>` | +| `TYPE_PARAMETER` | параметр типа | +| `EXPRESSION` | выражение (требует `SOURCE`) | +| `FILE` | файл, через `@file:` | +| `ANNOTATION_CLASS` | мета-аннотация | + +Без `@Target` аннотация применима почти везде, кроме `TYPE`, `EXPRESSION`, `FILE` и `TYPE_PARAMETER` - их нужно указывать явно. + +## Хранение - @Retention +```kotlin +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.CLASS) +annotation class Src + +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS) +annotation class Bin + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS) +annotation class Run + +@Target(AnnotationTarget.CLASS) +annotation class NoRetention + +@Src @Bin @Run +class All + +@NoRetention +class Default + +fun main() { + println("All=${All::class.annotations.map { it.annotationClass.simpleName }}") + println("Default=${Default::class.annotations.map { it.annotationClass.simpleName }}") +} +``` +``` +All=[Run] +Default=[NoRetention] +``` +| Retention | В байт-коде | Видна рефлексии | +|---|---|---| +| `SOURCE` | нет | нет | +| `BINARY` | да | нет | +| `RUNTIME` (по умолчанию) | да | да | + +Ключевое отличие от Java: **по умолчанию `RUNTIME`**, а не `CLASS`. Поэтому `@NoRetention` без явного указания видна через рефлексию - в Java аналогичная аннотация была бы невидима. Для аннотаций, которые читает только KSP или линтер, стоит явно ставить `SOURCE`: это убирает их из байт-кода. + +## Use-site targets +Свойство в Kotlin компилируется в несколько Java-элементов: поле, геттер, иногда сеттер, а для параметра конструктора ещё и параметр. Аннотация должна попасть на конкретный из них - для этого есть префиксы. + +```kotlin +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.javaConstructor +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaGetter +import kotlin.reflect.jvm.javaSetter + +@Target( + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.VALUE_PARAMETER, +) +@Retention(AnnotationRetention.RUNTIME) +annotation class Mark(val where: String) + +class Targets( + @Mark("без префикса") val implicit: Int, + @field:Mark("field") val onField: Int, + @get:Mark("getter") val onGetter: Int, + @property:Mark("property") val onProperty: Int, +) { + @set:Mark("setter") + var mutable: Int = 0 +} + +fun main() { + for (p in Targets::class.memberProperties.sortedBy { it.name }) { + val prop = p.annotations.filterIsInstance().map { it.where } + val field = p.javaField?.annotations?.filterIsInstance()?.map { it.where }.orEmpty() + val getter = p.javaGetter?.annotations?.filterIsInstance()?.map { it.where }.orEmpty() + println("${p.name}: property=$prop field=$field getter=$getter") + } + val ctor = Targets::class.primaryConstructor!!.javaConstructor!! + ctor.parameterAnnotations.forEachIndexed { i, anns -> + println("параметр $i: ${anns.filterIsInstance().map { it.where }}") + } + val mutable = Targets::class.memberProperties.first { it.name == "mutable" } + val setter = (mutable as KMutableProperty1<*, *>).javaSetter + println("setter=${setter?.annotations?.filterIsInstance()?.map { it.where }}") +} +``` +``` +implicit: property=[без префикса] field=[] getter=[] +mutable: property=[] field=[] getter=[] +onField: property=[] field=[field] getter=[] +onGetter: property=[] field=[] getter=[getter] +onProperty: property=[property] field=[] getter=[] +параметр 0: [без префикса] +параметр 1: [] +параметр 2: [] +параметр 3: [] +setter=[setter] +``` +| Префикс | Куда попадёт | +|---|---| +| `@property:` | свойство (видно только Kotlin-рефлексии) | +| `@field:` | backing field | +| `@get:` | геттер | +| `@set:` | сеттер | +| `@param:` | параметр конструктора | +| `@setparam:` | параметр сеттера | +| `@receiver:` | получатель функции-расширения | +| `@delegate:` | поле делегата | +| `@file:` | файл, до объявления `package` | +| `@all:` | все применимые цели сразу | + +### Правило выбора по умолчанию +Без префикса аннотация применяется к **первой подходящей** цели из списка, в порядке: `param` → `property` → `field`. Это проверяется легко: +```kotlin +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.javaConstructor +import kotlin.reflect.jvm.javaField + +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.PROPERTY, AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class Multi + +@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class PropOrField + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class FieldOnly + +class C( + @Multi val a: Int, + @PropOrField val b: Int, + @FieldOnly val c: Int, +) + +fun report(name: String) { + val p = C::class.memberProperties.first { it.name == name } + val onProperty = p.annotations.map { it.annotationClass.simpleName } + val onField = p.javaField?.annotations?.map { it.annotationClass.simpleName }.orEmpty() + val index = C::class.primaryConstructor!!.parameters.indexOfFirst { it.name == name } + val onParam = C::class.primaryConstructor!!.javaConstructor!! + .parameterAnnotations[index].map { it.annotationClass.simpleName } + println("$name: property=$onProperty field=$onField param=$onParam") +} + +fun main() { + report("a") + report("b") + report("c") +} +``` +``` +a: property=[Multi] field=[] param=[Multi] +b: property=[PropOrField] field=[] param=[] +c: property=[] field=[FieldOnly] param=[] +``` +Обратите внимание на `a`: аннотация, применимая и к параметру, и к свойству, попала **на оба** - на параметр конструктора и на Kotlin-свойство (но не на поле). Именно здесь возникают проблемы с Java-библиотеками: Jackson или Hibernate читают поля через Java Reflection, а там аннотации нет - нужен явный `@field:`. + +### @all +```kotlin +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.javaConstructor +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaGetter + +@Target( + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.VALUE_PARAMETER, +) +@Retention(AnnotationRetention.RUNTIME) +annotation class Mark + +class WithAll(@all:Mark val x: Int) + +fun main() { + val p = WithAll::class.memberProperties.first() + println("property=${p.annotations.size}") + println("field=${p.javaField?.annotations?.size}") + println("getter=${p.javaGetter?.annotations?.size}") + val params = WithAll::class.primaryConstructor!!.javaConstructor!!.parameterAnnotations + println("param=${params[0].size}") +} +``` +``` +property=1 +field=1 +getter=1 +param=1 +``` +`@all:` - решение давней проблемы: раньше приходилось писать `@field:Mark @get:Mark @param:Mark` подряд. В Kotlin 2.4 работает без дополнительных флагов компилятора. + +## Параметры аннотаций +```kotlin +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation + +interface Validator +class NotBlank : Validator +class MaxLength : Validator + +enum class Mode { FAST, SAFE } + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Validators(val value: Array>) + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Levels(val value: IntArray, val names: Array) + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Nested(val inner: Levels) + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class WithEnum(val mode: Mode) + +@Validators([NotBlank::class, MaxLength::class]) +@Levels(value = [1, 2, 3], names = ["low", "mid", "high"]) +@Nested(Levels([9], ["x"])) +@WithEnum(Mode.SAFE) +class Configured + +fun main() { + val kc = Configured::class + println("классы=${kc.findAnnotation()!!.value.map { it.simpleName }}") + val levels = kc.findAnnotation()!! + println("IntArray=${levels.value.toList()} names=${levels.names.toList()}") + println("вложенная=${kc.findAnnotation()!!.inner.value.toList()}") + println("enum=${kc.findAnnotation()!!.mode}") +} +``` +``` +классы=[NotBlank, MaxLength] +IntArray=[1, 2, 3] names=[low, mid, high] +вложенная=[9] +enum=SAFE +``` +Массивы задаются литералом `[a, b]` - старый синтаксис `arrayOf(a, b)` тоже работает, но литерал короче. Классы передаются как `KClass`, то есть `Foo::class`, а не `Foo::class.java`. + +## Значения по умолчанию и equals +```kotlin +import kotlin.reflect.full.findAnnotation + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class HasDefaults(val a: Int = 1, val b: String = "x", val c: IntArray = [1, 2]) + +@HasDefaults +class WithDefaults + +@HasDefaults(a = 5) +class Partial + +fun main() { + val d = WithDefaults::class.findAnnotation()!! + println("дефолты a=${d.a} b=${d.b} c=${d.c.toList()}") + val p = Partial::class.findAnnotation()!! + println("частично a=${p.a} b=${p.b}") + println("toString=$p") + println("равенство=${d == WithDefaults::class.findAnnotation()}") +} +``` +``` +дефолты a=1 b=x c=[1, 2] +частично a=5 b=x +toString=@HasDefaults(a=5, b="x", c={1, 2}) +равенство=true +``` +Экземпляры аннотаций сравниваются по значениям параметров, а `toString` даёт читаемое представление с подставленными дефолтами - удобно для отладки. + +## Повторяемые аннотации +```kotlin +import kotlin.reflect.full.findAnnotations + +@Repeatable +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Tag(val value: String) + +@Tag("a") +@Tag("b") +class Tagged + +fun main() { + println("все теги=${Tagged::class.findAnnotations().map { it.value }}") + println("через annotations=${Tagged::class.annotations.filterIsInstance().map { it.value }}") +} +``` +``` +все теги=[a, b] +через annotations=[a, b] +``` +В Java для повторяемых аннотаций нужен явный контейнер (`@Repeatable(Tags.class)` плюс класс `Tags` с массивом). В Kotlin достаточно `@Repeatable`, а для чтения есть `findAnnotations()`, возвращающий список. + +## Чтение через рефлексию +```kotlin +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Table(val name: String) + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class Column(val name: String, val nullable: Boolean = false) + +@Table("users") +class User( + @Column("user_id") val id: Int, + @Column("full_name", nullable = true) val name: String?, +) + +fun main() { + val kc = User::class + println("hasAnnotation=${kc.hasAnnotation
()}") + println("findAnnotation=${kc.findAnnotation
()?.name}") + for (p in kc.memberProperties.sortedBy { it.name }) { + val column = p.findAnnotation() + println("${p.name}: column=${column?.name} nullable=${column?.nullable}") + } +} +``` +``` +hasAnnotation=true +findAnnotation=users +id: column=user_id nullable=false +name: column=full_name nullable=true +``` +| Функция | Что возвращает | +|---|---| +| `annotations` | список всех `RUNTIME`-аннотаций элемента | +| `findAnnotation()` | первую аннотацию типа `T` или `null` | +| `findAnnotations()` | все аннотации типа `T` (для `@Repeatable`) | +| `hasAnnotation()` | есть ли аннотация | +| `annotationClass` | `KClass` самой аннотации | + +Эти функции есть у `KClass`, `KProperty`, `KFunction` и `KParameter` - интерфейс единый. Подробнее: [Рефлексия](Рефлексия.md) + +## Практический пример: валидация +```kotlin +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class NotBlank(val message: String = "не должно быть пустым") + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class Range(val min: Int, val max: Int) + +data class Form( + @NotBlank val name: String, + @NotBlank("email обязателен") val email: String, + @Range(18, 120) val age: Int, +) + +fun validate(obj: Any): List { + val errors = mutableListOf() + for (p in obj::class.memberProperties) { + p.isAccessible = true + val value = p.call(obj) + p.findAnnotation()?.let { rule -> + if ((value as? String).isNullOrBlank()) errors += "${p.name}: ${rule.message}" + } + p.findAnnotation()?.let { rule -> + val number = value as? Int ?: return@let + if (number !in rule.min..rule.max) { + errors += "${p.name}: должно быть от ${rule.min} до ${rule.max}" + } + } + } + return errors.sorted() +} + +fun main() { + println(validate(Form("Иван", "ivan@example.com", 30))) + println(validate(Form("", "", 5))) +} +``` +``` +[] +[age: должно быть от 18 до 120, email: email обязателен, name: не должно быть пустым] +``` +Это рабочая схема, но она платит рефлексией в рантайме: разбор метаданных при первом обращении и потеря проверок на этапе компиляции. Тот же результат даёт KSP-процессор, генерирующий код валидации при сборке. Подробнее: [KSP](../Инструментарий/KSP.md) + +## Аннотации компилятора +Часть аннотаций обрабатывает сам компилятор - они меняют генерируемый код или проверки. + +| Аннотация | Что делает | +|---|---| +| `@Deprecated` | предупреждение или ошибка при использовании | +| `@Suppress("NAME")` | подавляет предупреждение в области применения | +| `@JvmName` | имя в байт-коде | +| `@JvmStatic` | статический метод для членов `companion object` | +| `@JvmOverloads` | перегрузки для параметров со значениями по умолчанию | +| `@JvmField` | публичное поле вместо геттера | +| `@JvmInline` | обязательна для `value class` | +| `@Throws` | добавляет `throws` в сигнатуру для Java | +| `@OptIn`, `@RequiresOptIn` | механизм согласия на нестабильный API | +| `@PublishedApi` | доступ к `internal` из `public inline` | +| `@Volatile`, `@Synchronized` | то же, что в Java | +| `@Transient` | исключение из сериализации | +| `@DslMarker` | ограничение области в DSL | +| `@BuilderInference` | вывод типа в билдерах | + +```kotlin +import kotlin.reflect.full.findAnnotation + +@Deprecated("используйте new()", ReplaceWith("new()"), DeprecationLevel.WARNING) +fun old() = 1 + +fun new() = 2 + +fun main() { + val d = ::old.findAnnotation() + println("message=${d?.message}") + println("level=${d?.level}") + println("replaceWith=${d?.replaceWith?.expression}") +} +``` +``` +message=используйте new() +level=WARNING +replaceWith=new() +``` +`ReplaceWith` понимает IDE: она предлагает автозамену вызова. `DeprecationLevel.ERROR` делает использование ошибкой компиляции, `HIDDEN` убирает объявление из автодополнения, сохраняя бинарную совместимость. + +Аннотации интеропа меняют байт-код заметно: +```kotlin +@file:JvmName("InteropUtils") + +import java.io.IOException +import java.lang.reflect.Modifier + +class Interop { + @Throws(IOException::class) + fun mayThrow() = Unit + + @JvmField + val field = 1 + + @get:JvmName("fetchValue") + val value = 2 + + @JvmOverloads + fun withDefaults(a: Int, b: Int = 1, c: Int = 2) = a + b + c + + companion object { + @JvmStatic + fun staticFn() = 3 + } +} + +fun main() { + val c = Interop::class.java + println("методы=${c.declaredMethods.map { it.name }.sorted()}") + println("поля=${c.declaredFields.map { it.name }.sorted()}") + println("throws=${c.getDeclaredMethod("mayThrow").exceptionTypes.map { it.simpleName }}") + println("статические=${c.declaredMethods.filter { Modifier.isStatic(it.modifiers) }.map { it.name }.sorted()}") +} +``` +``` +методы=[fetchValue, mayThrow, staticFn, withDefaults, withDefaults, withDefaults, withDefaults$default] +поля=[Companion, field, value] +throws=[IOException] +статические=[staticFn, withDefaults$default] +``` +`@JvmOverloads` дал три перегрузки `withDefaults`, `@get:JvmName` переименовал геттер в `fetchValue`, `@JvmField` превратил свойство в поле без геттера. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) + +## Аннотации для KSP +Аннотация, предназначенная процессору, не нужна в рантайме: +```kotlin +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +annotation class GenerateBuilder + +@GenerateBuilder +data class Person(val name: String, val age: Int) + +fun main() { + println("в рантайме=${Person::class.annotations.size}") +} +``` +``` +в рантайме=0 +``` +`SOURCE` убирает аннотацию из байт-кода полностью - KSP читает исходники, а не классы. Это уменьшает размер артефакта и исключает случайное чтение рефлексией. Подробнее: [KSP](../Инструментарий/KSP.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `@interface Foo { String value(); }` | `annotation class Foo(val value: String)` | +> | `String value() default "x"` | `val value: String = "x"` | +> | retention по умолчанию `CLASS` | по умолчанию `RUNTIME` | +> | `@Target(ElementType.FIELD)` | `@Target(AnnotationTarget.FIELD)` | +> | нет понятия свойства | `@property:`, `@field:`, `@get:`, `@set:` | +> | `@Repeatable(Container.class)` + контейнер | просто `@Repeatable` | +> | `Foo.class` в параметре | `Foo::class` | +> | `{1, 2}` для массива | `[1, 2]` | +> | `getAnnotation(Foo.class)` | `findAnnotation()` | +> | аннотации на файл невозможны | `@file:JvmName(...)` | +> +> **Use-site targets существуют потому, что свойство - не поле.** В Java `@Column private String name` однозначна: цель одна. В Kotlin `val name: String` порождает поле, геттер и Kotlin-свойство как отдельную сущность в метаданных, а в конструкторе ещё и параметр - аннотация должна выбрать одну цель. По умолчанию выбирается первая применимая в порядке `param` → `property` → `field`, и это главная причина, по которой Java-библиотеки «не видят» аннотацию: Jackson читает поле, а аннотация оказалась на параметре. Префикс `@field:` или новый `@all:` решают проблему. Подробнее: [Свойства](../ООП/Свойства.md) +> +> **Retention по умолчанию - `RUNTIME`, а не `CLASS`.** Java по умолчанию не включает аннотацию в рантайм, поэтому каждая библиотека обязана ставить `@Retention(RUNTIME)` явно. Kotlin выбрал обратный дефолт - аннотации чаще читают рефлексией, чем процессором. Обратная сторона: аннотация для линтера без явного `SOURCE` попадёт и в байт-код, и в рантайм, увеличивая артефакт. +> +> **Повторяемость не требует класса-контейнера.** В Java `@Repeatable` обязывает объявить отдельную аннотацию-контейнер с массивом - лишний код, который приходится писать для каждой повторяемой аннотации. Kotlin генерирует контейнер сам; `findAnnotations()` возвращает список, и раскрывать контейнер вручную не нужно. +> +> **Аннотации на файл - новая цель, которой в Java нет.** `@file:JvmName("Utils")` меняет имя класса-контейнера для функций верхнего уровня, `@file:Suppress` подавляет предупреждения во всём файле. В Java единица объявления - всегда класс, поэтому и цели `FILE` быть не может. Подробнее: [Пакеты и импорты](../Синтаксис%20и%20типы/Базовый%20синтаксис/Пакеты%20и%20импорты.md) +> +> **Генерация кода вместо рефлексии - предпочтительный путь.** В Java-экосистеме аннотации почти всегда читаются рефлексией: Spring, Jackson, Hibernate строят модель при старте приложения. Kotlin подталкивает к другому: `kotlinx.serialization` и KSP обрабатывают аннотации при компиляции и генерируют код, поэтому `kotlin-reflect` не нужен, старт быстрее, а ошибки видны при сборке. Для Kotlin/Native и Kotlin/JS это единственный работающий вариант. Подробнее: [KSP](../Инструментарий/KSP.md) +> +> Сравнить: [Reflection API в Java](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md) + +> [!warning] Подводные камни +> **Аннотация без префикса может не попасть туда, куда нужно Java-библиотеке.** Проверено: аннотация с целями `VALUE_PARAMETER` и `PROPERTY` на `val` в конструкторе попадает на параметр и Kotlin-свойство, но **не на поле** - Jackson, Hibernate и JPA её не увидят. Нужен `@field:` или `@all:`. +> +> **`@property:` видна только Kotlin-рефлексии.** В Java-представлении такой аннотации нет вообще: она хранится в метаданных Kotlin. Библиотека на Java Reflection её не найдёт никогда. +> +> **Retention по умолчанию `RUNTIME` раздувает байт-код.** Аннотации для KSP, линтеров и внутренних проверок стоит помечать `@Retention(AnnotationRetention.SOURCE)` - иначе они остаются в классах и видны рефлексии без необходимости. +> +> **`BINARY` не видна рефлексии, хотя лежит в байт-коде.** Проверено: из `@Src @Bin @Run` рефлексия вернёт только `Run`. Это частая причина «аннотация есть в коде, а `findAnnotation` даёт `null`». +> +> **`null` в качестве значения аннотации невозможен.** Типы параметров не могут быть nullable, поэтому «отсутствие значения» кодируют пустой строкой или сигнальной константой. Это ограничение формата аннотаций в JVM, а не Kotlin. +> +> **Параметры аннотаций ограничены по типам.** `data class`, коллекции, `Duration` и функции использовать нельзя - только примитивы, `String`, `KClass`, `enum`, другая аннотация и массивы из них. Попытка передать `List` вместо `Array` не скомпилируется. +> +> **`KClass` в параметре, а не `Class`.** `@Validators([NotBlank::class])` - правильно, `NotBlank::class.java` в аннотации не примут. При чтении обратно получается `KClass`, и для Java-API нужен `.java`. +> +> **`@JvmStatic` работает только в `companion object` и `object`.** На обычном методе класса даёт ошибку компиляции - статических методов у Kotlin-классов не бывает. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) +> +> **`@JvmOverloads` генерирует перегрузки только справа налево.** Для `f(a, b = 1, c = 2)` появятся `f(a)`, `f(a, b)`, `f(a, b, c)` - вызвать `f(a, c)` из Java не получится. Проверено на реальном байт-коде. +> +> **`@Throws` обязательна для Java-вызывающих.** Kotlin не имеет проверяемых исключений, поэтому без `@Throws` Java-код не сможет написать `catch (IOException e)` - компилятор Java сообщит, что исключение не бросается. Подробнее: [Система исключений](../Исключения/Система%20исключений.md) +> +> **Аннотации на типах требуют явной цели `TYPE`.** `List<@Marker String>` не скомпилируется, если в `@Target` нет `AnnotationTarget.TYPE` - по умолчанию эта цель не включена. +> +> **`@Target(AnnotationTarget.EXPRESSION)` работает только с `SOURCE`.** Выражения не существуют в байт-коде, поэтому `RUNTIME` с такой целью даст ошибку компиляции. +> +> **`@all:` - новая возможность и ломает сборку на старых версиях Kotlin.** В 2.4 работает без флагов, в более ранних версиях требует `-Xannotation-target-all` или недоступна вовсе. Для библиотек с широкой совместимостью пока надёжнее перечислять цели вручную. +> +> **Чтение аннотаций рефлексией стоит дорого при первом обращении.** Это тот же разбор метаданных, что и у остальной рефлексии: ~150 мс на класс впервые. Кешируйте результат, а лучше переносите обработку в KSP. Подробнее: [Рефлексия](Рефлексия.md) +> +> **Обфускация ломает аннотации с именами в строках.** `@Column("user_id")` переживёт R8, а вот поиск свойства по имени из аннотации - нет: само свойство переименуется. Нужны правила `-keep`. + +Далее: [Kotlin](../Kotlin.md) diff --git a/Kotlin/Рефлексия/Рефлексия.md b/Kotlin/Рефлексия/Рефлексия.md new file mode 100644 index 0000000..ffd63f0 --- /dev/null +++ b/Kotlin/Рефлексия/Рефлексия.md @@ -0,0 +1,505 @@ +--- +title: Рефлексия +description: KClass, KProperty, KFunction, kotlin-reflect, ссылки на члены, интероп с Java Reflection +tags: + - kotlin + - reflection +--- + +Рефлексия - доступ к структуре программы во время выполнения: какие у класса свойства, какие параметры у функции, как вызвать то, что не известно на этапе компиляции. Kotlin даёт собственный API (`kotlin.reflect`) поверх Java Reflection, потому что в байт-коде не видно ни свойств, ни `suspend`, ни значений по умолчанию. + +```kotlin +import kotlin.reflect.full.memberProperties + +data class User(val id: Int, var name: String) + +fun main() { + val user = User(1, "Иван") + User::class.memberProperties + .sortedBy { it.name } + .forEach { println("${it.name} = ${it.call(user)}") } +} +``` +``` +id = 1 +name = Иван +``` + +## Два уровня API +Kotlin-рефлексия разделена на две части, и это важно практически. + +```kotlin +// Доступно всегда, только kotlin-stdlib +import kotlin.reflect.KClass + +enum class Status { OK } +data class P(val x: Int) + +fun main() { + println(Status::class.simpleName) + println(P::class.qualifiedName) + println(P::class.java.name) + println(P::class.isInstance(P(1))) +} +``` +``` +Status +P +P +true +``` +Без `kotlin-reflect` в classpath работают только `simpleName`, `qualifiedName`, `isInstance`, `java` и несколько похожих свойств. Всё остальное - `memberProperties`, `primaryConstructor`, `sealedSubclasses` - бросает `KotlinReflectionNotSupportedError`: +``` +Exception in thread "main" kotlin.jvm.KotlinReflectionNotSupportedError: +Kotlin reflection implementation is not found at runtime. +Make sure you have kotlin-reflect.jar in the classpath +``` + +Подключается отдельно: +```kotlin +// build.gradle.kts +dependencies { + implementation(kotlin("reflect")) +} +``` +`kotlin-reflect` весит около 3 МБ и тянет собственный парсер метаданных - для Android и serverless это значимо. Подробнее: [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md) + +## KClass +```kotlin +import kotlin.reflect.full.companionObject +import kotlin.reflect.full.declaredMemberFunctions +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor + +data class User(val id: Int, var name: String) { + val display: String get() = "#$id $name" + fun greet(greeting: String): String = "$greeting, $name" + companion object { const val TABLE = "users" } +} + +fun main() { + val kc = User::class + println("simpleName=${kc.simpleName}") + println("isData=${kc.isData}") + println("isFinal=${kc.isFinal}") + println("visibility=${kc.visibility}") + println("primary params=${kc.primaryConstructor!!.parameters.map { it.name }}") + println("memberProperties=${kc.memberProperties.map { it.name }.sorted()}") + println("функции=${kc.declaredMemberFunctions.map { it.name }.sorted()}") + println("companionObject=${kc.companionObject?.simpleName}") +} +``` +``` +simpleName=User +isData=true +isFinal=true +visibility=PUBLIC +primary params=[id, name] +memberProperties=[display, id, name] +функции=[component1, component2, copy, equals, greet, hashCode, toString] +companionObject=Companion +``` +Видно, что `data class` генерирует `componentN`, `copy`, `equals`, `hashCode`, `toString` - рефлексия показывает их наравне с написанными руками. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +| Свойство `KClass` | Что даёт | +|---|---| +| `simpleName`, `qualifiedName` | имена; `null` для локальных и анонимных | +| `isData`, `isSealed`, `isInner`, `isCompanion`, `isValue`, `isFun` | характер класса | +| `isAbstract`, `isFinal`, `isOpen` | модальность | +| `visibility` | `PUBLIC`, `INTERNAL`, `PROTECTED`, `PRIVATE` | +| `constructors`, `primaryConstructor` | конструкторы | +| `memberProperties`, `declaredMemberProperties` | свойства с унаследованными и без | +| `memberFunctions`, `declaredMemberFunctions` | функции | +| `supertypes`, `superclasses` | иерархия | +| `sealedSubclasses` | подклассы `sealed`-типа | +| `nestedClasses` | вложенные классы | +| `objectInstance` | экземпляр `object`, иначе `null` | +| `companionObject`, `companionObjectInstance` | компаньон | +| `typeParameters` | параметры типа | +| `java` | мост к `Class` | + +### sealed, enum, object +```kotlin +import kotlin.reflect.full.isSubclassOf + +sealed interface Shape { + data class Circle(val r: Double) : Shape + data class Square(val a: Double) : Shape +} + +enum class Status { OK, FAIL } + +object Single { val x = 1 } + +open class Base +class Derived : Base() + +fun main() { + println("sealedSubclasses=${Shape::class.sealedSubclasses.map { it.simpleName }}") + println("enum values=${Status::class.java.enumConstants.toList()}") + println("object instance=${Single::class.objectInstance?.x}") + println("supertypes=${Derived::class.supertypes}") + println("isSubclassOf=${Derived::class.isSubclassOf(Base::class)}") +} +``` +``` +sealedSubclasses=[Circle, Square] +enum values=[OK, FAIL] +object instance=1 +supertypes=[Base] +isSubclassOf=true +``` +`sealedSubclasses` даёт полный список вариантов - это основа для сериализаторов и фабрик по типу. Подробнее: [Sealed class и interface](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +## Ссылки на члены +```kotlin +import kotlin.reflect.KFunction2 +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.declaredMemberFunctions + +data class User(val id: Int, var name: String) { + fun greet(greeting: String): String = "$greeting, $name" +} + +fun topLevel(a: Int, b: String = "по умолчанию"): String = "$a-$b" + +fun main() { + val user = User(1, "Иван") + + // свойство как объект + @Suppress("UNCHECKED_CAST") + val nameProp = User::class.memberProperties + .first { it.name == "name" } as KMutableProperty1 + println("get=${nameProp.get(user)}") + nameProp.set(user, "Пётр") + println("после set=${user.name}") + + // функция класса + val greet = User::class.declaredMemberFunctions.first { it.name == "greet" } + println("call=${greet.call(user, "Привет")}") + + // ссылка на функцию верхнего уровня + val ref: KFunction2 = ::topLevel + println("name=${ref.name}, параметров=${ref.parameters.size}") + println("call=${ref.call(1, "b")}") + println("callBy с дефолтом=${ref.callBy(mapOf(ref.parameters[0] to 5))}") +} +``` +``` +get=Иван +после set=Пётр +call=Привет, Пётр +name=topLevel, параметров=2 +call=1-b +callBy с дефолтом=5-по умолчанию +``` +| Тип | Что представляет | +|---|---| +| `KProperty0` | свойство без получателя: `::topLevelVal`, `obj::prop` | +| `KProperty1` | свойство класса: `User::name` | +| `KProperty2` | свойство-расширение с двумя получателями | +| `KMutableProperty1` | то же для `var` - добавляет `set` | +| `KFunction` | функция; `KFunction1`, `KFunction2`, ... по арности | +| `KParameter` | параметр: `name`, `type`, `isOptional`, `kind` | +| `KType` | тип с аргументами и `isMarkedNullable` | + +`call` и `callBy` различаются принципиально: `call` требует все аргументы по порядку, `callBy` принимает `Map` и **учитывает значения по умолчанию** для отсутствующих. Значения по умолчанию в байт-коде не видны, поэтому через Java Reflection `callBy` не воспроизвести. Подробнее: [Функции](../Синтаксис%20и%20типы/Работа%20с%20данными/Функции.md) + +### Приватные члены +```kotlin +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.jvm.isAccessible + +class Hidden { + private val secret = 42 +} + +fun main() { + val prop = Hidden::class.declaredMemberProperties.first { it.name == "secret" } + + val error = runCatching { prop.call(Hidden()) }.exceptionOrNull() + println("без isAccessible: ${error!!::class.simpleName}") + + prop.isAccessible = true + println("с isAccessible: ${prop.call(Hidden())}") +} +``` +``` +без isAccessible: IllegalCallableAccessException +с isAccessible: 42 +``` +`isAccessible = true` - это Kotlin-обёртка над `setAccessible` из Java. На JDK 17+ она может упираться в строгую инкапсуляцию модулей для классов стандартной библиотеки; для собственного кода работает всегда. + +## Типы +```kotlin +import kotlin.reflect.full.memberProperties +import kotlin.reflect.typeOf + +class G { + val field: List> = emptyList() +} + +fun nullableParam(x: String?) = x + +fun main() { + val t = typeOf>>() + println("typeOf=$t") + println("classifier=${t.classifier}") + println("аргументы=${t.arguments}") + + val prop = G::class.memberProperties.first() + println("тип свойства=${prop.returnType}") + println("вложенный аргумент=${prop.returnType.arguments[0].type}") + + val p = ::nullableParam.parameters[0] + println("nullable=${p.type.isMarkedNullable}") +} +``` +``` +typeOf=kotlin.collections.List> +classifier=class kotlin.collections.List +аргументы=[kotlin.collections.Map] +тип свойства=kotlin.collections.List> +вложенный аргумент=kotlin.collections.Map +nullable=true +``` +`KType` сохраняет аргументы типов и признак nullable - эта информация берётся из метаданных Kotlin, а не из байт-кода. Но это **статическая** информация: тип объявления, а не фактический тип значения. + +```kotlin +fun main() { + val list: List = listOf("a") + println("класс=${list::class.simpleName}") + println("параметры типа=${list::class.typeParameters.map { it.name }}") +} +``` +``` +класс=SingletonList +параметры типа=[E] +``` +У значения узнать аргумент типа нельзя - дженерики стираются. `list::class` даёт лишь имя параметра `E`, а не `String`. Для сохранения типа нужен `reified` на этапе компиляции. Подробнее: [Reified и inline](../Дженерики/Reified%20и%20inline.md) + +## Делегаты +```kotlin +import kotlin.reflect.KProperty0 +import kotlin.reflect.jvm.isAccessible + +class Lazily { + val value: String by lazy { "вычислено" } +} + +fun main() { + val obj = Lazily() + val prop: KProperty0 = obj::value + prop.isAccessible = true + + println("делегат до чтения=${prop.getDelegate()}") + println("значение=${obj.value}") + println("делегат после=${prop.getDelegate()}") +} +``` +``` +делегат до чтения=Lazy value not initialized yet. +значение=вычислено +делегат после=вычислено +``` +`getDelegate()` возвращает сам объект-делегат, а не значение свойства - для `by lazy` это `Lazy`, чей `toString` до инициализации сообщает «not initialized yet». Это единственный способ узнать состояние ленивого свойства, не вычисляя его. Подробнее: [Делегирование](../ООП/Делегирование.md) + +## Интероп с Java Reflection +```kotlin +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.javaConstructor +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaMethod +import kotlin.reflect.jvm.kotlinFunction +import kotlin.reflect.full.memberProperties + +data class Point(val x: Int, val y: Int) + +class Target { + lateinit var late: String + val lazyProp: Int by lazy { 7 } +} + +fun topLevel(a: Int): Int = a + +fun main() { + println("KClass -> Class=${Point::class.java.name}") + println("Class -> KClass=${Point::class.java.kotlin.simpleName}") + println("javaMethod=${::topLevel.javaMethod?.name}") + println("Method -> KFunction=${::topLevel.javaMethod?.kotlinFunction?.name}") + println("javaConstructor=${Point::class.primaryConstructor?.javaConstructor}") + + val late = Target::class.memberProperties.first { it.name == "late" } + println("lateinit isLateinit=${late.isLateinit}, javaField=${late.javaField?.name}") + + val lz = Target::class.memberProperties.first { it.name == "lazyProp" } + println("lazy javaField=${lz.javaField?.name}") +} +``` +``` +KClass -> Class=Point +Class -> KClass=Point +javaMethod=topLevel +Method -> KFunction=topLevel +javaConstructor=public Point(int,int) +lateinit isLateinit=true, javaField=late +lazy javaField=lazyProp$delegate +``` +| Переход | Как | +|---|---| +| `KClass` → `Class` | `kc.java` | +| `Class` → `KClass` | `cls.kotlin` | +| `KFunction` → `Method` | `fn.javaMethod` | +| `KFunction` → `Constructor` | `fn.javaConstructor` | +| `KProperty` → `Field` | `prop.javaField` | +| `KProperty` → геттер/сеттер | `prop.javaGetter`, `prop.javaSetter` | +| `Method` → `KFunction` | `method.kotlinFunction` | +| `Field` → `KProperty` | `field.kotlinProperty` | + +Поле делегированного свойства называется `имя$delegate` - это важно для библиотек, которые ищут поля по имени. Подробнее: [Kotlin в байт-коде](../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) + +### Что Java Reflection не видит +```kotlin +class Visibility { + private val hidden = 1 + internal val internalProp = 2 + val open = 3 +} + +fun main() { + println("java методы=${Visibility::class.java.declaredMethods.map { it.name }.sorted()}") + println("java поля=${Visibility::class.java.declaredFields.map { it.name }.sorted()}") +} +``` +``` +java методы=[getInternalProp$main, getOpen] +java поля=[hidden, internalProp, open] +``` +Через Java API видны только геттеры (`private val` вообще не имеет метода), а `internal` превращается в `public` с манглированным именем `getInternalProp$main`. Kotlin-рефлексия показывает исходную картину: три свойства с видимостями `PRIVATE`, `INTERNAL`, `PUBLIC`. + +## Практический пример: маппинг +```kotlin +import kotlin.reflect.KClass +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor + +data class Config(val host: String, val port: Int, val debug: Boolean) + +fun toMap(obj: Any): Map = + obj::class.memberProperties.associate { it.name to it.call(obj) } + +fun fromMap(kClass: KClass, map: Map): T { + val ctor = requireNonNull(kClass.primaryConstructor) + val args = ctor.parameters + .filter { map.containsKey(it.name) || !it.isOptional } + .associateWith { map[it.name] } + return ctor.callBy(args) +} + +private fun requireNonNull(value: T?): T = + requireNotNull(value) { "у класса нет первичного конструктора" } + +fun main() { + val config = Config("localhost", 8080, true) + println(toMap(config).toSortedMap()) + + val restored = fromMap( + Config::class, + mapOf("host" to "example.com", "port" to 443, "debug" to false), + ) + println("восстановлено=$restored") +} +``` +``` +{debug=true, host=localhost, port=8080} +восстановлено=Config(host=example.com, port=443, debug=false) +``` +Фильтр по `isOptional` позволяет не передавать параметры со значениями по умолчанию - этого достаточно для простого маппера конфигурации. Для сериализации в продакшене лучше взять `kotlinx.serialization`: она генерирует код на этапе компиляции и работает без `kotlin-reflect`. + +## Производительность +Измерено на 200 000 обращений к свойству `data class`: +``` +прямой доступ мс=2 +через рефлексию мс=9 +замедление раз=3 +``` +Само обращение медленнее в несколько раз - для большинства задач приемлемо. Дороже стоит **первое** обращение к метаданным: +``` +первое обращение к memberProperties мс=158 +второе обращение мкс=16 +``` +158 миллисекунд против 16 микросекунд: `kotlin-reflect` при первом запросе разбирает аннотацию `@Metadata` класса и строит модель. Отсюда простое правило: результаты `memberProperties` и `primaryConstructor` нужно кешировать, а не запрашивать в цикле. + +Для многих задач рефлексия вообще не нужна: +| Задача | Альтернатива | +|---|---| +| сериализация | `kotlinx.serialization` - генерация в компиляции | +| DI | ручная сборка или KSP-генерация | +| валидация по аннотациям | KSP-процессор | +| копирование с изменением | `copy()` у `data class` | +| вызов по имени | `Map Unit>` | +| проверка типа | `is` и `when` по `sealed` | + +Подробнее: [KSP](../Инструментарий/KSP.md) + +> [!info] Отличия от Java +> | Java Reflection | kotlin.reflect | +> |---|---| +> | `Class` | `KClass` | +> | `Field` + `Method` раздельно | `KProperty` как единое целое | +> | `Method` | `KFunction` | +> | в JDK, доступна всегда | отдельная зависимость `kotlin-reflect` | +> | значения по умолчанию не видны | `callBy` их учитывает | +> | nullability не видна | `KType.isMarkedNullable` | +> | аргументы типов стёрты | `KType.arguments` из метаданных | +> | `setAccessible(true)` | `isAccessible = true` | +> | `getSuperclass()` | `supertypes`, `superclasses` | +> | подклассы `sealed` не перечислить | `sealedSubclasses` | +> +> **Свойство - первичная сущность, а не пара «поле + методы».** В Java `private String name` с геттером и сеттером - это `Field` и два `Method`, никак формально не связанные: код, обходящий бин, вынужден угадывать соответствие по именам (`getName` → `name`) и спотыкаться на `isActive` для `boolean`. `KProperty` представляет свойство целиком, с `get`, `set` (у `KMutableProperty`), типом и видимостью; переходы к Java-представлению есть, но нужны редко. Подробнее: [Свойства](../ООП/Свойства.md) +> +> **Метаданные Kotlin хранят то, чего в байт-коде нет.** Компилятор пишет в класс аннотацию `@Metadata` с описанием исходной структуры: nullability, аргументы типов, `suspend`, значения по умолчанию, `internal`-видимость, имена параметров. Java Reflection этого не видит и показывает результат компиляции - `getInternalProp$main` вместо `internal val internalProp`, `Object f(int, Continuation)` вместо `suspend fun f(x: Int): String`. Цена - 3 МБ `kotlin-reflect` и разбор метаданных при первом обращении. Подробнее: [Kotlin в байт-коде](../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) +> +> **Рефлексия не входит в стандартную библиотеку, и это осознанное решение.** В Java рефлексия доступна в любой программе - её используют Spring, Jackson, Hibernate, и платить за неё приходится всем. Kotlin целится и в Android с Kotlin/Native, где 3 МБ и динамический разбор метаданных неприемлемы, поэтому полная рефлексия вынесена в отдельный артефакт и на Native отсутствует вовсе. Это подталкивает к генерации кода: `kotlinx.serialization` и KSP решают те же задачи в компиляции. Подробнее: [KSP](../Инструментарий/KSP.md) +> +> **`callBy` возможен только благодаря метаданным.** Значения по умолчанию в Java-байт-коде превращаются в набор перегрузок или в синтетический метод с битовой маской - восстановить «вызови конструктор, остальное по умолчанию» через `Constructor.newInstance` нельзя. `callBy` принимает карту известных параметров и подставляет дефолты сам, что делает рефлективное создание объектов из `Map` практичным. +> +> Сравнить: [Reflection API в Java](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md) + +> [!warning] Подводные камни +> **Без `kotlin-reflect` в classpath большая часть API падает в рантайме.** `KotlinReflectionNotSupportedError` возникает при обращении к `memberProperties`, `primaryConstructor`, `sealedSubclasses` - компилятор ошибки не покажет, потому что объявления берутся из stdlib. Добавляйте `implementation(kotlin("reflect"))`. +> +> **Первое обращение к метаданным класса стоит ~150 мс.** Измерено: `memberProperties` впервые - 158 мс, повторно - 16 мкс. В цикле или на горячем пути это катастрофа. Кешируйте `KClass`-производные в `val` на уровне объекта или в `Map`. +> +> **`memberProperties` включает унаследованные, `declaredMemberProperties` - нет.** Путаница этих двух даёт либо лишние поля родителя при сериализации, либо потерю унаследованных. Выбирайте осознанно. +> +> **Доступ к `private` без `isAccessible = true` бросает `IllegalCallableAccessException`.** Это отдельный тип, не `IllegalAccessException` из Java - обработчики, ловящие только Java-исключение, его пропустят. +> +> **`isAccessible = true` может не сработать на JDK 17+.** Строгая инкапсуляция модулей блокирует доступ к внутренностям JDK и закрытых модулей; для своего кода ограничений нет, для чужих библиотек может потребоваться `--add-opens`. +> +> **У локальных и анонимных классов `qualifiedName` равен `null`.** У анонимного объекта `simpleName` тоже `null` - проверено. Код вида `kc.qualifiedName!!` падает именно на них, причём обычно в тестах, где анонимные объекты используются чаще. +> +> **Дженерики стираются, и фактический аргумент типа у значения недоступен.** `list::class.typeParameters` даёт `[E]`, а не `[String]`. Сохранить тип можно только через `reified` в момент компиляции или передавая `KType` параметром. Подробнее: [Reified и inline](../Дженерики/Reified%20и%20inline.md) +> +> **`KType` отражает объявленный тип, а не фактический.** У свойства типа `Any`, в котором лежит `String`, `returnType` останется `kotlin.Any` - для определения фактического нужен `value::class`. +> +> **`call` игнорирует значения по умолчанию, `callBy` - нет.** `ctor.call("a")` на конструкторе с тремя параметрами бросит `IllegalArgumentException` даже если два имеют дефолты. Для частичных аргументов нужен `callBy` с `Map`. +> +> **`callBy` с `null` для необязательного параметра передаст именно `null`, а не дефолт.** Ключ, присутствующий в карте, считается заданным; чтобы использовать значение по умолчанию, параметр нужно **не включать** в карту вовсе. +> +> **Поле делегированного свойства называется `имя$delegate`.** `javaField?.name` для `val x by lazy { }` даст `x$delegate`, а не `x` - код, ищущий поля по имени свойства, их не найдёт. +> +> **`getDelegate()` требует `isAccessible = true` и возвращает делегат, а не значение.** Для `by lazy` это `Lazy`, чей `toString` до инициализации - «Lazy value not initialized yet.»; попытка привести его к типу значения даст `ClassCastException`. +> +> **`internal` в Java-представлении становится `public` с манглированным именем.** `getInternalProp$main` - проверено; обход через `Class.getDeclaredMethods` покажет именно это имя, а не `getInternalProp`. +> +> **`objectInstance` равен `null` для обычных классов.** Для проверки «это `object`?» нужен `kc.objectInstance != null`, а не наличие свойства. +> +> **Рефлексия обходит проверки null-безопасности.** `prop.set(obj, null)` через `KMutableProperty` с типом `String` запишет `null` в непустое свойство: компилятор не участвует, а проверки `Intrinsics` стоят только на входе в функции. Дальше код упадёт NPE в неожиданном месте. Подробнее: [Null-безопасность](../Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md) +> +> **Рефлексия ломается при обфускации.** R8 и ProGuard переименовывают классы и члены; код, ищущий свойство по строковому имени, перестаёт работать в релизной сборке и работает в отладочной. Нужны правила `-keep`. +> +> **Kotlin/Native и Kotlin/JS не поддерживают полную рефлексию.** `kotlin-reflect` существует только для JVM; в Multiplatform-коде доступны лишь `::class.simpleName` и подобное. Общий код на рефлексии не построить. + +Далее: [Аннотации](Аннотации.md) diff --git a/Kotlin/Синтаксис и типы/Null-безопасность/Операторы null-безопасности.md b/Kotlin/Синтаксис и типы/Null-безопасность/Операторы null-безопасности.md index 091e0fb..f0c77be 100644 --- a/Kotlin/Синтаксис и типы/Null-безопасность/Операторы null-безопасности.md +++ b/Kotlin/Синтаксис и типы/Null-безопасность/Операторы null-безопасности.md @@ -155,6 +155,8 @@ println("abc".toIntOrNull()) // null — вместо исключения > | три вложенных `if` для цепочки | `a?.b?.c` | > > Java-аналог `Optional` требует оборачивания и разворачивания, работает только как возвращаемое значение и создаёт объект. Операторы Kotlin компилируются в обычные проверки `ifnull` в байт-коде - накладных расходов нет. +> +> Сравнить: [Приведение ссылочных типов в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Приведение%20ссылочных%20типов.md) > [!warning] Подводные камни > **`?.` возвращает nullable, цепочка «заражает» весь результат:** diff --git a/Kotlin/Синтаксис и типы/Базовые типы ООП/Any, Unit, Nothing.md b/Kotlin/Синтаксис и типы/Базовые типы ООП/Any, Unit, Nothing.md new file mode 100644 index 0000000..dafa322 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовые типы ООП/Any, Unit, Nothing.md @@ -0,0 +1,274 @@ +--- +title: Any, Unit, Nothing +description: Корень иерархии Any, тип-единица Unit и нижний тип Nothing +tags: + - kotlin + - types +--- + +Три особых типа образуют каркас системы типов Kotlin. + +| Тип | Сколько значений | Роль | +|---|---|---| +| `Any` | все объекты | супертип всего | +| `Unit` | одно (`Unit`) | «результата нет» | +| `Nothing` | ни одного | «возврата не будет» | + +```mermaid +graph TD + A["Any?"] --> B["Any"] + A --> N1["Nothing?"] + B --> C["String"] + B --> D["Int"] + B --> E["Unit"] + C --> N["Nothing"] + D --> N + E --> N + N1 --> N +``` +`Any` сверху, `Nothing` снизу: он подтип любого типа. + +## Any +Супертип всех non-null типов. Объявляет три метода: +```kotlin +fun main() { + val x: Any = 42 + println(x.toString()) // 42 + println(x.hashCode()) // 42 + println(x == 42) // true — вызов equals +} +``` +Больше у `Any` ничего нет: ни `wait`, ни `notify`, ни `getClass`. + +```kotlin +fun main() { + val items: List = listOf(1, "два", 3.0, true) + println(items.joinToString { it::class.simpleName ?: "?" }) + // Int, String, Double, Boolean +} +``` + +`Any` не принимает `null` - для этого есть `Any?`, самый широкий тип в языке: +```kotlin +fun main() { + // val a: Any = null // ошибка компиляции + val b: Any? = null // ok + println(b) // null +} +``` + +Расширения `Any?` можно звать на `null`: +```kotlin +fun main() { + val x: String? = null + println(x.toString()) // "null" — расширение Any?.toString() + println(x.hashCode()) // 0 +} +``` +Это не методы `Any`, а функции-расширения stdlib, объявленные для `Any?`. + +## Unit +Тип функций, которые ничего не возвращают. Это **настоящий объект**, а не отсутствие значения: +```kotlin +fun log(msg: String): Unit = println(msg) + +fun main() { + val result = log("привет") + println(result) // kotlin.Unit + println(result == Unit) // true +} +``` +`Unit` - `object`, у него ровно одно значение. Тип результата `Unit` можно опускать, и обычно так и делают: +```kotlin +fun log(msg: String) = println(msg) // тип выведен: Unit +fun log2(msg: String) { println(msg) } // то же самое +``` + +Поскольку `Unit` - полноценный тип, он подставляется в дженерики, где `void` в Java не работает: +```kotlin +fun main() { + val actions: List<() -> Unit> = listOf({ print("a") }, { print("b") }) + actions.forEach { it() } // ab + println() + + val map = mutableMapOf() + map["ключ"] = Unit // Unit как значение — законно + println(map) // {ключ=kotlin.Unit} +} +``` + +В лямбде с типом результата `Unit` последнее выражение игнорируется - возвращать ничего не нужно: +```kotlin +fun run2(block: () -> Unit) = block() + +fun main() { + run2 { 42 } // 42 отбрасывается, лямбда подходит под () -> Unit + println("ok") +} +``` + +## Nothing +Тип без значений. Функция с результатом `Nothing` **не может завершиться нормально** - только исключением или бесконечным циклом: +```kotlin +fun fail(message: String): Nothing = throw IllegalStateException(message) + +fun main() { + val x: String = if (true) "ok" else fail("нет значения") + println(x) // ok +} +``` + +Зачем это нужно: `Nothing` - подтип любого типа, поэтому выражение с типом `Nothing` подходит в любую позицию. Компилятор знает, что дальше кода не будет. + +```kotlin +fun fail(msg: String): Nothing = throw IllegalArgumentException(msg) + +fun parse(s: String): Int = s.toIntOrNull() ?: fail("не число: $s") + +fun main() { + println(parse("42")) // 42 + // parse("abc") // IllegalArgumentException: не число: abc +} +``` +Если бы `fail` возвращала `Unit`, этот код не скомпилировался бы: `Int` и `Unit` несовместимы. + +`throw` сам по себе - выражение типа `Nothing`: +```kotlin +fun main() { + val x: Int = try { + "abc".toInt() + } catch (e: NumberFormatException) { + throw IllegalStateException("плохие данные", e) // Nothing подходит + } + println(x) +} +``` + +### Анализ недостижимого кода +```kotlin +fun fail(): Nothing = throw RuntimeException() + +fun main() { + val x = 1 + if (x > 0) { + println("да") + } else { + fail() + // println("недостижимо") // предупреждение компилятора + } +} +``` +Компилятор использует `Nothing`, чтобы доказать: ветка не продолжается. Поэтому `TODO()` работает где угодно: +```kotlin +fun calculate(x: Int): String = TODO("ещё не реализовано") + +fun main() { + // calculate(1) // NotImplementedError + println("компилируется") +} +``` +`TODO()` объявлена как `fun TODO(): Nothing`, поэтому подходит под любой тип результата. + +### Nothing? и null +```kotlin +fun main() { + val x = null + println(x) // null + // тип x — Nothing?, единственное значение которого null + val list = listOf(null, null) // List + println(list.size) // 2 +} +``` +Пустые коллекции тоже используют `Nothing`: +```kotlin +fun main() { + val empty: List = emptyList() + val strings: List = empty // подходит — List ковариантен + println(strings.size) // 0 +} +``` +Один объект `EmptyList` подходит под `List` для любого `T` именно потому, что `List` - подтип всех `List`. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Object` | `Any` (но без `wait`/`notify`/`getClass`) | +> | `void` | `Unit` | +> | `Void` | `Unit` (или `Nothing?`) | +> | нет аналога | `Nothing` | +> | `Object o = null;` | `Any? = null`, `Any` не принимает null | +> +> **`Any` беднее `Object`.** Методы `wait()`, `notify()`, `getClass()` в `Any` не объявлены: первые два относятся к устаревшей модели синхронизации, третий заменён на `::class`. На JVM они доступны после приведения к `Object`, но из Kotlin их намеренно не видно. +> +> **`Unit` против `void`** - не косметика. `void` не тип, поэтому в Java нельзя написать `List` или `Function`; приходится использовать `Void` (у которого единственное значение - `null`) или `Consumer`. В Kotlin `() -> Unit` - обычный тип. +> +> **`Nothing` в Java нет вообще.** Из-за этого Java-компилятор не знает, что метод никогда не вернёт управление: +> ```java +> int parse(String s) { +> if (s == null) fail("null"); // fail объявлен void +> return Integer.parseInt(s); // компилятор требует этот return +> } +> ``` +> В Kotlin `fail(): Nothing` сообщает компилятору, что ветка обрывается, и анализ потока это учитывает. +> +> Сравнить: [Класс Object в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Класс%20Object.md) + +> [!warning] Подводные камни +> **`Any` - не то же самое, что `Any?`.** Функция, принимающая `Any`, не примет `null`: +> ```kotlin +> fun show(x: Any) = x.toString() +> +> fun main() { +> val n: String? = null +> // println(show(n)) // ошибка компиляции +> println(show(n ?: "пусто")) // пусто +> } +> ``` +> +> **Лямбда с результатом `Unit` молча глотает значение:** +> ```kotlin +> fun main() { +> val items = mutableListOf() +> listOf(1, 2).forEach { items.add(it) } // add возвращает Boolean, +> println(items) // но тип () -> Unit его отбрасывает +> } +> ``` +> Это обычно удобно, но если по ошибке вместо `map` написать `forEach`, результат исчезнет без предупреждения. +> +> **`return` с типом `Unit` писать не нужно**, а `return Unit` - признак непонимания: +> ```kotlin +> fun f(): Unit { +> return Unit // компилируется, но так не пишут +> } +> ``` +> +> **Функция с типом `Nothing` не может иметь тело-блок с `return`:** +> ```kotlin +> fun fail(): Nothing { +> // return // ошибка: ничего нельзя вернуть +> throw RuntimeException() // только выход через исключение +> } +> ``` +> +> **`val x = null` имеет тип `Nothing?`, а не `Any?`.** Присвоить такой переменной что-либо другое нельзя: +> ```kotlin +> fun main() { +> var x = null +> // x = "строка" // ошибка: ожидался Nothing? +> val y: String? = null // так +> println("$x $y") +> } +> ``` +> +> **`Nothing` в дженерике означает «значений не будет»:** +> ```kotlin +> fun main() { +> val producer: () -> Nothing = { throw RuntimeException("всегда падает") } +> // producer() — вызов гарантированно бросит исключение +> println("тип ${producer::class.simpleName} объявлен, но вызов невозможен") +> } +> ``` +> Если такой тип получился случайно (например, `emptyList()` без указания параметра), в коллекцию ничего нельзя добавить. + +Дополнительно: [equals, hashCode и toString](equals,%20hashCode%20и%20toString.md) +Далее: [Data class](Data%20class.md) diff --git a/Kotlin/Синтаксис и типы/Базовые типы ООП/Data class.md b/Kotlin/Синтаксис и типы/Базовые типы ООП/Data class.md new file mode 100644 index 0000000..a447a0f --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовые типы ООП/Data class.md @@ -0,0 +1,276 @@ +--- +title: Data class +description: Автоматические equals, hashCode, toString, copy и componentN, их границы и ловушки +tags: + - kotlin + - oop +--- + +`data class` - класс, для которого компилятор генерирует стандартный набор методов. + +```kotlin +data class User(val id: Int, val name: String) + +fun main() { + val u = User(1, "Иван") + println(u) // User(id=1, name=Иван) + println(u == User(1, "Иван")) // true + println(u.copy(name = "Пётр")) // User(id=1, name=Пётр) + val (id, name) = u + println("$id $name") // 1 Иван +} +``` + +Генерируется: + +| Метод | Что делает | +|---|---| +| `equals` | сравнивает все свойства основного конструктора | +| `hashCode` | считает по тем же свойствам | +| `toString` | `Имя(свойство=значение, ...)` | +| `componentN` | для [деструктуризации](../Работа%20с%20данными/Деструктуризация.md) | +| `copy` | копия с заменой части свойств | + +## Требования +```kotlin +data class Point(val x: Int, val y: Int) +``` +- основной конструктор с **хотя бы одним** параметром +- все параметры конструктора - `val` или `var` +- класс не может быть `abstract`, `open`, `sealed` или `inner` + +```kotlin +// data class Empty // ошибка: нет параметров +// data class Bad(x: Int) // ошибка: x не val/var +``` + +## Только свойства конструктора +Это главное, что нужно понимать про `data class`. Свойства, объявленные в теле, **не участвуют** в генерации: +```kotlin +data class User(val id: Int) { + var nickname: String = "" // в теле — игнорируется +} + +fun main() { + val a = User(1).apply { nickname = "иван" } + val b = User(1).apply { nickname = "пётр" } + println(a == b) // true! nickname не сравнивается + println(a) // User(id=1) — nickname не печатается + println(a.copy()) // User(id=1) — nickname потерян, станет "" +} +``` +Если свойство важно для равенства - оно должно быть в конструкторе. + +## copy +Создаёт новый объект, меняя указанные свойства: +```kotlin +data class User(val id: Int, val name: String, val active: Boolean = true) + +fun main() { + val u = User(1, "Иван") + println(u.copy()) // User(id=1, name=Иван, active=true) + println(u.copy(name = "Пётр")) // User(id=1, name=Пётр, active=true) + println(u.copy(id = 2, active = false)) + println(u) // исходный не изменился +} +``` +`copy` - основной способ «изменить» неизменяемый объект. Аргументы почти всегда именованные. + +## Вложенные data class +```kotlin +data class Address(val city: String, val street: String) +data class Person(val name: String, val address: Address) + +fun main() { + val p = Person("Иван", Address("Москва", "Арбат")) + println(p == Person("Иван", Address("Москва", "Арбат"))) // true + + // замена вложенного свойства — copy внутри copy + val moved = p.copy(address = p.address.copy(city = "Казань")) + println(moved) // Person(name=Иван, address=Address(city=Казань, street=Арбат)) +} +``` +Для глубоких структур такие цепочки становятся громоздкими - это известная цена иммутабельности. + +## Переопределение сгенерированного +Любой из методов можно написать руками - свой вариант побеждает: +```kotlin +data class User(val id: Int, val name: String) { + override fun toString() = "#$id $name" +} + +fun main() { + println(User(1, "Иван")) // #1 Иван + println(User(1, "Иван") == User(1, "Иван")) // true — equals сгенерирован +} +``` + +Частый случай - равенство только по идентификатору: +```kotlin +data class User(val id: Int, val name: String) { + override fun equals(other: Any?) = other is User && other.id == id + override fun hashCode() = id +} + +fun main() { + println(User(1, "Иван") == User(1, "Пётр")) // true — сравниваем id +} +``` +Но если равенство нестандартное, `data class` уже почти не нужен - остаются только `copy` и `toString`. + +## В коллекциях +Корректный `equals`/`hashCode` делает `data class` пригодным для `Set` и ключей `Map`: +```kotlin +data class Key(val a: Int, val b: String) + +fun main() { + val map = mapOf(Key(1, "x") to "значение") + println(map[Key(1, "x")]) // значение — новый объект находит запись + println(setOf(Key(1, "x"), Key(1, "x")).size) // 1 — дубликат убран + println(listOf(Key(1, "x")).contains(Key(1, "x"))) // true +} +``` + +## Наследование +`data class` не может быть `open`, но может реализовывать интерфейсы и наследоваться от обычного класса: +```kotlin +interface Named { val name: String } + +data class User(override val name: String, val id: Int) : Named + +fun main() { + val n: Named = User("Иван", 1) + println(n.name) // Иван +} +``` +Часто `data class` реализуют варианты [sealed-иерархии](Sealed%20class%20и%20interface.md): +```kotlin +sealed interface Event +data class Click(val x: Int, val y: Int) : Event +data class KeyPress(val code: Int) : Event +data object Close : Event + +fun handle(e: Event): String = when (e) { + is Click -> "клик ${e.x},${e.y}" + is KeyPress -> "клавиша ${e.code}" + Close -> "закрытие" +} + +fun main() { + println(handle(Click(1, 2))) // клик 1,2 + println(handle(Close)) // закрытие +} +``` + +## data object +Для варианта без данных есть `data object` (Kotlin 1.9+) - он отличается от обычного `object` осмысленным `toString`: +```kotlin +object Plain +data object Loading + +fun main() { + println(Plain) // Plain@1b6d3586 + println(Loading) // Loading +} +``` +Подробнее: [Object и companion object](Object%20и%20companion%20object.md) + +## Когда не нужен +- **Сущность с идентичностью** (entity в БД): равенство по всем полям неверно, нужно по `id` +- **Класс с поведением**: если методов больше, чем свойств, это не носитель данных +- **Публичный API, который будет развиваться**: порядок свойств в конструкторе становится контрактом из-за `componentN` + +> [!info] Отличия от Java +> Ближайший аналог - `record` из Java 16: +> ```java +> record User(int id, String name) {} +> ``` +> +> | | Java record | Kotlin data class | +> |---|---|---| +> | `equals`/`hashCode`/`toString` | да | да | +> | копия с изменением | нет | `copy()` | +> | деструктуризация | только в pattern matching | везде | +> | изменяемые поля | нет, всегда final | `var` разрешён | +> | наследование от класса | нет | можно | +> | значения по умолчанию | нет | есть | +> | своё тело | можно | можно | +> +> Главное практическое различие - `copy()`. В Java для изменения одного поля record приходится вызывать конструктор со всеми аргументами: +> ```java +> var moved = new User(u.id(), "Пётр"); +> ``` +> +> Второе - `record` всегда неизменяем, `data class` допускает `var`. Это гибкость, которой лучше не пользоваться. +> +> До появления `record` в Java такие классы писали руками или генерировали Lombok-ом (`@Data`, `@Value`). Lombok генерировал ещё и `@Builder`, аналога которому в Kotlin нет - его заменяют значения по умолчанию и именованные аргументы. +> +> Сравнить: [Record в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Record.md) + +> [!warning] Подводные камни +> **Массив в `data class` ломает равенство:** +> ```kotlin +> data class Packet(val data: ByteArray) +> +> fun main() { +> println(Packet(byteArrayOf(1)) == Packet(byteArrayOf(1))) // false! +> } +> ``` +> Сгенерированный `equals` вызывает `data.equals(other.data)`, а у массива это сравнение ссылок. Нужно писать руками: +> ```kotlin +> class Packet(val data: ByteArray) { +> override fun equals(other: Any?) = +> other is Packet && data.contentEquals(other.data) +> override fun hashCode() = data.contentHashCode() +> } +> +> fun main() { +> println(Packet(byteArrayOf(1)) == Packet(byteArrayOf(1))) // true +> } +> ``` +> Подробнее: [Массивы](../Работа%20с%20данными/Массивы.md) +> +> **`var` в `data class` + использование как ключа `Map` = потерянная запись:** +> ```kotlin +> data class Key(var id: Int) +> +> fun main() { +> val key = Key(1) +> val map = hashMapOf(key to "значение") +> key.id = 2 // hashCode изменился +> println(map[key]) // null — запись потеряна +> println(map[Key(1)]) // null — старый бакет, другой equals +> println(map.size) // 1 — но она там есть +> } +> ``` +> Это худшая ошибка с `data class`. Поэтому свойства делают `val`. +> +> **`copy` копирует поверхностно.** Вложенный изменяемый объект остаётся общим: +> ```kotlin +> data class Box(val items: MutableList) +> +> fun main() { +> val a = Box(mutableListOf(1)) +> val b = a.copy() +> b.items.add(2) +> println(a.items) // [1, 2] — изменился и оригинал! +> } +> ``` +> +> **Свойства в теле класса не входят ни в `equals`, ни в `copy`, ни в `toString`** - значение теряется при копировании. +> +> **Порядок свойств - часть API** из-за `componentN`. Перестановка свойств одинакового типа не вызовет ошибки компиляции у клиентов, но сломает их деструктуризацию. Подробнее: [Деструктуризация](../Работа%20с%20данными/Деструктуризация.md) +> +> **`copy` до Kotlin 2.0 игнорировал видимость конструктора.** Приватный конструктор не защищал от создания невалидного объекта через `copy`. С Kotlin 2.1 `copy` получает ту же видимость, что конструктор, если включён `-Xconsistent-data-class-copy-visibility`; это станет поведением по умолчанию. +> +> **`toString` печатает всё, включая пароли и токены.** Логирование `data class` с секретами - реальная утечка: +> ```kotlin +> data class Credentials(val login: String, val password: String) { +> override fun toString() = "Credentials(login=$login, password=***)" +> } +> +> fun main() { println(Credentials("ivan", "secret")) } +> ``` + +Дополнительно: [equals, hashCode и toString](equals,%20hashCode%20и%20toString.md) +Далее: [Enum class](Enum%20class.md) diff --git a/Kotlin/Синтаксис и типы/Базовые типы ООП/Enum class.md b/Kotlin/Синтаксис и типы/Базовые типы ООП/Enum class.md new file mode 100644 index 0000000..1407990 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовые типы ООП/Enum class.md @@ -0,0 +1,298 @@ +--- +title: Enum class +description: Перечисления со свойствами и методами, entries, when по enum и его границы +tags: + - kotlin + - oop +--- + +`enum class` - класс с фиксированным набором экземпляров. + +```kotlin +enum class Status { ACTIVE, BLOCKED, DELETED } + +fun main() { + val s = Status.ACTIVE + println(s) // ACTIVE + println(s.name) // ACTIVE — строка + println(s.ordinal) // 0 — позиция в объявлении +} +``` + +## Свойства и конструктор +Каждая константа - вызов конструктора: +```kotlin +enum class Planet(val radiusKm: Int, val hasRings: Boolean) { + EARTH(6371, false), + SATURN(58232, true), + MARS(3390, false); + + val radiusM: Int get() = radiusKm * 1000 + fun describe() = "$name: радиус $radiusKm км, кольца=$hasRings" +} + +fun main() { + println(Planet.SATURN.describe()) // SATURN: радиус 58232 км, кольца=true + println(Planet.EARTH.radiusM) // 6371000 +} +``` +Точка с запятой после последней константы обязательна, если дальше идёт тело класса. + +## Методы в константах +Константа может переопределить метод - это заменяет `when` по самой себе: +```kotlin +enum class Operation { + PLUS { override fun apply(a: Int, b: Int) = a + b }, + MINUS { override fun apply(a: Int, b: Int) = a - b }, + TIMES { override fun apply(a: Int, b: Int) = a * b }; + + abstract fun apply(a: Int, b: Int): Int +} + +fun main() { + println(Operation.PLUS.apply(2, 3)) // 5 + println(Operation.TIMES.apply(2, 3)) // 6 + println(Operation.entries.joinToString { "${it.name}=${it.apply(6, 3)}" }) + // PLUS=9, MINUS=3, TIMES=18 +} +``` +Каждая такая константа компилируется в анонимный подкласс. + +## Реализация интерфейса +```kotlin +interface Describable { fun describe(): String } + +enum class Level(val weight: Int) : Describable { + LOW(1), HIGH(10); + + override fun describe() = "$name (вес $weight)" +} + +fun main() { + val items: List = Level.entries + println(items.joinToString { it.describe() }) // LOW (вес 1), HIGH (вес 10) +} +``` + +## entries, values, valueOf +```kotlin +enum class Status { ACTIVE, BLOCKED } + +fun main() { + println(Status.entries) // [ACTIVE, BLOCKED] — список + println(Status.entries.size) // 2 + println(Status.valueOf("ACTIVE")) // ACTIVE + println(Status.entries.first().name) // ACTIVE + // println(Status.valueOf("НЕТ")) // IllegalArgumentException +} +``` + +`entries` (Kotlin 1.9+) заменяет `values()`. Разница практическая: + +| | `values()` | `entries` | +|---|---|---| +| тип | `Array` | `EnumEntries` (это `List`) | +| аллокация | **новый массив при каждом вызове** | один неизменяемый список | +| изменяемость | элементы можно подменить | нельзя | + +```kotlin +enum class Status { ACTIVE, BLOCKED } + +fun main() { + @Suppress("DEPRECATION") + println(Status.values().contentToString()) // [ACTIVE, BLOCKED] + println(Status.entries) // [ACTIVE, BLOCKED] +} +``` +В новом коде всегда `entries`. + +Безопасный разбор строки: +```kotlin +enum class Status { ACTIVE, BLOCKED } + +fun parseStatus(s: String): Status? = + Status.entries.find { it.name == s } + +fun main() { + println(parseStatus("ACTIVE")) // ACTIVE + println(parseStatus("нет")) // null — без исключения +} +``` +Через `enumValueOf` то же самое, но с исключением. Для внешних данных (JSON, БД, параметры запроса) нужна версия с `null`. + +## when по enum +Главное применение. Компилятор проверяет полноту, и `else` не нужен: +```kotlin +enum class Status { ACTIVE, BLOCKED, DELETED } + +fun message(s: Status): String = when (s) { + Status.ACTIVE -> "работает" + Status.BLOCKED -> "заблокирован" + Status.DELETED -> "удалён" +} + +fun main() { + println(message(Status.BLOCKED)) // заблокирован +} +``` +Если добавить константу `ARCHIVED`, этот `when` перестанет компилироваться - компилятор найдёт все места, которые нужно обновить. Поэтому `else` в `when` по enum - вредная привычка: он скрывает именно те ошибки, от которых enum защищает. + +Импорт констант сокращает запись: +```kotlin +enum class Status { ACTIVE, BLOCKED } + +fun message(s: Status): String { + return when (s) { + Status.ACTIVE -> "работает" + Status.BLOCKED -> "заблокирован" + } +} + +fun main() = println(message(Status.ACTIVE)) +``` + +## Сравнение и порядок +```kotlin +enum class Level { LOW, MEDIUM, HIGH } + +fun main() { + println(Level.LOW == Level.LOW) // true + println(Level.LOW < Level.HIGH) // true — по ordinal + println(Level.entries.sorted()) // [LOW, MEDIUM, HIGH] + println(Level.HIGH.ordinal) // 2 + println(Level.entries.maxOf { it.ordinal }) // 2 +} +``` +`enum` реализует `Comparable` по `ordinal`. Сравнивать через `==` безопасно: экземпляр единственный, поэтому `==` и `===` дают одно и то же. + +## EnumMap и EnumSet +Специализированные коллекции для enum - массив вместо хеш-таблицы: +```kotlin +import java.util.EnumMap +import java.util.EnumSet + +enum class Day { MON, TUE, WED } + +fun main() { + val hours = EnumMap(Day::class.java) + hours[Day.MON] = 8 + hours[Day.TUE] = 6 + println(hours) // {MON=8, TUE=6} + + val workdays = EnumSet.of(Day.MON, Day.TUE) + println(workdays.contains(Day.WED)) // false + println(EnumSet.complementOf(workdays)) // [WED] +} +``` +Это классы JDK; в stdlib Kotlin аналогов нет. Для небольших enum разница с `HashMap` несущественна. + +## companion object +```kotlin +enum class Status(val code: Int) { + ACTIVE(1), BLOCKED(2); + + companion object { + private val byCode = entries.associateBy { it.code } + fun fromCode(code: Int): Status? = byCode[code] + } +} + +fun main() { + println(Status.fromCode(2)) // BLOCKED + println(Status.fromCode(99)) // null +} +``` +Поиск по коду через заранее построенную `Map` - стандартный приём. Подробнее: [Object и companion object](Object%20и%20companion%20object.md) + +## Когда enum, а когда sealed +```kotlin +enum class Status { OK, ERROR } // варианты без данных + +sealed interface Result2 +data class Ok(val value: Int) : Result2 // варианты с разными данными +data class Error(val message: String) : Result2 +``` +`enum` - фиксированный набор **значений**, у всех одинаковая структура. `sealed` - фиксированный набор **типов**, у каждого свои свойства. Подробнее: [Sealed class и interface](Sealed%20class%20и%20interface.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `enum Status { ACTIVE }` | `enum class Status { ACTIVE }` | +> | `Status.values()` | `Status.entries` | +> | `Status.valueOf(s)` | `Status.valueOf(s)` или `entries.find` | +> | `switch` без проверки полноты до Java 21 | `when` проверяет всегда | +> | `EnumMap`/`EnumSet` | то же (из JDK) | +> +> Устройство одинаковое - на JVM это тот же `java.lang.Enum`. Два практических различия: +> +> **`entries` вместо `values()`.** Java-метод `values()` возвращает новый массив при каждом вызове, потому что массив изменяем и вернуть общий нельзя. `entries` возвращает неизменяемый список, поэтому экземпляр один. В цикле это заметная разница. +> +> **Проверка полноты.** В Java `switch` по enum без `default` долго не проверялся: добавление константы молча менялo поведение. Kotlin проверяет `when`-выражение с момента появления языка, а с Kotlin 2.0 - и `when`-инструкцию. +> +> Сравнить: [Enum в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Enum.md) + +> [!warning] Подводные камни +> **`ordinal` нельзя сохранять в БД или в файл.** Он меняется при вставке константы в середину: +> ```kotlin +> enum class Status { ACTIVE, BLOCKED } // BLOCKED.ordinal == 1 +> // добавили NEW в середину: +> // enum class Status { ACTIVE, NEW, BLOCKED } // BLOCKED.ordinal == 2 +> ``` +> Все сохранённые единицы станут означать `NEW`. Для сериализации используйте `name` или свой стабильный `code`. +> +> **`name` тоже хрупок при переименовании** - но его хотя бы нельзя изменить случайно. Самый надёжный вариант - явное свойство: +> ```kotlin +> enum class Status(val code: String) { +> ACTIVE("active"), BLOCKED("blocked") +> } +> +> fun main() { println(Status.ACTIVE.code) } // active +> ``` +> +> **`valueOf` бросает исключение на незнакомой строке**, а не возвращает `null`. Для внешних данных нужна своя обёртка через `entries.find`. +> +> **`values()` аллоцирует массив на каждый вызов:** +> ```kotlin +> enum class Status { ACTIVE } +> +> fun main() { +> @Suppress("DEPRECATION") +> repeat(3) { Status.values() } // три разных массива +> repeat(3) { Status.entries } // один и тот же список +> println("ok") +> } +> ``` +> +> **`else` в `when` по enum отключает проверку полноты.** Самая частая ошибка: добавили константу, компилятор промолчал, в рантайме сработала ветка `else`. +> +> **Enum нельзя наследовать и расширять.** Если набор вариантов должен быть расширяемым (например, плагинами), enum не подходит - нужен `sealed interface` или обычная иерархия. +> +> **Enum с изменяемым состоянием - глобальная переменная:** +> ```kotlin +> enum class Counter { +> INSTANCE; +> var count = 0 // общее состояние на всю программу +> } +> +> fun main() { +> Counter.INSTANCE.count++ +> println(Counter.INSTANCE.count) // 1 — и так во всех потоках +> } +> ``` +> Константы enum - синглтоны, поэтому `var` в них небезопасен при многопоточности. +> +> **`when` по nullable enum всё равно требует `else` или ветки `null`:** +> ```kotlin +> enum class Status { ACTIVE, BLOCKED } +> +> fun message(s: Status?): String = when (s) { +> Status.ACTIVE -> "работает" +> Status.BLOCKED -> "заблокирован" +> null -> "не задан" +> } +> +> fun main() { println(message(null)) } // не задан +> ``` + +Дополнительно: [Sealed class и interface](Sealed%20class%20и%20interface.md) +Далее: [Sealed class и interface](Sealed%20class%20и%20interface.md) diff --git a/Kotlin/Синтаксис и типы/Базовые типы ООП/Object и companion object.md b/Kotlin/Синтаксис и типы/Базовые типы ООП/Object и companion object.md new file mode 100644 index 0000000..f639034 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовые типы ООП/Object и companion object.md @@ -0,0 +1,347 @@ +--- +title: Object и companion object +description: Синглтон через object, объект-выражение, companion object вместо static и фабрики +tags: + - kotlin + - oop +--- + +Слово `object` в Kotlin означает три разные вещи: объявление синглтона, выражение-анонимный объект и компаньон класса. + +## object - синглтон +```kotlin +object Config { + val appName = "MyApp" + var debug = false + + fun describe() = "$appName debug=$debug" +} + +fun main() { + println(Config.appName) // MyApp + Config.debug = true + println(Config.describe()) // MyApp debug=true +} +``` +Экземпляр создаётся **один** и **лениво** - при первом обращении. Потокобезопасность обеспечивает JVM через механизм инициализации классов. + +```kotlin +object Lazy2 { + init { println("инициализация") } + val value = 42 +} + +fun main() { + println("до обращения") // до обращения + println(Lazy2.value) // инициализация, затем 42 + println(Lazy2.value) // 42 — повторной инициализации нет +} +``` + +Объект может наследоваться и реализовывать интерфейсы: +```kotlin +interface Logger { fun log(msg: String) } + +object ConsoleLogger : Logger { + override fun log(msg: String) = println("[LOG] $msg") +} + +fun main() { + val l: Logger = ConsoleLogger + l.log("привет") // [LOG] привет +} +``` +Это главное отличие от набора статических методов: синглтон - полноценный объект, его можно передать туда, где ожидается интерфейс. + +У `object` не может быть конструктора - передать ему параметры при создании невозможно. + +## data object +Для случаев, где нужен осмысленный `toString` (обычно в [sealed-иерархиях](Sealed%20class%20и%20interface.md)): +```kotlin +object Plain +data object Loading + +fun main() { + println(Plain) // Plain@1b6d3586 + println(Loading) // Loading + println(Loading == Loading) // true + println(Loading.hashCode() == Loading.hashCode()) // true +} +``` +`data object` генерирует `toString`, `equals` и `hashCode`. Для синглтона `equals` и так работал по ссылке - полезен именно `toString`. + +## Объект-выражение +Анонимный объект создаётся на месте: +```kotlin +interface ClickListener { + fun onClick(x: Int, y: Int) +} + +fun main() { + val listener = object : ClickListener { + override fun onClick(x: Int, y: Int) = println("клик $x,$y") + } + listener.onClick(1, 2) // клик 1,2 +} +``` +В отличие от объявления `object`, выражение создаёт **новый** экземпляр при каждом вычислении: +```kotlin +private fun makeCounter() = object { + var count = 0 + fun inc() = ++count +} + +fun main() { + val a = makeCounter() + val b = makeCounter() + a.inc(); a.inc() + println("${a.inc()} ${b.inc()}") // 3 1 — независимые объекты +} +``` + +Можно реализовать несколько интерфейсов сразу и даже ни одного: +```kotlin +fun main() { + val point = object { + val x = 1 + val y = 2 + override fun toString() = "($x, $y)" + } + println(point) // (1, 2) + println(point.x) // 1 +} +``` +Тип такого объекта анонимен, поэтому за пределы локальной области он не выходит: +```kotlin +class Holder { + private fun make() = object { val x = 1 } // private — тип виден + fun use() = make().x + + // fun bad() = object { val x = 1 } // public: тип станет Any, +} // и x будет недоступен + +fun main() { println(Holder().use()) } // 1 +``` + +Объект-выражение видит и может менять локальные переменные: +```kotlin +fun main() { + var clicks = 0 + val listener = object : Runnable { + override fun run() { clicks++ } + } + listener.run(); listener.run() + println(clicks) // 2 +} +``` + +## companion object +Члены, привязанные к классу, а не к экземпляру: +```kotlin +class User private constructor(val name: String) { + companion object { + const val MAX_NAME_LENGTH = 50 + + fun create(name: String): User? = + if (name.length <= MAX_NAME_LENGTH) User(name) else null + } +} + +fun main() { + println(User.MAX_NAME_LENGTH) // 50 + println(User.create("Иван")?.name) // Иван + println(User.create("x".repeat(99))) // null +} +``` +Обращение идёт через имя класса - как к статическим членам. Приватный конструктор + фабрика в компаньоне - стандартный способ контролировать создание объектов. + +Компаньон может быть только один, и у него может быть имя: +```kotlin +class User { + companion object Factory { + fun create() = User() + } +} + +fun main() { + println(User.create()::class.simpleName) // User + println(User.Factory.create()::class.simpleName) // User — через имя +} +``` +Имя нужно редко - в основном для вызова из Java. + +### Компаньон - это объект +Поэтому он может реализовывать интерфейсы: +```kotlin +interface Parser { + fun parse(s: String): T? +} + +class Color2(val rgb: Int) { + companion object : Parser { + override fun parse(s: String): Color2? = + s.removePrefix("#").toIntOrNull(16)?.let { Color2(it) } + } +} + +fun main() { + println(Color2.parse("#ff0000")?.rgb) // 16711680 + val p: Parser = Color2 // компаньон как значение + println(p.parse("#00ff00")?.rgb) // 65280 +} +``` +Статические методы в Java так передать нельзя - это реальное преимущество. + +### Расширения компаньона +```kotlin +class User(val name: String) { + companion object +} + +fun User.Companion.anonymous() = User("анонимный") + +fun main() { + println(User.anonymous().name) // анонимный +} +``` +Пустой `companion object` объявляют именно для этого - чтобы к классу можно было добавить «статическую» фабрику извне. + +## const и @JvmStatic +```kotlin +class Config { + companion object { + const val VERSION = "1.0" // константа компиляции + val runtime = System.nanoTime() // вычисляется при инициализации + + @JvmStatic + fun helper() = "вызывается из Java как Config.helper()" + } +} + +fun main() { + println(Config.VERSION) + println(Config.helper()) +} +``` +`const` работает только для примитивов и `String`, и только на верхнем уровне, в `object` или в `companion object`. Значение подставляется в место использования при компиляции. + +`@JvmStatic` делает член настоящим статическим в байт-коде. Без неё Java-код обращается через `Config.Companion.helper()`. Подробнее: [Kotlin из Java](../../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) + +## object или функции верхнего уровня +```kotlin +// вариант 1: функции файла +fun formatDate(d: Long) = "дата $d" + +// вариант 2: object +object DateUtils { + fun format(d: Long) = "дата $d" +} +``` +Если состояния нет и интерфейс реализовывать не нужно - функции верхнего уровня проще: не нужно писать префикс, и в байт-коде нет лишнего экземпляра. `object` оправдан, когда есть состояние или нужна реализация интерфейса. + +## Три формы рядом +| Форма | Экземпляров | Когда | +|---|---|---| +| `object Name { }` | один, ленивый | синглтон: конфиг, реестр, реализация интерфейса | +| `object : Type { }` | новый каждый раз | колбэк, одноразовая реализация | +| `companion object` | один на класс | фабрики, константы, «статика» | + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `static` поля и методы | `companion object` или функции файла | +> | `static final` константа | `const val` | +> | паттерн Singleton (enum/holder) | `object` | +> | анонимный класс `new Iface() {}` | `object : Iface {}` | +> | `static` блок инициализации | `init` в `object`/`companion object` | +> | статику нельзя передать как объект | компаньон реализует интерфейсы | +> | лямбда только для SAM | `object` для любого числа методов | +> +> **Ключевое: в Kotlin нет `static`.** Это сделано намеренно - статика не участвует в полиморфизме и не реализует интерфейсы. Вместо неё либо функции верхнего уровня (для чистых утилит), либо `companion object` (когда член логически принадлежит классу). +> +> **Синглтон через `object` решает классическую задачу Java.** Корректный синглтон в Java требует либо `enum` с единственной константой, либо holder-класса с двойной проверкой блокировки: +> ```java +> class Config { +> private Config() {} +> private static class Holder { static final Config INSTANCE = new Config(); } +> static Config getInstance() { return Holder.INSTANCE; } +> } +> ``` +> В Kotlin это `object Config { }` - с той же ленивостью и потокобезопасностью. +> +> **Анонимный класс Java не может реализовать два интерфейса**, объект-выражение Kotlin - может. +> +> Сравнить: [Singleton в Java](../../../Программирование%20на%20Java/Паттерны%20проектирования/Singleton.md), [Вложенные классы в Java](../../../Программирование%20на%20Java/ООП/Вложенные%20классы.md) + +> [!warning] Подводные камни +> **`object` - глобальное изменяемое состояние, если в нём есть `var`:** +> ```kotlin +> object Counter { +> var count = 0 // общий на всю программу, не потокобезопасен +> } +> +> fun main() { +> Counter.count++ +> println(Counter.count) // 1 +> } +> ``` +> Инкремент из нескольких потоков потеряет обновления. Нужен `AtomicInteger` или синхронизация. Подробнее: [Разделяемое состояние](../../Корутины/Разделяемое%20состояние.md) +> +> **Синглтон затрудняет тестирование.** Состояние `object` переживает тесты, и порядок их выполнения начинает влиять на результат. Для подменяемых зависимостей лучше внедрение через конструктор. +> +> **Инициализация `object` ленивая - и порядок может удивить:** +> ```kotlin +> object A { +> val value = B.value + 1 +> } +> object B { +> val value = 10 +> } +> +> fun main() { println(A.value) } // 11 +> ``` +> При круговой зависимости между объектами одно из значений окажется `null` или `0`, без всякой ошибки. +> +> **`const val` нельзя применить к сложному типу:** +> ```kotlin +> object Config { +> const val NAME = "app" // ok +> // const val LIST = listOf(1) // ошибка: только примитивы и String +> val LIST = listOf(1) // так +> } +> +> fun main() { println("${Config.NAME} ${Config.LIST}") } +> ``` +> +> **Компаньон не наследуется.** У подкласса нет доступа к компаньону родителя как к своему: +> ```kotlin +> open class Base { +> companion object { fun create() = "из Base" } +> } +> class Child : Base() +> +> fun main() { +> println(Base.create()) // из Base +> // println(Child.create()) // ошибка: unresolved reference +> } +> ``` +> +> **Тип объекта-выражения анонимен.** Если функция публичная, её тип результата схлопнется до супертипа (или `Any`), и дополнительные члены станут недоступны. +> +> **Из Java члены компаньона видны через `Companion`:** +> ```kotlin +> class Config { +> companion object { +> fun helper() = "без JvmStatic" +> @JvmStatic fun helper2() = "с JvmStatic" +> } +> } +> +> fun main() { println("${Config.helper()} / ${Config.helper2()}") } +> ``` +> Java-код вызывает первую как `Config.Companion.helper()`, вторую - как `Config.helper2()`. +> +> **`object` внутри класса - не то же, что `companion object`.** Вложенный `object` требует обращения через своё имя: `Outer.Inner.member`. + +Дополнительно: [Sealed class и interface](Sealed%20class%20и%20interface.md) +Далее: [Typealias](Typealias.md) diff --git a/Kotlin/Синтаксис и типы/Базовые типы ООП/Sealed class и interface.md b/Kotlin/Синтаксис и типы/Базовые типы ООП/Sealed class и interface.md new file mode 100644 index 0000000..0c1a756 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовые типы ООП/Sealed class и interface.md @@ -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 = 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 { + data class Ok(val value: T) : Parsed + data class Err(val message: String) : Parsed +} + +fun parse(s: String): Parsed = + 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` для варианта без данных - это работает благодаря `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`. + +Дополнительно: [Enum class](Enum%20class.md) +Далее: [Value class](Value%20class.md) diff --git a/Kotlin/Синтаксис и типы/Базовые типы ООП/Typealias.md b/Kotlin/Синтаксис и типы/Базовые типы ООП/Typealias.md new file mode 100644 index 0000000..9a6fe8d --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовые типы ООП/Typealias.md @@ -0,0 +1,228 @@ +--- +title: Typealias +description: Псевдоним типа для сокращения записи, его отличие от value class и границы применения +tags: + - kotlin + - types +--- + +`typealias` даёт типу другое имя. **Нового типа не создаётся** - это сокращение для чтения. + +```kotlin +typealias Name = String + +fun greet(name: Name) = "Привет, $name" + +fun main() { + println(greet("Иван")) // Привет, Иван — обычная строка подходит + val n: Name = "Пётр" + val s: String = n // взаимозаменяемы + println(s) +} +``` + +## Где полезно +**Длинные дженерик-типы:** +```kotlin +typealias UsersByCity = Map>> + +fun process(data: UsersByCity) = data.keys.sorted() + +fun main() { + val data: UsersByCity = mapOf("Москва" to listOf(1 to "Иван")) + println(process(data)) // [Москва] +} +``` + +**Функциональные типы:** +```kotlin +typealias ClickHandler = (x: Int, y: Int) -> Unit +typealias Validator = (T) -> String? + +fun onClick(handler: ClickHandler) = handler(1, 2) + +fun main() { + onClick { x, y -> println("клик $x,$y") } // клик 1,2 + + val notEmpty: Validator = + { if (it.isEmpty()) "пусто" else null } + println(notEmpty("")) // пусто + println(notEmpty("ok")) // null +} +``` +Имена параметров в псевдониме функционального типа - документация: `(x: Int, y: Int) -> Unit` понятнее, чем `(Int, Int) -> Unit`. + +**Разрешение конфликта имён при импорте:** +```kotlin +import java.util.Date +typealias SqlDate = java.sql.Date + +fun main() { + val a: Date = Date() + val b: SqlDate = SqlDate(0) + println("${a::class.simpleName} ${b::class.simpleName}") // Date Date +} +``` +Два класса с одинаковым простым именем нельзя импортировать оба - псевдоним решает это без полных имён в коде. + +**Сокращение вложенных имён:** +```kotlin +class Outer { + class Inner { + class Deep { fun hello() = "привет" } + } +} + +typealias Deep = Outer.Inner.Deep + +fun main() { + println(Deep().hello()) // привет +} +``` + +## Дженерики +Псевдоним может иметь свои параметры типа: +```kotlin +typealias Handler = (T) -> Unit +typealias Dict = Map +typealias IntDict = Dict // частичная подстановка + +fun main() { + val h: Handler = { println(it.uppercase()) } + h("привет") // ПРИВЕТ + + val d: IntDict = mapOf("a" to 1) + println(d["a"]) // 1 +} +``` + +## Только верхний уровень +```kotlin +typealias Ok = String // так + +class Wrong { + // typealias Bad = String // ошибка: псевдоним только на верхнем уровне +} + +fun main() { + val x: Ok = "ok" + println(x) +} +``` +Внутри класса, функции или `object` объявить псевдоним нельзя. + +## typealias и value class +Это часто путают, а задачи у них противоположные: + +```kotlin +typealias UserId = Int +@JvmInline value class AccountId(val value: Int) + +fun loadUser(id: UserId) = "user $id" +fun loadAccount(id: AccountId) = "account ${id.value}" + +fun main() { + println(loadUser(42)) // 42 — любое Int подойдёт + println(loadAccount(AccountId(42))) // нужен именно AccountId + // println(loadAccount(42)) // не скомпилируется +} +``` + +| | `typealias` | `value class` | +|---|---|---| +| новый тип | нет | да | +| ошибку в аргументах ловит | нет | да | +| накладные расходы | нет | обычно нет | +| нужна распаковка (`.value`) | нет | да | +| можно добавить методы | нет | да | +| видно в байт-коде | нет | mangling имён | + +Правило: если нужна **читаемость** - `typealias`. Если нужна **защита от перепутанных аргументов** - `value class`. Подробнее: [Value class](Value%20class.md) + +## Псевдоним не скрывает реализацию +```kotlin +typealias Cache = HashMap + +fun main() { + val c: Cache = HashMap() + println(c is HashMap<*, *>) // true — это и есть HashMap + println(c::class.simpleName) // HashMap +} +``` +`typealias` нельзя использовать как абстракцию: клиент видит настоящий тип, полагается на его методы, и заменить реализацию не получится. Для этого нужен интерфейс. + +## Когда не стоит +```kotlin +typealias S = String // экономия букв без смысла +typealias MyList = List // List и так понятен +``` +Псевдоним добавляет уровень косвенности: читатель должен найти объявление, чтобы понять настоящий тип. Он оправдан, когда сокращает реально длинную запись или даёт имя смысловой роли (`Validator`, `ClickHandler`), а не когда просто короче. + +> [!info] Отличия от Java +> В Java псевдонимов типов нет вообще. Длинные дженерик-типы приходится либо выписывать целиком, либо прятать за интерфейсом или наследованием: +> ```java +> // единственный способ "сократить" — создать настоящий тип +> class UsersByCity extends HashMap>> {} +> ``` +> Наследование от коллекции - плохая практика: тип получает весь API родителя и наружу просачивается реализация. Частичное облегчение даёт `var` (Java 10+), но только для локальных переменных: +> ```java +> var data = new HashMap>(); // тип не пишем дважды +> ``` +> +> | Java | Kotlin | +> |---|---| +> | нет аналога | `typealias X = Y` | +> | `extends HashMap<...>` как сокращение | `typealias` без наследования | +> | `var` для локальных переменных | вывод типов + `typealias` | +> | полное имя при конфликте импортов | `typealias` для одного из классов | +> +> Сравнить: [Параметрический полиморфизм в Java](../../../Программирование%20на%20Java/Дженерики/Параметрический%20полиморфизм.md) + +> [!warning] Подводные камни +> **Типобезопасности нет.** Это главное заблуждение: +> ```kotlin +> typealias Meters = Int +> typealias Seconds = Int +> +> fun speed(distance: Meters, time: Seconds) = distance / time +> +> fun main() { +> println(speed(100, 10)) // 10 +> val t: Seconds = 10 +> val d: Meters = 100 +> println(speed(t, d)) // 0 — аргументы перепутаны, компилятор молчит +> } +> ``` +> Для настоящей защиты нужен `value class`. +> +> **Псевдоним не наследуется и не расширяется.** Добавить метод «к `Meters`» нельзя - расширение попадёт на `Int`: +> ```kotlin +> typealias Meters = Int +> +> fun Meters.describe() = "$this м" // это расширение Int! +> +> fun main() { +> println(42.describe()) // 42 м — работает для любого Int +> } +> ``` +> +> **В сообщениях компилятора и в стектрейсах видно настоящий тип**, а не псевдоним. При разборе ошибки `expected Map>>` придётся сопоставлять его с `UsersByCity` вручную. +> +> **Из Java псевдоним не виден.** В сигнатурах, доступных Java-коду, стоит раскрытый тип. +> +> **Псевдоним на nullable-тип легко спутать:** +> ```kotlin +> typealias MaybeName = String? +> +> fun greet(n: MaybeName) = "Привет, ${n ?: "гость"}" +> +> fun main() { +> println(greet(null)) // Привет, гость +> } +> ``` +> По имени `MaybeName` не очевидно, что тип nullable. Лучше, когда `?` виден в сигнатуре. +> +> **Нельзя объявить внутри класса или функции** - только на верхнем уровне файла. + +Дополнительно: [Value class](Value%20class.md) +Далее: [equals, hashCode и toString](equals,%20hashCode%20и%20toString.md) diff --git a/Kotlin/Синтаксис и типы/Базовые типы ООП/Value class.md b/Kotlin/Синтаксис и типы/Базовые типы ООП/Value class.md new file mode 100644 index 0000000..e5eeb82 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовые типы ООП/Value class.md @@ -0,0 +1,265 @@ +--- +title: Value class +description: Обёртка без аллокации, типобезопасность примитивов, mangling имён и границы инлайнинга +tags: + - kotlin + - oop + - performance +--- + +`value class` - обёртка над одним значением, которая в байт-коде обычно **исчезает**: вместо объекта остаётся само значение. + +```kotlin +@JvmInline +value class UserId(val value: Int) + +fun load(id: UserId) = "пользователь ${id.value}" + +fun main() { + val id = UserId(42) + println(load(id)) // пользователь 42 + println(id.value) // 42 +} +``` +В скомпилированном коде `load` принимает `int`, а не объект. Аллокации нет. + +Аннотация `@JvmInline` обязательна на JVM - без неё компилятор выдаст ошибку. + +## Зачем +Основная причина - **типобезопасность без накладных расходов**. Классическая проблема: +```kotlin +fun transfer(from: Int, to: Int, amount: Int) = "$from -> $to: $amount" + +fun main() { + println(transfer(100, 1, 2)) // перепутали порядок — компилятор молчит +} +``` +Все параметры `Int`, поэтому их легко перепутать. С `value class` такая ошибка становится ошибкой компиляции: +```kotlin +@JvmInline value class AccountId(val value: Int) +@JvmInline value class Amount(val cents: Int) + +fun transfer(from: AccountId, to: AccountId, amount: Amount) = + "${from.value} -> ${to.value}: ${amount.cents}" + +fun main() { + println(transfer(AccountId(1), AccountId(2), Amount(100))) + // println(transfer(Amount(100), AccountId(1), AccountId(2))) // не скомпилируется +} +``` +Цена - ноль: в байт-коде всё те же три `int`. + +## Что можно внутри +```kotlin +@JvmInline +value class Email(val value: String) { + init { + require(value.contains("@")) { "неверный email: $value" } + } + + val domain: String get() = value.substringAfter("@") + fun masked() = value.replaceRange(0, value.indexOf("@"), "***") + + companion object { + fun parse(s: String): Email? = if (s.contains("@")) Email(s) else null + } +} + +fun main() { + val e = Email("ivan@example.com") + println(e.domain) // example.com + println(e.masked()) // ***@example.com + println(Email.parse("нет")) // null +} +``` +Разрешены: `init`, свойства с геттером, функции, `companion object`, реализация интерфейсов. + +Запрещены: второе свойство с полем, `var`-свойства с полем, наследование, блоки инициализации полей, `lateinit`. + +```kotlin +// @JvmInline value class Bad(val a: Int, val b: Int) // ошибка: только одно поле +// @JvmInline value class Bad2(var a: Int) // ошибка: нужен val +``` + +## Реализация интерфейса +```kotlin +interface Printable { fun print2(): String } + +@JvmInline +value class Code(val value: Int) : Printable { + override fun print2() = "код $value" +} + +fun main() { + println(Code(7).print2()) // код 7 + val p: Printable = Code(7) // упаковка! см. ниже + println(p.print2()) // код 7 +} +``` + +## Когда обёртка всё-таки создаётся +Инлайнинг не бесплатен в смысле предсказуемости: в некоторых позициях объект приходится создать. + +| Позиция | Инлайнится | +|---|---| +| параметр и результат функции | да | +| локальная переменная | да | +| nullable-тип (`UserId?`) | **нет** | +| приведение к супертипу/интерфейсу | **нет** | +| элемент коллекции (`List`) | **нет** - дженерики стираются | +| поле дженерик-класса | **нет** | + +```kotlin +@JvmInline value class UserId(val value: Int) + +fun main() { + val a: UserId = UserId(1) // int, без объекта + val b: UserId? = UserId(1) // объект UserId + val c: List = listOf(UserId(1)) // объекты внутри списка + println("${a.value} ${b?.value} ${c.size}") +} +``` +То есть `List` по памяти не лучше `List`. Выгода - в типобезопасности, а не в аллокациях. + +## Mangling имён +Чтобы разные `value class` над одним типом не давали одинаковые JVM-сигнатуры, компилятор добавляет суффикс к имени метода: +```kotlin +@JvmInline value class AccountId(val value: Int) + +fun process(id: AccountId) = id.value // в байт-коде: process-<хеш>(I)I + +fun main() { println(process(AccountId(1))) } // 1 +``` +Из Java такую функцию вызвать практически нельзя - имя непредсказуемо. Решение - `@JvmName`: +```kotlin +@JvmInline value class AccountId(val value: Int) + +@JvmName("processAccount") +fun process(id: AccountId) = id.value + +fun main() { println(process(AccountId(1))) } // 1 +``` +Подробнее: [Kotlin из Java](../../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) + +## Равенство +Сравнение идёт по содержимому, причём без упаковки: +```kotlin +@JvmInline value class UserId(val value: Int) + +fun main() { + println(UserId(1) == UserId(1)) // true + println(UserId(1) == UserId(2)) // false + println(setOf(UserId(1), UserId(1)).size) // 1 +} +``` +`equals`/`hashCode` генерируются автоматически и переопределять их нельзя. + +## value class и data class +```kotlin +@JvmInline value class Meters(val value: Double) +data class Point(val x: Int, val y: Int) +``` +`value class` - одно поле, без аллокации, нет `copy`. `data class` - любое число полей, всегда объект, есть `copy` и `componentN`. + +Для одного поля `data class` тоже работает, но создаёт объект: +```kotlin +data class SlowId(val value: Int) // объект на каждый вызов +@JvmInline value class FastId(val value: Int) // int + +fun main() { + println(SlowId(1) == SlowId(1)) // true, но два объекта были созданы + println(FastId(1) == FastId(1)) // true, объектов не было +} +``` + +## typealias - не замена +```kotlin +typealias UserId2 = Int + +fun load(id: UserId2) = id + +fun main() { + println(load(42)) // 42 — любое Int подходит, защиты нет +} +``` +`typealias` - только псевдоним, он не создаёт новый тип и не защищает от путаницы. Подробнее: [Typealias](Typealias.md) + +> [!info] Отличия от Java +> Прямого аналога нет. Java-разработчик выбирает между: +> - **обычным классом-обёрткой** - типобезопасно, но аллокация на каждый объект +> - **примитивом с комментарием** - быстро, но ошибки порядка аргументов не ловятся +> +> ```java +> // обёртка: безопасно, но объект +> final class UserId { +> private final int value; +> UserId(int value) { this.value = value; } +> // equals, hashCode, геттер... +> } +> ``` +> +> В Java это закрывает Project Valhalla (`value class` в JEP 401), но он ещё не вышел в релиз. Пока единственный способ получить безопасность без аллокации - `value class` Kotlin. +> +> | Java | Kotlin | +> |---|---| +> | класс-обёртка + аллокация | `@JvmInline value class` | +> | `int` напрямую | `value class` с тем же байт-кодом | +> | Valhalla (в разработке) | работает с Kotlin 1.5 | +> +> Сравнить: [Классы-обертки в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Классы-обертки.md) + +> [!warning] Подводные камни +> **`@JvmInline` обязательна.** Без неё на JVM компилятор выдаст ошибку, а не предупреждение: +> ```kotlin +> // value class Bad(val x: Int) // error: value class without @JvmInline +> @JvmInline value class Good(val x: Int) +> +> fun main() { println(Good(1).x) } +> ``` +> +> **Nullable-тип отменяет инлайнинг.** `UserId?` - настоящий объект: +> ```kotlin +> @JvmInline value class UserId(val value: Int) +> +> fun find(id: UserId?): String = id?.value?.toString() ?: "нет" +> +> fun main() { println(find(UserId(1))) } // 1 — но обёртка была создана +> ``` +> В горячем коде вместо `UserId?` лучше использовать значение-маркер. +> +> **В коллекциях выгоды нет.** `List` хранит объекты из-за стирания типов. +> +> **`init` вызывается, но не защищает от Java.** Java-код может создать экземпляр в обход проверки, потому что на уровне байт-кода это просто `int`: +> ```kotlin +> @JvmInline +> value class Age(val value: Int) { +> init { require(value >= 0) { "возраст отрицательный" } } +> } +> +> fun main() { +> println(Age(5).value) // 5 +> // Age(-1) // IllegalArgumentException из Kotlin +> } +> ``` +> +> **Mangling ломает вызов из Java и из фреймворков, работающих через рефлексию.** Jackson, Spring, JPA требуют отдельной настройки для `value class`; иногда он просто не поддерживается. +> +> **Переопределить `equals`, `hashCode` нельзя** - они генерируются. `toString` переопределить можно. +> +> **Два `value class` над одним типом взаимозаменяемы в байт-коде.** Приведение через `Any` обойдёт проверку типов: +> ```kotlin +> @JvmInline value class Meters(val v: Int) +> @JvmInline value class Feet(val v: Int) +> +> fun main() { +> val m = Meters(10) +> val any: Any = m +> println((any as Meters).v) // 10 +> // println((any as Feet).v) // ClassCastException — на уровне Kotlin проверка есть +> } +> ``` +> +> **Нельзя наследоваться и нельзя быть `open`** - иерархии из `value class` не построить. + +Дополнительно: [Typealias](Typealias.md) +Далее: [Object и companion object](Object%20и%20companion%20object.md) diff --git a/Kotlin/Синтаксис и типы/Базовые типы ООП/equals, hashCode и toString.md b/Kotlin/Синтаксис и типы/Базовые типы ООП/equals, hashCode и toString.md new file mode 100644 index 0000000..a5c5a65 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Базовые типы ООП/equals, hashCode и toString.md @@ -0,0 +1,320 @@ +--- +title: equals, hashCode и toString +description: Контракт равенства, == против ===, правила hashCode и как не сломать HashMap +tags: + - kotlin + - oop +--- + +Три метода объявлены в [Any](Any,%20Unit,%20Nothing.md) и есть у каждого объекта. Их реализация по умолчанию - по ссылке и по адресу в памяти. + +```kotlin +class Point(val x: Int, val y: Int) + +fun main() { + val a = Point(1, 2) + val b = Point(1, 2) + println(a == b) // false — equals по ссылке + println(a) // Point@1b6d3586 — toString по хешу +} +``` + +## == и === +```kotlin +fun main() { + val a = "Kotlin" + val b = StringBuilder("Kotlin").toString() + println(a == b) // true — структурное равенство, вызывает equals + println(a === b) // false — ссылочное равенство, тот же объект? + println(a !== b) // true +} +``` + +| Оператор | Что делает | +|---|---| +| `==` | `a?.equals(b) ?: (b === null)` | +| `===` | сравнение ссылок (для примитивов - значений) | + +`==` безопасен для `null`: левый операнд может быть `null`, NPE не будет. +```kotlin +fun main() { + val a: String? = null + println(a == "x") // false — без исключения + println(a == null) // true + println(null == null) // true +} +``` + +## Контракт equals +Реализация должна быть: +- **рефлексивной**: `a == a` всегда `true` +- **симметричной**: `a == b` тогда и только тогда, когда `b == a` +- **транзитивной**: `a == b` и `b == c` влечёт `a == c` +- **согласованной**: результат не меняется, пока объекты не изменились +- `a == null` всегда `false` + +```kotlin +class Point(val x: Int, val y: Int) { + override fun equals(other: Any?): Boolean { + if (this === other) return true // быстрый путь + if (other !is Point) return false // проверка типа и null сразу + return x == other.x && y == other.y + } + + override fun hashCode(): Int = 31 * x + y + + override fun toString() = "Point($x, $y)" +} + +fun main() { + val a = Point(1, 2) + println(a == Point(1, 2)) // true + println(a == null) // false + println(a) // Point(1, 2) +} +``` +`other !is Point` закрывает и `null`, и неверный тип: `is` для `null` даёт `false`. + +Параметр обязан иметь тип `Any?` - иначе это не переопределение, а перегрузка: +```kotlin +class Bad(val x: Int) { + fun equals(other: Bad) = x == other.x // перегрузка! override не стоит +} + +fun main() { + val a: Any = Bad(1) + println(a == Bad(1)) // false — вызвался equals из Any +} +``` +Компилятор не позволит поставить `override` на такую функцию - это и есть подсказка. + +## Контракт hashCode +Единственное жёсткое правило: **равные объекты обязаны иметь равный хеш**. + +```kotlin +class Broken(val x: Int) { + override fun equals(other: Any?) = other is Broken && other.x == x + // hashCode не переопределён — контракт нарушен +} + +fun main() { + val a = Broken(1) + val set = hashSetOf(a) + println(set.contains(Broken(1))) // false — объект "потерян" + println(a == Broken(1)) // true — хотя equals говорит "равны" +} +``` +`HashSet` сначала ищет бакет по хешу. Хеши разные - поиск даже не доходит до `equals`. + +Обратное неверно: разные объекты **могут** иметь одинаковый хеш (коллизия) - это нормально и лишь замедляет поиск. + +```kotlin +class Pair2(val a: Int, val b: Int) { + override fun equals(other: Any?) = other is Pair2 && other.a == a && other.b == b + override fun hashCode() = 31 * a + b +} + +fun main() { + println(Pair2(1, 32).hashCode()) // 63 + println(Pair2(2, 1).hashCode()) // 63 — коллизия, но всё корректно + println(Pair2(1, 32) == Pair2(2, 1)) // false +} +``` + +Стандартный способ считать хеш по нескольким полям: +```kotlin +class User(val id: Int, val name: String, val tags: List) { + override fun equals(other: Any?) = + other is User && other.id == id && other.name == name && other.tags == tags + + override fun hashCode(): Int { + var result = id + result = 31 * result + name.hashCode() + result = 31 * result + tags.hashCode() + return result + } +} + +fun main() { + val a = User(1, "Иван", listOf("admin")) + println(a == User(1, "Иван", listOf("admin"))) // true + println(a.hashCode() == User(1, "Иван", listOf("admin")).hashCode()) // true +} +``` +Множитель 31 - соглашение из JDK: простое число, а умножение на него оптимизируется в сдвиг и вычитание. + +Короче - через `Objects.hash`, но он создаёт массив: +```kotlin +import java.util.Objects + +class User(val id: Int, val name: String) { + override fun equals(other: Any?) = other is User && other.id == id && other.name == name + override fun hashCode() = Objects.hash(id, name) +} + +fun main() { println(User(1, "a") == User(1, "a")) } // true +``` + +## data class делает это за вас +```kotlin +data class Point(val x: Int, val y: Int) + +fun main() { + val a = Point(1, 2) + println(a == Point(1, 2)) // true + println(a) // Point(x=1, y=2) + println(hashSetOf(a).contains(Point(1, 2))) // true +} +``` +В большинстве случаев ручная реализация не нужна - достаточно `data class`. Писать руками приходится, когда: равенство только по части полей, в классе есть массив, или класс - сущность с идентификатором. Подробнее: [Data class](Data%20class.md) + +## toString +Единственный из трёх, у которого нет контракта - но есть ожидания. Он попадает в логи и в сообщения об ошибках: +```kotlin +class Order(val id: Int, val items: List) { + override fun toString() = "Order(id=$id, items=${items.size})" +} + +fun main() { + println(Order(1, listOf("a", "b"))) // Order(id=1, items=2) + println("заказ: ${Order(1, emptyList())}") // заказ: Order(id=1, items=0) + println(listOf(Order(1, emptyList()))) // [Order(id=1, items=0)] +} +``` +Строковый шаблон, конкатенация и печать коллекции - все вызывают `toString`. Поэтому класс без нормального `toString` превращает лог в набор адресов. + +## Равенство и наследование +Строгая симметричность при наследовании достигается проверкой точного класса: +```kotlin +open class Base(val x: Int) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + return x == (other as Base).x + } + override fun hashCode() = x +} + +class Derived(x: Int, val y: Int) : Base(x) { + override fun equals(other: Any?) = + super.equals(other) && y == (other as Derived).y + override fun hashCode() = 31 * super.hashCode() + y +} + +fun main() { + println(Base(1) == Derived(1, 2)) // false — разные классы + println(Derived(1, 2) == Derived(1, 2)) // true +} +``` +С `other is Base` вместо сравнения классов получилась бы асимметрия: `base == derived` истина, `derived == base` ложь. Это нарушение контракта, которое ломает коллекции. + +Проще всего не наследоваться там, где важно равенство - именно поэтому `data class` нельзя сделать `open`. + +## Равенство примитивов и упаковка +```kotlin +fun main() { + val a: Int = 1000 + val b: Int = 1000 + println(a == b) // true + println(a === b) // true — примитивы, сравнение значений + + val c: Int? = 1000 + val d: Int? = 1000 + println(c == d) // true + println(c === d) // false! разные объекты Integer +} +``` +Nullable-число упаковывается в `Integer`, и `===` сравнивает ссылки. Для значений от -128 до 127 работает кеш JVM, и `===` даёт `true` - то есть результат зависит от величины числа. Для чисел используйте только `==`. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `a.equals(b)` | `a == b` | +> | `a == b` (ссылки) | `a === b` | +> | `Objects.equals(a, b)` для null | `a == b` | +> | `@Override boolean equals(Object o)` | `override fun equals(other: Any?)` | +> | `o instanceof Point p` (Java 16+) | `other is Point` + умное приведение | +> | `Objects.hash(a, b)` | то же, или вручную | +> | генерация в IDE или Lombok | `data class` | +> | `String.valueOf(obj)` для null | `obj.toString()` работает на `null?` | +> +> **Главное: `==` в Kotlin вызывает `equals`.** Это убирает самый частый класс ошибок Java - сравнение строк через `==`, которое иногда «работает» из-за пула строк и ломается на строках, собранных в рантайме. +> +> **`==` не бросает NPE**, в отличие от `a.equals(b)` в Java. Обёртка `Objects.equals` больше не нужна. +> +> **`toString()` можно звать на `null`:** +> ```kotlin +> fun main() { +> val s: String? = null +> println(s.toString()) // "null" — расширение Any?.toString() +> } +> ``` +> В Java `null.toString()` - это NPE, нужен `String.valueOf`. +> +> **`equals` в Kotlin не перегрузить случайно.** Параметр с типом `Any?` обязателен для `override`, поэтому ошибка «написал `equals(Point)` вместо `equals(Object)`» ловится компилятором. +> +> Сравнить: [Контракт между equals и hashCode](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Контракт%20между%20equals%20и%20hashCode.md), [HashMap в Java](../../../Программирование%20на%20Java/Коллекции/HashMap.md) + +> [!warning] Подводные камни +> **Переопределить `equals` без `hashCode`** - значит получить объект, который теряется в `HashSet` и `HashMap`. IDE предупреждает, компилятор - нет. +> +> **Изменяемый объект как ключ `HashMap`:** +> ```kotlin +> data class Key(var id: Int) +> +> fun main() { +> val key = Key(1) +> val map = hashMapOf(key to "значение") +> key.id = 2 +> println(map[key]) // null — ищем в новом бакете +> println(map.keys.first()) // Key(id=2) — запись на месте, но недоступна +> println(map.size) // 1 +> } +> ``` +> Запись становится невидимой и неудаляемой. Ключи обязаны быть неизменяемыми. +> +> **Массив внутри класса ломает `equals` по содержимому:** +> ```kotlin +> class Packet(val data: ByteArray) { +> override fun equals(other: Any?) = +> other is Packet && data.contentEquals(other.data) +> override fun hashCode() = data.contentHashCode() +> } +> +> fun main() { +> println(Packet(byteArrayOf(1)) == Packet(byteArrayOf(1))) // true +> } +> ``` +> Без `contentEquals`/`contentHashCode` сравнение идёт по ссылке. Подробнее: [Массивы](../Работа%20с%20данными/Массивы.md) +> +> **`===` для чисел зависит от значения:** +> ```kotlin +> fun main() { +> val a: Int? = 127 +> val b: Int? = 127 +> println(a === b) // true — из кеша Integer +> val c: Int? = 128 +> val d: Int? = 128 +> println(c === d) // false — за пределами кеша +> } +> ``` +> Это не особенность Kotlin, а кеш `Integer.valueOf` в JVM. Вывод: `===` на числах не использовать. +> +> **`equals` с `is` вместо проверки класса даёт асимметрию при наследовании** - и вместе с ней непредсказуемое поведение коллекций. +> +> **`hashCode`, зависящий от изменяемого поля, нарушает согласованность.** Объект должен давать один и тот же хеш всё время, пока лежит в хеш-коллекции. +> +> **`toString` печатает всё, включая секреты:** +> ```kotlin +> data class Token(val value: String) { +> override fun toString() = "Token(***)" +> } +> +> fun main() { println(Token("secret-abc")) } // Token(***) +> ``` +> `data class` с паролем или токеном попадёт в лог целиком, если не переопределить `toString`. +> +> **`hashCode` не обязан быть стабильным между запусками JVM.** `Object.hashCode` зависит от адреса, `String.hashCode` стабилен по спецификации, но для своего класса гарантий нет - сохранять хеш в БД нельзя. + +Дополнительно: [Data class](Data%20class.md) +Далее: [ООП](../../ООП/ООП.md) diff --git a/Kotlin/Синтаксис и типы/Базовый синтаксис/Пакеты и импорты.md b/Kotlin/Синтаксис и типы/Базовый синтаксис/Пакеты и импорты.md index 54d87da..7a1ffa5 100644 --- a/Kotlin/Синтаксис и типы/Базовый синтаксис/Пакеты и импорты.md +++ b/Kotlin/Синтаксис и типы/Базовый синтаксис/Пакеты и импорты.md @@ -113,6 +113,8 @@ fun helper() { } // из Java: Utils.helper() > | `package-private` по умолчанию | `public` по умолчанию | > > Последняя строка важна: в Java класс без модификатора виден только своему пакету, в Kotlin - всем. Для сокрытия внутренностей библиотеки используют `internal`. +> +> Сравнить: [Модификаторы в Java](../../../Программирование%20на%20Java/ООП/Инкапсуляция/Модификаторы.md) > [!warning] Подводные камни > **Нет аналога package-private.** `internal` ограничивает *модулем* (единицей компиляции Gradle), а не пакетом. Разделить код на пакеты и спрятать их друг от друга внутри одного модуля нельзя - только разносить по модулям. diff --git a/Kotlin/Синтаксис и типы/Работа с данными/Char.md b/Kotlin/Синтаксис и типы/Работа с данными/Char.md new file mode 100644 index 0000000..b1602c5 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Работа с данными/Char.md @@ -0,0 +1,140 @@ +--- +title: Char +description: Char как отдельный тип, код символа, проверки категорий и работа с цифрами +tags: + - kotlin + - stdlib +--- + +`Char` - один символ в одинарных кавычках. Хранит кодовую единицу UTF-16 (16 бит). + +```kotlin +val c: Char = 'A' +val tab = '\t' +val quote = '\'' +val unicode = '\u00A9' // © +``` + +Ключевое отличие от Java: **`Char` не является числом**. Арифметику с ним делать можно, но неявного превращения в `Int` нет. + +```kotlin +val c = 'A' +// val n: Int = c // ошибка компиляции +val n: Int = c.code // 65 — явно +println(c + 1) // B — Char + Int = Char +println('z' - 'a') // 25 — Char - Char = Int +println(c.toString()) // A +``` + +| Выражение | Результат | Тип | +|---|---|---| +| `'A' + 1` | `'B'` | `Char` | +| `'z' - 'a'` | `25` | `Int` | +| `'A'.code` | `65` | `Int` | +| `65.toChar()` | `'A'` | `Char` | + +## Проверки категорий +```kotlin +println('a'.isLetter()) // true +println('5'.isDigit()) // true +println('a'.isLetterOrDigit()) // true +println(' '.isWhitespace()) // true +println('a'.isLowerCase()) // true +println('A'.isUpperCase()) // true +``` +Все эти функции учитывают Unicode: `'я'.isLetter()` тоже `true`. + +## Регистр +```kotlin +println('a'.uppercaseChar()) // A — возвращает Char +println('A'.lowercaseChar()) // a +println('a'.uppercase()) // "A" — возвращает String +println('ß'.uppercase()) // "SS" — два символа, поэтому String +``` +Есть две версии: `uppercaseChar()` даёт `Char`, `uppercase()` - `String`. Вторая нужна, потому что в Unicode регистр не всегда меняется один-к-одному. + +## Цифры +```kotlin +println('7'.digitToInt()) // 7 — число, а не код символа +println('f'.digitToInt(16)) // 15 +println('x'.digitToIntOrNull()) // null — безопасная версия +println(7.digitToChar()) // '7' +``` +Это главный способ разобрать цифру. Старый трюк из Java (`c - '0'`) тоже работает, но `digitToInt()` понятнее и проверяет корректность: +```kotlin +println('7' - '0') // 7 — работает +// println('x'.digitToInt()) // IllegalArgumentException +``` + +## Диапазоны и сравнение +```kotlin +println('a' in 'a'..'z') // true +println('5' in '0'..'9') // true +for (c in 'a'..'e') print(c) // abcde +println('a' < 'b') // true — по коду +println(('a'..'e').toList()) // [a, b, c, d, e] +``` + +## Строки и Char +```kotlin +val s = "Kotlin" +println(s[0]) // K — элемент строки это Char +println(s.first()) // K +println(s.toCharArray().size) // 6 +println(String(charArrayOf('a','b'))) // ab — собрать строку из массива +println(s.toList()) // [K, o, t, l, i, n] +println("abc".map { it + 1 }) // [b, c, d] +println(charArrayOf('a','b').concatToString()) // ab +``` + +Приписать `Char` к строке можно сложением - слева обязательно строка: +```kotlin +println("ab" + 'c') // abc +// println('c' + "ab") // ошибка: Char.plus ожидает Int +println("${'c'}ab") // cab — через шаблон +``` + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `char` - числовой тип | `Char` - отдельный тип | +> | `int n = c;` работает | нужен `c.code` | +> | `Character.isDigit(c)` | `c.isDigit()` | +> | `Character.toUpperCase(c)` | `c.uppercaseChar()` | +> | `Character.getNumericValue(c)` | `c.digitToInt()` | +> | `c - '0'` для цифры | `c.digitToInt()` | +> | `switch (c)` по числам | `when (c)` по символам | +> +> В Java `char` участвует в арифметике как число, из-за чего `System.out.println('a' + 1)` печатает `98`, а не `b`. В Kotlin тот же код даёт `b` - тип сохраняется. +> +> Все утилиты из `Character` стали методами `Char`, поэтому пишутся через точку и находятся автодополнением. +> +> Сравнить: [Character в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Character.md) + +> [!warning] Подводные камни +> **`Char` не вмещает символы за пределами BMP.** Эмодзи и редкие иероглифы кодируются двумя `Char` (surrogate pair): +> ```kotlin +> val emoji = "😀" +> println(emoji.length) // 2 +> println(emoji[0].isLetter()) // false — половина пары бессмысленна +> println(emoji.codePointAt(0)) // 128512 — полный код символа +> ``` +> Для посимвольной обработки текста с эмодзи нужны кодовые точки, а не `Char`. +> +> **`digitToInt()` бросает исключение, а не возвращает -1:** +> ```kotlin +> println('x'.digitToIntOrNull()) // null — безопасно +> // println('x'.digitToInt()) // IllegalArgumentException +> ``` +> +> **`'1'.code` - это 49, а не 1.** Путать код символа с его числовым значением - классическая ошибка: +> ```kotlin +> println('1'.code) // 49 +> println('1'.digitToInt()) // 1 +> ``` +> +> **`Char` в `when` требует `else`** - перечислить все 65 536 значений компилятор не может. +> +> **`uppercase()` возвращает `String`, и это не придирка.** `'ß'.uppercase()` даёт `"SS"` - два символа. Если нужен именно `Char`, используйте `uppercaseChar()`, но помните, что для `'ß'` он вернёт сам `'ß'`. + +Далее: [Числа и переполнение](Числа%20и%20переполнение.md) diff --git a/Kotlin/Синтаксис и типы/Работа с данными/Деструктуризация.md b/Kotlin/Синтаксис и типы/Работа с данными/Деструктуризация.md new file mode 100644 index 0000000..c87e99a --- /dev/null +++ b/Kotlin/Синтаксис и типы/Работа с данными/Деструктуризация.md @@ -0,0 +1,218 @@ +--- +title: Деструктуризация +description: Разбор объекта на переменные, componentN, деструктуризация в лямбдах и циклах +tags: + - kotlin + - syntax +--- + +Деструктуризация - разбор объекта на отдельные переменные одной конструкцией. + +```kotlin +data class Point(val x: Int, val y: Int) + +fun main() { + val (x, y) = Point(1, 2) + println("$x $y") // 1 2 +} +``` + +Компилятор превращает это в вызовы `component1()`, `component2()` и так далее: +```kotlin +data class Point(val x: Int, val y: Int) + +fun main() { + val p = Point(1, 2) + val x = p.component1() + val y = p.component2() + println("$x $y") // 1 2 +} +``` + +## Что можно деструктурировать +Любой тип, у которого есть функции `componentN()`. Автоматически они появляются у: +- [data class](../Базовые%20типы%20ООП/Data%20class.md) - по свойствам основного конструктора +- `Pair` и `Triple` +- `Map.Entry` - ключ и значение +- списков и массивов (первые 5 элементов) + +```kotlin +fun main() { + val (a, b) = Pair(1, "x") + val (c, d, e) = Triple(1, 2, 3) + val (first, second) = listOf("a", "b", "c") // третий игнорируется + println("$a $b | $c $d $e | $first $second") +} +``` + +## Свой componentN +Для обычного класса операторы объявляются вручную: +```kotlin +class Range2(val from: Int, val to: Int) { + operator fun component1() = from + operator fun component2() = to +} + +fun main() { + val (from, to) = Range2(1, 10) + println("$from..$to") // 1..10 +} +``` +Слово `operator` обязательно. + +## Пропуск компонентов +```kotlin +data class User(val id: Int, val name: String, val email: String) + +fun main() { + val (_, name, _) = User(1, "Иван", "i@x.ru") + println(name) // Иван +} +``` +Подчёркивание означает «этот компонент не нужен» - `componentN()` для него даже не вызовется. + +Если нужны только первые компоненты, лишние просто не перечисляют: +```kotlin +data class User(val id: Int, val name: String, val email: String) + +fun main() { + val (id, name) = User(1, "Иван", "i@x.ru") // email не берём + println("$id $name") // 1 Иван +} +``` + +## В циклах +```kotlin +fun main() { + val map = mapOf("a" to 1, "b" to 2) + for ((key, value) in map) { + println("$key=$value") // a=1, затем b=2 + } + + val list = listOf("x", "y") + for ((index, item) in list.withIndex()) { + println("$index:$item") // 0:x, затем 1:y + } +} +``` +Это самое частое применение деструктуризации. + +## В лямбдах +```kotlin +fun main() { + val map = mapOf("a" to 1, "b" to 2) + map.forEach { (k, v) -> println("$k$v") } // a1 b2 + println(map.map { (k, v) -> "$k=$v" }) // [a=1, b=2] + map.forEach { (_, v) -> print(v) } // 12 + println() + + val points = listOf(1 to 2, 3 to 4) + println(points.map { (x, y) -> x + y }) // [3, 7] +} +``` +Параметр в скобках `{ (k, v) -> }` - это **один** параметр, который разбирается, а не два отдельных. Без скобок `{ k, v -> }` лямбда ожидала бы два аргумента, и код не скомпилировался бы. + +## Возврат нескольких значений +Классическое применение - функция, которая должна вернуть пару значений: +```kotlin +data class Result2(val value: Int, val error: String?) + +fun parse(s: String): Result2 = + s.toIntOrNull()?.let { Result2(it, null) } ?: Result2(0, "не число") + +fun main() { + val (value, error) = parse("42") + println("$value $error") // 42 null + + val (v2, e2) = parse("abc") + println("$v2 $e2") // 0 не число +} +``` +`Pair` для этого тоже подходит, но свой `data class` лучше: имена компонентов осмысленны, а не `first`/`second`. + +## Деструктуризация по именам (Kotlin 2.3+) +Классическая деструктуризация позиционная - порядок переменных должен совпадать с порядком свойств. Это хрупко: перестановка свойств в `data class` молча меняет смысл кода. + +С Kotlin 2.3 разрабатывается деструктуризация по именам. Синтаксис меняется так: +- `val [a, b] = obj` - **квадратные** скобки, позиционно (как было) +- `val (name, email) = obj` - **круглые** скобки, теперь **по именам** свойств + +```kotlin +data class User(val name: String, val email: String) + +fun main() { + val user = User("ИМЯ", "ПОЧТА") + + val (email, name) = user // по именам — порядок не важен + println("name=$name email=$email") // name=ИМЯ email=ПОЧТА + + val [first, second] = user // позиционно + println("first=$first") // first=ИМЯ +} +``` + +Фича пока за флагом компилятора: +```bash +kotlinc -Xname-based-destructuring=complete App.kt +``` +Значения флага: `only-syntax` (включить синтаксис `[]` и полную форму), `name-mismatch` (предупреждать, когда имена в позиционной деструктуризации не совпадают со свойствами), `complete` (включить короткую форму по именам). + +> [!warning] Это ломающее изменение +> Круглые скобки меняют смысл: был порядок, станут имена. Код `val (email, name) = user` сегодня присвоит `email` первое свойство, а после включения фичи - свойство с именем `email`. Пока флаг не включён, вся деструктуризация в вашем коде позиционная. + +> [!info] Отличия от Java +> В Java деструктуризации объектов нет. Ближайший аналог - record patterns из Java 21: +> ```java +> record Point(int x, int y) {} +> if (obj instanceof Point(int x, int y)) { ... } +> ``` +> Но это работает только в `instanceof` и `switch`, а не при обычном объявлении переменных. Разбор `Map.Entry` в цикле в Java невозможен - нужно писать `e.getKey()` и `e.getValue()`. +> +> | Java | Kotlin | +> |---|---| +> | `var k = e.getKey(); var v = e.getValue();` | `val (k, v) = e` | +> | `for (var e : map.entrySet())` + геттеры | `for ((k, v) in map)` | +> | `p.x(); p.y();` | `val (x, y) = p` | +> | нет возврата нескольких значений | `Pair`, `Triple`, `data class` | +> +> Сравнить: [Record в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Record.md) + +> [!warning] Подводные камни +> **Деструктуризация позиционная, а не по именам.** Это главная опасность: +> ```kotlin +> data class User(val name: String, val email: String) +> +> fun main() { +> val (name, email) = User("Иван", "i@x.ru") +> println("$name / $email") // Иван / i@x.ru +> } +> ``` +> Если позже в `data class` поменять порядок свойств на `(email, name)`, этот код **продолжит компилироваться**, но переменные получат чужие значения. Компилятор не поможет, потому что типы совпадают. Поэтому порядок свойств в `data class` - часть публичного API. +> +> **У обычного класса деструктуризации нет** - только у `data class` или при ручном объявлении `componentN()`. +> +> **В лямбде нужны скобки:** +> ```kotlin +> fun main() { +> val map = mapOf("a" to 1) +> map.forEach { (k, v) -> println("$k$v") } // верно: один параметр-пара +> map.forEach { e -> println("${e.key}${e.value}") } // тоже верно +> } +> ``` +> Запись `{ k, v -> }` для `forEach` на `Map` не скомпилируется. +> +> **Список короче числа переменных - исключение в рантайме:** +> ```kotlin +> fun main() { +> val list = listOf(1) +> val (a, b) = list // компилируется! +> println("$a $b") // IndexOutOfBoundsException +> } +> ``` +> У `List` есть `component1()`..`component5()`, и компилятор не знает фактическую длину. С `data class` такой проблемы нет. +> +> **`component1()` вызывается даже для неиспользуемой переменной** - если это не `_`. Для свойства с тяжёлым геттером лишняя работа. +> +> **Деструктуризация `Pair` теряет смысл имён.** `val (a, b) = getCoordinates()` не говорит, что где. Лучше `data class Coordinates(val lat: Double, val lon: Double)`. + +Далее: [Приведение типов](Приведение%20типов.md) diff --git a/Kotlin/Синтаксис и типы/Работа с данными/Лямбды и функциональные типы.md b/Kotlin/Синтаксис и типы/Работа с данными/Лямбды и функциональные типы.md new file mode 100644 index 0000000..976a504 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Работа с данными/Лямбды и функциональные типы.md @@ -0,0 +1,273 @@ +--- +title: Лямбды и функциональные типы +description: Синтаксис лямбд, it, функциональные типы, ссылки на функции и анонимные функции +tags: + - kotlin + - functional +--- + +Функциональный тип описывает функцию как значение: `(параметры) -> результат`. + +```kotlin +val square: (Int) -> Int = { x -> x * x } +val greet: (String) -> String = { "Привет, $it" } +val action: () -> Unit = { println("сделано") } +val sum: (Int, Int) -> Int = { a, b -> a + b } + +fun main() { + println(square(4)) // 16 + println(greet("мир")) // Привет, мир + action() // сделано + println(sum(2, 3)) // 5 +} +``` + +## Синтаксис лямбды +Лямбда всегда в фигурных скобках: +```kotlin +{ параметры -> тело } +``` +```kotlin +val f = { x: Int, y: Int -> x + y } // типы указаны в лямбде +val g: (Int) -> Int = { it * 2 } // тип указан у переменной +``` +Тип нужно указать либо у переменной, либо у параметров лямбды - где-то компилятор должен его увидеть. + +Результат - **последнее выражение** тела, `return` не нужен и в обычной лямбде запрещён: +```kotlin +val describe: (Int) -> String = { n -> + val prefix = if (n > 0) "плюс" else "минус" + "$prefix $n" // это и есть результат +} + +fun main() { println(describe(5)) } // плюс 5 +``` + +## it - неявный параметр +Если параметр один, его можно не объявлять: +```kotlin +val double: (Int) -> Int = { it * 2 } +println(listOf(1, 2, 3).map { it * 10 }) // [10, 20, 30] +``` +`it` удобен в короткой лямбде. Если тело длинное или лямбды вложены - давайте имя: +```kotlin +val users = listOf("Иван", "Пётр") +println(users.map { name -> name.uppercase() }) // [ИВАН, ПЁТР] +``` + +## Лямбда последним аргументом +Если последний параметр функции - функция, лямбду выносят за скобки: +```kotlin +fun measure(name: String, block: () -> Unit) { + val start = System.nanoTime() + block() + println("$name занял ${System.nanoTime() - start} нс") +} + +fun main() { + measure("цикл") { // лямбда вне скобок + repeat(1000) { } + } + measure("пусто", { }) // так тоже можно, но не принято +} +``` +Когда функция принимает **только** лямбду, круглые скобки опускаются совсем. Именно на этом правиле построены `forEach`, `let`, `apply` и все DSL: +```kotlin +listOf(1, 2).forEach { println(it) } +``` +Подробнее: [DSL](../../Функциональный%20Kotlin/DSL.md) + +## Неиспользуемые параметры +```kotlin +val map = mapOf("a" to 1) +map.forEach { (_, value) -> println(value) } // 1 — ключ не нужен +``` +Подчёркивание говорит компилятору, что параметр намеренно игнорируется. + +## Ссылки на функции - :: +Вместо лямбды, которая только вызывает существующую функцию, используют ссылку: +```kotlin +fun isEven(n: Int) = n % 2 == 0 + +fun main() { + val nums = listOf(1, 2, 3, 4) + println(nums.filter { isEven(it) }) // [2, 4] — лямбда-обёртка + println(nums.filter(::isEven)) // [2, 4] — ссылка, короче + nums.forEach(::println) +} +``` + +Виды ссылок: +```kotlin +class User(val name: String) { + fun greet() = "Привет, $name" +} + +fun main() { + val byName: (User) -> String = User::name // ссылка на свойство + val greeter: (User) -> String = User::greet // на метод класса + val factory: (String) -> User = ::User // на конструктор + + val u = User("Иван") + println(byName(u)) // Иван + println(greeter(u)) // Привет, Иван + println(factory("Пётр").name) // Пётр + + val bound = u::greet // привязанная к объекту + println(bound()) // Привет, Иван +} +``` + +## Анонимные функции +Когда нужен явный `return` или явный тип результата: +```kotlin +val parse = fun(s: String): Int? { + if (s.isBlank()) return null // return работает + return s.toIntOrNull() +} + +fun main() { + println(parse(" ")) // null + println(parse("42")) // 42 +} +``` +Отличие от лямбды: `return` выходит из самой анонимной функции, а в лямбде он запрещён (или выходит из внешней функции, если лямбда inline). + +## Функциональные типы в подробностях +```kotlin +val a: () -> Unit // без параметров, без результата +val b: (Int) -> String // один параметр +val c: (Int, Int) -> Int // два параметра +val d: ((Int) -> Int) -> Int // принимает функцию +val e: (Int) -> ((Int) -> Int) // возвращает функцию +val f: String.(Int) -> String // с получателем (receiver) +val g: ((Int) -> Int)? // nullable +val h: suspend () -> Unit // suspend-функция +``` + +Тип с получателем вызывается как метод: +```kotlin +val repeatTimes: String.(Int) -> String = { n -> this.repeat(n) } + +fun main() { + println("ab".repeatTimes(2)) // abab +} +``` +На этом построены `apply`, `buildString` и DSL. Подробнее: [Receivers и context parameters](../../Функциональный%20Kotlin/Receivers%20и%20context%20parameters.md) + +## Замыкания +Лямбда захватывает переменные из окружения - **включая изменяемые**: +```kotlin +fun counter(): () -> Int { + var count = 0 // захвачена по ссылке + return { ++count } +} + +fun main() { + val next = counter() + println(next()) // 1 + println(next()) // 2 + println(next()) // 3 +} +``` +Это важное отличие от Java, где захватить можно только effectively final переменную. + +```kotlin +fun main() { + var sum = 0 + listOf(1, 2, 3).forEach { sum += it } // в Java так нельзя + println(sum) // 6 +} +``` + +## Вызов +```kotlin +val f: (Int) -> Int = { it + 1 } + +fun main() { + println(f(1)) // 2 — обычный вызов + println(f.invoke(1)) // 2 — то же самое + val g: ((Int) -> Int)? = null + println(g?.invoke(1)) // null — безопасный вызов nullable-функции +} +``` + +## Что происходит под капотом +Функциональный тип компилируется в интерфейс `FunctionN`: +```kotlin +val f: (Int) -> Int = { it * 2 } // объект класса Function1 +``` +Это значит аллокацию на каждую лямбду, если она захватывает переменные. Для функций высшего порядка в горячем коде существует `inline` - он подставляет тело лямбды в место вызова, и объекта не создаётся вовсе. + +Подробнее: [Inline-функции](../../Функциональный%20Kotlin/Inline-функции.md), [Kotlin в байт-коде](../../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `x -> x * 2` | `{ x -> x * 2 }` или `{ it * 2 }` | +> | `Function` | `(Int) -> Int` | +> | `BiFunction` | `(A, B) -> C` | +> | `Supplier` | `() -> T` | +> | `Consumer` | `(T) -> Unit` | +> | `Predicate` | `(T) -> Boolean` | +> | `Runnable` | `() -> Unit` | +> | `String::length` | `String::length` | +> | нет неявного параметра | `it` | +> | захват только effectively final | захват изменяемых переменных | +> | лямбда только для SAM-интерфейса | функциональный тип первого класса | +> +> В Java лямбда - это реализация функционального интерфейса, поэтому для каждой формы нужен свой тип (`Function`, `BiFunction`, `Supplier`, `Consumer`, `Predicate`…). В Kotlin тип функции записывается напрямую: `(Int, String) -> Boolean` не требует придумывать имя. +> +> Захват изменяемых переменных - практическая разница. Java требует `final` или effectively final, вынуждая использовать массив-обёртку `int[] sum = {0}`. В Kotlin `var sum = 0` захватывается как есть (компилятор оборачивает его в `Ref`-объект). +> +> Сравнить: [Функции в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md) + +> [!warning] Подводные камни +> **`return` в лямбде запрещён** - он относился бы к внешней функции: +> ```kotlin +> fun find(items: List): Int? { +> items.forEach { +> if (it > 1) return it // выход из find, а не из лямбды! +> } // работает только потому, что forEach inline +> return null +> } +> +> fun main() { println(find(listOf(1, 2, 3))) } // 2 +> ``` +> Это «нелокальный return», он разрешён только в inline-функциях. Для выхода из самой лямбды нужна метка: +> ```kotlin +> fun main() { +> listOf(1, 2, 3).forEach { +> if (it == 2) return@forEach // пропустить итерацию, как continue +> println(it) // 1, затем 3 +> } +> } +> ``` +> +> **`it` во вложенных лямбдах затеняется:** +> ```kotlin +> fun main() { +> val nested = listOf(listOf(1, 2)) +> // println(nested.map { it.map { it * 2 } }) // непонятно, какое it +> println(nested.map { row -> row.map { v -> v * 2 } }) // [[2, 4]] +> } +> ``` +> +> **Каждая не-inline лямбда с захватом - объект.** В цикле на миллион итераций это миллион аллокаций. Стандартные операции (`map`, `filter`, `forEach`) объявлены `inline`, поэтому бесплатны; свои функции высшего порядка в горячем коде тоже стоит делать `inline`. +> +> **Лямбда, захватившая `this`, держит объект в памяти** - классическая утечка в Android, когда колбэк переживает активити. +> +> **Ссылка на перегруженную функцию неоднозначна:** +> ```kotlin +> fun show(x: Int) = "int" +> fun show(x: String) = "str" +> +> fun main() { +> // val f = ::show // ошибка: какая из двух? +> val f: (Int) -> String = ::show // тип разрешает неоднозначность +> println(f(1)) // int +> } +> ``` + +Дополнительно: [Функции высшего порядка](../../Функциональный%20Kotlin/Функции%20высшего%20порядка.md) +Далее: [Деструктуризация](Деструктуризация.md) diff --git a/Kotlin/Синтаксис и типы/Работа с данными/Массивы.md b/Kotlin/Синтаксис и типы/Работа с данными/Массивы.md new file mode 100644 index 0000000..a4e53f3 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Работа с данными/Массивы.md @@ -0,0 +1,249 @@ +--- +title: Массивы +description: Array, специализированные IntArray, сравнение через contentEquals и почему обычно нужен List +tags: + - kotlin + - stdlib +--- + +Массив - фиксированная по размеру последовательность элементов. В Kotlin это класс `Array`, а не синтаксическая конструкция. + +```kotlin +val a = arrayOf(1, 2, 3) +val b = Array(3) { it * 2 } // [0, 2, 4] — по лямбде от индекса +val c = arrayOfNulls(3) // [null, null, null] +val d = emptyArray() + +println(a[0]) // 1 +a[0] = 10 +println(a.size) // 3 +println(a.joinToString()) // 10, 2, 3 +``` +Размер задаётся при создании и не меняется. Элементы изменяемы всегда - даже если массив объявлен через `val`. + +## Специализированные массивы +Для чисел есть отдельные классы без упаковки: +```kotlin +val ints = intArrayOf(1, 2, 3) +val zeros = IntArray(5) // [0, 0, 0, 0, 0] — заполнен нулями +val squares = IntArray(5) { it * it } // [0, 1, 4, 9, 16] +val doubles = doubleArrayOf(1.0, 2.0) +val chars = charArrayOf('a', 'b') +val bools = BooleanArray(3) // [false, false, false] +``` + +| Класс | Байт-код | Аналог в Java | +|---|---|---| +| `IntArray` | `int[]` | `int[]` | +| `LongArray` | `long[]` | `long[]` | +| `DoubleArray` | `double[]` | `double[]` | +| `CharArray` | `char[]` | `char[]` | +| `BooleanArray` | `boolean[]` | `boolean[]` | +| `Array` | `Integer[]` | `Integer[]` | + +Разница принципиальна: `Array` хранит **упакованные** объекты, `IntArray` - примитивы. В горячем коде это заметно по памяти и скорости. + +```kotlin +val boxed: Array = arrayOf(1, 2, 3) // Integer[] — аллокация на элемент +val raw: IntArray = intArrayOf(1, 2, 3) // int[] — без упаковки +println("${boxed.size} ${raw.size}") +``` + +## Сравнение - только contentEquals +`==` на массивах сравнивает **ссылки**, а не содержимое. Это главная ловушка: +```kotlin +val a = arrayOf(1, 2, 3) +val b = arrayOf(1, 2, 3) +println(a == b) // false — разные объекты! +println(a.contentEquals(b)) // true — так правильно +println(a.contentHashCode()) // хеш по содержимому +``` +Для вложенных массивов - глубокие версии: +```kotlin +val x = arrayOf(arrayOf(1), arrayOf(2)) +val y = arrayOf(arrayOf(1), arrayOf(2)) +println(x.contentEquals(y)) // false — сравнивает ссылки вложенных +println(x.contentDeepEquals(y)) // true +``` + +## Печать +`toString()` массива бесполезен: +```kotlin +val a = arrayOf(1, 2, 3) +println(a) // [Ljava.lang.Integer;@1b6d3586 +println(a.contentToString()) // [1, 2, 3] +println(a.joinToString()) // 1, 2, 3 +println("$a".startsWith("[L")) // true — шаблон вызывает тот же toString +``` +Внутри строкового шаблона всегда нужен `contentToString()` или `joinToString()`. + +## Обход +```kotlin +val a = arrayOf("x", "y", "z") +for (item in a) print(item) // xyz +for (i in a.indices) print(a[i]) // xyz +for ((i, v) in a.withIndex()) print("$i$v ") // 0x 1y 2z +a.forEach { print(it) } // xyz +a.forEachIndexed { i, v -> print("$i$v ") } +println() +println(a.lastIndex) // 2 +``` + +## Операции +Массивы поддерживают те же операции, что коллекции: +```kotlin +val a = intArrayOf(3, 1, 2) +println(a.sorted()) // [1, 2, 3] — возвращает List! +println(a.sortedArray()) // новый IntArray +a.sort() // сортировка на месте +println(a.contentToString()) // [1, 2, 3] +println(a.sum()) // 6 +println(a.average()) // 2.0 +println(a.max()) // 3 +println(a.filter { it > 1 }) // [2, 3] — List +println(a.map { it * 2 }) // [2, 4, 6] — List +println(a.reversed()) // [3, 2, 1] — List +println(a.reversedArray().contentToString()) // [3, 2, 1] +println(a.contains(2)) // true +println(2 in a) // true +println(a.indexOf(2)) // 1 +``` +Обратите внимание: `filter`, `map`, `sorted`, `reversed` возвращают `List`, а не массив. Версии, сохраняющие тип, имеют суффикс `Array`. + +## Копирование и изменение размера +Размер массива неизменен, поэтому «добавление» - это создание нового: +```kotlin +val a = intArrayOf(1, 2, 3) +println((a + 4).contentToString()) // [1, 2, 3, 4] — новый массив +println(a.copyOf().contentToString()) // [1, 2, 3] +println(a.copyOf(5).contentToString()) // [1, 2, 3, 0, 0] — дополнен нулями +println(a.copyOf(2).contentToString()) // [1, 2] — обрезан +println(a.copyOfRange(1, 3).contentToString()) // [2, 3] +println(a.sliceArray(0..1).contentToString()) // [1, 2] +a.fill(0) +println(a.contentToString()) // [0, 0, 0] +``` + +## Преобразование в коллекции и обратно +```kotlin +val a = arrayOf(1, 2, 3) +val list: List = a.toList() +val set = a.toSet() +val back: Array = list.toTypedArray() +val raw: IntArray = list.toIntArray() +println("${list} ${set} ${back.size} ${raw.size}") +``` + +## Многомерные массивы +Отдельного типа нет - это массив массивов: +```kotlin +val grid = Array(3) { IntArray(4) } // 3x4, заполнен нулями +grid[1][2] = 5 +println(grid[1].contentToString()) // [0, 0, 5, 0] + +val table = Array(2) { r -> IntArray(2) { c -> r * 2 + c } } +println(table.joinToString { it.contentToString() }) // [0, 1], [2, 3] +``` +Строки могут иметь разную длину (рваный массив): +```kotlin +val jagged = arrayOf(intArrayOf(1), intArrayOf(1, 2, 3)) +println(jagged.joinToString { it.size.toString() }) // 1, 3 +``` + +## Когда массив, а когда List +Массив нужен редко. Используйте его, если: +- важна производительность и нужны примитивы без упаковки (`IntArray`) +- работаете с API, требующим массив (`vararg`, Java-библиотеки) +- размер фиксирован и известен заранее + +В остальных случаях - `List`: он не даёт менять себя случайно, имеет больше операций и корректный `equals`. + +```kotlin +val list = listOf(1, 2, 3) +println(list == listOf(1, 2, 3)) // true — работает из коробки +``` +Подробнее: [Коллекции](../../Коллекции/Коллекции.md) + +## vararg +Параметр с переменным числом аргументов внутри функции - массив: +```kotlin +fun sum(vararg nums: Int): Int { + var total = 0 + for (n in nums) total += n // nums это IntArray + return total +} + +fun main() { + println(sum(1, 2, 3)) // 6 + println(sum()) // 0 + val arr = intArrayOf(1, 2) + println(sum(*arr)) // 3 — spread-оператор +} +``` +Подробнее: [Функции](Функции.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `int[] a = {1,2,3}` | `val a = intArrayOf(1,2,3)` | +> | `new int[5]` | `IntArray(5)` | +> | `a.length` | `a.size` | +> | `Arrays.toString(a)` | `a.contentToString()` | +> | `Arrays.equals(a,b)` | `a.contentEquals(b)` | +> | `Arrays.deepEquals(a,b)` | `a.contentDeepEquals(b)` | +> | `Arrays.sort(a)` | `a.sort()` | +> | `Arrays.copyOf(a,n)` | `a.copyOf(n)` | +> | `Arrays.fill(a,0)` | `a.fill(0)` | +> | `int[]` и `Integer[]` | `IntArray` и `Array` | +> | `f(arr)` для varargs | `f(*arr)` — нужен spread | +> +> Статические методы `Arrays` стали методами массива - находятся автодополнением, а не поиском по документации. +> +> Разделение `IntArray` / `Array` сделано намеренно: в Java `Integer[]` и `int[]` тоже разные, но это легко не заметить. В Kotlin разные имена не позволяют спутать упакованный массив с примитивным. +> +> **Массивы в Kotlin инвариантны**, в Java - ковариантны. Java разрешает `Object[] o = new String[1]; o[0] = 1;` и падает в рантайме (`ArrayStoreException`). В Kotlin `Array = Array` не скомпилируется. Подробнее: [Вариантность](../../Дженерики/Вариантность.md) +> +> Сравнить: [Массивы в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Массивы.md) + +> [!warning] Подводные камни +> **`==` на массивах сравнивает ссылки.** Самая частая ошибка: +> ```kotlin +> val a = arrayOf(1, 2) +> println(a == arrayOf(1, 2)) // false +> println(a.contentEquals(arrayOf(1, 2))) // true +> ``` +> По этой же причине массив нельзя использовать как ключ `Map` или в `Set` - `hashCode` считается по ссылке. +> +> **Массив внутри `data class` ломает `equals`:** +> ```kotlin +> data class Packet(val data: ByteArray) +> +> fun main() { +> val p1 = Packet(byteArrayOf(1)) +> println(p1 == Packet(byteArrayOf(1))) // false! +> } +> ``` +> Сгенерированный `equals` сравнивает массив по ссылке, а не по содержимому. Поэтому в `data class` с массивом `equals`/`hashCode` пишут руками. Подробнее: [Data class](../Базовые%20типы%20ООП/Data%20class.md) +> +> **`println(array)` печатает мусор** вида `[Ljava.lang.Integer;@1b6d3586`. То же в строковых шаблонах. +> +> **`val` не защищает элементы:** +> ```kotlin +> val a = intArrayOf(1, 2) +> a[0] = 99 // разрешено +> println(a.contentToString()) // [99, 2] +> ``` +> Неизменяемость даёт `List`, а не `val`. +> +> **`Array` вместо `IntArray` - скрытая просадка.** Каждый элемент упакован в `Integer`: больше памяти и промахи кеша. +> +> **`arrayOfNulls` и `Array(n)` для непримитивных типов требуют разного подхода:** +> ```kotlin +> val a = arrayOfNulls(3) // Array +> val b = Array(3) { "" } // Array — инициализатор обязателен +> println("${a.size} ${b.size}") +> ``` +> Создать `Array` без инициализатора нельзя - компилятор не позволит оставить non-null элементы пустыми. + +Дополнительно: [Коллекции](../../Коллекции/Коллекции.md) +Далее: [Функции](Функции.md) diff --git a/Kotlin/Синтаксис и типы/Работа с данными/Приведение типов.md b/Kotlin/Синтаксис и типы/Работа с данными/Приведение типов.md new file mode 100644 index 0000000..e8f5a45 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Работа с данными/Приведение типов.md @@ -0,0 +1,254 @@ +--- +title: Приведение типов +description: Проверка is, приведение as и as?, числовые преобразования и стирание типов +tags: + - kotlin + - types +--- + +## Проверка типа - is +```kotlin +fun describe(x: Any): String = when { + x is String -> "строка длины ${x.length}" + x is Int -> "число ${x + 1}" + x !is Boolean -> "не логическое" + else -> "логическое" +} + +fun main() { + println(describe("ab")) // строка длины 2 + println(describe(5)) // число 6 + println(describe(1.0)) // не логическое +} +``` +После успешной проверки компилятор сам приводит тип - это [умное приведение](../Null-безопасность/Умные%20приведения.md), явный каст не нужен. + +## Небезопасное приведение - as +```kotlin +fun main() { + val x: Any = "text" + val s = x as String // ok + println(s.length) // 4 + + val n: Any = 42 + // val bad = n as String // ClassCastException +} +``` +`as` используют, когда тип известен точно, а компилятор об этом знать не может. + +## Безопасное приведение - as? +Возвращает `null` вместо исключения: +```kotlin +fun main() { + val x: Any = "text" + println(x as? Int) // null + println(x as? String) // text + println((x as? Int) ?: 0) // 0 — частая связка с elvis +} +``` +Результат всегда nullable, даже при приведении к non-null типу. + +Сравнение способов: + +| Запись | При несовпадении типа | +|---|---| +| `x is T` | `false` | +| `x as T` | `ClassCastException` | +| `x as? T` | `null` | + +## Приведение к nullable +```kotlin +fun main() { + val a: Any? = null + println(a as? String) // null + // println(a as String) // NPE: null cannot be cast to non-null type + println(a as String?) // null — приведение к nullable разрешено +} +``` + +## Числовые преобразования +Для чисел `as` не применяется - только явные функции: +```kotlin +fun main() { + val i = 42 + println(i.toLong()) // 42 + println(i.toDouble()) // 42.0 + println(i.toByte()) // 42 + println(i.toChar()) // * + println(3.99.toInt()) // 3 — обрезание, не округление + println("42".toInt()) // 42 +} +``` +Неявных преобразований между числовыми типами нет: +```kotlin +fun main() { + val i: Int = 42 + // val l: Long = i // ошибка компиляции + val l: Long = i.toLong() // так +} +``` + +Потеря данных происходит молча: +```kotlin +fun main() { + println(300.toByte()) // 44 — старшие биты отброшены + println(1e20.toInt()) // 2147483647 — обрезано + println(Double.NaN.toInt()) // 0 +} +``` +Подробнее: [Числа и переполнение](Числа%20и%20переполнение.md) + +## Стирание типов и дженерики +Параметры типа стираются при компиляции, поэтому проверить их в рантайме нельзя: +```kotlin +fun check(x: Any) { + if (x is List<*>) { // можно: звёздочка вместо типа + println("список из ${x.size}") + } + // if (x is List) // ошибка: cannot check for instance + // // of erased type +} + +fun main() { check(listOf(1, 2)) } // список из 2 +``` +`List<*>` - это star-проекция: «список чего-то неизвестного». Элементы из него читаются как `Any?`. + +Приведение к конкретному дженерику компилируется с предупреждением и небезопасно: +```kotlin +@Suppress("UNCHECKED_CAST") +fun castList(x: Any): List = x as List // unchecked cast + +fun main() { + val nums: List = castList(listOf("a", "b")) // не упадёт здесь! + // println(nums.first() + 1) // ClassCastException только при обращении + println(nums.size) // 2 — работает, типы не проверяются +} +``` +Это отложенное падение: ошибка проявится далеко от места приведения. + +Обойти стирание можно через `reified` в inline-функциях: +```kotlin +inline fun Any.safeCast(): T? = this as? T + +fun main() { + println("text".safeCast()) // text + println("text".safeCast()) // null +} +``` +Подробнее: [Reified и inline](../../Дженерики/Reified%20и%20inline.md) + +## Приведение в иерархии +```kotlin +open class Animal(val name: String) +class Dog(name: String) : Animal(name) { + fun bark() = "Гав" +} + +fun main() { + val a: Animal = Dog("Рекс") // вверх по иерархии — неявно + println(a.name) // Рекс + + val d = a as Dog // вниз — только явно + println(d.bark()) // Гав + + val other: Animal = Animal("кто-то") + println((other as? Dog)?.bark()) // null — безопасно +} +``` +Приведение вверх (к супертипу) всегда безопасно и неявно. Приведение вниз требует `as` или `as?`. + +## Коллекции и вариантность +```kotlin +fun main() { + val strings: List = listOf("a") + val anys: List = strings // ok — List ковариантен по out T + println(anys.size) // 1 + + val mStrings: MutableList = mutableListOf("a") + // val mAnys: MutableList = mStrings // ошибка — MutableList инвариантен +} +``` +`List` разрешает такое присваивание, `MutableList` - нет, иначе можно было бы положить `Int` в список строк. Подробнее: [Вариантность](../../Дженерики/Вариантность.md) + +## Получение типа в рантайме +```kotlin +fun main() { + val x: Any = "text" + println(x::class) // class kotlin.String + println(x::class.simpleName) // String + println(x::class.java) // class java.lang.String — Java-класс + println(x is CharSequence) // true — проверка по интерфейсу +} +``` +Подробнее: [Рефлексия](../../Рефлексия/Рефлексия.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `x instanceof String` | `x is String` | +> | `(String) x` | `x as String` | +> | нет безопасного каста | `x as? String` | +> | `(int) doubleValue` | `doubleValue.toInt()` | +> | `long l = intValue;` неявно | `intValue.toLong()` явно | +> | `x.getClass()` | `x::class` или `x::class.java` | +> | после `instanceof` нужен каст | умное приведение | +> | массивы ковариантны | массивы инвариантны | +> +> Главные приобретения: `as?` вместо пары `instanceof` + каст, и запрет неявных числовых преобразований, из-за которых в Java `long total = intA * intB` тихо переполняется. +> +> Ковариантность массивов в Java - известная дыра в типизации: +> ```java +> Object[] objs = new String[1]; +> objs[0] = 42; // компилируется, падает в рантайме +> ``` +> В Kotlin `Array` инвариантен, и такой код не соберётся. +> +> Сравнить: [Приведение ссылочных типов в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Приведение%20ссылочных%20типов.md) + +> [!warning] Подводные камни +> **`as` на null даёт NPE, а не ClassCastException:** +> ```kotlin +> fun main() { +> val a: Any? = null +> // println(a as String) // NullPointerException +> println(a as String?) // null — ok +> } +> ``` +> +> **`as?` маскирует обе причины.** `null` в результате означает либо неверный тип, либо что значение изначально было `null` - различить невозможно: +> ```kotlin +> fun main() { +> val a: Any? = null +> val b: Any = 42 +> println("${a as? String} ${b as? String}") // null null +> } +> ``` +> +> **Unchecked cast падает не там, где написан:** +> ```kotlin +> @Suppress("UNCHECKED_CAST") +> fun main() { +> val list = listOf("a") as List // здесь не падает +> println(list.size) // 1 — и здесь тоже +> // println(list[0] + 1) // ClassCastException только тут +> } +> ``` +> Поэтому `@Suppress("UNCHECKED_CAST")` - всегда повод задуматься. +> +> **`toInt()` на `Double` обрезает дробную часть, а не округляет.** `3.99.toInt()` даёт `3`. Для округления - `roundToInt()`. +> +> **`toByte()` и `toShort()` молча теряют старшие биты:** `300.toByte()` возвращает `44`. +> +> **`is` не проверяет параметры дженерика.** `x is List` не скомпилируется, а `x is List<*>` ничего не говорит об элементах. Проверять придётся вручную: +> ```kotlin +> fun main() { +> val x: Any = listOf(1, "a") +> val strings = (x as? List<*>)?.filterIsInstance() +> println(strings) // [a] — только элементы нужного типа +> } +> ``` +> +> **`x::class` и `x::class.java` - разные вещи.** Первое - `KClass` Kotlin, второе - `Class` Java. Для `Int` они могут отличаться (`int` против `Integer`). + +Дополнительно: [Умные приведения](../Null-безопасность/Умные%20приведения.md) +Далее: [Any, Unit, Nothing](../Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md) diff --git a/Kotlin/Синтаксис и типы/Работа с данными/Строки.md b/Kotlin/Синтаксис и типы/Работа с данными/Строки.md new file mode 100644 index 0000000..57ef3e4 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Работа с данными/Строки.md @@ -0,0 +1,222 @@ +--- +title: Строки +description: String как неизменяемый тип, полезные методы, StringBuilder и buildString +tags: + - kotlin + - stdlib +--- + +`String` - неизменяемый тип. Любая «модификация» создаёт новую строку. + +```kotlin +val s = "Kotlin" +println(s.length) // 6 +println(s[0]) // K — доступ по индексу, как у массива +println(s.uppercase()) // KOTLIN +println(s) // Kotlin — исходная строка не изменилась +``` + +На JVM это тот же `java.lang.String`, со всем его поведением: пул строк, интернирование, представление в LATIN-1 или UTF-16. + +## Сравнение +```kotlin +val a = "Kotlin" +val b = StringBuilder("Kotlin").toString() +println(a == b) // true — сравнение содержимого +println(a === b) // false — разные объекты +println(a.equals("KOTLIN", ignoreCase = true)) // true +``` +`==` вызывает `equals()`, поэтому работает правильно всегда. Это главное практическое отличие от Java, где `==` на строках сравнивает ссылки. + +Для сортировки и порядка - `compareTo`: +```kotlin +println("abc" < "abd") // true +println("a".compareTo("b")) // -1 +println(listOf("b", "a", "c").sorted()) // [a, b, c] +``` + +## Итерация +Строка - последовательность `Char`: +```kotlin +for (c in "abc") print("$c.") // a.b.c. +println("abc".toList()) // [a, b, c] +println("hello".count { it == 'l' }) // 2 +println("hello".filter { it != 'l' }) // heo +println("abc".map { it.uppercaseChar() }) // [A, B, C] +println("abc".reversed()) // cba +``` +Работают все операции над коллекциями: `filter`, `map`, `any`, `count`. Только `filter` и `reversed` возвращают `String`, а `map` - `List`. + +## Поиск и проверки +```kotlin +val s = "Hello, Kotlin" +println(s.contains("Kotlin")) // true +println("Kotlin" in s) // true — то же самое +println(s.startsWith("Hello")) // true +println(s.endsWith("lin")) // true +println(s.indexOf("o")) // 4 — первое вхождение, -1 если нет +println(s.lastIndexOf("o")) // 8 +println(s.isEmpty()) // false — длина 0 +println(" ".isBlank()) // true — пусто или только пробелы +println(s.isNotEmpty()) // true +``` +Для nullable-строк есть безопасные версии, их можно звать без `?.`: +```kotlin +val n: String? = null +println(n.isNullOrEmpty()) // true +println(n.isNullOrBlank()) // true +println(n.orEmpty()) // "" — пустая строка вместо null +``` + +## Извлечение частей +```kotlin +val s = "Hello, Kotlin" +println(s.substring(7)) // Kotlin +println(s.substring(0, 5)) // Hello — [0, 5) +println(s.take(5)) // Hello — первые 5 +println(s.takeLast(6)) // Kotlin +println(s.drop(7)) // Kotlin — отбросить первые 7 +println(s.dropLast(7)) // Hello +println(s.first()) // H +println(s.last()) // n +println(s.getOrNull(100)) // null — вместо исключения +``` +`take` и `drop` безопасны: если запросить больше длины, вернётся вся строка, а не исключение. `substring` за границами падает. + +```kotlin +println("abc".take(100)) // abc +// println("abc".substring(100)) // StringIndexOutOfBoundsException +``` + +## Изменение +Функции `capitalize()` и `decapitalize()` устарели и удалены. Замена пишется так: +```kotlin +fun String.capitalize2() = + replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + +fun main() { println("hello".capitalize2()) } // Hello +``` + +Остальные методы возвращают новую строку: +```kotlin +fun String.capitalize2() = + replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + +val s = " Hello, Kotlin " +println(s.trim()) // "Hello, Kotlin" — с обеих сторон +println(s.trimStart()) // "Hello, Kotlin " +println(s.trimEnd()) // " Hello, Kotlin" +println("Hello".replace("l", "L")) // HeLLo — все вхождения +println("Hello".replaceFirst("l", "L")) // HeLlo +println("Hello".lowercase()) // hello +println("hello".uppercase()) // HELLO +println("hello".capitalize2()) // Hello +println("ab".repeat(3)) // ababab +println("5".padStart(3, '0')) // 005 +println("5".padEnd(3)) // "5 " +``` + +## Разбиение и сборка +```kotlin +println("a,b,c".split(",")) // [a, b, c] +println("a1b2c".split("1", "2")) // [a, b, c] — несколько разделителей +println("a,b,c".split(",", limit = 2)) // [a, b,c] +println("a\nb".lines()) // [a, b] — по переводам строк +println(listOf("a","b").joinToString("-")) // a-b +println(listOf(1,2,3).joinToString( + separator = ", ", prefix = "[", postfix = "]")) // [1, 2, 3] +println("hello".chunked(2)) // [he, ll, o] +``` +`joinToString` - основной способ собрать строку из коллекции, у него есть и `transform`: +```kotlin +println(listOf(1, 2).joinToString { "#$it" }) // #1, #2 +``` + +## Преобразование в число +```kotlin +println("42".toInt()) // 42 +println("42".toIntOrNull()) // 42 +println("abc".toIntOrNull()) // null — не бросает исключение +println("3.14".toDouble()) // 3.14 +println("ff".toInt(16)) // 255 — с основанием +// println("abc".toInt()) // NumberFormatException +``` +В коде, который разбирает внешние данные, используйте только версии `*OrNull`. + +## StringBuilder и buildString +Для сборки строки в цикле конкатенация создаёт мусор - на каждой итерации новый объект. `StringBuilder` меняет один буфер: +```kotlin +val sb = StringBuilder() +for (i in 1..3) sb.append(i).append(",") +println(sb.toString()) // 1,2,3, +``` +Идиоматичнее - `buildString`, он сам создаёт билдер и возвращает результат: +```kotlin +val s = buildString { + append("Привет") + append(", ") + append("Kotlin") + appendLine("!") + append("Строка 2") +} +println(s) +``` +Полезные методы билдера: `append`, `appendLine`, `insert(i, x)`, `deleteAt(i)`, `setLength(n)`, `reverse()`, `clear()`. + +## Многострочные строки +```kotlin +val json = """ + {"name": "Kotlin"} +""".trimIndent() +``` +Подробнее: [Строковые шаблоны и вывод](../Базовый%20синтаксис/Строковые%20шаблоны%20и%20вывод.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `s.equals(t)` | `s == t` | +> | `s == t` (ссылки) | `s === t` | +> | `s.charAt(0)` | `s[0]` | +> | `s.isEmpty()` | `s.isEmpty()`, плюс `isBlank()`, `isNullOrBlank()` | +> | `s.toUpperCase()` | `s.uppercase()` | +> | `new StringBuilder()` + `toString()` | `buildString { }` | +> | `String.join(",", list)` | `list.joinToString(",")` | +> | `Integer.parseInt(s)` | `s.toInt()` / `s.toIntOrNull()` | +> | `s.split(",")` → `String[]` | → `List` | +> | `s.substring(100)` падает | `s.take(100)` безопасен | +> +> Главное приобретение - `*OrNull`-функции и безопасные `take`/`drop` вместо исключений на каждой опечатке во входных данных. +> +> `uppercase()` вместо `toUpperCase()` - не просто переименование: старая версия Java зависела от системной локали, из-за чего `"i".toUpperCase()` в турецкой локали давало `İ`. Kotlin по умолчанию использует `Locale.ROOT`. +> +> Сравнить: [Строки в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Строки.md) + +> [!warning] Подводные камни +> **`length` считает кодовые единицы UTF-16, а не символы.** Эмодзи и редкие иероглифы занимают две: +> ```kotlin +> println("😀".length) // 2, а не 1 +> println("😀".count()) // 2 — то же самое +> println("😀".codePointCount(0, "😀".length)) // 1 — настоящее число символов +> ``` +> Разрезать такую строку по индексу - значит получить битые символы. +> +> **`split` с пустым разделителем и пустой строкой дают неожиданный результат:** +> ```kotlin +> println("".split(",")) // [] — один пустой элемент, размер 1 +> println("".split(",").size) // 1 +> println("a,,b".split(",")) // [a, , b] — пустой элемент сохраняется +> ``` +> +> **`trimIndent` не обрезает пробелы внутри строк** - только общий отступ. +> +> **`replace` принимает строку, а не regex** - в отличие от Java: +> ```kotlin +> println("a.b".replace(".", "-")) // a-b — точка как символ +> println("a.b".replace(Regex("."), "-")) // --- — regex +> ``` +> В Java `String.replaceAll` работает с regex, и это частый источник путаницы при переходе. +> +> **Строка в `when` сравнивается по значению, но полнота не проверяется** - для `String` всегда нужен `else`. + +Дополнительно: [Char](Char.md) +Далее: [Char](Char.md) diff --git a/Kotlin/Синтаксис и типы/Работа с данными/Функции.md b/Kotlin/Синтаксис и типы/Работа с данными/Функции.md new file mode 100644 index 0000000..7d4b200 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Работа с данными/Функции.md @@ -0,0 +1,280 @@ +--- +title: Функции +description: Объявление функций, значения по умолчанию, именованные аргументы, vararg и локальные функции +tags: + - kotlin + - syntax +--- + +Функция объявляется словом `fun`. В Kotlin она может жить на верхнем уровне файла, внутри класса или внутри другой функции. + +```kotlin +fun greet(name: String): String { + return "Привет, $name" +} + +fun main() { + println(greet("Kotlin")) // Привет, Kotlin +} +``` +Порядок: `fun` → имя → параметры → `:` тип результата → тело. + +## Выражение вместо тела +Если функция - одно выражение, фигурные скобки и `return` убираются: +```kotlin +fun square(x: Int): Int = x * x +fun greet(name: String) = "Привет, $name" // тип выведен: String +fun max(a: Int, b: Int) = if (a > b) a else b +``` +Это основная форма в идиоматичном Kotlin. Тип результата можно опустить - компилятор выведет его. Но для публичного API его лучше писать явно: это контракт, и его случайное изменение не должно быть незаметным. + +## Unit - отсутствие результата +```kotlin +fun log(message: String): Unit { + println(message) +} + +fun log2(message: String) { // : Unit можно опустить + println(message) +} +``` +`Unit` - настоящий тип с единственным значением, а не пустота. Подробнее: [Any, Unit, Nothing](../Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md) + +## Параметры +Параметры всегда **только для чтения** - это `val`, присвоить им нельзя: +```kotlin +fun bad(x: Int) { + // x = 5 // ошибка компиляции + val y = x + 1 + println(y) +} +``` +Тип параметра обязателен - вывода типов здесь нет. + +## Значения по умолчанию +```kotlin +fun connect(host: String, port: Int = 8080, secure: Boolean = false) { + println("$host:$port secure=$secure") +} + +fun main() { + connect("localhost") // localhost:8080 secure=false + connect("localhost", 443) // localhost:443 secure=false + connect("localhost", 443, true) // localhost:443 secure=true +} +``` +Это заменяет перегрузки: вместо трёх версий метода - одна функция. Значением по умолчанию может быть любое выражение, включая вызов функции и другие параметры: +```kotlin +fun repeat2(text: String, times: Int = text.length) = text.repeat(times) + +fun main() { + println(repeat2("ab")) // abab — times = 2 + println(repeat2("ab", 3)) // ababab +} +``` +Выражение вычисляется при каждом вызове, в котором аргумент опущен. + +## Именованные аргументы +Аргумент можно передать по имени параметра: +```kotlin +fun connect(host: String, port: Int = 80, secure: Boolean = false, timeout: Int = 30) { + println("$host:$port s=$secure t=$timeout") +} + +fun main() { + connect("localhost", secure = true) // пропустили port + connect(host = "localhost", timeout = 60) + connect(port = 443, host = "localhost") // порядок не важен +} +``` +Две причины использовать: +1. **Пропустить** часть параметров со значениями по умолчанию +2. **Читаемость** на вызовах с непонятными литералами + +```kotlin +// что здесь true? +// createUser("Иван", true, false, true) +// так понятно: +// createUser("Иван", admin = true, banned = false, verified = true) +``` + +Правило: позиционные аргументы идут до именованных. После первого именованного все следующие тоже должны быть именованными - кроме `vararg` со spread. + +## vararg +Переменное число аргументов: +```kotlin +fun sum(vararg nums: Int): Int { + var total = 0 + for (n in nums) total += n // nums имеет тип IntArray + return total +} + +fun main() { + println(sum(1, 2, 3)) // 6 + println(sum()) // 0 + val arr = intArrayOf(1, 2, 3) + println(sum(*arr)) // 6 — spread-оператор обязателен +} +``` +Правила: +- `vararg` может быть **только один** в функции +- он не обязан быть последним, но тогда остальные передаются по имени +- внутри функции это массив (`Array` или специализированный) +- для передачи готового массива нужен `*` (spread) + +```kotlin +fun tag(vararg classes: String, id: String) = "$id: ${classes.joinToString()}" + +fun main() { + println(tag("a", "b", id = "x")) // x: a, b +} +``` + +## Локальные функции +Функция внутри функции - видит переменные внешней: +```kotlin +fun process(items: List): List { + val prefix = ">> " // захватывается + + fun format(x: Int): String = "$prefix$x" // локальная функция + + return items.map { format(it) } +} + +fun main() { + println(process(listOf(1, 2))) // [>> 1, >> 2] +} +``` +Нужны, когда логика используется только внутри одной функции и не стоит того, чтобы выносить её в класс или в приватную функцию файла. + +## Перегрузка +Функции с одним именем и разными параметрами: +```kotlin +fun show(x: Int) = "число $x" +fun show(x: String) = "строка $x" +fun show(x: Int, y: Int) = "пара $x,$y" + +fun main() { + println(show(1)) // число 1 + println(show("a")) // строка a + println(show(1, 2)) // пара 1,2 +} +``` +Сигнатуру образуют имя и типы параметров. Тип результата в неё **не входит** - две функции, отличающиеся только им, не скомпилируются. + +Чаще всего перегрузка не нужна: её заменяют значения по умолчанию. + +## Передача аргументов +Аргументы передаются **по значению**, как в Java: +- примитив - копируется значение +- объект - копируется ссылка + +Значит, переприсвоить параметр внутри функции нельзя (он `val`), но изменяемый объект изменить можно: +```kotlin +fun addItem(list: MutableList) { + list.add(99) // объект изменён — виден снаружи +} + +fun main() { + val items = mutableListOf(1) + addItem(items) + println(items) // [1, 99] +} +``` + +## Однострочные функции-расширения +Функции можно добавлять к существующим типам: +```kotlin +fun String.shout() = uppercase() + "!" + +fun main() { + println("привет".shout()) // ПРИВЕТ! +} +``` +Подробнее: [Функции-расширения](../../Функциональный%20Kotlin/Функции-расширения.md) + +## Функции как значения +Функция может быть параметром и результатом: +```kotlin +fun apply2(x: Int, f: (Int) -> Int) = f(x) + +fun main() { + println(apply2(3) { it * 2 }) // 6 + println(apply2(3, ::square)) // 9 — ссылка на функцию +} + +fun square(x: Int) = x * x +``` +Подробнее: [Лямбды и функциональные типы](Лямбды%20и%20функциональные%20типы.md), [Функции высшего порядка](../../Функциональный%20Kotlin/Функции%20высшего%20порядка.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | метод только внутри класса | функция на верхнем уровне | +> | `int f(int x) { return x; }` | `fun f(x: Int) = x` | +> | `void` | `Unit` (можно опустить) | +> | нет значений по умолчанию | `fun f(x: Int = 0)` | +> | нет именованных аргументов | `f(x = 1)` | +> | `int... nums` | `vararg nums: Int` | +> | `f(arr)` для varargs | `f(*arr)` | +> | параметр можно переприсвоить | параметр - `val` | +> | перегрузки для опциональных параметров | одна функция со значениями по умолчанию | +> +> Значения по умолчанию + именованные аргументы убирают главный источник шума в Java API - «телескопические» перегрузки: +> ```java +> void connect(String host) { connect(host, 8080); } +> void connect(String host, int port) { connect(host, port, false); } +> void connect(String host, int port, boolean secure) { ... } +> ``` +> В Kotlin это одна строка. Для вызова из Java есть `@JvmOverloads`, который генерирует такие перегрузки автоматически - см. [Kotlin из Java](../../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md). +> +> Сравнить: [Функции в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md) + +> [!warning] Подводные камни +> **Изменяемое значение по умолчанию вычисляется на каждом вызове** - в отличие от Python, где оно создаётся один раз. Это как раз правильное поведение: +> ```kotlin +> fun add(item: Int, list: MutableList = mutableListOf()): List { +> list.add(item) +> return list +> } +> +> fun main() { +> println(add(1)) // [1] +> println(add(2)) // [2] — новый список, а не [1, 2] +> } +> ``` +> +> **Значения по умолчанию не видны из Java.** Без `@JvmOverloads` Java-код обязан передавать все аргументы. +> +> **Переопределённый метод не может изменить значения по умолчанию:** +> ```kotlin +> open class A { open fun f(x: Int = 1) = x } +> class B : A() { +> override fun f(x: Int) = x * 2 // значение по умолчанию наследуется, +> } // переобъявить его нельзя +> ``` +> +> **`vararg` без spread принимает массив как один элемент:** +> ```kotlin +> fun show(vararg items: Any) = items.size +> +> fun main() { +> val arr = arrayOf(1, 2, 3) +> println(show(arr)) // 1 — массив целиком как один аргумент +> println(show(*arr)) // 3 — распакован +> } +> ``` +> +> **Перегрузка + значения по умолчанию = неоднозначность.** Компилятор выберет наиболее специфичную перегрузку, а не ту, что со значениями по умолчанию: +> ```kotlin +> fun f(x: Int, y: Int = 0) = "два" +> fun f(x: Int) = "один" +> +> fun main() { +> println(f(1)) // "один" — перегрузка без default побеждает +> } +> ``` +> Такого сочетания лучше избегать. + +Дополнительно: [Лямбды и функциональные типы](Лямбды%20и%20функциональные%20типы.md) +Далее: [Лямбды и функциональные типы](Лямбды%20и%20функциональные%20типы.md) diff --git a/Kotlin/Синтаксис и типы/Работа с данными/Числа и переполнение.md b/Kotlin/Синтаксис и типы/Работа с данными/Числа и переполнение.md new file mode 100644 index 0000000..59aded9 --- /dev/null +++ b/Kotlin/Синтаксис и типы/Работа с данными/Числа и переполнение.md @@ -0,0 +1,231 @@ +--- +title: Числа и переполнение +description: Переполнение целых, точность Double, BigDecimal для денег и математические функции +tags: + - kotlin + - stdlib +--- + +## Переполнение целых +Kotlin, как и Java, **не проверяет переполнение**. При выходе за границы значение «заворачивается»: +```kotlin +println(Int.MAX_VALUE) // 2147483647 +println(Int.MAX_VALUE + 1) // -2147483648 — тихо +println(Int.MIN_VALUE - 1) // 2147483647 +``` +Это не исключение и даже не предупреждение - просто неверный результат. + +Границы типов: +```kotlin +println("${Byte.MIN_VALUE}..${Byte.MAX_VALUE}") // -128..127 +println("${Int.MIN_VALUE}..${Int.MAX_VALUE}") // -2147483648..2147483647 +println("${Long.MIN_VALUE}..${Long.MAX_VALUE}") // -9223372036854775808..9223372036854775807 +``` + +### Где это кусает +Самый частый случай - умножение `Int`, результат которого не влезает: +```kotlin +val ms = 1000 * 60 * 60 * 24 * 30 // -1702967296 — переполнение! +val msOk = 1000L * 60 * 60 * 24 * 30 // 2592000000 — первый операнд Long +println("$ms $msOk") +``` +Правило: если промежуточный результат может превысить 2 млрд, делайте `Long` **первый** операнд. Приписать `L` к последнему множителю недостаточно - умножение идёт слева направо. + +### Проверяемая арифметика +Явные функции, бросающие исключение при переполнении: +```kotlin +println(Math.addExact(1, 2)) // 3 +// println(Math.addExact(Int.MAX_VALUE, 1)) // ArithmeticException +// println(Math.multiplyExact(100000, 100000)) // ArithmeticException +``` +Доступны `addExact`, `subtractExact`, `multiplyExact`, `incrementExact`, `toIntExact`. Это функции JDK, в stdlib Kotlin аналогов нет. + +## Деление +```kotlin +println(7 / 2) // 3 — целочисленное, дробная часть отброшена +println(-7 / 2) // -3 — округление к нулю, не к меньшему +println(7 % 2) // 1 +println(-7 % 2) // -1 — знак от делимого +println(7.0 / 2) // 3.5 +``` +Деление на целочисленный ноль - исключение, на вещественный - нет: +```kotlin +// println(1 / 0) // ArithmeticException: / by zero +println(1.0 / 0) // Infinity +println(-1.0 / 0) // -Infinity +println(0.0 / 0) // NaN +``` + +### Остаток для отрицательных +`%` в Kotlin даёт остаток со знаком делимого, что ломает индексацию по кругу: +```kotlin +println(-1 % 3) // -1 — не 2! +println((-1).mod(3)) // 2 — математический модуль +println((-1).rem(3)) // -1 — то же, что % +``` +Для циклических индексов нужен `mod`, а не `%`. + +## Вещественные числа +`Float` и `Double` следуют IEEE 754 и **не представляют десятичные дроби точно**: +```kotlin +println(0.1 + 0.2) // 0.30000000000000004 +println(0.1 + 0.2 == 0.3) // false +println(1.0 - 0.9) // 0.09999999999999998 +``` + +Сравнивать нужно с допуском: +```kotlin +fun almostEqual(a: Double, b: Double, eps: Double = 1e-9) = + kotlin.math.abs(a - b) < eps + +println(almostEqual(0.1 + 0.2, 0.3)) // true +``` + +### NaN и бесконечности +```kotlin +val nan = Double.NaN +println(nan == nan) // false! NaN не равен ничему, включая себя +println(nan.isNaN()) // true — единственный верный способ проверки +println(Double.POSITIVE_INFINITY) // Infinity +println(1.0.isFinite()) // true +println(0.0 == -0.0) // true +``` + +`NaN` ведёт себя по-разному в операторах и в коллекциях - это редкая, но болезненная ловушка: +```kotlin +println(Double.NaN == Double.NaN) // false — оператор +println(Double.NaN.equals(Double.NaN)) // true — метод equals +println(listOf(Double.NaN).contains(Double.NaN)) // true — через equals +``` + +## Деньги - BigDecimal +Для денег `Double` использовать нельзя: ошибки округления накапливаются. +```kotlin +import java.math.BigDecimal +import java.math.RoundingMode + +fun main() { + val a = BigDecimal("0.1") // только из строки! + val b = BigDecimal("0.2") + println(a + b) // 0.3 — точно + println(a.add(b) == BigDecimal("0.3")) // true + + val price = BigDecimal("100.555") + println(price.setScale(2, RoundingMode.HALF_UP)) // 100.56 + + // операторы перегружены, работают привычно + println(BigDecimal("10") * BigDecimal("3")) // 30 + println(BigDecimal("10").divide(BigDecimal("4"))) // 2.5 +} +``` +Создавать **только из строки**: `BigDecimal(0.1)` подхватит уже испорченное значение `Double`: +```kotlin +import java.math.BigDecimal + +fun main() { + println(BigDecimal(0.1)) // 0.1000000000000000055511151231257827... + println(BigDecimal("0.1")) // 0.1 +} +``` + +`BigDecimal.equals` сравнивает и масштаб, поэтому `2.0` и `2.00` не равны: +```kotlin +import java.math.BigDecimal + +fun main() { + println(BigDecimal("2.0") == BigDecimal("2.00")) // false + println(BigDecimal("2.0").compareTo(BigDecimal("2.00")) == 0) // true +} +``` +Для сравнения значений - `compareTo`, не `==`. + +## Математические функции +```kotlin +import kotlin.math.* + +fun main() { + println(abs(-5)) // 5 + println(max(3, 7)) // 7 + println(min(3, 7)) // 3 + println(sqrt(16.0)) // 4.0 + println(2.0.pow(10)) // 1024.0 + println(PI) // 3.141592653589793 + println(round(2.5)) // 2.0 — не 3.0! округление к чётному + println(round(3.5)) // 4.0 + println(floor(2.9)) // 2.0 + println(ceil(2.1)) // 3.0 + println(truncate(2.9)) // 2.0 + println(2.567.roundToInt()) // 3 + println(hypot(3.0, 4.0)) // 5.0 +} +``` +Пакет `kotlin.math` нужно импортировать - автоматически он не подключается. + +Обратите внимание на `round`: при ровной половине он округляет **к ближайшему чётному** (IEEE 754 round-half-even), поэтому `round(2.5)` даёт `2.0`, а `round(3.5)` - `4.0`. Если нужно привычное «половина вверх», берите `roundToInt()`: `2.5.roundToInt()` даёт `3`. + +### Ограничение диапазона +```kotlin +println(15.coerceIn(0, 10)) // 10 +println((-5).coerceIn(0, 10)) // 0 +println(5.coerceAtLeast(0)) // 5 +println(15.coerceAtMost(10)) // 10 +``` +Удобнее, чем `max(0, min(10, x))`. + +## Преобразования и потеря данных +```kotlin +import kotlin.math.roundToInt + +fun main() { + println(300.toByte()) // 44 — старшие биты отброшены + println(3.99.toInt()) // 3 — отбрасывание, не округление + println((-3.99).toInt()) // -3 + println(3.99.roundToInt()) // 4 — настоящее округление + println(Double.NaN.toInt()) // 0 — тихо! + println(1e20.toInt()) // 2147483647 — обрезано до MAX_VALUE +} +``` +`toInt()` на `Double` никогда не падает - он тихо даёт неверный результат. Для контроля используйте `roundToInt()` и проверку `isFinite()`. + +## Представление и биты +```kotlin +println(255.toString(2)) // 11111111 — в двоичной +println(255.toString(16)) // ff +println("ff".toInt(16)) // 255 +println(5.countOneBits()) // 2 — число единичных битов +println(Integer.toBinaryString(-1)) // 32 единицы +``` + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Math.abs(x)` | `kotlin.math.abs(x)` | +> | `Math.pow(2, 10)` | `2.0.pow(10)` | +> | `Integer.MAX_VALUE` | `Int.MAX_VALUE` | +> | `Integer.parseInt(s)` | `s.toInt()` | +> | `Integer.toBinaryString(n)` | `n.toString(2)` | +> | `Math.floorMod(-1, 3)` | `(-1).mod(3)` | +> | неявное `int` → `long` | только явный `toLong()` | +> | `Math.addExact` | то же (из JDK) | +> +> Поведение переполнения, деления и IEEE 754 полностью унаследовано от JVM - здесь Kotlin ничего не меняет и не защищает. Выигрыш только в читаемости: `2.0.pow(10)` вместо `Math.pow(2, 10)`, `coerceIn` вместо вложенных `min`/`max`. +> +> Запрет неявных преобразований - реальная защита: в Java `long total = intA * intB;` переполняется до присваивания, и компилятор молчит. В Kotlin такой код не соберётся без явного `toLong()`. +> +> Сравнить: [Типы данных в Java](../../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Типы%20данных.md) + +> [!warning] Подводные камни +> **`1000 * 60 * 60 * 24 * 30` переполняется** - самая частая реальная ошибка с числами. Все операнды `Int`, результат считается в `Int`, и только потом присваивается в `Long`. +> +> **`Double` для денег даёт расхождения в копейках**, которые накапливаются на больших объёмах. Только `BigDecimal` из строки или целые копейки в `Long`. +> +> **`NaN == NaN` ложно, а `listOf(NaN).contains(NaN)` истинно.** Сортировка и `distinct()` с `NaN` ведут себя неинтуитивно. +> +> **`toInt()` на `Double` обрезает, а `NaN` превращает в 0** - без единого предупреждения. +> +> **`%` для отрицательных чисел даёт отрицательный остаток.** Код `arr[(i - 1) % n]` упадёт на `i = 0`; нужен `arr[(i - 1).mod(n)]`. +> +> **`BigDecimal("2.0") != BigDecimal("2.00")`** - `equals` учитывает масштаб. Сравнивайте через `compareTo`. + +Дополнительно: [Типы данных](../Базовый%20синтаксис/Типы%20данных.md) +Далее: [Массивы](Массивы.md) diff --git a/Kotlin/Тестирование/Kotest.md b/Kotlin/Тестирование/Kotest.md new file mode 100644 index 0000000..460f35f --- /dev/null +++ b/Kotlin/Тестирование/Kotest.md @@ -0,0 +1,722 @@ +--- +title: Kotest +description: Спецификации, матчеры, data-driven тесты, property-based тестирование, жизненный цикл и изоляция +tags: + - kotlin + - testing + - kotest +--- + +Kotest - полноценный фреймворк тестирования, написанный на Kotlin и для Kotlin. Он даёт три независимо применимых вещи: DSL спецификаций вместо `@Test`, богатый набор матчеров и property-based тестирование. + +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +class StringSpecDemo : StringSpec({ + "длина строки" { + "абвг".length shouldBe 4 + } + "матчеры строк" { + "привет мир" shouldContain "мир" + } +}) +``` +Тест - это строка-описание и лямбда. Никаких аннотаций, никаких ограничений на имена. + +## Подключение +```kotlin +// build.gradle.kts +dependencies { + testImplementation("io.kotest:kotest-runner-junit5:6.0.4") + testImplementation("io.kotest:kotest-assertions-core:6.0.4") + testImplementation("io.kotest:kotest-property:6.0.4") +} + +kotlin { jvmToolchain(21) } + +tasks.test { useJUnitPlatform() } +``` +| Артефакт | Зачем | +|---|---| +| `kotest-runner-junit5` | движок + спецификации | +| `kotest-assertions-core` | матчеры `shouldBe` и остальные | +| `kotest-property` | property-based тестирование | +| `kotest-framework-datatest` | `withData` (входит в runner) | +| `kotest-assertions-json` | матчеры для JSON | +| `kotest-extensions-testcontainers` | интеграция с Testcontainers | + +Три части используются независимо: матчеры работают в обычных `@Test`-классах JUnit, а спецификации - без матчеров Kotest. + +## Стили спецификаций +Kotest предлагает семь стилей. Все эквивалентны по возможностям - выбор только о читаемости. + +### StringSpec - самый лаконичный +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.collections.shouldHaveSize + +class StringSpecStyle : StringSpec({ + "пустой список имеет размер ноль" { + emptyList() shouldHaveSize 0 + } + "сложение работает" { + (2 + 3) shouldBe 5 + } +}) +``` + +### FunSpec - с явным `test` и группами +```kotlin +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.collections.shouldHaveSize + +class FunSpecStyle : FunSpec({ + test("сложение") { + (2 + 3) shouldBe 5 + } + context("операции со списком") { + test("размер") { + listOf(1, 2) shouldHaveSize 2 + } + test("первый элемент") { + listOf(1, 2).first() shouldBe 1 + } + } +}) +``` + +### DescribeSpec - в стиле Jest и RSpec +```kotlin +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe + +class DescribeSpecStyle : DescribeSpec({ + describe("калькулятор") { + it("складывает") { + (1 + 1) shouldBe 2 + } + context("при делении на ноль") { + it("бросает ArithmeticException") { + shouldThrow { 1 / 0 } + } + } + } +}) +``` + +### BehaviorSpec - given/when/then +```kotlin +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.collections.shouldHaveSize + +class BehaviorSpecStyle : BehaviorSpec({ + given("пустой список") { + `when`("добавляем элемент") { + then("размер становится один") { + mutableListOf().apply { add(1) } shouldHaveSize 1 + } + } + } +}) +``` +`when` - ключевое слово Kotlin, поэтому обратные кавычки обязательны. + +### Остальные три +```kotlin +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.spec.style.WordSpec +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.collections.shouldHaveSize + +class ShouldSpecStyle : ShouldSpec({ + should("работать без обёрток") { + true shouldBe true + } +}) + +class WordSpecStyle : WordSpec({ + "список" When { + "пуст" should { + "иметь размер 0" { + emptyList() shouldHaveSize 0 + } + } + } +}) + +class FreeSpecStyle : FreeSpec({ + "группа" - { + "вложенный тест" { + 1 shouldBe 1 + } + } +}) +``` +| Стиль | Синтаксис теста | Когда удобен | +|---|---|---| +| `StringSpec` | `"имя" { }` | простые плоские тесты | +| `FunSpec` | `test("имя") { }`, `context { }` | универсальный выбор | +| `DescribeSpec` | `describe`, `context`, `it` | привычен после Jest | +| `BehaviorSpec` | `given`, `when`, `then` | BDD-сценарии | +| `ShouldSpec` | `should("имя") { }` | плоские тесты с акцентом на поведение | +| `WordSpec` | `"x" When { "y" should { } }` | многословные описания | +| `FreeSpec` | `"группа" - { "тест" { } }` | произвольная вложенность | + +В отчёте вложенность отображается деревом: +``` +└─ Kotest ✔ + ├─ DescribeSpecStyle ✔ + │ └─ калькулятор ✔ + │ ├─ складывает ✔ + │ └─ при делении на ноль ✔ + │ └─ бросает ArithmeticException ✔ + └─ BehaviorSpecStyle ✔ + └─ Given: пустой список ✔ + └─ When: добавляем элемент ✔ + └─ Then: размер становится один ✔ +``` + +## Матчеры +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.string.shouldEndWith +import io.kotest.matchers.string.shouldMatch +import io.kotest.matchers.string.shouldBeBlank +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldBeSorted +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.ints.shouldBeInRange +import io.kotest.matchers.doubles.shouldBeWithinPercentageOf +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.result.shouldBeSuccess + +class MatchersDemo : StringSpec({ + "равенство" { + 5 shouldBe 5 + 5 shouldNotBe 4 + } + "строки" { + "привет мир" shouldContain "мир" + "привет мир" shouldStartWith "прив" + "привет мир" shouldEndWith "мир" + "abc123" shouldMatch Regex("[a-z]+\\d+") + " ".shouldBeBlank() + } + "коллекции" { + listOf(1, 2, 3) shouldContainExactly listOf(1, 2, 3) + listOf(1, 2, 3) shouldContainAll listOf(3, 1) + listOf(1, 2) shouldHaveSize 2 + listOf(1, 2, 3).shouldBeSorted() + emptyList().shouldBeEmpty() + } + "числа" { + 5 shouldBeGreaterThan 3 + 5 shouldBeInRange 1..10 + (0.1 + 0.2).shouldBeWithinPercentageOf(0.3, 0.001) + } + "null и типы" { + null.shouldBeNull() + "есть".shouldNotBeNull() + "текст".shouldBeInstanceOf() + } + "map и Result" { + mapOf("k" to 1) shouldContainKey "k" + runCatching { 42 }.shouldBeSuccess() + } +}) +``` +| Группа | Примеры | +|---|---| +| общие | `shouldBe`, `shouldNotBe`, `shouldBeSameInstanceAs` | +| строки | `shouldContain`, `shouldMatch`, `shouldBeBlank`, `shouldHaveLength` | +| коллекции | `shouldContainExactly`, `shouldContainAll`, `shouldBeSorted`, `shouldBeUnique` | +| числа | `shouldBeGreaterThan`, `shouldBeInRange`, `shouldBeWithinPercentageOf` | +| null | `shouldBeNull`, `shouldNotBeNull` | +| типы | `shouldBeInstanceOf`, `shouldBeTypeOf` | +| map | `shouldContainKey`, `shouldContainValue` | +| `Result` | `shouldBeSuccess`, `shouldBeFailure` | +| файлы | `shouldExist`, `shouldBeADirectory` | + +Матчеры инфиксные, поэтому читаются слева направо: `actual shouldBe expected` - порядок обратный `assertEquals`, и это намеренно. + +### shouldNotBeNull даёт умное приведение +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe + +class SmartCastDemo : StringSpec({ + "после shouldNotBeNull тип известен" { + val value: String? = "текст" + value.shouldNotBeNull() + // компилятор знает, что value это String + value.length shouldBe 5 + } +}) +``` + +## Исключения +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.throwables.shouldThrowAny +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +class ExceptionsDemo : StringSpec({ + "shouldThrow возвращает исключение" { + val e = shouldThrow { + require(false) { "проверка не прошла" } + } + e.message shouldBe "проверка не прошла" + } + "shouldThrowExactly требует точный тип" { + shouldThrowExactly { error("сбой") } + } + "shouldThrowAny ловит любое" { + val e = shouldThrowAny { 1 / 0 } + e.message.shouldContain("zero") + } + "shouldNotThrowAny для успешного пути" { + shouldNotThrowAny { listOf(1).first() } + } +}) +``` +`shouldThrow` принимает подтипы, `shouldThrowExactly` - только точный тип. Разница важна для иерархий исключений. Подробнее: [Система исключений](../Исключения/Система%20исключений.md) + +## assertSoftly и inspectors +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.assertions.assertSoftly +import io.kotest.inspectors.forAll +import io.kotest.inspectors.forExactly +import io.kotest.inspectors.forAtLeastOne +import io.kotest.matchers.shouldBe +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.string.shouldStartWith + +data class User(val id: Int, val name: String, val age: Int) + +class SoftAndInspectors : StringSpec({ + "assertSoftly проверяет все условия" { + val user = User(1, "Иван", 30) + assertSoftly { + user.id shouldBe 1 + user.name shouldBe "Иван" + user.age shouldBeGreaterThan 18 + } + } + "assertSoftly на объекте как receiver" { + val user = User(2, "Пётр", 25) + assertSoftly(user) { + id shouldBe 2 + name shouldStartWith "П" + } + } + "inspectors проверяют элементы коллекции" { + val users = listOf(User(1, "Иван", 30), User(2, "Пётр", 25)) + users.forAll { it.age shouldBeGreaterThan 18 } + users.forExactly(1) { it.name shouldBe "Иван" } + users.forAtLeastOne { it.age shouldBeGreaterThan 28 } + } +}) +``` +`assertSoftly` собирает все провалы в одно сообщение вместо остановки на первом. Inspectors - это проверки с кванторами: `forAll`, `forNone`, `forExactly(n)`, `forAtLeastOne`, `forAny`, `forSome`. + +## Data-driven тесты +```kotlin +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.shouldBe + +data class AddCase(val a: Int, val b: Int, val sum: Int) + +class DataDriven : FunSpec({ + withData( + AddCase(2, 3, 5), + AddCase(0, 0, 0), + AddCase(-1, 1, 0), + AddCase(100, -50, 50), + ) { (a, b, sum) -> + (a + b) shouldBe sum + } + + withData( + nameFn = { "строка длины ${it.length} пуста" }, + "", " ", " ", + ) { s -> + s.isBlank() shouldBe true + } + + withData( + mapOf( + "положительное" to 5, + "ноль" to 0, + "отрицательное" to -5, + ), + ) { n -> + (n * 0) shouldBe 0 + } +}) +``` +Каждый случай становится отдельным тестом в отчёте: +``` +├─ DataDriven ✔ +│ ├─ AddCase(a=2, b=3, sum=5) ✔ +│ ├─ AddCase(a=0, b=0, sum=0) ✔ +│ ├─ AddCase(a=-1, b=1, sum=0) ✔ +│ └─ AddCase(a=100, b=-50, sum=50) ✔ +``` +Имя теста берётся из `toString()` аргумента, поэтому `data class` для случаев удобнее кортежей. Форма с `Map` даёт явные имена - предпочтительна, когда `toString()` неинформативен. + +## Property-based тестирование +Вместо конкретных примеров задаётся свойство, которое должно выполняться для любых входных данных. Kotest генерирует случайные значения и проверяет свойство на каждом. + +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.string +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.map +import io.kotest.property.checkAll +import io.kotest.property.forAll +import io.kotest.matchers.shouldBe +import io.kotest.matchers.collections.shouldHaveSize + +class PropertyBased : StringSpec({ + "сложение коммутативно" { + checkAll { a, b -> + (a + b) shouldBe (b + a) + } + } + "reverse дважды даёт исходную строку" { + checkAll(Arb.string()) { s -> + s.reversed().reversed() shouldBe s + } + } + "forAll с булевым результатом" { + forAll(Arb.int(1..100), Arb.int(1..100)) { a, b -> + a + b > a + } + } + "sorted не меняет размер" { + checkAll(Arb.list(Arb.int(), 0..20)) { l -> + l.sorted() shouldHaveSize l.size + } + } + "производные генераторы" { + checkAll(Arb.int().filter { it != 0 }) { n -> + (n / n) shouldBe 1 + } + checkAll(Arb.int(1..100).map { it * 2 }) { even -> + (even % 2) shouldBe 0 + } + } +}) +``` +Разница `checkAll` и `forAll`: первый ожидает утверждений внутри лямбды, второй - возврата `Boolean`. + +| Генератор | Что даёт | +|---|---| +| `Arb.int()`, `Arb.int(1..100)` | числа, с диапазоном | +| `Arb.string()`, `Arb.string(5..10)` | строки | +| `Arb.boolean()`, `Arb.double()`, `Arb.long()` | примитивы | +| `Arb.list(gen, 0..10)`, `Arb.set(gen)` | коллекции | +| `Arb.enum()` | значения enum | +| `Arb.of(a, b, c)` | из фиксированного набора | +| `Arb.bind(g1, g2) { a, b -> ... }` | составные объекты | +| `gen.filter { }`, `gen.map { }` | производные | +| `Arb.int().orNull()` | с вероятностью `null` | + +### Shrinking +Главная ценность property-based тестов - автоматический поиск минимального контрпримера. При провале Kotest уменьшает входные данные: +``` +Attempting to shrink arg 960 +Shrink #1: 0 pass +Shrink #2: 1 pass +Shrink #3: 320 pass +Shrink #4: 480 pass +Shrink #5: 640 fail +... +Shrink result (after 124 shrinks) => 500 +Property failed after 3 attempts +Repeat this test by using seed 2234853214744756250 +``` +Вместо случайного `960` сообщение называет точную границу `500`. Seed позволяет воспроизвести тот же набор значений - его стоит сохранять в тесте при отладке: +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.property.Arb +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.int +import io.kotest.property.checkAll +import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual + +class SeedDemo : StringSpec({ + "воспроизводимый прогон" { + checkAll( + PropTestConfig(seed = 2234853214744756250, iterations = 50), + Arb.int(0..1000), + ) { n -> + n shouldBeGreaterThanOrEqual 0 + } + } +}) +``` +По умолчанию - 1000 итераций; `PropTestConfig(iterations = n)` меняет их число. + +## Жизненный цикл +```kotlin +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class LifecycleDemo : FunSpec({ + var counter = 0 + + beforeSpec { counter = 100 } + beforeTest { counter += 1 } + afterTest { counter -= 1 } + afterSpec { counter = 0 } + + test("первый") { counter shouldBe 101 } + test("второй") { counter shouldBe 101 } +}) +``` +| Коллбэк | Когда | +|---|---| +| `beforeSpec` | один раз перед всеми тестами класса | +| `beforeTest` | перед каждым тестом | +| `beforeContainer` | перед группой (`context`, `describe`) | +| `afterTest` | после каждого теста | +| `afterSpec` | один раз после всех тестов | +| `beforeEach` / `afterEach` | только листовые тесты | +| `afterProject` | после всего прогона (в ProjectConfig) | + +Коллбэки - лямбды внутри конструктора, а не методы с аннотациями, поэтому `@JvmStatic` и `companion object` не нужны - в отличие от JUnit 5. + +## Изоляция - главная ловушка +По умолчанию Kotest создаёт **один** экземпляр спецификации на все тесты, и состояние протекает: +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class DefaultIsolation : StringSpec({ + var state = 0 + + "первый" { + state += 1 + state shouldBe 1 + } + "второй видит изменения первого" { + state += 1 + state shouldBe 2 // не 1! + } +}) +``` +Это противоположно JUnit, где экземпляр создаётся на каждый тест. Исправляется явно: +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.core.spec.IsolationMode +import io.kotest.matchers.shouldBe + +class IsolatedSpec : StringSpec({ + isolationMode = IsolationMode.InstancePerLeaf + var state = 0 + + "первый видит нулевое состояние" { + state += 1 + state shouldBe 1 + } + "второй тоже видит нулевое состояние" { + state += 1 + state shouldBe 1 + } +}) +``` +| Режим | Поведение | +|---|---| +| `SingleInstance` (по умолчанию) | один экземпляр на спецификацию | +| `InstancePerLeaf` | новый экземпляр на каждый листовой тест | +| `InstancePerRoot` | новый экземпляр на каждый корневой тест | + +`InstancePerLeaf` даёт поведение, привычное после JUnit, но замедляет прогон: конструктор спецификации выполняется заново на каждый тест. + +## Конфигурация тестов +```kotlin +import io.kotest.core.spec.style.FunSpec +import io.kotest.core.annotation.Ignored +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import kotlin.time.Duration.Companion.seconds + +class ConfigDemo : FunSpec({ + test("повторяется трижды").config(invocations = 3, timeout = 2.seconds) { + (1 + 1) shouldBe 2 + } + test("отключён").config(enabled = false) { + error("не выполняется") + } + test("по условию").config(enabledIf = { System.getenv("CI") == null }) { + true shouldBe true + } + test("помечен тегом").config(tags = setOf(Slow)) { + true shouldBe true + } +}) + +object Slow : io.kotest.core.Tag() + +@Ignored +class IgnoredSpec : StringSpec({ + "никогда не запускается" { error("нет") } +}) +``` +| Параметр `.config()` | Что делает | +|---|---| +| `invocations` | повторить N раз | +| `timeout` | ограничение времени | +| `enabled` | включить/выключить | +| `enabledIf` | условие по лямбде | +| `tags` | теги для фильтрации | +| `retries`, `retryDelay` | повторы при провале | +| `severity` | уровень важности для фильтрации | +| `coroutineDebugProbes` | трассировка корутин при зависании | + +## Конфигурация проекта +```kotlin +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.IsolationMode +import io.kotest.core.names.DuplicateTestNameMode +import io.kotest.engine.concurrency.SpecExecutionMode + +class ProjectConfig : AbstractProjectConfig() { + override val isolationMode = IsolationMode.InstancePerLeaf + override val specExecutionMode = SpecExecutionMode.LimitedConcurrency(4) + override val duplicateTestNameMode = DuplicateTestNameMode.Error + override val globalAssertSoftly = true +} +``` +Файл с классом, наследующим `AbstractProjectConfig`, Kotest находит автоматически. Это единственное место, где можно задать режим изоляции для всего проекта - иначе придётся повторять в каждой спецификации. + +| Свойство | Что задаёт | +|---|---| +| `isolationMode` | изоляция для всех спецификаций | +| `specExecutionMode` | `Sequential`, `Concurrent`, `LimitedConcurrency(n)` для спецификаций | +| `testExecutionMode` | то же для тестов внутри спецификации | +| `duplicateTestNameMode` | `Warn` (по умолчанию), `Error`, `Silent` | +| `globalAssertSoftly` | оборачивать каждый тест в `assertSoftly` | +| `timeout`, `invocationTimeout`, `projectTimeout` | таймауты | + +## Фильтрация и запуск +``` +./gradlew test +./gradlew test --tests "com.example.CacheSpec" +./gradlew test -Dkotest.tags="!Slow" +./gradlew test -Dkotest.tags="Integration & !Slow" +``` +```kotlin +// build.gradle.kts +tasks.test { + useJUnitPlatform() + systemProperty("kotest.tags", "!Slow") +} +``` +Движок Kotest работает поверх JUnit Platform, поэтому Gradle, IDE и CI видят тесты как обычные - отчёты, фильтры и интеграции работают без настройки. + +## Kotest и JUnit вместе +Матчеры Kotest не требуют его движка: +```kotlin +import kotlin.test.Test +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.collections.shouldHaveSize + +class MixedTest { + @Test + fun `матчеры Kotest в обычном тесте JUnit`() { + "привет мир" shouldContain "мир" + listOf(1, 2) shouldHaveSize 2 + (2 + 3) shouldBe 5 + } +} +``` +Это разумный компромисс: читаемые матчеры без перехода на другой фреймворк. Достаточно `kotest-assertions-core`, `kotest-runner-junit5` не нужен. + +## Когда брать Kotest +**Стоит**, если нужны: вложенные спецификации с группировкой, data-driven тесты (`withData` короче `@ParameterizedTest`), property-based тестирование, богатые матчеры, тесты для Multiplatform. + +**Не стоит**, если: проект небольшой и `kotlin.test` хватает, команда привыкла к JUnit, важна минимальная зависимость. Подробнее: [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md) + +Промежуточный вариант - `kotest-assertions-core` поверх JUnit: самая заметная часть выгоды при минимальных изменениях. + +> [!info] Отличия от Java +> | Java | Kotest | +> |---|---| +> | JUnit 5 + AssertJ | один фреймворк | +> | `@Test void x()` | `"описание" { }` | +> | `assertThat(x).isEqualTo(y)` | `x shouldBe y` | +> | `@ParameterizedTest` + `@CsvSource` | `withData(...)` | +> | jqwik для property-based | `kotest-property` встроен | +> | `@Nested static class` | вложенность через DSL | +> | `@BeforeAll static` | `beforeSpec { }` | +> | новый экземпляр на тест | один экземпляр по умолчанию | +> +> **DSL спецификаций возможен из-за лямбд с receiver.** Spock даёт похожий синтаксис в Groovy, но ценой динамической типизации и отдельного языка. Kotest строит DSL на типизированных лямбдах с получателем: `StringSpec({ ... })` - это конструктор, принимающий лямбду, внутри которой `String.invoke(block)` - оператор. Весь синтаксис - обычный Kotlin с проверкой типов. Подробнее: [DSL](../Функциональный%20Kotlin/DSL.md) +> +> **Матчеры читаются обратно `assertEquals`.** `x shouldBe y` ставит фактическое значение первым, `assertEquals(expected, actual)` - ожидаемое. Это следствие инфиксной формы: по-английски читается как предложение. При переходе с JUnit порядок приходится переучивать, зато перепутать его труднее - неправильный порядок звучит неестественно. +> +> **Property-based тестирование встроено, а не отдельная библиотека.** В Java это jqwik или QuickTheories - сторонние инструменты со своим стилем тестов. В Kotest `checkAll` работает внутри любой спецификации рядом с обычными тестами, и shrinking находит минимальный контрпример автоматически. Это снижает порог: добавить одно свойство к существующим тестам ничего не стоит. +> +> **Изоляция устроена противоположно JUnit, и это главный источник ошибок.** JUnit создаёт новый экземпляр тестового класса на каждый тест - изоляция по умолчанию. Kotest создаёт один экземпляр на спецификацию, потому что спецификация - это лямбда в конструкторе, и её повторное выполнение стоит дороже. Результат: переменные в теле спецификации разделяются между тестами, и порядок начинает иметь значение. Решается `IsolationMode.InstancePerLeaf`, но включать нужно осознанно. +> +> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **Изоляции по умолчанию нет.** `var` в теле спецификации разделяется между всеми тестами, и второй тест видит изменения первого. Это противоположно JUnit и ломает тесты неочевидным образом - особенно при параллельном запуске. Задавайте `IsolationMode.InstancePerLeaf` в `ProjectConfig`, если не уверены. +> +> **Kotest собран под JVM target 11.** Компиляция с дефолтным `-jvm-target 1.8` даёт ошибку «cannot inline bytecode built with JVM target 11 into bytecode that is being built with JVM target 1.8». Нужен `jvmToolchain(11)` или выше - для Gradle это обычно уже так, но при ручной компиляции флаг обязателен. Подробнее: [kotlinc](../Инструментарий/kotlinc.md) +> +> **`withData` даёт дублирующиеся имена при похожих значениях.** Строки `" "` и `" "` после обработки дают одинаковое имя теста, Kotest выводит «WARN: Duplicated test name» и добавляет префикс `(1)`. Используйте `nameFn = { ... }` или форму с `Map`, где имена заданы явно. +> +> **`when` требует обратных кавычек в `BehaviorSpec`.** `when("...")` не скомпилируется - это ключевое слово. Нужно `` `when`("...") ``. В `WordSpec` аналогично используется `When` с заглавной буквы. +> +> **`shouldBe` на массивах сравнивает ссылки.** Как и `assertEquals`: `intArrayOf(1) shouldBe intArrayOf(1)` провалится. Нужен `shouldContainExactly` или преобразование в список. Подробнее: [Массивы](../Синтаксис%20и%20типы/Работа%20с%20данными/Массивы.md) +> +> **`checkAll` по умолчанию делает 1000 итераций.** Для медленной проверки (обращение к БД, сложный алгоритм) это минуты. Задавайте `PropTestConfig(iterations = 100)` явно. +> +> **Генератор может не находить контрпример.** `Arb.int()` редко выдаёт конкретное значение вроде `Int.MAX_VALUE`, хотя Kotest и подмешивает граничные случаи. Если свойство ломается только на краях, добавьте их явно через `Arb.of(...)` или `Arb.int().withEdgecases(...)`. +> +> **Провал property-теста без сохранённого seed невоспроизводим.** Сообщение содержит seed - его нужно скопировать в `PropTestConfig(seed = ...)`, иначе следующий прогон сгенерирует другие значения, и ошибка «исчезнет». +> +> **`shouldThrow` ловит подтипы.** `shouldThrow` пройдёт на любом исключении, включая `NullPointerException` от ошибки в самом тесте. Указывайте конкретный тип или `shouldThrowExactly`. +> +> **`assertSoftly` не ловит исключения.** Он собирает провалы матчеров, но `NullPointerException` внутри блока прервёт тест сразу, и остальные проверки не выполнятся. +> +> **Вложенные `context` без тестов внутри молча пропускаются.** Группа без листовых тестов не считается провалом - она просто не появляется в отчёте, и легко не заметить, что проверки не выполняются. +> +> **Параллелизм спецификаций и тестов настраивается раздельно.** `specExecutionMode` влияет только на спецификации; тесты внутри одной спецификации по умолчанию идут последовательно, пока не задан `testExecutionMode = TestExecutionMode.Concurrent`. Старые `parallelism` и `.config(threads = n)` в Kotest 6 убраны - код из статей по версии 5 не скомпилируется. +> +> **Две версии Kotest в classpath дают неочевидные ошибки.** Артефакты `kotest-runner-junit5`, `kotest-assertions-core` и `kotest-property` должны быть одной версии; расхождение проявляется как `NoSuchMethodError` в рантайме. Держите версию в `libs.versions.toml`. Подробнее: [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md) +> +> **`ProjectConfig` находится по наследованию, а не по имени.** Класс должен наследовать `AbstractProjectConfig` и лежать в тестовом source set; имя значения не имеет, но два таких класса в проекте дают неопределённое поведение. + +Далее: [MockK](MockK.md) diff --git a/Kotlin/Тестирование/MockK.md b/Kotlin/Тестирование/MockK.md new file mode 100644 index 0000000..511c177 --- /dev/null +++ b/Kotlin/Тестирование/MockK.md @@ -0,0 +1,780 @@ +--- +title: MockK +description: Моки, стабы, spyk, проверка вызовов, мокирование object, companion, функций верхнего уровня и suspend +tags: + - kotlin + - testing + - mockk +--- + +MockK - библиотека моков, написанная для Kotlin. Главное её отличие от Mockito в том, что финальные классы, `object`, функции верхнего уровня и `suspend`-функции мокаются без дополнительной настройки. + +```kotlin +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertEquals + +data class User(val id: Int, val name: String) + +interface UserRepo { + fun findById(id: Int): User? +} + +class FirstMockTest { + @Test + fun `мок возвращает заданное значение`() { + val repo = mockk() + every { repo.findById(1) } returns User(1, "Иван") + + assertEquals("Иван", repo.findById(1)?.name) + verify { repo.findById(1) } + } +} +``` +Три шага: `mockk()` создаёт заглушку, `every { } returns` задаёт поведение, `verify { }` проверяет вызов. + +## Подключение +```kotlin +// build.gradle.kts +dependencies { + testImplementation("io.mockk:mockk:1.14.6") +} + +tasks.test { useJUnitPlatform() } +``` +| Артефакт | Зачем | +|---|---| +| `io.mockk:mockk` | всё для JVM-тестов, включая JUnit 5 extension | +| `io.mockk:mockk-android` | инструментальные тесты Android | +| `io.mockk:mockk-agent-jvm` | подтягивается транзитивно, отдельно не нужен | + +Зависимость одна - `mockk` тянет `byte-buddy` и `objenesis` сам. + +## Создание моков +```kotlin +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals + +interface Repo { + fun count(): Int + fun name(): String + fun flag(): Boolean + fun items(): List + fun clear() +} + +class MockModesTest { + @Test + fun `строгий мок требует стаба на каждый вызов`() { + val repo = mockk() + every { repo.count() } returns 7 + assertEquals(7, repo.count()) + // repo.name() бросил бы MockKException + } + + @Test + fun `relaxed возвращает значения по умолчанию`() { + val repo = mockk(relaxed = true) + assertEquals(0, repo.count()) + assertEquals("", repo.name()) + assertEquals(false, repo.flag()) + assertEquals(emptyList(), repo.items()) + } + + @Test + fun `relaxUnitFun ослабляет только Unit-методы`() { + val repo = mockk(relaxUnitFun = true) + repo.clear() // ок + // repo.count() всё ещё бросит MockKException + } + + @Test + fun `имя мока попадает в сообщение об ошибке`() { + val repo = mockk(name = "репозиторий") + val message = runCatching { repo.count() }.exceptionOrNull()?.message + assertEquals(true, message?.contains("репозиторий")) + } +} +``` +| Форма | Поведение на нестабованном вызове | +|---|---| +| `mockk()` | `MockKException: no answer found` | +| `mockk(relaxed = true)` | значение по умолчанию: `0`, `""`, `false`, пустая коллекция, мок для ссылочного типа | +| `mockk(relaxUnitFun = true)` | `Unit`-методы проходят, остальные бросают | +| `mockk(name = "x")` | строгий, но с понятным именем в ошибке | + +`relaxed = true` для ссылочных типов возвращает не `null`, а дочерний мок - `repo.findById(1)` даст `User(child of #1#2)`. Это удобно для цепочек, но скрывает забытые стабы. + +## Задание поведения +```kotlin +import io.mockk.every +import io.mockk.just +import io.mockk.Runs +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals + +data class User(val id: Int, val name: String) + +interface UserRepo { + fun findById(id: Int): User? + fun count(): Int + fun deleteAll() +} + +class AnswersTest { + @Test + fun `returns и returnsMany`() { + val repo = mockk() + every { repo.count() } returnsMany listOf(1, 2, 3) + // последнее значение повторяется для всех дальнейших вызовов + assertEquals(listOf(1, 2, 3, 3), List(4) { repo.count() }) + } + + @Test + fun `answers видит аргументы вызова`() { + val repo = mockk() + every { repo.findById(any()) } answers { + User(firstArg(), "пользователь ${firstArg()}") + } + assertEquals("пользователь 7", repo.findById(7)?.name) + } + + @Test + fun `throws`() { + val repo = mockk() + every { repo.count() } throws IllegalStateException("нет соединения") + val e = runCatching { repo.count() }.exceptionOrNull() + assertEquals("нет соединения", e?.message) + } + + @Test + fun `just Runs для Unit`() { + val repo = mockk() + every { repo.deleteAll() } just Runs + repo.deleteAll() + } +} +``` +| Инфикс | Что делает | +|---|---| +| `returns x` | всегда одно значение | +| `returnsMany listOf(...)` | значения по порядку, последнее повторяется | +| `answers { }` | лямбда с доступом к `firstArg()`, `secondArg()`, `arg(n)`, `call` | +| `answersMany listOf(...)` | список ответов | +| `throws e` | бросает исключение | +| `throwsMany listOf(...)` | исключения по порядку | +| `just Runs` | для `Unit`-функций | +| `propertyType` / `nullablePropertyType` | уточнение типа для дженериков | + +`returnsMany` после исчерпания списка **повторяет последний элемент**, а не бросает - из четырёх вызовов `[1, 2, 3]` даст `1 2 3 3`. + +## Сопоставители аргументов +```kotlin +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +data class User(val id: Int, val name: String) + +interface UserRepo { + fun findById(id: Int): User? +} + +class MatchersTest { + @Test + fun `сопоставители по условию`() { + val repo = mockk() + every { repo.findById(more(10)) } returns User(0, "большой") + every { repo.findById(range(1, 9)) } returns User(0, "малый") + every { repo.findById(eq(0)) } returns null + + assertEquals("большой", repo.findById(50)?.name) + assertEquals("малый", repo.findById(5)?.name) + assertNull(repo.findById(0)) + } +} +``` +| Сопоставитель | Условие | +|---|---| +| `any()` | любой аргумент | +| `eq(x)` / `neq(x)` | равен / не равен по `equals` | +| `refEq(x)` | та же ссылка | +| `isNull()` / `isNull(inverse = true)` | `null` или нет | +| `ofType()` | конкретный тип | +| `more(x)` / `less(x)` | больше / меньше, с `andEquals` для нестрогого | +| `range(a, b)` | в диапазоне | +| `match { it.name.isNotBlank() }` | произвольный предикат | +| `capture(slot)` | захват значения | +| `withArg { assert(...) }` | проверка внутри `verify` | + +Важно: позже заданный стаб **перекрывает** ранее заданный для тех же аргументов. `every { repo.findById(1) } returns a` после `every { repo.findById(any()) } returns b` даёт `a` для `1`; в обратном порядке `any()` перехватит и `1`. + +## Захват аргументов +```kotlin +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +data class User(val id: Int, val name: String) + +interface UserRepo { + fun findById(id: Int): User? + fun save(user: User): User +} + +class CaptureTest { + @Test + fun `slot хранит последний аргумент`() { + val repo = mockk() + val saved = slot() + every { repo.save(capture(saved)) } answers { saved.captured } + + repo.save(User(5, "Анна")) + assertEquals("Анна", saved.captured.name) + } + + @Test + fun `список собирает все вызовы`() { + val repo = mockk(relaxed = true) + val ids = mutableListOf() + every { repo.findById(capture(ids)) } returns null + + repo.findById(1) + repo.findById(2) + repo.findById(3) + assertEquals(listOf(1, 2, 3), ids) + } + + @Test + fun `withArg проверяет прямо в verify`() { + val repo = mockk(relaxed = true) + repo.save(User(9, "Олег")) + verify { repo.save(withArg { assertTrue(it.name.startsWith("О")) }) } + } +} +``` +`slot()` хранит одно значение - обращение к `captured` до вызова даёт `IllegalStateException`. Для всех вызовов нужен `MutableList`. `withArg` короче, когда захваченное значение нужно только для одной проверки. + +## Проверка вызовов +```kotlin +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import io.mockk.verifySequence +import kotlin.test.Test + +data class User(val id: Int, val name: String) + +interface UserRepo { + fun findById(id: Int): User? + fun save(user: User): User + fun findAll(): List +} + +class VerifyTest { + @Test + fun `количество вызовов`() { + val repo = mockk(relaxed = true) + repo.findById(1) + repo.findById(1) + + verify(exactly = 2) { repo.findById(1) } + verify(atLeast = 1) { repo.findById(any()) } + verify(exactly = 0) { repo.findAll() } + } + + @Test + fun `порядок и полная последовательность`() { + val repo = mockk(relaxed = true) + repo.findById(1) + repo.save(User(1, "a")) + repo.findAll() + + // verifyOrder допускает вызовы между проверяемыми + verifyOrder { + repo.findById(1) + repo.findAll() + } + // verifySequence требует ровно эти вызовы и только их + verifySequence { + repo.findById(1) + repo.save(any()) + repo.findAll() + } + } + + @Test + fun `confirmVerified ловит непроверенные вызовы`() { + val repo = mockk(relaxed = true) + repo.findById(1) + repo.findAll() + + verify { repo.findById(1) } + // findAll не проверен - confirmVerified бросит AssertionError + val e = runCatching { confirmVerified(repo) }.exceptionOrNull() + kotlin.test.assertTrue(e is AssertionError) + } +} +``` +| Функция | Что требует | +|---|---| +| `verify { }` | хотя бы один подходящий вызов | +| `verify(exactly = n)` | ровно `n` вызовов | +| `verify(atLeast = n)`, `verify(atMost = n)` | границы | +| `verify(exactly = 0)` | вызова не было | +| `verifyAll { }` | все перечисленные вызовы и ничего больше, порядок свободный | +| `verifyOrder { }` | перечисленные в этом порядке, между ними допустимы другие | +| `verifySequence { }` | ровно эти вызовы, ровно в этом порядке | +| `confirmVerified(mock)` | все вызовы мока были проверены | +| `excludeRecords { }` | исключить вызовы из учёта `confirmVerified` | +| `checkUnnecessaryStub(mock)` | ни один стаб не остался неиспользованным | + +`confirmVerified` и `checkUnnecessaryStub` бросают `AssertionError`, то есть выглядят как обычный провал утверждения, а не как ошибка библиотеки. + +## spyk - частичный мок +```kotlin +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import kotlin.test.Test +import kotlin.test.assertEquals + +data class User(val id: Int, val name: String) + +interface UserRepo { + fun findById(id: Int): User? +} + +open class Service(private val repo: UserRepo) { + open fun greet(id: Int): String = "Привет, ${repo.findById(id)?.name ?: "гость"}" + open fun helper(): Int = 42 + fun usesHelper(): Int = helper() * 2 +} + +class SpykTest { + @Test + fun `spyk выполняет настоящий код пока метод не застабован`() { + val repo = mockk() + every { repo.findById(1) } returns User(1, "Иван") + val service = spyk(Service(repo)) + + assertEquals("Привет, Иван", service.greet(1)) + + every { service.helper() } returns 10 + assertEquals(20, service.usesHelper()) + } +} +``` +`spyk` оборачивает **существующий** объект: незастабованные методы работают как раньше, застабованные подменяются. Это нужно, когда тестируется класс, часть методов которого хочется изолировать. + +Приватные методы требуют отдельного флага: +```kotlin +import io.mockk.every +import io.mockk.spyk +import kotlin.test.Test +import kotlin.test.assertEquals + +class WithPrivate { + private fun secret(): Int = 1 + fun public(): Int = secret() * 10 +} + +class PrivateCallsTest { + @Test + fun `приватный метод стабуется по имени`() { + val spy = spyk(WithPrivate(), recordPrivateCalls = true) + every { spy["secret"]() } returns 5 + assertEquals(50, spy.public()) + } +} +``` +Доступ по строковому имени не проверяется компилятором - при переименовании метода тест молча перестанет стабовать нужное. Это крайняя мера; обычно приватный метод стоит вынести в отдельный класс. + +## Kotlin-специфичное мокирование +Здесь MockK даёт то, чего Mockito без расширений не умеет. + +### Финальные классы +```kotlin +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals + +class FinalRepo { + fun load(id: Int): String = "реальный $id" +} + +class FinalClassTest { + @Test + fun `финальный класс мокается без настройки`() { + val repo = mockk() + every { repo.load(1) } returns "мок" + assertEquals("мок", repo.load(1)) + } +} +``` +В Kotlin классы финальны по умолчанию, поэтому это основной случай, а не исключение. Подробнее: [Наследование](../ООП/Наследование.md) + +### object и companion object +```kotlin +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlin.test.Test +import kotlin.test.assertEquals + +object Config { + fun url(): String = "prod://db" +} + +class WithCompanion { + companion object { + fun create(): String = "реальный" + } +} + +class ObjectMockTest { + @Test + fun `mockkObject подменяет синглтон`() { + mockkObject(Config) + every { Config.url() } returns "test://mem" + assertEquals("test://mem", Config.url()) + + unmockkObject(Config) + assertEquals("prod://db", Config.url()) + } + + @Test + fun `companion мокается как объект`() { + mockkObject(WithCompanion.Companion) + every { WithCompanion.create() } returns "мок" + assertEquals("мок", WithCompanion.create()) + unmockkObject(WithCompanion.Companion) + } +} +``` +`mockkObject` оборачивает **реальный** экземпляр - это spy, а не чистый мок: незастабованные методы продолжают работать. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) + +### Функции верхнего уровня и расширения +```kotlin +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlin.test.Test +import kotlin.test.assertEquals + +fun topLevel(x: Int): Int = x * 2 + +fun String.shout(): String = "$this!" + +class StaticMockTest { + @Test + fun `функция верхнего уровня`() { + mockkStatic(::topLevel) + every { topLevel(any()) } returns 100 + assertEquals(100, topLevel(1)) + + unmockkStatic(::topLevel) + assertEquals(2, topLevel(1)) + } + + @Test + fun `функция-расширение`() { + mockkStatic(String::shout) + every { any().shout() } returns "замок" + assertEquals("замок", "привет".shout()) + + unmockkStatic(String::shout) + assertEquals("привет!", "привет".shout()) + } +} +``` +Обе компилируются в статические методы класса файла, поэтому используется `mockkStatic`. Ссылка на функцию (`::topLevel`) предпочтительнее строки с именем класса - её проверяет компилятор. Подробнее: [Kotlin в байт-коде](../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) + +### Конструкторы +```kotlin +import io.mockk.every +import io.mockk.mockkConstructor +import io.mockk.unmockkConstructor +import kotlin.test.Test +import kotlin.test.assertEquals + +class FinalRepo { + fun load(id: Int): String = "реальный $id" +} + +class ConstructorMockTest { + @Test + fun `mockkConstructor перехватывает создание объектов`() { + mockkConstructor(FinalRepo::class) + every { anyConstructed().load(any()) } returns "из конструктора" + + assertEquals("из конструктора", FinalRepo().load(5)) + + unmockkConstructor(FinalRepo::class) + assertEquals("реальный 5", FinalRepo().load(5)) + } +} +``` +Это обходной путь для кода, создающего зависимости внутри себя. Нужда в `mockkConstructor` - сигнал, что зависимость стоит передавать через конструктор. + +### suspend-функции +```kotlin +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.Runs +import io.mockk.mockk +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +interface AsyncRepo { + suspend fun fetch(id: Int): String + suspend fun saveAll(items: List) +} + +class SuspendMockTest { + @Test + fun `coEvery и coVerify`() = runTest { + val repo = mockk() + coEvery { repo.fetch(1) } returns "данные" + coEvery { repo.saveAll(any()) } just Runs + + assertEquals("данные", repo.fetch(1)) + repo.saveAll(listOf("a")) + + coVerify(exactly = 1) { repo.fetch(1) } + coVerify { repo.saveAll(listOf("a")) } + } + + @Test + fun `coAnswers может приостанавливаться`() = runTest { + val repo = mockk() + coEvery { repo.fetch(any()) } coAnswers { + delay(1000) + "после задержки" + } + assertEquals("после задержки", repo.fetch(1)) + } +} +``` +Для `suspend`-функций нужны `coEvery`, `coVerify`, `coAnswers` - обычные `every` и `verify` не могут вызвать приостанавливаемую лямбду. `delay` внутри `coAnswers` под `runTest` виртуализируется и не замедляет тест. Подробнее: [Тестирование корутин](Тестирование%20корутин.md) + +## Аннотации +```kotlin +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import kotlin.test.Test +import kotlin.test.assertTrue + +data class User(val id: Int, val name: String) +interface UserRepo { fun findById(id: Int): User? } +interface Mailer { fun send(to: String, body: String) } + +class Notifier(private val repo: UserRepo, private val mailer: Mailer) { + fun notify(id: Int): Boolean { + val user = repo.findById(id) ?: return false + mailer.send(user.name, "привет") + return true + } +} + +class AnnotationsTest { + @MockK lateinit var repo: UserRepo + @RelaxedMockK lateinit var mailer: Mailer + @InjectMockKs lateinit var notifier: Notifier + + @BeforeEach fun setUp() = MockKAnnotations.init(this) + @AfterEach fun tearDown() = clearAllMocks() + + @Test + fun `зависимости подставляются автоматически`() { + every { repo.findById(1) } returns User(1, "Иван") + + assertTrue(notifier.notify(1)) + verify { mailer.send("Иван", "привет") } + } +} +``` +`@InjectMockKs` ищет моки по типу и передаёт их в конструктор. Это избавляет от ручного `Notifier(repo, mailer)` - полезно, когда конструктор большой и часто меняется. + +JUnit 5 extension делает `init` сам: +```kotlin +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.Test +import kotlin.test.assertEquals + +data class User(val id: Int, val name: String) +interface UserRepo { fun findById(id: Int): User? } + +@ExtendWith(MockKExtension::class) +class ExtensionTest { + @MockK lateinit var repo: UserRepo + + @Test + fun `extension инициализирует моки`() { + every { repo.findById(2) } returns User(2, "Пётр") + assertEquals("Пётр", repo.findById(2)?.name) + } +} +``` +| Аннотация | Что делает | +|---|---| +| `@MockK` | строгий мок | +| `@RelaxedMockK` | `relaxed = true` | +| `@SpyK` | `spyk` существующего значения | +| `@InjectMockKs` | создаёт объект, подставляя моки | +| `@OverrideMockKs` | как `@InjectMockKs`, но перезаписывает уже заданные поля | +| `@ExtendWith(MockKExtension::class)` | автоматический `init` и `unmockk` | + +`MockKExtension` также понимает `@MockKExtension.ConfirmVerification` и `@MockKExtension.CheckUnnecessaryStub` на классе - включает соответствующие проверки для всех тестов. + +## Сброс состояния +```kotlin +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +data class User(val id: Int, val name: String) +interface UserRepo { fun findAll(): List } + +class ClearTest { + @Test + fun `clearMocks снимает стабы и историю вызовов`() { + val repo = mockk() + every { repo.findAll() } returns listOf(User(1, "x")) + assertEquals(1, repo.findAll().size) + + clearMocks(repo) + // стаб снят - вызов снова бросает + val e = runCatching { repo.findAll() }.exceptionOrNull() + assertTrue(e != null) + } +} +``` +| Функция | Что сбрасывает | +|---|---| +| `clearMocks(a, b)` | стабы и записанные вызовы указанных моков | +| `clearMocks(m, answers = false)` | только историю вызовов, стабы остаются | +| `clearAllMocks()` | все моки, включая `mockkObject` и `mockkStatic` | +| `unmockkObject(obj)`, `unmockkStatic(...)`, `unmockkConstructor(...)` | снимают конкретную подмену | +| `unmockkAll()` | снимает все глобальные подмены | + +Статические подмены и `mockkObject` **глобальны на весь JVM-процесс**, поэтому их обязательно снимать: иначе следующий тест в том же прогоне получит подменённый `object`. + +## Когда MockK, когда нет +**Стоит**, если: код на Kotlin с финальными классами, есть `object` и функции верхнего уровня, нужны моки `suspend`-функций, хочется DSL вместо `when(...).thenReturn(...)`. + +**Не стоит**, если: тестируемый код легко собрать вручную (fake-реализация интерфейса часто читается лучше мока), или проект смешанный и в нём уже есть Mockito с настроенным `mockito-inline`. + +Fake вместо мока: +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals + +data class User(val id: Int, val name: String) + +interface UserRepo { + fun findById(id: Int): User? + fun save(user: User): User +} + +class FakeUserRepo : UserRepo { + private val storage = mutableMapOf() + override fun findById(id: Int) = storage[id] + override fun save(user: User) = user.also { storage[it.id] = it } +} + +class FakeTest { + @Test + fun `fake хранит состояние и читается без DSL`() { + val repo = FakeUserRepo() + repo.save(User(1, "Иван")) + assertEquals("Иван", repo.findById(1)?.name) + } +} +``` +Для интерфейса с состоянием fake обычно короче набора `every`-стабов и не ломается при изменении порядка вызовов. MockK нужнее там, где важно именно проверить взаимодействие. + +> [!info] Отличия от Java +> | Mockito | MockK | +> |---|---| +> | `mock(Repo.class)` | `mockk()` | +> | `when(m.f()).thenReturn(x)` | `every { m.f() } returns x` | +> | `verify(m).f()` | `verify { m.f() }` | +> | `verify(m, times(2)).f()` | `verify(exactly = 2) { m.f() }` | +> | финальные классы требуют `mockito-inline` | работают из коробки | +> | статику мокает только `mockStatic` с try-with-resources | `mockkStatic(::f)` | +> | `suspend` недоступны | `coEvery`, `coVerify` | +> | `ArgumentCaptor` + `getValue()` | `slot()` + `captured` | +> | `@Mock` + `MockitoAnnotations.openMocks` | `@MockK` + `MockKAnnotations.init` | +> +> **Финальность по умолчанию - причина существования MockK.** В Java классы открыты, поэтому Mockito годами обходился генерацией подклассов. В Kotlin класс без `open` финален, и подкласс создать нельзя - старый Mockito на таком коде просто падал. MockK использует инструментацию байт-кода через агент, поэтому финальность ему безразлична. Mockito догнал это `mockito-inline`, но подключать его нужно отдельно. Подробнее: [Наследование](../ООП/Наследование.md) +> +> **`object` и функции верхнего уровня не имеют аналога в Java.** Java-код обычно внедряет зависимости через конструктор, и статический вызов считается плохим стилем, который мокать не нужно. В Kotlin `object` - идиоматичный синглтон, а функция верхнего уровня - обычный способ написать утилиту; оба встречаются в коде, который требуется тестировать. `mockkObject` и `mockkStatic` закрывают этот разрыв, но ценой глобального состояния: подмена живёт до `unmockk`. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) +> +> **DSL на лямбдах вместо цепочек методов.** `when(m.f()).thenReturn(x)` вызывает `m.f()` по-настоящему, а Mockito перехватывает вызов постфактум - отсюда странности с `void`-методами (`doReturn().when()`). В MockK `every { }` - это лямбда, которую библиотека выполняет в режиме записи, поэтому `Unit`-функции, `suspend`-функции и свойства обрабатываются одинаково. Подробнее: [Лямбды и функциональные типы](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) +> +> **`null` в Mockito-моках был постоянной проблемой; в Kotlin он не компилируется.** Mockito по умолчанию возвращает `null` для незастабованных методов, и Java-код падал с NPE внутри теста. MockK вместо этого бросает `MockKException` с понятным сообщением либо, в `relaxed`-режиме, возвращает непустое значение по умолчанию. Строгая типизация `null` в Kotlin делает первый вариант единственно корректным. Подробнее: [Null-безопасность](../Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md) +> +> Сравнить: [Reflection API в Java](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md) + +> [!warning] Подводные камни +> **Порядок стабов определяет приоритет.** Последний заданный `every` выигрывает при совпадении аргументов. `every { repo.findById(1) } returns a` после `every { repo.findById(any()) } returns b` работает как ожидается, но в обратном порядке `any()` перехватит и `1` - проверено: вызов `findById(1)` вернёт значение из `any()`. +> +> **`relaxed = true` маскирует забытые стабы.** Для ссылочных типов возвращается дочерний мок, а не `null`, поэтому `repo.findById(1)?.name` даст не исключение, а бессмысленную строку. Для тестов, где важна полнота стабов, используйте строгий `mockk()` или `relaxUnitFun = true`. +> +> **`mockkObject` и `mockkStatic` глобальны и переживают тест.** Подмена живёт до `unmockkObject` / `unmockkStatic` / `unmockkAll` в пределах JVM, поэтому забытый вызов портит последующие тесты - причём непредсказуемо, так как порядок тестов не гарантирован. Всегда снимайте подмену в `@AfterEach` или через `MockKExtension`. +> +> **`mockkObject` - это spy, а не чистый мок.** Незастабованные методы `object` продолжают выполнять реальный код, в том числе обращаться к сети и базе. Для полной изоляции нужно застабовать каждый используемый метод. +> +> **`every` и `verify` не работают с `suspend`.** Нужны `coEvery` и `coVerify`; обычная форма даст ошибку компиляции или зависнет. Исключение - `coAnswers` вместо `answers`, когда внутри ответа есть `delay`. +> +> **`slot.captured` до первого вызова бросает `IllegalStateException`.** Проверять захваченное значение можно только после того, как мок был вызван; при `verify(exactly = 0)` обращение к `captured` всегда провалится. +> +> **`slot` хранит только последний аргумент.** При нескольких вызовах предыдущие значения теряются молча. Для проверки всех вызовов нужен `capture(mutableListOf())`. +> +> **`verifySequence` ломается от любого лишнего вызова.** Он требует ровно перечисленных вызовов, включая те, которые делает сам тестируемый код внутри. Для проверки только порядка нужных вызовов берите `verifyOrder`. +> +> **`confirmVerified` и `checkUnnecessaryStub` бросают `AssertionError`.** Это удобно (похоже на провал утверждения), но означает, что `runCatching { ... }.onFailure { }` в тесте проглотит ошибку вместе с реальными провалами. +> +> **`spyk` с `recordPrivateCalls` использует строковое имя метода.** `spy["secret"]()` не проверяется компилятором: переименование метода оставит тест зелёным, хотя стаб больше не применяется. +> +> **Сопоставление идёт по `equals`, поэтому `data class` ведёт себя иначе, чем обычный класс.** `verify { repo.save(User(1, "Иван")) }` проходит для `data class`, потому что `equals` сравнивает поля; для обычного класса без `equals` нужен тот же экземпляр или `refEq`. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) +> +> **`returnsMany` после исчерпания списка повторяет последнее значение.** Четвёртый вызов при `returnsMany listOf(1, 2, 3)` вернёт `3`, а не бросит исключение - тест на «не больше трёх вызовов» так не напишешь, нужен `verify(exactly = 3)`. +> +> **Мокирование `inline`-функций не работает.** Тело `inline`-функции подставляется в место вызова на этапе компиляции, поэтому перехватывать нечего. Это же относится к `inline`-свойствам и функциям с `reified`. Подробнее: [Reified и inline](../Дженерики/Reified%20и%20inline.md) +> +> **MockK медленнее Mockito на старте.** Агент инструментирует классы при первом обращении; в больших наборах тестов это десятки секунд. Переиспользование моков через `clearMocks` вместо создания новых в каждом тесте заметно помогает. +> +> **`@InjectMockKs` подбирает зависимости по типу.** Два мока одного типа в тесте приводят к неопределённому выбору, а конструктор с примитивами и строками получит значения по умолчанию вместо осмысленных. Если инъекция неочевидна - собирайте объект руками. + +Далее: [Тестирование корутин](Тестирование%20корутин.md) diff --git a/Kotlin/Тестирование/kotlin.test и JUnit 5.md b/Kotlin/Тестирование/kotlin.test и JUnit 5.md new file mode 100644 index 0000000..9c1a1d5 --- /dev/null +++ b/Kotlin/Тестирование/kotlin.test и JUnit 5.md @@ -0,0 +1,610 @@ +--- +title: kotlin.test и JUnit 5 +description: Полный набор утверждений kotlin.test, аннотации JUnit 5, параметризация, вложенные тесты и жизненный цикл +tags: + - kotlin + - testing + - junit +--- + +`kotlin.test` - многоплатформенный API утверждений и аннотаций. На JVM он делегирует работу JUnit 5, поэтому вы получаете стандартный движок и привычную инфраструктуру, но пишете код, который компилируется и для JS, и для Native. + +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals + +class StringUtilsTest { + @Test + fun `slugify заменяет пробелы`() { + assertEquals("привет-мир", "Привет Мир".lowercase().replace(" ", "-")) + } +} +``` + +## Подключение +```kotlin +// build.gradle.kts +dependencies { + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} +``` +`kotlin("test")` на JVM автоматически выбирает вариант под настроенный движок. Если нужны аннотации JUnit 5 напрямую (`@ParameterizedTest`, `@Nested`), добавляется `org.junit.jupiter:junit-jupiter`. + +## Все утверждения +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertNotSame +import kotlin.test.assertContentEquals +import kotlin.test.assertContains +import kotlin.test.assertIs +import kotlin.test.assertIsNot +import kotlin.test.assertFailsWith + +class AllAssertions { + @Test + fun `весь набор kotlin test`() { + assertEquals(5, 2 + 3) + assertNotEquals(5, 4) + assertTrue(1 < 2) + assertFalse(1 > 2) + assertNull(null) + assertNotNull("есть") + assertSame("a", "a") // одна и та же ссылка + assertNotSame(listOf(1), listOf(1)) // разные объекты + assertContentEquals(listOf(1, 2), listOf(1, 2)) // поэлементно + assertContains(listOf(1, 2), 2) + assertContains("строка", "рок") + assertIs("текст") + assertIsNot("текст") + assertEquals(0.1 + 0.2, 0.3, absoluteTolerance = 1e-9) // для Double + + val e = assertFailsWith { error("сбой") } + assertEquals("сбой", e.message) + } +} +``` +| Утверждение | Проверяет | +|---|---| +| `assertEquals(expected, actual)` | равенство через `equals` | +| `assertNotEquals` | неравенство | +| `assertTrue` / `assertFalse` | булево условие | +| `assertNull` / `assertNotNull` | `null` | +| `assertSame` / `assertNotSame` | идентичность ссылок (`===`) | +| `assertContentEquals` | поэлементное равенство коллекций и массивов | +| `assertContains` | вхождение элемента, подстроки, диапазона | +| `assertIs` / `assertIsNot` | тип, со smart cast после проверки | +| `assertFailsWith { }` | исключение; возвращает его | +| `assertEquals(a, b, absoluteTolerance)` | `Double`/`Float` с допуском | +| `fail("сообщение")` | безусловный провал | + +Порядок аргументов - `expected, actual`. При провале сообщение гласит «Expected <...>, actual <...>», и перестановка делает отчёт неверным. + +### assertIs даёт умное приведение +```kotlin +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertEquals + +sealed interface Result +data class Ok(val value: String) : Result +data class Err(val code: Int) : Result + +fun compute(flag: Boolean): Result = if (flag) Ok("готово") else Err(500) + +class AssertIsTest { + @Test + fun `assertIs приводит тип`() { + val result = compute(true) + assertIs(result) + // после assertIs компилятор знает, что result это Ok + assertEquals("готово", result.value) + } +} +``` +Это преимущество перед `assertTrue(result is Ok)`: там приведения не будет, и понадобится `as`. Подробнее: [Умные приведения](../Синтаксис%20и%20типы/Null-безопасность/Умные%20приведения.md) + +### assertContentEquals против assertEquals +```kotlin +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ContentEqualsTest { + @Test + fun `массивы сравниваются только через assertContentEquals`() { + val a = intArrayOf(1, 2, 3) + val b = intArrayOf(1, 2, 3) + + // equals у массивов — это сравнение ссылок + assertFalse(a == b) + assertTrue(a.contentEquals(b)) + assertContentEquals(a.toList(), b.toList()) + } + + @Test + fun `список и множество не равны даже при одном содержимом`() { + assertFalse(listOf(1, 2) == setOf(1, 2)) + assertContentEquals(listOf(1, 2), setOf(1, 2).toList()) + } +} +``` +Для массивов `assertEquals` сравнивает ссылки и всегда провалится. Подробнее: [Массивы](../Синтаксис%20и%20типы/Работа%20с%20данными/Массивы.md) + +### Исключения +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class Validator { + fun parse(input: String): Int { + require(input.isNotBlank()) { "вход пуст" } + return input.toIntOrNull() ?: throw NumberFormatException("не число: $input") + } +} + +class ExceptionTest { + private val v = Validator() + + @Test + fun `пустой вход даёт IllegalArgumentException`() { + val e = assertFailsWith { v.parse(" ") } + assertEquals("вход пуст", e.message) + } + + @Test + fun `нечисловой вход даёт NumberFormatException`() { + val e = assertFailsWith { v.parse("abc") } + assertTrue(e.message!!.contains("abc")) + } + + @Test + fun `корректный вход не бросает`() { + assertEquals(42, v.parse("42")) + } +} +``` +`assertFailsWith` требует **точного** типа или подтипа. `assertFailsWith` поймает что угодно - это обычно нежелательно, потому что тест перестанет различать ожидаемую ошибку и случайный `NullPointerException`. + +## Аннотации +| `kotlin.test` | JUnit 5 | Когда вызывается | +|---|---|---| +| `@Test` | `@Test` | каждый тест | +| `@BeforeTest` | `@BeforeEach` | перед каждым тестом | +| `@AfterTest` | `@AfterEach` | после каждого теста | +| - | `@BeforeAll` | один раз перед классом | +| - | `@AfterAll` | один раз после класса | +| `@Ignore` | `@Disabled` | пропустить | +| - | `@DisplayName` | имя в отчёте | +| - | `@Nested` | вложенный класс | +| - | `@RepeatedTest` | повторить N раз | +| - | `@Timeout` | ограничить время | +| - | `@Tag` | группировка для фильтрации | + +```kotlin +import kotlin.test.Test +import kotlin.test.BeforeTest +import kotlin.test.AfterTest +import kotlin.test.Ignore +import kotlin.test.assertEquals +import kotlin.test.fail + +class LifecycleTest { + private lateinit var state: MutableList + + @BeforeTest + fun setUp() { + state = mutableListOf("начальный") + } + + @AfterTest + fun tearDown() { + state.clear() + } + + @Test + fun `первый тест`() { + state += "добавлено" + assertEquals(2, state.size) + } + + @Test + fun `второй тест видит свежее состояние`() { + assertEquals(1, state.size) + } + + @Test + @Ignore + fun `пока не реализовано`() { + fail("не должен выполняться") + } +} +``` +`@Ignore` без причины - плохая практика: тест тихо не выполняется месяцами. В JUnit 5 причину можно указать: `@Disabled("ждёт исправления KT-12345")`. + +### @BeforeAll требует companion +```kotlin +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import kotlin.test.Test +import kotlin.test.assertTrue + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BeforeAllTest { + private var expensiveResource: String = "" + + @BeforeAll + fun initOnce() { + expensiveResource = "дорогой ресурс" + } + + @AfterAll + fun cleanUpOnce() { + expensiveResource = "" + } + + @Test + fun `ресурс доступен`() { + assertTrue(expensiveResource.isNotEmpty()) + } +} +``` +Без `@TestInstance(PER_CLASS)` метод `@BeforeAll` должен быть статическим, то есть лежать в `companion object` с `@JvmStatic`: +```kotlin +import org.junit.jupiter.api.BeforeAll +import kotlin.test.Test +import kotlin.test.assertTrue + +class CompanionBeforeAllTest { + companion object { + private var resource: String = "" + + @JvmStatic + @BeforeAll + fun initOnce() { + resource = "инициализировано" + } + } + + @Test + fun `ресурс доступен`() { + assertTrue(resource.isNotEmpty()) + } +} +``` +`@TestInstance(PER_CLASS)` короче, но меняет семантику: экземпляр класса создаётся один для всех тестов, и состояние в полях протекает между ними. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) + +## Параметризованные тесты +```kotlin +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.NullAndEmptySource +import org.junit.jupiter.params.provider.MethodSource +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +enum class Status { ACTIVE, BLOCKED, DELETED } + +class ParameterizedTests { + @ParameterizedTest + @ValueSource(strings = ["", " ", "\t", "\n"]) + fun `все эти строки пустые`(input: String) { + assertTrue(input.isBlank()) + } + + @ParameterizedTest + @ValueSource(ints = [1, 2, 3, 5, 8]) + fun `все эти числа положительны`(n: Int) { + assertTrue(n > 0) + } + + @ParameterizedTest + @CsvSource("2, 3, 5", "0, 0, 0", "-1, 1, 0", "100, -50, 50") + fun `сложение по таблице`(a: Int, b: Int, expected: Int) { + assertEquals(expected, a + b) + } + + @ParameterizedTest + @EnumSource(Status::class) + fun `все статусы имеют непустое имя`(status: Status) { + assertTrue(status.name.isNotEmpty()) + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = [" "]) + fun `null пустая и пробельная строка одинаково бесполезны`(input: String?) { + assertTrue(input.isNullOrBlank()) + } + + @ParameterizedTest + @MethodSource("слоги") + fun `слоги короткие`(syllable: String) { + assertTrue(syllable.length <= 3) + } + + companion object { + @JvmStatic + fun слоги() = listOf("ма", "па", "ба") + } +} +``` +| Источник | Что даёт | +|---|---| +| `@ValueSource` | массив литералов одного типа | +| `@CsvSource` | строки CSV → несколько параметров | +| `@CsvFileSource` | CSV из ресурса | +| `@EnumSource` | все значения enum | +| `@NullSource` / `@EmptySource` / `@NullAndEmptySource` | граничные значения | +| `@MethodSource` | статический метод, возвращающий коллекцию | +| `@ArgumentsSource` | свой провайдер | + +`@MethodSource` на Kotlin требует `@JvmStatic` в `companion object` - иначе JUnit не найдёт метод. Это частая причина ошибки «Could not find factory method». + +## Вложенные тесты +```kotlin +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.DisplayName +import kotlin.test.Test +import kotlin.test.BeforeTest +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Cache { + private val store = mutableMapOf() + fun put(k: String, v: String) { store[k] = v } + fun get(k: String) = store[k] + fun size() = store.size +} + +@DisplayName("Кэш") +class CacheTest { + + @Nested + @DisplayName("когда пуст") + inner class WhenEmpty { + private val cache = Cache() + + @Test + fun `размер ноль`() { + assertEquals(0, cache.size()) + } + + @Test + fun `любой ключ даёт null`() { + assertNull(cache.get("любой")) + } + } + + @Nested + @DisplayName("когда заполнен") + inner class WhenFilled { + private val cache = Cache() + + @BeforeTest + fun fill() { + cache.put("ключ", "значение") + } + + @Test + fun `размер один`() { + assertEquals(1, cache.size()) + } + + @Test + fun `известный ключ возвращает значение`() { + assertEquals("значение", cache.get("ключ")) + } + + @Test + fun `неизвестный ключ даёт null`() { + assertTrue(cache.get("другой") == null) + } + } +} +``` +`@Nested` требует `inner class` - вложенный класс в Kotlin по умолчанию статический, и JUnit его не примет. Это даёт группировку в отчёте: «Кэш → когда пуст → размер ноль». Подробнее: [Вложенные и внутренние классы](../ООП/Вложенные%20и%20внутренние%20классы.md) + +## Повторы и таймауты +```kotlin +import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.Tag +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertTrue + +class RepeatAndTimeoutTest { + @RepeatedTest(5) + fun `генератор всегда в диапазоне`() { + val value = (1..100).random() + assertTrue(value in 1..100) + } + + @Test + @Timeout(value = 500, unit = TimeUnit.MILLISECONDS) + fun `быстрая операция укладывается в срок`() { + assertTrue((1..1000).sum() > 0) + } + + @Test + @Tag("slow") + fun `помеченный тест можно отфильтровать`() { + assertTrue(true) + } +} +``` +Фильтрация по тегам: +```kotlin +// build.gradle.kts +tasks.test { + useJUnitPlatform { + excludeTags("slow") + } +} + +tasks.register("slowTest") { + useJUnitPlatform { + includeTags("slow") + } +} +``` + +## Утверждение нескольких условий сразу +```kotlin +import org.junit.jupiter.api.assertAll +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +data class User(val id: Int, val name: String, val age: Int) + +class AssertAllTest { + @Test + fun `все поля проверяются даже при провале одного`() { + val user = User(1, "Иван", 30) + assertAll( + { assertEquals(1, user.id) }, + { assertEquals("Иван", user.name) }, + { assertTrue(user.age >= 18) }, + ) + } +} +``` +Без `assertAll` первый провалившийся `assertEquals` прерывает тест, и об остальных полях вы не узнаете. Это особенно полезно при проверке DTO. + +## Что не стоит тестировать +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals + +data class Point(val x: Int, val y: Int) { + init { + require(x >= 0 && y >= 0) { "координаты неотрицательны" } + } + + fun distanceTo(other: Point): Double { + val dx = (x - other.x).toDouble() + val dy = (y - other.y).toDouble() + return kotlin.math.sqrt(dx * dx + dy * dy) + } +} + +class PointTest { + // equals, hashCode, toString, copy генерирует компилятор — не тестируем + + @Test + fun `валидация в init работает`() { + val e = runCatching { Point(-1, 0) }.exceptionOrNull() + assertEquals("координаты неотрицательны", e?.message) + } + + @Test + fun `расстояние считается верно`() { + assertEquals(5.0, Point(0, 0).distanceTo(Point(3, 4)), absoluteTolerance = 1e-9) + } +} +``` +Сгенерированные члены `data class` не содержат вашей логики. Тестируйте валидацию в `init`, кастомные методы и всё, что вы написали руками. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +## Запуск +``` +./gradlew test +./gradlew test --tests "com.example.CacheTest" +./gradlew test --tests "*Cache*" +./gradlew test --tests "com.example.CacheTest.WhenEmpty" +./gradlew test --info +./gradlew test --rerun-tasks +``` +Отчёт - `build/reports/tests/test/index.html`. + +> [!info] Отличия от Java +> | Java (JUnit 5) | Kotlin (kotlin.test) | +> |---|---| +> | `Assertions.assertEquals` | `assertEquals` (top-level) | +> | `assertThrows(T.class) { }` | `assertFailsWith { }` | +> | `assertInstanceOf(T.class, x)` | `assertIs(x)` + smart cast | +> | `assertArrayEquals` | `assertContentEquals` | +> | `@BeforeEach` | `@BeforeTest` | +> | `@Disabled` | `@Ignore` | +> | `void shouldDoX()` | `fun \`делает X\`()` | +> | `@BeforeAll static` | `companion object` + `@JvmStatic` | +> | `@Nested static class` | `@Nested inner class` | +> +> **`kotlin.test` - фасад, а не фреймворк.** `Assertions` в JUnit 5 - это сам JUnit. `kotlin.test` - тонкий слой, который на JVM делегирует JUnit, на JS - Jasmine/Mocha, на Native - собственный движок. Поэтому код утверждений переносится между платформами без изменений, а инфраструктура остаётся родной для каждой. Цена - набор утверждений беднее, чем у специализированных библиотек: нет цепочек вроде AssertJ. +> +> **`assertIs` даёт то, чего `assertInstanceOf` в Java дать не может.** Java-версия возвращает приведённое значение, которое нужно присвоить переменной. `assertIs` объявлен с контрактом `returns() implies (value is T)`, поэтому компилятор применяет умное приведение к исходной переменной. Это следствие механизма контрактов Kotlin. Подробнее: [Проверки и контракты](../Исключения/Проверки%20и%20контракты.md) +> +> **Статические методы требуют `companion object` + `@JvmStatic`.** JUnit 5 проектировался под Java, где `static` - модификатор. В Kotlin статики нет, и `@BeforeAll`, `@MethodSource`, `@AfterAll` требуют либо `@JvmStatic` в companion, либо `@TestInstance(PER_CLASS)`. Это самая частая точка трения при работе с JUnit из Kotlin. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) +> +> **`@Nested` требует `inner`.** В Java вложенный класс нестатический по умолчанию, и `@Nested` работает сразу. В Kotlin всё наоборот: `class Inner` внутри класса статический, и нужен явный `inner`. Без него JUnit сообщает, что `@Nested`-класс должен быть нестатическим. +> +> **Проверяемых исключений нет, поэтому `assertFailsWith` важнее.** В Java компилятор заставляет объявить `throws`, и наличие исключения видно в сигнатуре. В Kotlin исключение - невидимая часть контракта, и тест становится единственной документацией того, что функция может упасть. Подробнее: [Система исключений](../Исключения/Система%20исключений.md) +> +> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **Порядок аргументов `assertEquals(expected, actual)`.** Перестановка не вызывает ошибки, но отчёт о провале становится обратным: «Expected <фактическое>, actual <ожидаемое>». Отлаживать такой тест заметно труднее. +> +> **`assertEquals` на массивах всегда падает:** +> ```kotlin +> import kotlin.test.Test +> import kotlin.test.assertFalse +> import kotlin.test.assertTrue +> +> class ArrayPitfall { +> @Test +> fun `equals у массивов сравнивает ссылки`() { +> val a = intArrayOf(1, 2) +> val b = intArrayOf(1, 2) +> assertFalse(a == b) +> assertTrue(a.contentEquals(b)) +> } +> } +> ``` +> Нужен `assertContentEquals` или `contentEquals`. +> +> **`assertEquals` для `Double` без допуска ненадёжен.** `assertEquals(0.3, 0.1 + 0.2)` провалится: результат `0.30000000000000004`. Всегда указывайте `absoluteTolerance`. Подробнее: [Числа и переполнение](../Синтаксис%20и%20типы/Работа%20с%20данными/Числа%20и%20переполнение.md) +> +> **`assertEquals(1, 1L)` не компилируется.** Типы `Int` и `Long` различаются, и перегрузки для смешанного сравнения нет. Приводите явно: `assertEquals(1L, value)`. +> +> **`@MethodSource` без `@JvmStatic` не находится.** Ошибка - «Could not find factory method». Метод должен лежать в `companion object` и быть помечен `@JvmStatic`, либо класс должен иметь `@TestInstance(PER_CLASS)`. +> +> **`@BeforeAll` без `@JvmStatic` молча не вызывается** или даёт ошибку о нестатическом методе. Та же причина. +> +> **`@TestInstance(PER_CLASS)` протекает состоянием.** Один экземпляр класса на все тесты означает, что мутация поля в первом тесте видна во втором, и порядок начинает иметь значение. Используйте только когда действительно нужна дорогая инициализация. +> +> **`@Nested` без `inner` не работает.** Сообщение JUnit - «@Nested class must not be static». В Kotlin вложенный класс статический по умолчанию. +> +> **`assertFailsWith` слишком широк.** Он поймает и `NullPointerException` от опечатки в тесте, и тест останется зелёным при неправильной реализации. Указывайте конкретный тип. +> +> **Без `useJUnitPlatform()` ничего не запустится.** Gradle по умолчанию использует JUnit 4; тесты JUnit 5 не найдутся, и вывод будет «0 tests found» без ошибки. +> +> **Три разных `@Test` в classpath.** `kotlin.test.Test`, `org.junit.jupiter.api.Test` и `org.junit.Test` (JUnit 4). Импорт не того варианта приводит к тому, что тест не запускается. При наличии `kotlin-test` используйте `kotlin.test.Test`. +> +> **Обратные кавычки запрещают часть символов.** `` `тест: случай` `` или `` `a.b` `` не скомпилируются - двоеточие, точка, слэши и угловые скобки недопустимы в именах методов JVM. +> +> **`@Ignore` без причины превращается в мёртвый код.** Через месяц никто не помнит, почему тест отключён. Используйте `@Disabled("причина")` из JUnit 5. +> +> **`assertSame` на строках даёт неожиданный результат.** `assertSame("a", "a")` проходит из-за интернирования литералов, но `assertSame("a", buildString { append("a") })` провалится. Для содержимого используйте `assertEquals`. +> +> **Тест в `@BeforeTest` с `lateinit` падает непонятно.** Если `@BeforeTest` бросил исключение, `lateinit`-поле останется неинициализированным, и тест упадёт с `UninitializedPropertyAccessException` - маскируя настоящую причину. Проверяйте вывод `@BeforeTest` в отчёте. + +Далее: [Kotest](Kotest.md) diff --git a/Kotlin/Тестирование/Тестирование корутин.md b/Kotlin/Тестирование/Тестирование корутин.md new file mode 100644 index 0000000..970ce00 --- /dev/null +++ b/Kotlin/Тестирование/Тестирование корутин.md @@ -0,0 +1,680 @@ +--- +title: Тестирование корутин +description: runTest, виртуальное время, TestDispatcher, backgroundScope, тестирование Flow и Turbine +tags: + - kotlin + - testing + - coroutines +--- + +Корутины ломают привычную модель теста: результат появляется не в момент вызова, а когда-то позже, а `delay` делает тест медленным. Библиотека `kotlinx-coroutines-test` решает обе проблемы - она даёт `runTest` с виртуальным временем, где `delay(10.seconds)` выполняется мгновенно. + +```kotlin +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +class FirstCoroutineTest { + @Test + fun `delay не замедляет тест`() = runTest { + val start = currentTime + delay(10.seconds) + assertEquals(10_000, currentTime - start) + } +} +``` +Тест завершается за миллисекунды, хотя по виртуальным часам прошло десять секунд. + +## Подключение +```kotlin +// build.gradle.kts +dependencies { + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") + testImplementation("app.cash.turbine:turbine:1.2.1") +} + +tasks.test { useJUnitPlatform() } +``` +| Артефакт | Зачем | +|---|---| +| `kotlinx-coroutines-test` | `runTest`, `TestDispatcher`, `TestScope` | +| `app.cash.turbine:turbine` | удобная проверка `Flow` по элементам | +| `kotlinx-coroutines-debug` | дампы корутин при зависании | + +Версия `kotlinx-coroutines-test` должна совпадать с версией `kotlinx-coroutines-core` - иначе возможен `NoSuchMethodError` в рантайме. + +## runTest +```kotlin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class RunTestControlTest { + @Test + fun `runCurrent выполняет уже запланированное`() = runTest { + val log = mutableListOf() + launch { log += "сразу" } + // корутина запланирована, но ещё не запущена + assertEquals(emptyList(), log) + runCurrent() + assertEquals(listOf("сразу"), log) + } + + @Test + fun `advanceTimeBy продвигает часы на заданное время`() = runTest { + val log = mutableListOf() + launch { + delay(100); log += "a" + delay(100); log += "b" + } + advanceTimeBy(150) + assertEquals(listOf("a"), log) + advanceUntilIdle() + assertEquals(listOf("a", "b"), log) + } + + @Test + fun `testScheduler доступен напрямую`() = runTest { + assertEquals(0, testScheduler.currentTime) + delay(5) + assertEquals(5, testScheduler.currentTime) + } +} +``` +| Инструмент | Что делает | +|---|---| +| `runTest { }` | запускает тело как корутину с виртуальным временем | +| `currentTime` | виртуальные часы в миллисекундах | +| `runCurrent()` | выполняет задачи, запланированные на текущий момент | +| `advanceTimeBy(ms)` | продвигает часы, выполняя всё по пути | +| `advanceUntilIdle()` | продвигает часы до исчерпания задач | +| `testScheduler` | планировщик, общий для диспетчеров теста | +| `backgroundScope` | scope, отменяемый по завершении теста | +| `runTest(timeout = 5.seconds)` | реальный таймаут на весь тест, по умолчанию 60 с | + +`runTest` возвращает `TestResult`, поэтому тело теста пишется как `= runTest { }`, а не `{ runTest { } }` - на JVM это `Unit`, но форма с `=` работает и в Multiplatform. + +После выхода из тела `runTest` ждёт завершения всех дочерних корутин, запущенных в его scope. Это именно то, что нужно в большинстве тестов: явный `advanceUntilIdle` в конце не требуется. + +## Диспетчеры теста +```kotlin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class DispatchersTest { + @Test + fun `StandardTestDispatcher откладывает запуск`() = runTest(StandardTestDispatcher()) { + val log = mutableListOf() + launch { log += "дочерняя" } + assertEquals(emptyList(), log) + runCurrent() + assertEquals(listOf("дочерняя"), log) + } + + @Test + fun `UnconfinedTestDispatcher запускает немедленно`() = runTest(UnconfinedTestDispatcher()) { + val log = mutableListOf() + launch { log += "дочерняя" } + assertEquals(listOf("дочерняя"), log) + } + + @Test + fun `общий scheduler связывает диспетчеры`() = runTest { + val other = StandardTestDispatcher(testScheduler) + val log = mutableListOf() + launch(other) { delay(100); log += "другой" } + advanceUntilIdle() + assertEquals(listOf("другой"), log) + } +} +``` +| Диспетчер | Поведение | +|---|---| +| `StandardTestDispatcher` | корутина ставится в очередь, нужен `runCurrent` или `advanceUntilIdle` | +| `UnconfinedTestDispatcher` | корутина выполняется до первой приостановки немедленно | + +`runTest` без аргумента использует `StandardTestDispatcher` - это осознанный выбор: отложенный запуск приближает тест к реальному многопоточному исполнению и находит гонки, которые `Unconfined` скрывает. + +`UnconfinedTestDispatcher` удобен, когда проверяется результат, а не порядок - особенно при сборе `StateFlow`, где конфлюирование съедает промежуточные значения. + +Главное про `scheduler`: виртуальные часы живут в нём, а не в диспетчере. Диспетчер, созданный **без** передачи `testScheduler`, получает свой собственный планировщик, и `advanceUntilIdle` теста на него не действует. + +## backgroundScope +```kotlin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class BackgroundScopeTest { + @Test + fun `бесконечная корутина не подвешивает тест`() = runTest { + var ticks = 0 + backgroundScope.launch { + while (true) { + delay(100) + ticks++ + } + } + advanceTimeBy(350) + assertEquals(3, ticks) + } +} +``` +`runTest` ждёт дочерние корутины своего scope, поэтому бесконечный цикл в `launch { }` приведёт к `UncompletedCoroutinesError` по таймауту. `backgroundScope` отменяется автоматически по завершении теста - это единственно верное место для долгоживущих коллекторов, подписок и имитации фоновой работы. + +## Внедрение диспетчера +Виртуальное время работает только для диспетчеров, связанных с `testScheduler`. Жёстко прописанный `Dispatchers.IO` в продакшн-коде делает тест реально медленным: +```kotlin +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class Hardcoded { + suspend fun work(): String = withContext(Dispatchers.IO) { + delay(1000) + "результат" + } +} + +class Injected(private val io: CoroutineDispatcher) { + suspend fun work(): String = withContext(io) { + delay(1000) + "результат" + } +} + +class DispatcherInjectionTest { + @Test + fun `внедрённый диспетчер виртуализирует delay`() = runTest { + val wall = System.currentTimeMillis() + val service = Injected(StandardTestDispatcher(testScheduler)) + assertEquals("результат", service.work()) + assertTrue(System.currentTimeMillis() - wall < 500) + } + + @Test + fun `жёстко заданный диспетчер ждёт реальную секунду`() = runTest { + val wall = System.currentTimeMillis() + assertEquals("результат", Hardcoded().work()) + assertTrue(System.currentTimeMillis() - wall >= 900) + } +} +``` +Оба теста проходят, но второй занимает реальную секунду. В продакшн-коде диспетчер стоит передавать параметром с значением по умолчанию: +```kotlin +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +class Service( + private val io: CoroutineDispatcher = Dispatchers.IO, +) { + suspend fun load(): String = withContext(io) { + delay(1000) + "данные" + } +} +``` +Вызывающий код ничего не меняет, а тест подставляет `StandardTestDispatcher(testScheduler)`. Подробнее: [Диспетчеры и контекст](../Корутины/Диспетчеры%20и%20контекст.md) + +## Dispatchers.Main +`Dispatchers.Main` на JVM без UI-фреймворка не существует, поэтому его подменяют: +```kotlin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.Test +import kotlin.test.assertEquals + +class ViewModelLike(private val scope: CoroutineScope) { + private val _state = MutableStateFlow("начало") + val state: StateFlow = _state + + fun load() { + scope.launch { + _state.value = "загрузка" + delay(1000) + _state.value = "готово" + } + } +} + +class MainDispatcherTest { + @Test + fun `setMain подменяет Main на тестовый диспетчер`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val vm = ViewModelLike(CoroutineScope(Dispatchers.Main)) + vm.load() + runCurrent() + assertEquals("загрузка", vm.state.value) + advanceUntilIdle() + assertEquals("готово", vm.state.value) + } finally { + Dispatchers.resetMain() + } + } +} +``` +`setMain` и `resetMain` - глобальное состояние, поэтому `try`/`finally` обязателен. Повторять его в каждом тесте не нужно - проще вынести в расширение JUnit: +```kotlin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.ExtensionContext +import kotlin.test.Test +import kotlin.test.assertEquals + +class MainDispatcherExtension( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : BeforeEachCallback, AfterEachCallback { + override fun beforeEach(context: ExtensionContext) = Dispatchers.setMain(dispatcher) + override fun afterEach(context: ExtensionContext) = Dispatchers.resetMain() +} + +class ViewModel { + private val scope = CoroutineScope(Dispatchers.Main) + private val _state = MutableStateFlow("начало") + val state: StateFlow = _state + + fun load() { + scope.launch { + _state.value = "загрузка" + delay(1000) + _state.value = "готово" + } + } +} + +@ExtendWith(MainDispatcherExtension::class) +class ViewModelTest { + @Test + fun `Main подменён для всех тестов класса`() = runTest { + val vm = ViewModel() + vm.load() + assertEquals("загрузка", vm.state.value) + advanceUntilIdle() + assertEquals("готово", vm.state.value) + } +} +``` +Подробнее про расширения: [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md) + +## Тестирование Flow +Конечный поток проверяется сборкой в список: +```kotlin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class FlowCollectTest { + @Test + fun `toList собирает конечный поток`() = runTest { + val source = flow { + emit(1); delay(100) + emit(2); delay(100) + emit(3) + } + assertEquals(listOf(1, 2, 3), source.toList()) + } + + @Test + fun `take ограничивает бесконечный поток`() = runTest { + val infinite = flow { + var i = 0 + while (true) { + emit(i++) + delay(10) + } + } + assertEquals(listOf(0, 1, 2), infinite.take(3).toList()) + } +} +``` +`toList()` на бесконечном потоке подвесит тест до таймаута - ограничение через `take` обязательно. + +### StateFlow и конфлюирование +```kotlin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class StateFlowTest { + @Test + fun `Unconfined видит каждое значение`() = runTest { + val state = MutableStateFlow(0) + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + state.collect { seen += it } + } + state.value = 1 + state.value = 2 + runCurrent() + assertEquals(listOf(0, 1, 2), seen) + } + + @Test + fun `Standard конфлюирует промежуточные значения`() = runTest { + val state = MutableStateFlow(0) + val seen = mutableListOf() + backgroundScope.launch { state.collect { seen += it } } + state.value = 1 + state.value = 2 + runCurrent() + // коллектор не успел запуститься и получил только последнее + assertEquals(listOf(2), seen) + } +} +``` +Разница принципиальная: со `StandardTestDispatcher` коллектор стартует только при `runCurrent`, к тому моменту `value` уже равно `2`, а `StateFlow` не хранит историю. Для проверки последовательности значений нужен `UnconfinedTestDispatcher(testScheduler)` - и обязательно с передачей `testScheduler`. Подробнее: [StateFlow и SharedFlow](../Корутины/StateFlow%20и%20SharedFlow.md) + +Если проверяется только итог, достаточно `state.value` без коллектора вообще. + +## Turbine +Ручной сбор в список быстро становится громоздким: нужно управлять scope, продвигать время и помнить про конфлюирование. Turbine даёт императивный API над потоком: +```kotlin +import app.cash.turbine.test +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TurbineTest { + @Test + fun `awaitItem забирает элементы по одному`() = runTest { + flow { emit(1); delay(100); emit(2) }.test { + assertEquals(1, awaitItem()) + assertEquals(2, awaitItem()) + awaitComplete() + } + } + + @Test + fun `StateFlow отдаёт начальное значение первым`() = runTest { + val state = MutableStateFlow(0) + state.test { + assertEquals(0, awaitItem()) + state.value = 1 + assertEquals(1, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `awaitError ловит исключение потока`() = runTest { + flow { emit(1); throw IllegalStateException("сбой") }.test { + assertEquals(1, awaitItem()) + assertEquals("сбой", awaitError().message) + } + } + + @Test + fun `skipItems и expectNoEvents`() = runTest { + flowOf(1, 2, 3, 4).test { + skipItems(2) + assertEquals(3, awaitItem()) + assertEquals(4, awaitItem()) + awaitComplete() + } + MutableSharedFlow().test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } +} +``` +| Функция | Что делает | +|---|---| +| `awaitItem()` | ждёт следующий элемент | +| `awaitComplete()` | ждёт нормального завершения | +| `awaitError()` | ждёт исключения и возвращает его | +| `skipItems(n)` | пропускает `n` элементов | +| `expectNoEvents()` | проверяет, что событий не было | +| `expectMostRecentItem()` | берёт последний, отбрасывая предыдущие | +| `cancelAndIgnoreRemainingEvents()` | завершает сбор, не требуя разбора остатка | +| `cancelAndConsumeRemainingEvents()` | то же, но возвращает остаток | + +Ключевое свойство: **незабранные события проваливают тест**. Если поток выдал три элемента, а проверены два, Turbine бросит `TurbineAssertionError` при выходе из блока. Это ловит лишние эмиссии, которые ручной `toList()` просто не замечает. + +Несколько потоков сразу - через `turbineScope`: +```kotlin +import app.cash.turbine.turbineScope +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TurbineScopeTest { + @Test + fun `два потока проверяются вперемешку`() = runTest { + turbineScope { + val numbers = flowOf(1, 2).testIn(backgroundScope) + val letters = flowOf("x").testIn(backgroundScope) + + assertEquals(1, numbers.awaitItem()) + assertEquals("x", letters.awaitItem()) + assertEquals(2, numbers.awaitItem()) + numbers.awaitComplete() + letters.awaitComplete() + } + } +} +``` + +## Исключения и отмена +```kotlin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class ErrorsTest { + @Test + fun `исключение дочерней корутины доходит до теста`() = runTest { + val e = runCatching { + coroutineScope { + launch { throw IllegalStateException("из дочерней") } + } + }.exceptionOrNull() + assertEquals("из дочерней", e?.message) + } + + @Test + fun `незавершённая корутина в основном scope даёт UncompletedCoroutinesError`() { + val e = runCatching { + runTest(timeout = 1.seconds) { + launch { while (true) { delay(100) } } + } + }.exceptionOrNull() + // UncompletedCoroutinesError внутренний, поэтому проверяем по имени + assertEquals("UncompletedCoroutinesError", e!!::class.simpleName) + } + + @Test + fun `TestScope создаётся вручную вне runTest`() { + val scope = TestScope() + var done = false + scope.launch { delay(1000); done = true } + scope.advanceUntilIdle() + assertTrue(done) + } +} +``` +Необработанное исключение дочерней корутины проваливает `runTest` - специально ловить его не нужно. `TestScope()` вручную пригодится, когда тестируемый класс сам принимает `CoroutineScope`: в этом случае scope передаётся в конструктор, а тест управляет временем через `advanceUntilIdle`. Подробнее: [Исключения в корутинах](../Корутины/Исключения%20в%20корутинах.md) + +## Моки suspend-функций +```kotlin +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +interface Api { + suspend fun fetch(id: Int): String +} + +class Repository(private val api: Api) { + suspend fun load(id: Int): String = "данные: ${api.fetch(id)}" +} + +class MockSuspendTest { + @Test + fun `coEvery с виртуальной задержкой`() = runTest { + val api = mockk() + coEvery { api.fetch(1) } coAnswers { + delay(5000) + "ответ" + } + + assertEquals("данные: ответ", Repository(api).load(1)) + assertEquals(5000, currentTime) + coVerify(exactly = 1) { api.fetch(1) } + } +} +``` +`delay` внутри `coAnswers` под `runTest` виртуализируется - имитация медленного сетевого вызова не замедляет тест. Подробнее: [MockK](MockK.md) + +## Корутины в Kotest +```kotlin +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest + +class KotestCoroutines : StringSpec({ + "тело теста уже suspend" { + withContext(Dispatchers.Default) { 42 } shouldBe 42 + } + "runTest нужен для виртуального времени" { + runTest { + delay(10_000) + currentTime shouldBe 10_000 + } + } +}) +``` +Тело теста в Kotest - уже `suspend`-функция, поэтому `suspend`-вызовы работают без обёртки. Но виртуального времени там нет: `delay(100)` займёт реальные 100 мс, пока тест не завёрнут в `runTest`. Подробнее: [Kotest](Kotest.md) + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Thread.sleep` в тесте - реальная пауза | `delay` в `runTest` виртуален | +> | `CountDownLatch` для синхронизации | `advanceUntilIdle()` | +> | `Awaitility.await().until { }` | детерминированный планировщик | +> | `future.get(5, SECONDS)` | `runTest { }` ждёт сам | +> | `ExecutorService` + `shutdownNow` | `backgroundScope` отменяется сам | +> | флаки из-за таймингов | порядок задан планировщиком | +> +> **Виртуальное время заменяет ожидание реального.** В Java тест асинхронного кода либо спит (`Thread.sleep(100)` - медленно и ненадёжно), либо синхронизируется через `CountDownLatch` и `Awaitility` - это работает, но требует явных точек синхронизации в продакшн-коде или угадывания таймаутов. `TestCoroutineScheduler` вместо этого контролирует время: `delay` регистрирует задачу на виртуальный момент, а `advanceTimeBy` исполняет её немедленно. Тест становится детерминированным и мгновенным одновременно. Подробнее: [Взаимодейтсвие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md) +> +> **Порядок выполнения задан, а не случаен.** Java-тесты на `ExecutorService` зависят от решений планировщика ОС: один и тот же тест проходит локально и падает в CI. `StandardTestDispatcher` исполняет корутины в строго определённом порядке - по очереди задач. Это убирает флаки, но меняет требования к коду: тест, рассчитывающий на немедленный запуск `launch`, нужно писать с `runCurrent()`. +> +> **Структурированная конкурентность делает «забытую задачу» ошибкой компилятора времени выполнения.** В Java поток, оставшийся после теста, просто живёт дальше и может испортить следующий тест или задержать завершение JVM. `runTest` ждёт все дочерние корутины своего scope и бросает `UncompletedCoroutinesError`, если какая-то не завершилась. Для осознанно долгоживущих задач предусмотрен `backgroundScope` - забыть отменить подписку в тесте просто нельзя. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md) +> +> **`Dispatchers.Main` приходится подменять, потому что это не настоящий диспетчер.** В Java нет понятия «главного потока» на уровне стандартной библиотеки - есть Swing EDT и Android main looper, каждый со своим API. Kotlin вводит `Dispatchers.Main` как общую абстракцию, и на JVM без UI-фреймворка он отсутствует: обращение без `setMain` даёт `IllegalStateException`. Это цена единого API для всех платформ. +> +> Сравнить: [Reflection API в Java](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md) + +> [!warning] Подводные камни +> **Диспетчер без `testScheduler` подвешивает тест.** `UnconfinedTestDispatcher()` вне `runTest` создаёт **свой** планировщик, поэтому `advanceUntilIdle()` на него не действует: корутина остаётся активной, и тест падает с `UncompletedCoroutinesError` через 60 секунд. Всегда передавайте планировщик: `UnconfinedTestDispatcher(testScheduler)`. +> +> **`Dispatchers.Main` без `setMain` роняет не только тест, а весь прогон.** Исключение возникает внутри потока корутины, поэтому может не попасть в отчёт конкретного теста и проявиться как необъяснимое падение JVM. Подменяйте `Main` в `@BeforeEach` или через расширение. +> +> **`Dispatchers.setMain` - глобальное состояние.** Забытый `resetMain` ломает тесты, которым нужен настоящий `Main`, причём порядок тестов не гарантирован. Используйте `try`/`finally` или расширение JUnit. +> +> **`StateFlow` конфлюирует, и промежуточные значения теряются.** Со `StandardTestDispatcher` коллектор запускается позже эмиссий и увидит только последнее значение - проверено: из `0 → 1 → 2` в список попадёт `[2]`. Для последовательности нужен `UnconfinedTestDispatcher(testScheduler)`. Подробнее: [StateFlow и SharedFlow](../Корутины/StateFlow%20и%20SharedFlow.md) +> +> **`MutableSharedFlow` без буфера приостанавливает `emit` до появления подписчика.** Это не потеря данных, как часто пишут, а приостановка: `launch { shared.emit(1) }` до подписки дождётся коллектора и доставит значение. Но если `emit` вызван напрямую в теле теста, тест повиснет. Подписывайтесь до эмиссии и вызывайте `runCurrent()`. +> +> **Жёстко прописанный `Dispatchers.IO` или `Default` делает тест реально медленным.** `withContext(Dispatchers.IO) { delay(1000) }` займёт настоящую секунду даже внутри `runTest` - планировщик теста такой диспетчер не контролирует. Внедряйте диспетчер параметром. +> +> **Бесконечная корутина в основном scope `runTest` даёт `UncompletedCoroutinesError`.** `launch { while (true) { delay(100) } }` ждёт до таймаута (60 с по умолчанию). Для долгоживущих задач используйте `backgroundScope`. +> +> **`toList()` на бесконечном потоке висит до таймаута.** Ограничивайте через `take(n)`, `timeout` или проверяйте через Turbine с `cancelAndIgnoreRemainingEvents()`. +> +> **Незабранные события в Turbine проваливают тест - и это правильно.** `TurbineAssertionError` при выходе из `test { }` означает, что поток выдал больше элементов, чем проверено. Если остаток не важен, завершайте явным `cancelAndIgnoreRemainingEvents()`. +> +> **`advanceUntilIdle()` не исполняет задачи чужих планировщиков.** Если тестируемый класс создаёт корутины на `Dispatchers.Default`, они продолжат работать в реальном времени параллельно тесту - результат непредсказуем. +> +> **`runTest` ждёт дочерние корутины, но не сами задачи `Dispatchers.Default`.** Тест может завершиться раньше реальной фоновой работы, а исключение из неё всплывёт в следующем тесте. +> +> **`currentTime` требует отдельного импорта.** Это свойство-расширение `TestScope`, а не член класса, поэтому `import kotlinx.coroutines.test.currentTime` обязателен - без него IDE покажет `unresolved reference` внутри `runTest`. В обычной `suspend`-функции вне `TestScope` его нет вовсе. +> +> **`runTest(timeout = ...)` - реальный таймаут, а не виртуальный.** Он ограничивает физическое время прогона и не связан с `currentTime`; виртуально тест может «прожить» часы. +> +> **Версии `kotlinx-coroutines-core` и `-test` должны совпадать.** Расхождение даёт `NoSuchMethodError` в рантайме, причём сообщение не указывает на причину. Держите версию в `libs.versions.toml`. Подробнее: [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md) +> +> **В Kotest тело теста `suspend`, но времени это не виртуализирует.** `delay(100)` в спецификации занимает реальные 100 мс. Для виртуального времени оборачивайте в `runTest` явно. + +Далее: [Рефлексия](../Рефлексия/Рефлексия.md) diff --git a/Kotlin/Тестирование/Тестирование.md b/Kotlin/Тестирование/Тестирование.md new file mode 100644 index 0000000..0bc82b1 --- /dev/null +++ b/Kotlin/Тестирование/Тестирование.md @@ -0,0 +1,366 @@ +--- +title: Тестирование +description: Обзор инструментов тестирования Kotlin — kotlin.test, JUnit 5, Kotest, MockK, coroutines-test — и когда что выбирать +tags: + - kotlin + - testing +--- + +В Kotlin четыре слоя инструментов тестирования, и выбор на каждом уровне независим: фреймворк запуска, библиотека утверждений, библиотека моков и поддержка корутин. + +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class Calculator { + fun add(a: Int, b: Int) = a + b + fun divide(a: Int, b: Int): Int { + require(b != 0) { "деление на ноль" } + return a / b + } +} + +class CalculatorTest { + private val calc = Calculator() + + @Test + fun `сложение работает`() { + assertEquals(5, calc.add(2, 3)) + } + + @Test + fun `деление на ноль бросает исключение`() { + val e = assertFailsWith { calc.divide(1, 0) } + assertEquals("деление на ноль", e.message) + } +} +``` +Две особенности, заметные сразу: имена тестов в обратных кавычках читаются как предложения, а `assertFailsWith` возвращает исключение для дальнейших проверок. + +## Четыре слоя +```mermaid +graph TD + A["запуск тестов"] --> B["JUnit 5 Platform"] + B --> C["JUnit Jupiter
@Test"] + B --> D["Kotest engine
StringSpec, FunSpec"] + E["утверждения"] --> F["kotlin.test
assertEquals"] + E --> G["kotest-assertions
shouldBe"] + H["моки"] --> I["MockK"] + J["корутины"] --> K["kotlinx-coroutines-test
runTest"] +``` + +| Слой | Варианты | Рекомендация | +|---|---|---| +| движок запуска | JUnit 5, Kotest | JUnit 5 (универсальный) | +| утверждения | `kotlin.test`, kotest-assertions, AssertJ | `kotlin.test` + kotest при нужде | +| моки | MockK, Mockito-kotlin | MockK | +| корутины | `kotlinx-coroutines-test` | обязательно | +| свойства | kotest-property | при алгоритмах | + +Слои комбинируются: можно писать `@Test` из JUnit с утверждениями `shouldBe` из Kotest и моками MockK. + +## Настройка +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") version "2.4.0" +} + +repositories { mavenCentral() } + +dependencies { + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") + testImplementation("io.mockk:mockk:1.14.6") + testImplementation("io.kotest:kotest-assertions-core:6.0.4") + testImplementation("io.kotest:kotest-runner-junit5:6.0.4") + testImplementation("io.kotest:kotest-property:6.0.4") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "failed", "skipped") + } +} +``` +`testImplementation(kotlin("test"))` подтягивает `kotlin-test` версии, совпадающей с плагином. Без `useJUnitPlatform()` Gradle использует JUnit 4, и тесты JUnit 5 просто не найдутся. + +## Что выбрать +**`kotlin.test` + JUnit 5** - разумный дефолт. Многоплатформенный API утверждений, стандартный движок, вся инфраструктура (IDE, CI, отчёты) работает из коробки. Подробнее: [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md) + +**Kotest** - когда нужны вложенные спецификации, data-driven тесты, property-based тестирование или богатые матчеры. Полноценный фреймворк со своим движком и DSL. Подробнее: [Kotest](Kotest.md) + +**MockK** - стандарт для моков в Kotlin. В отличие от Mockito, понимает `final` классы (а в Kotlin все классы `final` по умолчанию), `object`, расширения и `suspend`-функции. Подробнее: [MockK](MockK.md) + +**`kotlinx-coroutines-test`** - не опция, а необходимость, если в коде есть `suspend`. Даёт виртуальное время: `delay(1000)` проходит мгновенно. Подробнее: [Тестирование корутин](Тестирование%20корутин.md) + +## Структура тестов +``` +src/ +├── main/kotlin/com/example/ +│ └── UserService.kt +└── test/kotlin/com/example/ + └── UserServiceTest.kt +``` +Соглашение - зеркальная структура и суффикс `Test`. Тесты в том же пакете видят `internal`-члены: Gradle-плагин связывает test и main source sets как один модуль компиляции. + +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class Repository { + internal fun load() = "данные" +} + +class RepositoryTest { + @Test + fun `internal доступен из теста`() { + assertEquals("данные", Repository().load()) + } +} +``` +Это важное преимущество: не нужно расширять видимость ради тестируемости. Подробнее: [Модификаторы видимости](../ООП/Модификаторы%20видимости.md) + +## Имена тестов +```kotlin +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.assertEquals + +class NamingTest { + @Test + fun `пустой список не содержит элементов`() { + assertTrue(emptyList().isEmpty()) + } + + @Test + fun `given пустой кэш when запрос then возвращается null`() { + val cache = mutableMapOf() + assertEquals(null, cache["ключ"]) + } +} +``` +Обратные кавычки разрешают пробелы и кириллицу в именах функций. Это не трюк, а поддержанная возможность - именно для тестов. Ограничение: имя не может содержать `.`, `;`, `[`, `]`, `/`, `\`, `<`, `>`, `:` - это запрещено форматом класс-файла JVM. + +## Типичный тест сервиса +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +data class User(val id: Int, val name: String) + +interface UserRepository { + fun findById(id: Int): User? +} + +class UserService(private val repo: UserRepository) { + fun displayName(id: Int): String = + repo.findById(id)?.name ?: throw NoSuchElementException("нет пользователя $id") +} + +class UserServiceTest { + // ручная заглушка вместо мока — для простых интерфейсов этого достаточно + private val repo = object : UserRepository { + private val users = mapOf(1 to User(1, "Иван")) + override fun findById(id: Int) = users[id] + } + private val service = UserService(repo) + + @Test + fun `существующий пользователь возвращает имя`() { + assertEquals("Иван", service.displayName(1)) + } + + @Test + fun `отсутствующий пользователь даёт исключение`() { + val e = assertFailsWith { service.displayName(99) } + assertEquals("нет пользователя 99", e.message) + } +} +``` +Для интерфейса с двумя методами ручная заглушка через `object :` короче и понятнее мока. Моки нужны, когда важно проверить **факт вызова** или настроить сложное поведение. + +## Что тестировать в Kotlin иначе +| Конструкция | На что обратить внимание | +|---|---| +| `data class` | `equals`/`hashCode` генерируются - тестировать не нужно | +| nullable-типы | тест на `null` обязателен: компилятор не проверит входные данные из Java | +| `sealed` + `when` | исчерпывающий `when` проверяется компилятором | +| значения по умолчанию | тестировать вызов и без аргументов, и со всеми | +| функции-расширения | тестируются как обычные функции | +| `suspend` | только через `runTest` | +| `Flow` | через `toList()` или `turbine` | +| `object` (singleton) | состояние живёт между тестами - сбрасывать | +| `lateinit` | тест на обращение до инициализации | + +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class Config { + lateinit var url: String +} + +class DefaultsTest { + fun greet(name: String, greeting: String = "Привет") = "$greeting, $name!" + + @Test + fun `значение по умолчанию применяется`() { + assertEquals("Привет, Мир!", greet("Мир")) + } + + @Test + fun `значение можно переопределить`() { + assertEquals("Hi, Мир!", greet("Мир", "Hi")) + } + + @Test + fun `lateinit до инициализации бросает исключение`() { + assertFailsWith { Config().url } + } +} +``` + +## Параметризованные тесты +```kotlin +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ParameterizedExamples { + @ParameterizedTest + @ValueSource(strings = ["", " ", "\t"]) + fun `пустые строки распознаются`(input: String) { + assertTrue(input.isBlank()) + } + + @ParameterizedTest + @CsvSource("2, 3, 5", "0, 0, 0", "-1, 1, 0") + fun `сложение по таблице`(a: Int, b: Int, expected: Int) { + assertEquals(expected, a + b) + } +} +``` +В Kotest для той же задачи есть `withData` - читается лучше, но требует перехода на его спецификации. + +## Жизненный цикл +```kotlin +import kotlin.test.BeforeTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class LifecycleTest { + private lateinit var items: MutableList + + @BeforeTest + fun setUp() { + items = mutableListOf("начальный") + } + + @AfterTest + fun tearDown() { + items.clear() + } + + @Test + fun `первый тест видит свежее состояние`() { + items += "добавленный" + assertEquals(2, items.size) + } + + @Test + fun `второй тест тоже видит свежее состояние`() { + assertEquals(1, items.size) + } +} +``` +По умолчанию JUnit создаёт новый экземпляр тестового класса на каждый тест, поэтому поля изолированы. Это делает `@BeforeTest` нужным только для дорогой инициализации - обычно достаточно инициализации поля напрямую: +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals + +class SimplerTest { + // новый экземпляр на каждый тест, поэтому список свежий + private val items = mutableListOf("начальный") + + @Test + fun `первый`() { + items += "ещё" + assertEquals(2, items.size) + } + + @Test + fun `второй`() { + assertEquals(1, items.size) + } +} +``` + +## Карта раздела +| Заметка | О чём | +|---|---| +| [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md) | утверждения, аннотации, параметризация, жизненный цикл | +| [Kotest](Kotest.md) | спецификации, матчеры, data-driven, property-based | +| [MockK](MockK.md) | моки, стабы, верификация, `object`, расширения, `suspend` | +| [Тестирование корутин](Тестирование%20корутин.md) | `runTest`, виртуальное время, `TestDispatcher`, `Flow` | + +## Запуск +``` +./gradlew test # все тесты +./gradlew test --tests "com.example.UserServiceTest" # один класс +./gradlew test --tests "*UserService*" # по шаблону +./gradlew test --info # с выводом тестов +./gradlew test --rerun-tasks # игнорируя кеш +``` +Отчёт - в `build/reports/tests/test/index.html`. Gradle кеширует результаты: повторный `test` без изменений не запускает ничего, что иногда выглядит как «тесты не работают». + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `@Test void shouldAddNumbers()` | `@Test fun \`сложение работает\`()` | +> | `Assertions.assertEquals` | `kotlin.test.assertEquals` | +> | Mockito | MockK | +> | `@Mock`, `@InjectMocks` | `mockk()`, `every { }` | +> | AssertJ `assertThat(x).isEqualTo(y)` | kotest `x shouldBe y` | +> | нет аналога | `runTest` с виртуальным временем | +> | package-private для тестируемости | `internal` виден тестам | +> +> **Имена тестов в обратных кавычках - маленькая возможность с большим эффектом.** В Java имя теста - идентификатор, поэтому пишут `shouldReturnNullWhenCacheIsEmpty` или добавляют `@DisplayName`. В Kotlin имя функции может быть предложением, и отчёт о тестах читается как спецификация без дополнительных аннотаций. Ограничение - запрещённые символы JVM (`.`, `:`, `/` и другие), и на Android до API 30 обратные кавычки в именах не поддерживались вовсе. +> +> **`internal` решает проблему «package-private для тестов».** В Java тестируемые детали делают package-private, и тест обязан лежать в том же пакете - иначе нужен `public`. В Kotlin `internal` виден всему модулю, а Gradle связывает `test` и `main` в один модуль компиляции: тест видит `internal` независимо от пакета, но внешний код - нет. +> +> **MockK появился потому, что Mockito плохо работает с Kotlin.** Все классы в Kotlin `final` по умолчанию, а Mockito мокает через наследование - поэтому нужен `mockito-inline` или `all-open`-плагин. Плюс Mockito не умеет `object`, функции-расширения и `suspend`. MockK написан для Kotlin и покрывает всё это нативно, включая `coEvery` для корутин. +> +> **Виртуальное время для корутин - того, чего в Java нет.** Тест асинхронного Java-кода либо спит реальное время, либо требует сложной синхронизации через `CountDownLatch`. `runTest` пропускает `delay` мгновенно и гарантирует, что все корутины завершились до конца теста - это следствие структурированной конкурентности. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md) +> +> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md) + +> [!warning] Подводные камни +> **Без `useJUnitPlatform()` тесты JUnit 5 не запускаются.** Gradle по умолчанию использует JUnit 4, и `@Test` из `org.junit.jupiter` просто не находится. Вывод - «0 tests found», без ошибки. Это первое, что нужно проверить, если тесты «не видны». +> +> **Два разных `@Test`.** `kotlin.test.Test` - псевдоним, который на JVM указывает на нужный движок; `org.junit.jupiter.api.Test` - прямая аннотация JUnit 5; `org.junit.Test` - JUnit 4. Смешивание в одном проекте приводит к тому, что часть тестов не запускается. Используйте `kotlin.test.Test`, если нет причин для конкретного движка. +> +> **Обратные кавычки не принимают часть символов.** Имя `` `a.b` `` или `` `тест: случай` `` не скомпилируется: точка и двоеточие запрещены форматом класс-файла. Ошибка компилятора при этом не всегда очевидна. +> +> **`object` сохраняет состояние между тестами.** Singleton не пересоздаётся, поэтому мутация в одном тесте видна в другом, и результат зависит от порядка запуска. Такое состояние нужно сбрасывать в `@BeforeTest` или не использовать `object` для изменяемого состояния. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) +> +> **Gradle кеширует результат `test`.** Повторный запуск без изменений кода выводит `UP-TO-DATE` и ничего не выполняет. Если нужно принудительно - `--rerun-tasks`. +> +> **`suspend`-функцию нельзя вызвать из обычного `@Test`.** Понадобится `runTest` (или `runBlocking`, что хуже - реальные задержки). Без него код не скомпилируется, но попытка обойти через `runBlocking` даёт медленные тесты. Подробнее: [Тестирование корутин](Тестирование%20корутин.md) +> +> **Тесты другого модуля не видят `internal`.** Связь `test`↔`main` работает в пределах одного Gradle-модуля. Тест в модуле `app`, проверяющий `internal` из модуля `core`, не скомпилируется. +> +> **Параллельный запуск тестов ломается на общем состоянии.** `maxParallelForks` ускоряет, но тесты, использующие `object`, статические поля, временные файлы с фиксированными именами или один порт, начинают случайно падать. Включайте параллельность только для изолированных тестов. +> +> **`assertEquals` различает `Int` и `Long`.** `assertEquals(1, 1L)` не скомпилируется (разные типы) - в отличие от Java, где сработало бы расширение типа. Это скорее помогает, но удивляет при миграции. +> +> **Тесты на `data class` обычно избыточны.** `equals`, `hashCode`, `toString`, `copy` генерируются компилятором и не содержат вашей логики. Тестировать стоит только кастомные члены и валидацию в `init`. + +Далее: [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md) diff --git a/Kotlin/Функциональный Kotlin/DSL.md b/Kotlin/Функциональный Kotlin/DSL.md new file mode 100644 index 0000000..e9bb5f2 --- /dev/null +++ b/Kotlin/Функциональный Kotlin/DSL.md @@ -0,0 +1,444 @@ +--- +title: DSL +description: Из чего собирается предметно-ориентированный язык на Kotlin и когда он оправдан +tags: + - kotlin + - functional + - dsl +--- + +DSL (domain-specific language) на Kotlin - это обычный Kotlin-код, который выглядит как декларативное описание. Отдельного механизма для этого нет: DSL собирается из четырёх уже знакомых возможностей. + +| Возможность | Что даёт | +|---|---| +| [лямбда с получателем](Receivers%20и%20context%20parameters.md) `T.() -> Unit` | блок, внутри которого доступны методы объекта | +| [лямбда последним аргументом](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) | `html { }` вместо `html({ })` | +| [функции-расширения](Функции-расширения.md) | методы только внутри DSL | +| [операторы и `infix`](Инфиксные%20и%20операторные%20функции.md) | `"a" to 1`, `x shouldBe y` | + +## Минимальный пример +```kotlin +class Pizza { + private val toppings = mutableListOf() + var size = "средняя" + + fun topping(name: String) { toppings.add(name) } + + override fun toString() = "$size пицца: ${toppings.joinToString()}" +} + +fun pizza(block: Pizza.() -> Unit): Pizza = Pizza().apply(block) + +fun main() { + val order = pizza { + size = "большая" + topping("сыр") + topping("грибы") + } + println(order) // большая пицца: сыр, грибы +} +``` +Три строки инфраструктуры (`fun pizza(block: ...) = Pizza().apply(block)`) превращают класс в DSL. Это весь базовый приём. + +## Вложенные блоки +```kotlin +@DslMarker +annotation class HtmlDsl + +@HtmlDsl +class Tag(private val name: String) { + private val children = mutableListOf() + private val attributes = mutableMapOf() + private var text: String? = null + + fun attr(key: String, value: String) { attributes[key] = value } + + operator fun String.unaryPlus() { text = this } + + fun tag(name: String, block: Tag.() -> Unit = {}) { + children.add(Tag(name).apply(block)) + } + + fun div(block: Tag.() -> Unit) = tag("div", block) + fun p(block: Tag.() -> Unit) = tag("p", block) + + fun render(): String { + val attrs = attributes.entries.joinToString("") { " ${it.key}=\"${it.value}\"" } + val inner = text ?: children.joinToString("") { it.render() } + return "<$name$attrs>$inner" + } +} + +fun html(block: Tag.() -> Unit): String = Tag("html").apply(block).render() + +fun main() { + val page = html { + div { + attr("class", "container") + p { +"Первый абзац" } + p { +"Второй абзац" } + } + } + println(page) + //

Первый абзац

Второй абзац

+} +``` +Разбор приёмов: +- `Tag.() -> Unit` даёт вложенность: внутри `div { }` доступны методы `Tag` +- `operator fun String.unaryPlus()` даёт синтаксис `+"текст"` для содержимого +- `@DslMarker` запрещает вызвать `div { }` изнутри `div { }` через внешний получатель +- значение по умолчанию `block: Tag.() -> Unit = {}` разрешает теги без тела + +## Типобезопасность +Главное отличие Kotlin-DSL от строковых шаблонов: ошибки видит компилятор. +```kotlin +class Query { + private var table: String? = null + private val conditions = mutableListOf() + private var limit: Int? = null + + fun from(name: String) { table = name } + fun where(condition: String) { conditions.add(condition) } + fun limit(n: Int) { limit = n } + + fun build(): String { + val t = requireNotNull(table) { "не указана таблица" } + return buildString { + append("SELECT * FROM $t") + if (conditions.isNotEmpty()) append(" WHERE ${conditions.joinToString(" AND ")}") + limit?.let { append(" LIMIT $it") } + } + } +} + +fun query(block: Query.() -> Unit): String = Query().apply(block).build() + +fun main() { + println(query { + from("users") + where("age > 18") + where("active = true") + limit(10) + }) + // SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10 + + try { + query { where("x = 1") } + } catch (e: IllegalArgumentException) { + println(e.message) // не указана таблица + } +} +``` +Проверка обязательных полей здесь в рантайме. Вынести её в компиляцию можно через типы-состояния: +```kotlin +class EmptyQuery { + fun from(table: String) = TableQuery(table) +} + +class TableQuery(private val table: String) { + private val conditions = mutableListOf() + + fun where(condition: String): TableQuery = apply { conditions.add(condition) } + + fun build(): String = + "SELECT * FROM $table" + + if (conditions.isEmpty()) "" else " WHERE ${conditions.joinToString(" AND ")}" +} + +fun main() { + println(EmptyQuery().from("orders").where("total > 100").build()) + // SELECT * FROM orders WHERE total > 100 + + // println(EmptyQuery().build()) // ошибка компиляции: метода нет +} +``` +Такой приём (phantom types) делает невозможное состояние непредставимым, но усложняет код. Для большинства DSL достаточно `require` в `build()`. + +## Коллекции внутри DSL +```kotlin +class Routes { + private val routes = mutableListOf>() + + infix fun String.handledBy(handler: String) { routes.add(this to handler) } + + fun group(prefix: String, block: Routes.() -> Unit) { + val nested = Routes().apply(block) + nested.routes.forEach { (path, handler) -> routes.add("$prefix$path" to handler) } + } + + fun render() = routes.joinToString("\n") { "${it.first} -> ${it.second}" } +} + +fun routes(block: Routes.() -> Unit) = Routes().apply(block).render() + +fun main() { + println(routes { + "/" handledBy "HomeController" + group("/api") { + "/users" handledBy "UsersController" + "/orders" handledBy "OrdersController" + } + }) + // / -> HomeController + // /api/users -> UsersController + // /api/orders -> OrdersController +} +``` +`infix` расширение внутри класса (`String.handledBy`) существует только внутри блока `routes { }` - снаружи такого метода у `String` нет. + +## Готовые DSL в stdlib +```kotlin +fun main() { + val text = buildString { + append("Привет") + appendLine(", мир") + append("Вторая строка") + } + println(text) + + val list = buildList { + add(1) + addAll(listOf(2, 3)) + } + println(list) // [1, 2, 3] + + val map = buildMap { + put("a", 1) + putAll(mapOf("b" to 2)) + } + println(map) // {a=1, b=2} +} +``` +Эти функции - ровно тот же приём `T.() -> Unit` + `apply`. + +Числовые и временные DSL: +```kotlin +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.minutes + +fun main() { + val timeout = 2.minutes + 30.seconds + println(timeout) // 2m 30s + println(timeout.inWholeSeconds) // 150 + + println((1..10 step 2).toList()) // [1, 3, 5, 7, 9] + println((10 downTo 1 step 3).toList()) // [10, 7, 4, 1] +} +``` +`2.minutes` - свойство-расширение на `Int`. Такой «единичный» DSL делается одной строкой и заметно улучшает читаемость конфигураций. + +## Известные DSL в экосистеме +| DSL | Для чего | +|---|---| +| Gradle Kotlin DSL | сборка проекта, `build.gradle.kts` | +| Ktor | маршруты и конфигурация сервера | +| kotlinx.html | генерация HTML | +| Exposed | SQL-запросы с типобезопасностью | +| Kotest | описание тестов и матчеры | +| Compose | описание UI | + +Пример в стиле Gradle - разбор см. [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md): +```kotlin +class Dependencies { + private val list = mutableListOf() + fun implementation(dep: String) { list.add("implementation $dep") } + fun testImplementation(dep: String) { list.add("testImplementation $dep") } + fun render() = list.joinToString("\n") +} + +class Project2 { + private var name = "unnamed" + private var deps = Dependencies() + + fun name(value: String) { name = value } + fun dependencies(block: Dependencies.() -> Unit) { deps = Dependencies().apply(block) } + + fun render() = "project $name\n${deps.render()}" +} + +fun project(block: Project2.() -> Unit) = Project2().apply(block).render() + +fun main() { + println(project { + name("demo") + dependencies { + implementation("kotlin-stdlib") + testImplementation("kotest") + } + }) + // project demo + // implementation kotlin-stdlib + // testImplementation kotest +} +``` + +## Когда DSL оправдан +| Признак | Вывод | +|---|---| +| структура описывается многократно, вид стабилен | DSL уместен | +| вложенная иерархия (UI, конфиг, разметка) | DSL уместен | +| писать будут люди, не знающие внутренностей | DSL уместен | +| альтернатива - длинный конструктор или `Map` | DSL уместен | +| одно-два места использования | обычный код или `data class` | +| нужна сериализация/валидация схемы | формат данных (JSON/YAML) + парсер | +| структура меняется каждую неделю | DSL придётся переписывать | + +Главная цена DSL - его нужно изучать. Для внутреннего инструмента, который используют трое, именованные аргументы `data class` часто выигрывают: +```kotlin +data class ServerConfig( + val host: String = "localhost", + val port: Int = 8080, + val tls: Boolean = false, + val timeoutSeconds: Int = 30, +) + +fun main() { + val config = ServerConfig(host = "example.com", port = 443, tls = true) + println(config) + // ServerConfig(host=example.com, port=443, tls=true, timeoutSeconds=30) +} +``` +Это читается не хуже DSL, проверяется компилятором лучше, бесплатно получает `copy`, `equals` и сериализацию. DSL нужен, когда структура **вложенная** и **повторяющаяся** - там, где `data class` требует громоздких вложенных конструкторов. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | fluent builder с `return this` | `T.() -> Unit` | +> | XML/YAML/properties для конфигурации | типобезопасный DSL | +> | анонимный класс с методами | лямбда с получателем | +> | строковая конкатенация SQL | DSL с проверкой типов | +> | нет аналога | `@DslMarker` | +> +> **В Java DSL ограничен цепочками методов.** Максимум, что доступно - fluent interface: +> ```java +> Html.create() +> .div().attr("class", "container") +> .p().text("абзац").end() +> .end() +> .render(); +> ``` +> Вложенность приходится выражать парами `.div()`/`.end()`, и компилятор не проверит их баланс. В Kotlin вложенность выражается вложенностью блоков - забыть «закрывающий тег» физически невозможно. +> +> **Это причина, по которой Gradle перешёл на Kotlin.** `build.gradle` на Groovy был динамическим: опечатка в имени свойства обнаруживалась при запуске сборки, автодополнения не было. `build.gradle.kts` - обычный Kotlin-код, поэтому IDE знает все доступные методы, а ошибка в имени не компилируется. Цена - более медленная первая сборка (скрипт нужно скомпилировать). +> +> **Конфигурация в коде вместо формата данных.** В Java-мире окружение описывают в XML (Spring, Maven) или YAML (Spring Boot). Kotlin-DSL даёт проверку типов и переиспользование (функции, циклы, условия прямо в конфиге), но теряет главное свойство данных: конфигурацию нельзя прочитать и изменить без компилятора. Для конфигурации, которую правят администраторы, формат данных остаётся правильным выбором. +> +> **Лямбда с получателем - ключевая возможность, которой нет в Java.** Без неё все остальные приёмы (операторы, расширения) не складываются в DSL, поэтому в Java аналога попросту не существует. +> +> Сравнить: [Builder](../../Программирование%20на%20Java/Паттерны%20проектирования/Builder.md), [Интерфейсы в Java](../../Программирование%20на%20Java/ООП/Интерфейсы.md) + +> [!warning] Подводные камни +> **Без `@DslMarker` внешний получатель виден изнутри:** +> ```kotlin +> class Node { +> val children = mutableListOf() +> fun child(name: String) { children.add(name) } +> fun nested(block: Node.() -> Unit) { children.add(Node().apply(block).children.toString()) } +> } +> +> fun main() { +> val n = Node().apply { +> child("a") +> nested { child("b") } +> } +> println(n.children) // [a, [b]] +> } +> ``` +> Здесь всё верно, но стоит добавить метод, которого нет у внутреннего уровня, и вызов молча уйдёт на внешний. `@DslMarker` стоит ставить сразу. +> +> **DSL скрывает порядок выполнения:** +> ```kotlin +> class Steps { +> private val log = mutableListOf() +> fun step(name: String) { log.add(name) } +> fun render() = log.joinToString(" -> ") +> } +> +> fun main() { +> val s = Steps().apply { +> step("первый") +> step("второй") +> } +> println(s.render()) // первый -> второй +> } +> ``` +> Блок выглядит декларативно, но это императивный код: вызовы выполняются по порядку, и переставить строки - значит изменить результат. Пользователь DSL об этом не догадывается. +> +> **Изменяемое состояние в билдере переиспользуется:** +> ```kotlin +> class Builder2 { +> private val items = mutableListOf() +> fun item(x: String) { items.add(x) } +> fun build() = items.toList() +> } +> +> fun main() { +> val b = Builder2() +> b.item("a") +> val first = b.build() +> b.item("b") +> val second = b.build() +> println("$first / $second") // [a] / [a, b] +> } +> ``` +> Если билдер доступен снаружи, повторный `build()` даёт другой результат. Делайте билдер одноразовым: создавайте его внутри функции-входа и не возвращайте. +> +> **`var` в DSL без валидации допускает некорректное состояние:** +> ```kotlin +> class Window { +> var width = 0 +> var height = 0 +> fun area() = width * height +> } +> +> fun window(block: Window.() -> Unit) = Window().apply(block) +> +> fun main() { +> println(window { width = -5; height = 10 }.area()) // -50 +> } +> ``` +> Нужна проверка в `build()`/`apply` или сеттер с `require`. +> +> **Вложенные лямбды с получателем трудно отлаживать.** Стек вызовов состоит из `invoke`-методов сгенерированных лямбд, а точка останова внутри блока не показывает, какому уровню она принадлежит. Для глубоких DSL это заметная проблема. +> +> **DSL без документации непригоден.** Пользователь видит `pizza { }` и не знает, какие методы доступны - IDE подскажет, но ревью в браузере и примеры в README необходимы. Это основной скрытый расход на DSL. +> +> **Методы DSL попадают в автодополнение не там, где ожидалось.** Если расширение объявлено на верхнем уровне (а не внутри класса DSL), оно появится везде: +> ```kotlin +> // плохо: operator fun String.unaryPlus() на верхнем уровне сломает код, +> // где +"строка" означает что-то другое +> class Container2 { +> private val parts = mutableListOf() +> operator fun String.unaryPlus() { parts.add(this) } // только внутри Container2 +> fun render() = parts.joinToString() +> } +> +> fun container(block: Container2.() -> Unit) = Container2().apply(block).render() +> +> fun main() { println(container { +"a"; +"b" }) } // a, b +> ``` +> Объявляйте операторы DSL **внутри** класса-контекста. +> +> **`apply` возвращает билдер, а не результат:** +> ```kotlin +> class Doc { +> private val lines = mutableListOf() +> fun line(x: String) { lines.add(x) } +> fun render() = lines.joinToString("\n") +> } +> +> fun docWrong(block: Doc.() -> Unit) = Doc().apply(block) // возвращает Doc +> fun docRight(block: Doc.() -> Unit) = Doc().apply(block).render() // возвращает String +> +> fun main() { +> println(docWrong { line("a") }::class.simpleName) // Doc +> println(docRight { line("a") }) // a +> } +> ``` +> Утечка билдера наружу позволяет менять «готовый» объект после сборки. +> +> **DSL - не замена валидации данных.** Типобезопасность проверяет структуру, а не смысл: `port = 70000` скомпилируется. Проверки диапазонов, обязательных полей и взаимных зависимостей нужно писать вручную. Подробнее: [Проверки и контракты](../Исключения/Проверки%20и%20контракты.md) +> +> **DSL тяжело версионировать.** Переименование метода - несовместимое изменение для всех пользователей, а `@Deprecated` на методах внутри класса-контекста работает, но предупреждения в больших блоках теряются. + +Далее: [Паттерны проектирования](Паттерны%20проектирования.md) diff --git a/Kotlin/Функциональный Kotlin/Inline-функции.md b/Kotlin/Функциональный Kotlin/Inline-функции.md new file mode 100644 index 0000000..dabb0ae --- /dev/null +++ b/Kotlin/Функциональный Kotlin/Inline-функции.md @@ -0,0 +1,322 @@ +--- +title: Inline-функции +description: Подстановка тела на этапе компиляции, noinline и crossinline, нелокальный return и цена inline +tags: + - kotlin + - functional + - performance +--- + +`inline` говорит компилятору: не вызывай эту функцию, а подставь её тело в место вызова. Для обычных функций это почти бесполезно (JIT справляется сам), а для функций с лямбдами - убирает объект лямбды. + +```kotlin +inline fun measure(block: () -> Unit): Long { + val start = System.nanoTime() + block() + return System.nanoTime() - start +} + +fun main() { + val time = measure { (1..1000).sum() } + println(time > 0) // true +} +``` +После компиляции в `main` не остаётся ни вызова `measure`, ни объекта `Function0` - там просто `val start = ...; (1..1000).sum(); ...`. + +## Что именно экономится +Без `inline` на каждый вызов создаётся объект, реализующий `Function0`: +```kotlin +fun notInlined(block: () -> Int): Int = block() +inline fun inlined(block: () -> Int): Int = block() + +fun main() { + var captured = 0 + + repeat(3) { notInlined { captured + 1 } } // лямбда захватывает captured -> новый объект каждый раз + repeat(3) { inlined { captured + 1 } } // объектов нет вообще + + println(notInlined { 42 }) // 42 + println(inlined { 42 }) // 42 +} +``` +Лямбда **без захвата** компилируется в синглтон, поэтому объект создаётся один раз - тут `inline` экономит только вызов. Лямбда **с захватом** создаёт объект на каждое вычисление, и вот здесь разница существенна. + +Поэтому все операции над коллекциями в stdlib объявлены `inline`: +```kotlin +fun main() { + val sum = listOf(1, 2, 3).filter { it > 1 }.sumOf { it * 2 } + println(sum) // 10 +} +``` +В байт-коде это два обычных цикла `for` с телами лямбд внутри. Ни одного объекта лямбды и ни одного виртуального вызова - именно поэтому короткие цепочки в Kotlin обычно быстрее Java-стримов. + +## Нелокальный return +Второе следствие подстановки: из inline-лямбды можно выйти из внешней функции. +```kotlin +inline fun List.forEachInline(action: (T) -> Unit) { for (item in this) action(item) } + +fun findFirstNegative(numbers: List): Int? { + numbers.forEachInline { if (it < 0) return it } // выход из findFirstNegative + return null +} + +fun main() { + println(findFirstNegative(listOf(1, 2, -3, 4))) // -3 + println(findFirstNegative(listOf(1, 2))) // null +} +``` +Это работает, потому что `return` оказывается физически внутри `findFirstNegative`. В не-inline функции такой `return` запрещён: +```kotlin +fun List.forEachSlow(action: (T) -> Unit) { for (item in this) action(item) } + +fun firstNegative2(numbers: List): Int? { + numbers.forEachSlow { if (it < 0) return@forEachSlow } // только локальный выход + return null +} + +fun main() { println(firstNegative2(listOf(-1))) } // null +``` +Именно поэтому `forEach` из stdlib ведёт себя как `continue`, а не как `break`: +```kotlin +fun main() { + listOf(1, 2, 3).forEach { + if (it == 2) return@forEach // пропустить элемент + print("$it ") + } + println() // 1 3 +} +``` + +## noinline +Если лямбду нужно сохранить или передать дальше, подставить её нельзя - у подставленного тела нет объекта: +```kotlin +inline fun register( + name: String, + noinline handler: (String) -> Unit, + validator: (String) -> Boolean, +): (String) -> Unit { + println("регистрирую $name, валиден: ${validator(name)}") + return handler // возвращаем объект — нужен noinline +} + +fun main() { + val h = register("событие", handler = { println("обработано: $it") }, validator = { it.isNotEmpty() }) + h("данные") + // регистрирую событие, валиден: true + // обработано: данные +} +``` +Без `noinline` компилятор откажется: «Illegal usage of inline-parameter». Правило: лямбду, которая только вызывается внутри функции, можно инлайнить; лямбду, которую сохраняют в поле, возвращают или передают в не-inline функцию - нельзя. + +## crossinline +Промежуточный случай: лямбда вызывается не напрямую, а внутри другой лямбды или объекта. +```kotlin +inline fun runLater(crossinline block: () -> Unit): Runnable = Runnable { block() } + +fun main() { + val task = runLater { println("выполнено позже") } + task.run() // выполнено позже +} +``` +`crossinline` означает «инлайнить можно, но нелокальный `return` запрещён». Логика такая: тело лямбды подставляется внутрь `Runnable`, а оттуда выйти из вызывающей функции физически невозможно - она уже завершилась. + +```kotlin +inline fun repeatTwice(crossinline block: () -> Unit) { + val wrapped = { block(); block() } + wrapped() +} + +fun demo() { + repeatTwice { println("раз") } + // repeatTwice { return } // ошибка: нелокальный return запрещён +} + +fun main() { demo() } // раз \n раз +``` + +| Модификатор | Лямбда подставляется | Нелокальный `return` | Можно сохранить/передать | +|---|---|---|---| +| (по умолчанию) | да | да | нет | +| `crossinline` | да | нет | только внутрь другой лямбды | +| `noinline` | нет | нет | да | + +## reified +Третье следствие подстановки: в месте вызова известен фактический тип, поэтому параметр типа можно «материализовать». +```kotlin +inline fun filterByType(items: List): List = items.filterIsInstance() + +fun main() { + val mixed = listOf(1, "a", 2.0, 3) + println(filterByType(mixed)) // [1, 3] + println(filterByType(mixed)) // [a] +} +``` +Это основная причина объявлять `inline` функцию без лямбд вообще. Подробнее: [Reified и inline](../Дженерики/Reified%20и%20inline.md) + +## Inline-свойства +`inline` применим и к аксессорам: +```kotlin +class Config2(val map: Map) { + inline val isEmpty: Boolean get() = map.isEmpty() + + val host: String + inline get() = map["host"] ?: "localhost" +} + +fun main() { + val c = Config2(mapOf("host" to "example.com")) + println("${c.isEmpty} ${c.host}") // false example.com +} +``` +Обратите внимание: `map` объявлен `val` без `private`. Для `private val` сниппет не скомпилируется - на inline-аксессоры действует то же правило, что на inline-функции: подставленное тело окажется в чужом классе, где приватный член недоступен. + +Выгода минимальна - JIT инлайнит такие геттеры и сам. Реально `inline`-свойства нужны редко: когда геттер должен использовать `reified` или когда свойство - часть публичного API, которое не хочется оставлять виртуальным. + +## Когда inline оправдан +| Случай | Нужен ли `inline` | +|---|---| +| функция принимает лямбду, которая вызывается сразу | да | +| нужен `reified` | да, обязательно | +| нужен нелокальный `return` из лямбды | да, обязательно | +| функция без лямбд и без `reified` | нет, JIT справится | +| тело большое (десятки строк) | нет, байт-код раздуется | +| функция рекурсивная | нельзя | +| публичное API библиотеки | осторожно - см. подводные камни | + +## Что происходит с байт-кодом +```kotlin +inline fun twice(block: () -> Unit) { block(); block() } + +fun main() { + var count = 0 + twice { count++ } + twice { count += 10 } + println(count) // 22 +} +``` +Тело `twice` скопировано в `main` дважды, и каждая копия содержит своё тело лямбды - итого четыре инкремента в коде. Для короткой функции это дешевле вызова; для функции на сто строк, вызываемой из пятидесяти мест, это пять тысяч строк байт-кода. + +Компилятор предупреждает, когда `inline` бесполезен: +```kotlin +inline fun uselessInline(x: Int): Int = x * 2 // warning: expected performance impact is insignificant + +fun main() { println(uselessInline(2)) } // 4 +``` +Такое предупреждение - сигнал убрать `inline`, если не нужен `reified`. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | нет inline на уровне языка | `inline fun` | +> | JIT инлайнит в рантайме | компилятор инлайнит в байт-код | +> | лямбда всегда объект | при `inline` объекта нет | +> | `return` из лямбды невозможен | нелокальный `return` при `inline` | +> | `Class` параметром | `reified T` | +> | `final` подсказывает JIT | `inline` - директива компилятору | +> +> **Java инлайнит в рантайме, Kotlin - при компиляции.** HotSpot инлайнит горячие методы агрессивнее, чем любой компилятор: он знает фактические типы, статистику ветвлений и может деоптимизировать решение. Поэтому `inline` в Kotlin - не про скорость исполнения тела, а про три вещи, недоступные JIT: отсутствие объекта лямбды, нелокальный `return` и `reified`. +> +> **Escape analysis частично закрывает ту же проблему в Java.** Если JIT доказывает, что объект лямбды не покидает метод, он может не выделять его в куче (scalar replacement). Но это оптимизация «на удачу»: она применяется не всегда и ломается при полиморфном вызове. `inline` в Kotlin гарантирован и виден в байт-коде. +> +> **Нелокального `return` в Java нет вообще.** Из тела `forEach` нельзя выйти из внешнего метода - приходится либо возвращаться к обычному `for`, либо бросать исключение, либо использовать `findFirst` на стриме. Kotlin позволяет писать абстракции обхода, которые ведут себя как встроенный цикл: +> ```kotlin +> inline fun Iterable.firstMatching(p: (T) -> Boolean): T? { +> for (item in this) if (p(item)) return item +> return null +> } +> +> fun main() { println(listOf(1, 2, 3).firstMatching { it > 1 }) } // 2 +> ``` +> +> **Цена - совместимость библиотек.** В Java изменение тела метода не требует перекомпиляции клиентов. В Kotlin тело inline-функции физически скопировано в байт-код клиента, поэтому исправление в библиотеке не подействует до пересборки вызывающего кода. Для публичного API это серьёзный аргумент, и stdlib помечает такие функции `@PublishedApi`, когда они обращаются к internal-коду. +> +> **Inline-функцию нельзя вызвать из Java** с сохранением смысла: Java-компилятор не умеет подставлять тело, он вызовет сгенерированный метод как обычный. `reified` при этом не работает. +> +> Сравнить: [JIT в Java](../../Программирование%20на%20Java/JVM/JIT.md), [Байт-код](../../Программирование%20на%20Java/JVM/Байт-код.md) + +> [!warning] Подводные камни +> **Inline раздувает байт-код.** Тело копируется в каждое место вызова. Для больших функций это рост размера класса, давление на кеш инструкций процессора и более медленная загрузка классов. `inline` оправдан для коротких функций - в stdlib почти все inline-функции занимают несколько строк. +> +> **Изменение тела inline-функции не действует без перекомпиляции клиентов:** +> ```kotlin +> inline fun version() = "v1" // если выпустить v2, клиенты со старой сборкой увидят v1 +> +> fun main() { println(version()) } // v1 +> ``` +> Это ломает обычные ожидания от семантического версионирования библиотек. +> +> **Inline-функция не видит `private` членов своего класса:** +> ```kotlin +> class Service3 { +> private val secret = "скрыто" +> // inline fun leak() = secret // ошибка: public-inline не может обращаться к private +> fun ok() = secret +> internal inline fun fine() = ok() // а так можно +> } +> +> fun main() { println(Service3().fine()) } // скрыто +> ``` +> Причина: тело окажется в чужом классе, где `private` недоступен. Обход - `@PublishedApi internal` на члене. +> +> **Рекурсивную функцию нельзя сделать inline:** +> ```kotlin +> // inline fun fact(n: Int): Int = if (n <= 1) 1 else n * fact(n - 1) // ошибка +> fun fact(n: Int): Int = if (n <= 1) 1 else n * fact(n - 1) +> +> fun main() { println(fact(5)) } // 120 +> ``` +> Подстановка была бы бесконечной. +> +> **Лямбду из inline-параметра нельзя сохранить:** +> ```kotlin +> inline fun broken(block: () -> Unit) { +> // val saved = block // ошибка: Illegal usage of inline-parameter +> block() +> } +> +> inline fun fixed(noinline block: () -> Unit): () -> Unit = block +> +> fun main() { +> broken { println("ок") } +> fixed { println("сохранено") }() +> } +> ``` +> +> **`crossinline` запрещает нелокальный `return`, и ошибка возникает у пользователя функции**, а не у автора. Если лямбда оборачивается в `Runnable`, объект или другую лямбду - `crossinline` обязателен, и вызывающий код теряет возможность выйти из своей функции. +> +> **Нелокальный `return` из inline-лямбды пропускает код после вызова:** +> ```kotlin +> inline fun withLogging(block: () -> Unit) { +> println("до") +> block() +> println("после") // не выполнится при return из лямбды +> } +> +> fun run1() { +> withLogging { return } +> } +> +> fun main() { run1(); println("конец") } // до \n конец +> ``` +> Строка «после» потерялась. Если в функции есть освобождение ресурсов, его нужно ставить в `finally` - иначе нелокальный `return` его обойдёт: +> ```kotlin +> inline fun safeLogging(block: () -> Unit) { +> println("до") +> try { block() } finally { println("после") } +> } +> +> fun run2() { safeLogging { return } } +> +> fun main() { run2() } // до \n после +> ``` +> Это важнее, чем кажется: `use`, `withLock` и транзакционные обёртки обязаны использовать `finally` именно по этой причине. +> +> **`inline` на функции без лямбд вызывает предупреждение** и почти не влияет на производительность. Единственные оправдания - `reified` и необходимость подставить тело в клиентский код. +> +> **Внутри inline-лямбды нельзя использовать `suspend`-вызовы, если не помечено специально.** Для функций вроде `measure` это выглядит рабочим, но обёртка над `suspend`-кодом требует либо `suspend inline`, либо `crossinline` и явного учёта контекста. Подробнее: [Suspend-функции](../Корутины/Suspend-функции.md) +> +> **Инлайн не убирает упаковку примитивов в функциональных типах автоматически** - но при полной подстановке упаковки не остаётся, потому что лямбды как объекта нет. А вот с `noinline` параметром `(Int) -> Int` упаковка сохраняется. +> +> **Профилируйте, прежде чем добавлять `inline` вручную.** Накладные расходы лямбды измеримы только в горячем коде с миллионами вызовов. В обычном бизнес-коде преждевременный `inline` даёт лишь раздутый байт-код и проблемы совместимости. + +Далее: [Функции-расширения](Функции-расширения.md) diff --git a/Kotlin/Функциональный Kotlin/Receivers и context parameters.md b/Kotlin/Функциональный Kotlin/Receivers и context parameters.md new file mode 100644 index 0000000..913a6c2 --- /dev/null +++ b/Kotlin/Функциональный Kotlin/Receivers и context parameters.md @@ -0,0 +1,439 @@ +--- +title: Receivers и context parameters +description: Лямбды с получателем, несколько получателей в области видимости, DslMarker и context parameters вместо context receivers +tags: + - kotlin + - functional +--- + +Получатель (receiver) - это объект, доступный в блоке кода как `this` без явного указания. На этом механизме построены расширения, scope-функции, билдеры и все DSL в Kotlin. + +```kotlin +fun StringBuilder.addLine(text: String) { + append(text) // this.append — this есть неявно + append("\n") +} + +fun main() { + val sb = StringBuilder() + sb.addLine("первая") + sb.addLine("вторая") + print(sb) + // первая + // вторая +} +``` + +## Функциональный тип с получателем +Тип `T.() -> R` - это функция, внутри которой `this` имеет тип `T`: +```kotlin +fun buildText(block: StringBuilder.() -> Unit): String { + val sb = StringBuilder() + sb.block() // вызываем лямбду с sb как получателем + return sb.toString() +} + +fun main() { + val text = buildText { + append("Привет") // this == StringBuilder + append(", ") + append("мир") + } + println(text) // Привет, мир +} +``` +Разница с обычным `(StringBuilder) -> Unit` только в том, как обращаться к объекту: +```kotlin +fun withReceiver(block: StringBuilder.() -> Unit) = StringBuilder().apply(block).toString() +fun withParameter(block: (StringBuilder) -> Unit) = StringBuilder().also(block).toString() + +fun main() { + println(withReceiver { append("через this") }) // через this + println(withParameter { it.append("через it") }) // через it +} +``` +Типы взаимозаменяемы при передаче: лямбда с получателем подходит там, где ожидается функция с параметром, и наоборот - компилятор конвертирует их сам. + +Так устроены все scope-функции: +```kotlin +// В stdlib: +// inline fun T.run(block: T.() -> R): R = block() +// inline fun T.apply(block: T.() -> Unit): T { block(); return this } +// inline fun with(receiver: T, block: T.() -> R): R = receiver.block() +// inline fun T.let(block: (T) -> R): R = block(this) +// inline fun T.also(block: (T) -> Unit): T { block(this); return this } +``` +`run`/`apply`/`with` принимают `T.() -> R`, `let`/`also` - `(T) -> R`. Вот и всё различие между `this` и `it` в [scope-функциях](Scope-функции.md). + +## Два получателя одновременно +Расширение, объявленное внутри класса, имеет **два** получателя: экземпляр класса (dispatch receiver) и объект расширения (extension receiver). +```kotlin +class HtmlBuilder { + private val sb = StringBuilder() + + fun String.tag(name: String) { // this: String, this@HtmlBuilder: HtmlBuilder + sb.append("<$name>").append(this).append("") + } + + fun build(block: HtmlBuilder.() -> Unit): String { + block() + return sb.toString() + } +} + +fun main() { + val html = HtmlBuilder().build { + "Заголовок".tag("h1") + "Текст".tag("p") + } + println(html) //

Заголовок

Текст

+} +``` +Внутри `tag` доступны оба: `this` (или `this@tag`) - строка, `this@HtmlBuilder` - билдер. Приоритет у ближайшего, то есть у extension receiver. + +## Квалифицированный this +Когда получателей несколько, их различают метками: +```kotlin +class Outer2 { + val name = "внешний" + + inner class Inner3 { + val name = "внутренний" + + fun show(): String { + val local = "локальный" + return "$name / ${this@Inner3.name} / ${this@Outer2.name} / $local" + } + } +} + +fun main() { + println(Outer2().Inner3().show()) + // внутренний / внутренний / внешний / локальный +} +``` +То же в лямбдах с получателем: +```kotlin +fun main() { + val result = StringBuilder().apply { + append("a") + "b".run { + this@apply.append(this) // this — String "b", this@apply — StringBuilder + this@apply.append("c") + } + } + println(result) // abc +} +``` +Метка берётся из имени функции, принимающей лямбду. Поэтому для двух вложенных `run` метка `this@run` указала бы на **внутренний** - ближайший одноимённый. Когда уровней больше одного, выбирайте разные scope-функции или явные имена параметров; если имени нет (анонимная функция), доступ только через параметры. + +## Вложенные билдеры и DslMarker +Проблема вложенных получателей: внутренний блок видит и внешний. +```kotlin +class Table { + private val rows = mutableListOf() + fun row(block: Row.() -> Unit) { rows.add(Row().apply(block).render()) } + fun render() = rows.joinToString("\n") +} + +class Row { + private val cells = mutableListOf() + fun cell(text: String) { cells.add(text) } + fun render() = cells.joinToString(" | ") +} + +fun table(block: Table.() -> Unit) = Table().apply(block).render() + +fun main() { + println(table { + row { + cell("a") + cell("b") + // row { } // компилируется! внешний получатель видим + } + row { cell("c") } + }) + // a | b + // c +} +``` +Вложенный `row { }` внутри `row` - логическая ошибка, но компилятор её не видит. Решение - аннотация `@DslMarker`: +```kotlin +@DslMarker +annotation class TableDsl + +@TableDsl +class Table2 { + private val rows = mutableListOf() + fun row(block: Row2.() -> Unit) { rows.add(Row2().apply(block).render()) } + fun render() = rows.joinToString("\n") +} + +@TableDsl +class Row2 { + private val cells = mutableListOf() + fun cell(text: String) { cells.add(text) } + fun render() = cells.joinToString(" | ") +} + +fun table2(block: Table2.() -> Unit) = Table2().apply(block).render() + +fun main() { + println(table2 { + row { + cell("a") + // row { } // теперь ошибка компиляции + } + }) + // a +} +``` +`@DslMarker` запрещает неявный доступ к внешнему получателю того же «семейства». Явный `this@table2.row { }` при этом остаётся доступен. Подробнее: [DSL](DSL.md) + +## Получатель в расширении для DSL +Полезный приём - расширение, доступное только внутри определённого контекста: +```kotlin +class Validator { + private val errors = mutableListOf() + + fun String.required(field: String) { + if (isBlank()) errors.add("$field обязателен") + } + + fun String.maxLength(field: String, max: Int) { + if (length > max) errors.add("$field длиннее $max") + } + + fun result(): List = errors +} + +fun validate(block: Validator.() -> Unit): List = Validator().apply(block).result() + +fun main() { + val errors = validate { + "".required("имя") + "очень длинное значение".maxLength("описание", 5) + "ок".required("статус") + } + println(errors) // [имя обязателен, описание длиннее 5] +} +``` +`required` существует только внутри `validate { }` - снаружи такого расширения на `String` нет. Это способ не засорять глобальное пространство имён. + +## Context parameters +Начиная с Kotlin 2.2 (стабилизированы в 2.4) есть **контекстные параметры** - неявные зависимости, которые передаются по области видимости, а не через аргументы. Они заменили экспериментальные context receivers. + +```kotlin +interface Logger2 { + fun log(message: String) +} + +context(logger: Logger2) +fun processOrder(id: Int) { + logger.log("обработка заказа $id") + logger.log("заказ $id готов") +} + +class ConsoleLogger : Logger2 { + override fun log(message: String) = println("[LOG] $message") +} + +fun main() { + with(ConsoleLogger()) { + processOrder(42) + } + // [LOG] обработка заказа 42 + // [LOG] заказ 42 готов +} +``` +Отличия от получателя: +- контекстный параметр **именованный** (`logger`), а не безымянный `this` +- он не участвует в разрешении вызовов: `log("x")` без `logger.` не скомпилируется +- таких параметров может быть несколько +- он не делает функцию расширением - вызов остаётся `processOrder(42)` + +```kotlin +interface Transaction { fun commit() } +interface Metrics { fun count(name: String) } + +context(tx: Transaction, metrics: Metrics) +fun saveUser(name: String) { + metrics.count("user.save") + println("сохранён $name") + tx.commit() +} + +fun main() { + val tx = object : Transaction { override fun commit() = println("транзакция закрыта") } + val metrics = object : Metrics { override fun count(name: String) = println("метрика: $name") } + + with(tx) { + with(metrics) { + saveUser("Иван") + } + } + // метрика: user.save + // сохранён Иван + // транзакция закрыта +} +``` +Задача, которую это решает: передать «окружение» (логгер, транзакцию, локаль, конфигурацию) через несколько уровней вызовов, не протаскивая параметр руками и не используя глобальное состояние. + +Контекстный параметр можно объявить и у свойства: +```kotlin +interface Locale2 { val code: String } + +context(locale: Locale2) +val greeting: String + get() = if (locale.code == "ru") "Привет" else "Hello" + +fun main() { + val ru = object : Locale2 { override val code = "ru" } + val en = object : Locale2 { override val code = "en" } + + println(with(ru) { greeting }) // Привет + println(with(en) { greeting }) // Hello +} +``` + +| | Параметр | Получатель (`T.()`) | Контекстный параметр | +|---|---|---|---| +| вызов | `f(x, dep)` | `x.f()` | `f(x)`, зависимость из области | +| доступ внутри | по имени | `this`, неявно | по имени | +| сколько | сколько угодно | один | несколько | +| влияет на разрешение вызовов | нет | да | нет | +| видно в сигнатуре вызова | да | да | нет | + +Практическое правило: получатель - когда объект **главный** в операции (`sb.append`), контекстный параметр - когда это **служебная** зависимость (логгер, транзакция), обычный параметр - когда это данные. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `this` только в методах своего класса | `this` в любой лямбде с получателем | +> | билдер с `return this` | `T.() -> Unit` | +> | передача зависимостей аргументами или DI-контейнером | контекстные параметры | +> | `ThreadLocal` для «окружения» | контекстные параметры | +> | нет аналога | `@DslMarker` | +> +> **Лямбда с получателем аналога в Java не имеет.** Это и есть причина, по которой DSL на Kotlin (Gradle Kotlin DSL, HTML-билдеры, конфигурация Ktor) выглядят как отдельный язык, а в Java ограничиваются цепочками методов: +> ```java +> new StringBuilder().append("a").append("b").toString(); +> ``` +> В Kotlin блок кода сам становится областью, где доступны методы объекта - без повторения имени переменной и без `return this` в каждом методе класса. +> +> **Контекстные параметры решают задачу, для которой в Java используют `ThreadLocal` или DI.** Типичный Java-подход к «окружению» - либо протащить параметр через все слои, либо положить его в `ThreadLocal` (как делают Spring-транзакции и MDC в логировании), либо внедрить через контейнер. Первый вариант шумный, второй - неявное глобальное состояние, ломающееся при переходе между потоками, третий требует фреймворка. Контекстный параметр проверяется компилятором: функция не вызовется без нужного контекста: +> ```kotlin +> interface Tenant { val id: String } +> +> context(tenant: Tenant) +> fun currentTenantId(): String = tenant.id +> +> fun main() { +> val t = object : Tenant { override val id = "acme" } +> println(with(t) { currentTenantId() }) // acme +> // println(currentTenantId()) // ошибка компиляции: нет контекста +> } +> ``` +> Для корутин у той же задачи есть другое решение - `CoroutineContext`. Подробнее: [Диспетчеры и контекст](../Корутины/Диспетчеры%20и%20контекст.md) +> +> **Квалифицированный `this@Label` обобщает Java-овский `Outer.this`.** В Java `Outer.this` работает только для вложенных классов; в Kotlin метки действуют и для лямбд, и для расширений, поэтому вложенные области разрешаются одним механизмом. +> +> Сравнить: [Вложенные классы в Java](../../Программирование%20на%20Java/ООП/Вложенные%20классы.md), [Builder](../../Программирование%20на%20Java/Паттерны%20проектирования/Builder.md) + +> [!warning] Подводные камни +> **Внутренний получатель затеняет внешний:** +> ```kotlin +> fun main() { +> val result = StringBuilder("внешний").run { +> StringBuilder("внутренний").run { +> toString() // внутренний — не тот, что ожидали? +> } +> } +> println(result) // внутренний +> } +> ``` +> При вложенности больше одного уровня используйте `this@label` или обычные переменные. +> +> **Без `@DslMarker` вложенные билдеры принимают неверный код:** +> ```kotlin +> class Menu { +> private val items = mutableListOf() +> fun item(name: String) { items.add(name) } +> fun submenu(block: Menu.() -> Unit) { items.add("[${Menu().apply(block).items.joinToString()}]") } +> fun render() = items.joinToString(", ") +> } +> +> fun main() { +> val m = Menu().apply { +> item("файл") +> submenu { item("открыть") } +> } +> println(m.render()) // файл, [открыть] +> } +> ``` +> Здесь `item` внутри `submenu` попадает во внутреннее меню - как и задумано. Но если имена методов у разных уровней совпадают лишь частично, вызов молча уйдёт не туда, куда ожидал автор. +> +> **Получатель и параметр с одинаковым именем:** +> ```kotlin +> class Box9(val value: Int) +> +> fun Box9.compare(value: Int) = "${this.value} vs $value" +> +> fun main() { +> println(Box9(1).compare(2)) // 1 vs 2 +> } +> ``` +> Без `this.` обращение уходит к параметру - ближайшая область выигрывает. Это корректно, но при чтении легко ошибиться. +> +> **Лямбда с получателем скрывает, откуда взялся метод:** +> ```kotlin +> fun main() { +> val text = buildString { +> append("a") +> appendLine("b") +> insert(0, "!") // метод StringBuilder, не видно из вызова +> } +> println(text.trim()) // !ab +> } +> ``` +> В длинном блоке непонятно, какие функции доступны - помогает только IDE. Для публичных DSL это аргумент за короткие блоки и явные имена. +> +> **Контекстные параметры не участвуют в разрешении вызовов:** +> ```kotlin +> interface Printer2 { fun show(m: String) } +> +> context(printer: Printer2) +> fun demo2() { +> printer.show("через имя") // так +> // show("без имени") // ошибка: не найдено +> } +> +> fun main() { +> with(object : Printer2 { override fun show(m: String) = println(m) }) { demo2() } +> } +> ``` +> Это сознательное отличие от отменённых context receivers: там метод вызывался без префикса, что делало код неоднозначным. +> +> **Контекст передаётся через `with`, и это выглядит странно.** Чтобы вызвать функцию с контекстным параметром, нужно внести значение в область видимости - обычно через `with`. Для нескольких контекстов получается вложенность `with(a) { with(b) { ... } }`, которая читается хуже, чем явные аргументы. Для двух зависимостей обычный параметр часто честнее. +> +> **Контекстные параметры - свежая фича.** Они стабилизированы недавно, поддержка в инструментах и библиотеках неполная, а до них была экспериментальная версия (context receivers) с другим синтаксисом, которую убрали. Код из старых статей (`context(Logger)` без имени параметра) не компилируется. Подробнее: [Компилятор K2](../Инструментарий/Компилятор%20K2.md) +> +> **Из Java функции с контекстными параметрами выглядят как функции с дополнительными аргументами** - контекст становится обычным параметром в начале списка. Никакой «неявности» для Java-клиента нет. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) +> +> **Расширение внутри класса недоступно снаружи:** +> ```kotlin +> class Scope { +> fun String.shout() = uppercase() + "!" +> fun use() = "привет".shout() +> } +> +> fun main() { +> println(Scope().use()) // ПРИВЕТ! +> // println("привет".shout()) // ошибка: вне Scope расширения нет +> } +> ``` +> Это полезное свойство (ограничение области), но в документации к DSL его нужно объяснять - иначе пользователь не поймёт, почему метод «исчезает». +> +> **`this` в лямбде с получателем нельзя переназначить.** Если нужно и внешний объект, и внутренний одновременно, единственный способ - метки или явный параметр: `fun with2(block: (Outer, Inner) -> Unit)`. + +Далее: [DSL](DSL.md) diff --git a/Kotlin/Функциональный Kotlin/Scope-функции.md b/Kotlin/Функциональный Kotlin/Scope-функции.md new file mode 100644 index 0000000..cbc59c0 --- /dev/null +++ b/Kotlin/Функциональный Kotlin/Scope-функции.md @@ -0,0 +1,441 @@ +--- +title: Scope-функции +description: let, run, apply, also, with - различия, выбор и типичные ошибки +tags: + - kotlin + - functional +--- + +Пять функций stdlib, которые выполняют блок кода в контексте объекта. Делают они почти одно и то же, различаясь двумя вещами: как доступен объект (`this` или `it`) и что возвращается (объект или результат блока). + +| Функция | Объект внутри | Возвращает | Расширение | +|---|---|---|---| +| `let` | `it` | результат блока | да | +| `run` | `this` | результат блока | да | +| `run` (без получателя) | - | результат блока | нет | +| `apply` | `this` | сам объект | да | +| `also` | `it` | сам объект | да | +| `with` | `this` | результат блока | нет (принимает аргумент) | + +```kotlin +data class Person3(var name: String, var age: Int) + +fun main() { + val p = Person3("Иван", 30) + + val letResult = p.let { "${it.name} ${it.age}" } // it, результат блока + val runResult = p.run { "$name $age" } // this, результат блока + val applyResult = p.apply { age = 31 } // this, сам объект + val alsoResult = p.also { println("создан ${it.name}") } // it, сам объект + val withResult = with(p) { "$name $age" } // this, результат блока + + println(letResult) // Иван 30 + println(runResult) // Иван 30 + println(applyResult) // Person3(name=Иван, age=31) + println(alsoResult === p) // true + println(withResult) // Иван 31 +} +``` + +```mermaid +graph TD + A["Нужна scope-функция"] --> B{"Что возвращать?"} + B -->|"сам объект"| C{"Как обращаться?"} + B -->|"результат блока"| D{"Как обращаться?"} + C -->|"this - настройка полей"| E["apply"] + C -->|"it - побочный эффект"| F["also"] + D -->|"it - преобразование"| G["let"] + D -->|"this - много обращений"| H["run"] + D -->|"объект уже есть, не цепочка"| I["with"] +``` + +## let +Основное применение - работа с nullable и преобразование значения: +```kotlin +fun main() { + val name: String? = "Иван" + + val length = name?.let { it.length } ?: 0 + println(length) // 4 + + name?.let { println("имя: $it") } // имя: Иван + + val empty: String? = null + empty?.let { println("не выполнится") } + println("после") // после +} +``` +`?.let { }` - идиоматичная замена `if (x != null)`, когда внутри нужно значение. Подробнее: [Операторы null-безопасности](../Синтаксис%20и%20типы/Null-безопасность/Операторы%20null-безопасности.md) + +Второе применение - ограничить область видимости промежуточного значения: +```kotlin +fun main() { + val result = listOf(1, 2, 3, 4) + .filter { it % 2 == 0 } + .let { evens -> "чётных: ${evens.size}, сумма: ${evens.sum()}" } + println(result) // чётных: 2, сумма: 6 +} +``` +Переменная `evens` не утекает наружу. Явное имя параметра вместо `it` здесь делает код понятнее. + +## run +То же, что `let`, но объект доступен как `this` - удобно, когда обращений к нему много: +```kotlin +data class Config3(val host: String, val port: Int, val tls: Boolean) + +fun main() { + val url = Config3("example.com", 8443, true).run { + val scheme = if (tls) "https" else "http" + "$scheme://$host:$port" + } + println(url) // https://example.com:8443 +} +``` + +`run` без получателя - просто блок, возвращающий значение: +```kotlin +fun main() { + val value = run { + val a = 2 + val b = 3 + a * b + } + println(value) // 6 +} +``` +Это способ вычислить `val` несколькими строками, не заводя функцию. Полезно в инициализации свойств. + +## apply +Возвращает сам объект - значит, предназначен для настройки: +```kotlin +class Request2 { + var url: String = "" + var method: String = "GET" + val headers = mutableMapOf() + + override fun toString() = "$method $url $headers" +} + +fun main() { + val request = Request2().apply { + url = "https://example.com" + method = "POST" + headers["Content-Type"] = "application/json" + } + println(request) // POST https://example.com {Content-Type=application/json} +} +``` +Это замена builder-паттерна для классов с изменяемыми свойствами. Подробнее: [Паттерны проектирования](Паттерны%20проектирования.md) + +Частый случай - настройка при возврате: +```kotlin +fun buildList2(): List = mutableListOf().apply { + add(1) + add(2) + addAll(listOf(3, 4)) +} + +fun main() { + println(buildList2()) // [1, 2, 3, 4] +} +``` +Хотя для коллекций в stdlib уже есть `buildList`, который чище. + +## also +Возвращает объект, доступ через `it` - для побочных эффектов, не меняющих объект: +```kotlin +fun main() { + val numbers = listOf(3, 1, 2) + .also { println("исходный: $it") } + .sorted() + .also { println("отсортированный: $it") } + .filter { it > 1 } + .also { println("отфильтрованный: $it") } + + println(numbers) + // исходный: [3, 1, 2] + // отсортированный: [1, 2, 3] + // отфильтрованный: [2, 3] + // [2, 3] +} +``` +`also` вставляется в цепочку, ничего в ней не меняя - идеально для логирования и отладки. + +Второе применение - валидация и регистрация при создании: +```kotlin +class Resource(val name: String) { + fun close() = println("закрыт $name") +} + +fun main() { + val registry = mutableListOf() + + val r = Resource("файл").also { registry.add(it) } + println("${r.name}, в реестре: ${registry.size}") // файл, в реестре: 1 +} +``` + +## with +Единственная не-расширение: объект передаётся аргументом. +```kotlin +fun main() { + val sb = StringBuilder() + with(sb) { + append("Привет") + append(", ") + append("мир") + append("!") + } + println(sb) // Привет, мир! +} +``` +`with(x) { }` читается как «с объектом x сделай» и применяется, когда объект уже есть в переменной и цепочка не нужна. Результат - значение блока: +```kotlin +data class Point2(val x: Int, val y: Int) + +fun main() { + val distance = with(Point2(3, 4)) { + kotlin.math.sqrt((x * x + y * y).toDouble()) + } + println(distance) // 5.0 +} +``` + +## Как выбирать +Практические правила, покрывающие почти все случаи: + +| Задача | Функция | +|---|---| +| выполнить код, если значение не `null` | `?.let { }` | +| преобразовать значение в другое | `let` | +| настроить свойства нового объекта | `apply` | +| залогировать значение в середине цепочки | `also` | +| вычислить значение из нескольких полей объекта | `run` | +| сгруппировать вызовы на существующем объекте | `with` | +| вычислить `val` из нескольких строк | `run { }` без получателя | + +Выбор между `it` и `this` - вопрос читаемости: `this` короче при многих обращениях к полям, `it` яснее, когда объект используется один-два раза или когда внутри есть другой `this`. + +## Вложенность и затенение +```kotlin +data class Inner(val value: Int) +data class Outer(val inner: Inner, val name: String) + +fun main() { + val o = Outer(Inner(42), "внешний") + + // плохо: два this, непонятно какой + val bad = o.run { inner.run { "$value" } } + println(bad) // 42 + + // хорошо: явные имена + val good = o.let { outer -> outer.inner.let { inner -> "${outer.name}: ${inner.value}" } } + println(good) // внешний: 42 +} +``` +Вложенные scope-функции - главный источник нечитаемого кода на Kotlin. Правило: вложенность больше одного уровня - заводите явные имена параметров или обычные переменные. + +## Scope-функции и null +```kotlin +fun main() { + val maybe: String? = "текст" + + println(maybe?.let { it.length }) // 5 — блок выполнится, если не null + println(maybe?.run { length }) // 5 — то же + println(maybe.let { it?.length }) // 5 — блок выполнится всегда, it nullable + + val nothing: String? = null + println(nothing?.let { it.length }) // null + println(nothing.let { it?.length }) // null — но блок выполнился! +} +``` +Разница в месте `?.`: `x?.let { }` не вызывает блок при `null`, `x.let { }` вызывает всегда с nullable `it`. Это часто путают. + +## takeIf и takeUnless +Родственные функции - фильтр для одного значения: +```kotlin +fun main() { + val number = 42 + + println(number.takeIf { it > 0 }) // 42 + println(number.takeIf { it > 100 }) // null + println(number.takeUnless { it > 0 }) // null + + val name = " " + println(name.takeIf { it.isNotBlank() } ?: "(по умолчанию)") // (по умолчанию) + + val config = mapOf("port" to "8080") + val port = config["port"]?.takeIf { it.isNotBlank() }?.toIntOrNull() ?: 80 + println(port) // 8080 +} +``` +`takeIf` превращает условие в nullable-значение, которое дальше обрабатывается через `?:` и `?.`. Это заменяет `if`-цепочки в конвейерах преобразования. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `if (x != null) { ... }` | `x?.let { ... }` | +> | `Optional.map` | `let` | +> | `Optional.ifPresent` | `?.let` или `?.also` | +> | builder с `return this` | `apply` | +> | промежуточная переменная | `let`/`run` | +> | `Objects.requireNonNullElse(x, y)` | `x ?: y` | +> | нет аналога | `also` в середине цепочки | +> | нет аналога | `with` | +> +> **Scope-функции заменяют `Optional` и промежуточные переменные.** В Java цепочка преобразований с проверками на null выглядит так: +> ```java +> String result = Optional.ofNullable(user) +> .map(User::getAddress) +> .map(Address::getCity) +> .orElse("неизвестно"); +> ``` +> В Kotlin `?.` и `?:` делают то же без обёртки, а `let` подключается, когда нужен не просто вызов метода: +> ```kotlin +> data class Address(val city: String) +> data class User11(val address: Address?) +> +> fun main() { +> val user: User11? = User11(Address("Москва")) +> val result = user?.address?.city?.let { it.uppercase() } ?: "неизвестно" +> println(result) // МОСКВА +> } +> ``` +> Принципиальная разница: `Optional` - объект-обёртка со своей стоимостью и своим API, а `?.` - синтаксис над обычной ссылкой. Поэтому `Optional` в Kotlin не используют вообще. +> +> **`apply` заменяет builder-паттерн для изменяемых объектов.** В Java чтобы настраивать объект цепочкой, класс должен возвращать `this` из каждого сеттера - это решение автора класса. `apply` работает с любым классом, включая чужой: +> ```kotlin +> fun main() { +> val props = java.util.Properties().apply { +> setProperty("host", "localhost") +> setProperty("port", "8080") +> } +> println(props.getProperty("host")) // localhost +> } +> ``` +> `Properties` из JDK не имеет fluent-API, но с `apply` ведёт себя как будто имеет. +> +> **`also` в цепочке аналога в Java не имеет.** `Stream.peek` похож, но работает только внутри стрима и его использование не рекомендуется. `also` применим к любому значению. +> +> **Это не магия, а обычные inline-расширения.** `let` объявлена как `inline fun T.let(block: (T) -> R): R = block(this)` - одна строка. Поэтому накладных расходов нет: после компиляции остаётся только тело блока. Подробнее: [Inline-функции](Inline-функции.md) +> +> Сравнить: [Функции в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md), [Builder](../../Программирование%20на%20Java/Паттерны%20проектирования/Builder.md) + +> [!warning] Подводные камни +> **`apply` возвращает объект, даже если блок что-то вычислил:** +> ```kotlin +> fun main() { +> val wrong = StringBuilder().apply { append("текст"); length } +> println(wrong) // текст — не 5! +> val right = StringBuilder().run { append("текст"); length } +> println(right) // 5 +> } +> ``` +> Перепутать `apply` и `run` легко, а ошибка типов возникает далеко от места. +> +> **`let` на nullable без `?.` выполняется всегда:** +> ```kotlin +> fun main() { +> val x: String? = null +> x.let { println("выполнилось, it = $it") } // выполнилось, it = null +> x?.let { println("не выполнится") } +> } +> ``` +> +> **Вложенные `this` и `it` путаются:** +> ```kotlin +> data class Address2(var city: String) +> data class User12(var name: String, val address: Address2) +> +> fun main() { +> val u = User12("Иван", Address2("Москва")) +> u.apply { +> name = "Пётр" +> address.apply { +> city = "Казань" +> // name = "x" // name внешнего this — компилируется, но запутанно +> } +> } +> println(u) // User12(name=Пётр, address=Address2(city=Казань)) +> } +> ``` +> Внутренний блок видит и свой `this`, и внешний. Компилятор разрешает обращаться к членам обоих, и ошибку заметить трудно. +> +> **`also` и `apply` не защищают от изменения объекта.** Оба возвращают **тот же** объект, а не копию: +> ```kotlin +> data class Counter3(var value: Int) +> +> fun main() { +> val c = Counter3(1) +> val same = c.also { it.value = 99 } +> println("${c.value} ${same === c}") // 99 true +> } +> ``` +> +> **`?.let` на цепочке проверяет только последнее звено:** +> ```kotlin +> data class Inner2(val value: Int?) +> +> fun main() { +> val i: Inner2? = Inner2(null) +> println(i?.let { it.value?.plus(1) }) // null +> println(i?.value?.plus(1)) // null — короче и яснее +> } +> ``` +> Когда достаточно `?.`, `let` только добавляет шум. Используйте `let`, если внутри нужно больше одного действия. +> +> **Цепочка из scope-функций читается хуже обычного кода:** +> ```kotlin +> fun main() { +> val bad = "42".let { it.toInt() }.let { it * 2 }.also { println(it) }.let { "$it" } +> println(bad) // 84 \n 84 +> +> val good = ("42".toInt() * 2).toString() +> println(good) // 84 +> } +> ``` +> Scope-функции - инструмент, а не стиль. Если без них короче - пишите без них. +> +> **`run` без получателя путается с `run` на объекте.** `x.run { }` и `run { }` - разные функции с одним именем; при чтении диффа разница в одну точку. +> +> **Нелокальный `return` внутри scope-функции выходит из внешней функции:** +> ```kotlin +> fun process5(value: String?): String { +> value?.let { +> if (it.isBlank()) return "пусто" // выход из process5! +> } +> return "обработано: $value" +> } +> +> fun main() { +> println(process5(" ")) // пусто +> println(process5("текст")) // обработано: текст +> } +> ``` +> Это работает благодаря `inline`, но для читателя неочевидно: `return` внутри лямбды выглядит как возврат из лямбды. +> +> **`takeIf` возвращает nullable - и это заразно:** +> ```kotlin +> fun main() { +> val port: Int? = 8080.takeIf { it > 1024 } +> // println(port + 1) // ошибка: Int? не складывается +> println(port?.plus(1)) // 8081 +> } +> ``` +> Каждый `takeIf` добавляет в тип `?`, который нужно где-то разрешить. +> +> **`apply` на `val`-свойствах бесполезен:** +> ```kotlin +> data class Immutable(val a: Int, val b: Int) +> +> fun main() { +> val x = Immutable(1, 2).apply { +> // a = 10 // ошибка: val +> } +> println(x) // Immutable(a=1, b=2) +> println(Immutable(1, 2).copy(a = 10)) // Immutable(a=10, b=2) +> } +> ``` +> Для неизменяемых классов нужен `copy`, а не `apply`. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +Далее: [Инфиксные и операторные функции](Инфиксные%20и%20операторные%20функции.md) diff --git a/Kotlin/Функциональный Kotlin/Инфиксные и операторные функции.md b/Kotlin/Функциональный Kotlin/Инфиксные и операторные функции.md new file mode 100644 index 0000000..3ebf03c --- /dev/null +++ b/Kotlin/Функциональный Kotlin/Инфиксные и операторные функции.md @@ -0,0 +1,504 @@ +--- +title: Инфиксные и операторные функции +description: infix-нотация, перегрузка операторов, invoke, деструктуризация и делегирование свойств +tags: + - kotlin + - functional +--- + +Kotlin позволяет задать для своих типов тот же синтаксис, что у встроенных: `a + b`, `a[i]`, `a()`, `a in b`. Делается это обычными функциями с модификатором `operator` или `infix`. + +## infix +```kotlin +infix fun Int.упаковатьВ(unit: String): String = "$this $unit" + +data class Money(val amount: Long, val currency: String) + +infix fun Long.of(currency: String) = Money(this, currency) + +fun main() { + println(5 упаковатьВ "кг") // 5 кг + println(100L of "RUB") // Money(amount=100, currency=RUB) + println(100L.of("RUB")) // Money(amount=100, currency=RUB) — обычный вызов тоже работает +} +``` +Требования к `infix`: ровно один параметр, не `vararg`, без значения по умолчанию, и функция должна быть методом или расширением. + +В stdlib `infix` используется для операций, читающихся как фраза: +```kotlin +fun main() { + println(setOf(1, 2) union setOf(2, 3)) // [1, 2, 3] + println(setOf(1, 2) intersect setOf(2, 3)) // [2] + println("a" to 1) // (a, 1) + println(5 shl 2) // 20 + println(5 and 3) // 1 + println(1 until 5) // 1..4 + println(5 downTo 1) // 5 downTo 1 step 1 + println(1..10 step 2) // 1..9 step 2 +} +``` + +Приоритет инфиксных функций ниже арифметики, но выше сравнений - это источник ошибок: +```kotlin +infix fun Int.plusTen(x: Int) = this + x + 10 + +fun main() { + println(1 plusTen 2 * 3) // 17 — это 1 plusTen (2*3) + println((1 plusTen 2) * 3) // 39 +} +``` +При сомнении ставьте скобки. + +## Перегрузка операторов +Каждый оператор отображается в функцию с фиксированным именем: + +| Оператор | Функция | +|---|---| +| `a + b` | `a.plus(b)` | +| `a - b` | `a.minus(b)` | +| `a * b` | `a.times(b)` | +| `a / b` | `a.div(b)` | +| `a % b` | `a.rem(b)` | +| `+a` / `-a` | `a.unaryPlus()` / `a.unaryMinus()` | +| `!a` | `a.not()` | +| `a++` / `a--` | `a.inc()` / `a.dec()` | +| `a += b` | `a.plusAssign(b)` или `a = a.plus(b)` | +| `a == b` | `a?.equals(b) ?: (b === null)` | +| `a > b` | `a.compareTo(b) > 0` | +| `a[i]` | `a.get(i)` | +| `a[i] = v` | `a.set(i, v)` | +| `a()` | `a.invoke()` | +| `a in b` | `b.contains(a)` | +| `a..b` | `a.rangeTo(b)` | +| `a.. x + 1 -> y + else -> throw IndexOutOfBoundsException("индекс $index") + } + + val length: Double get() = kotlin.math.sqrt(x * x + y * y) +} + +fun main() { + val a = Vec(1.0, 2.0) + val b = Vec(3.0, 4.0) + + println(a + b) // Vec(x=4.0, y=6.0) + println(b - a) // Vec(x=2.0, y=2.0) + println(a * 2.0) // Vec(x=2.0, y=4.0) + println(-a) // Vec(x=-1.0, y=-2.0) + println(b[0]) // 3.0 + println(b.length) // 5.0 +} +``` +Умножение на скаляр справа - метод класса; чтобы работало и слева, нужно расширение: +```kotlin +data class Vec2(val x: Double, val y: Double) { + operator fun times(scalar: Double) = Vec2(x * scalar, y * scalar) +} + +operator fun Double.times(v: Vec2) = v * this + +fun main() { + val v = Vec2(1.0, 2.0) + println(v * 3.0) // Vec2(x=3.0, y=6.0) + println(3.0 * v) // Vec2(x=3.0, y=6.0) +} +``` + +## Операторы как расширения +Оператор можно добавить к чужому типу: +```kotlin +operator fun String.times(n: Int) = repeat(n) + +operator fun List.get(range: IntRange): List = subList(range.first, range.last + 1) + +fun main() { + println("ab" * 3) // ababab + println(listOf(1, 2, 3, 4, 5)[1..3]) // [2, 3, 4] +} +``` +Так в stdlib добавлены `Map.get` через `[]`, `List.plus` и операции над множествами. + +## invoke +`operator fun invoke` позволяет вызывать объект как функцию: +```kotlin +class Multiplier(private val factor: Int) { + operator fun invoke(x: Int) = x * factor +} + +fun main() { + val triple = Multiplier(3) + println(triple(5)) // 15 + println(triple.invoke(5)) // 15 — то же самое +} +``` +Практическое применение - объект с состоянием, который ведёт себя как функция: +```kotlin +class RateLimiter(private val maxCalls: Int) { + private var calls = 0 + operator fun invoke(): Boolean { + calls++ + return calls <= maxCalls + } +} + +fun main() { + val limiter = RateLimiter(2) + println("${limiter()} ${limiter()} ${limiter()}") // true true false +} +``` +`invoke` на `companion object` даёт фабрику с синтаксисом конструктора: +```kotlin +class Email private constructor(val value: String) { + companion object { + operator fun invoke(raw: String): Email? = + if (raw.contains("@")) Email(raw.trim().lowercase()) else null + } + override fun toString() = value +} + +fun main() { + println(Email(" Ivan@Example.COM ")) // ivan@example.com + println(Email("не почта")) // null +} +``` +Это способ сделать «конструктор, возвращающий null» - настоящий конструктор так не может. + +## Сравнение: compareTo +```kotlin +class Version(val major: Int, val minor: Int) : Comparable { + override fun compareTo(other: Version): Int = + compareValuesBy(this, other, { it.major }, { it.minor }) + + override fun toString() = "$major.$minor" +} + +fun main() { + val a = Version(1, 5) + val b = Version(2, 0) + + println(a < b) // true + println(a >= Version(1, 5)) // true + println(listOf(b, a, Version(1, 9)).sorted()) // [1.5, 1.9, 2.0] + println(maxOf(a, b)) // 2.0 + println(a in Version(1, 0)..Version(1, 9)) // true +} +``` +Одна функция `compareTo` даёт `<`, `>`, `<=`, `>=`, сортировку, `maxOf`/`minOf` и диапазоны. `compareValuesBy` сравнивает по нескольким ключам по порядку. + +## in: contains +```kotlin +data class DateRange(val from: Int, val to: Int) { + operator fun contains(day: Int) = day in from..to +} + +fun main() { + val week = DateRange(1, 7) + println(3 in week) // true + println(10 in week) // false + println(10 !in week) // true — !in получается автоматически +} +``` + +## Итератор +```kotlin +class Countdown(private val from: Int) { + operator fun iterator(): Iterator = object : Iterator { + private var current = from + override fun hasNext() = current > 0 + override fun next() = current-- + } +} + +fun main() { + for (i in Countdown(3)) print("$i ") + println() // 3 2 1 +} +``` +`iterator` можно добавить и расширением - тогда `for` заработает на чужом типе: +```kotlin +operator fun ClosedRange.iterator(): Iterator = + object : Iterator { + private var current = start + override fun hasNext() = current <= endInclusive + override fun next() = current.also { current = current.plusDays(1) } + } + +fun main() { + val from = java.time.LocalDate.of(2026, 1, 1) + val to = java.time.LocalDate.of(2026, 1, 3) + for (d in from..to) print("${d.dayOfMonth} ") + println() // 1 2 3 +} +``` + +## get и set +```kotlin +class Matrix(private val rows: Int, private val cols: Int) { + private val data = DoubleArray(rows * cols) + + operator fun get(i: Int, j: Int): Double = data[i * cols + j] + operator fun set(i: Int, j: Int, value: Double) { data[i * cols + j] = value } + + override fun toString() = (0 until rows).joinToString("\n") { i -> + (0 until cols).joinToString(" ") { j -> this[i, j].toString() } + } +} + +fun main() { + val m = Matrix(2, 2) + m[0, 0] = 1.0 + m[1, 1] = 2.0 + println(m[0, 0]) // 1.0 + println(m) + // 1.0 0.0 + // 0.0 2.0 +} +``` +`get` и `set` принимают любое число аргументов - это даёт многомерную индексацию, которой нет в Java. + +## plusAssign против plus +```kotlin +class Basket { + private val items = mutableListOf() + operator fun plusAssign(item: String) { items.add(item) } + override fun toString() = items.toString() +} + +fun main() { + val basket = Basket() + basket += "хлеб" + basket += "молоко" + println(basket) // [хлеб, молоко] +} +``` +Если объявлены оба (`plus` и `plusAssign`) и переменная `var` - компилятор сообщит об неоднозначности. Правило: `plusAssign` для изменяемых коллекций-подобных типов, `plus` для значений. + +## Деструктуризация +```kotlin +class Pair3(val first: Int, val second: String) { + operator fun component1() = first + operator fun component2() = second +} + +fun main() { + val (num, text) = Pair3(1, "один") + println("$num = $text") // 1 = один +} +``` +`data class` объявляет `componentN` автоматически. Подробнее: [Деструктуризация](../Синтаксис%20и%20типы/Работа%20с%20данными/Деструктуризация.md) + +## Делегирование свойств +Операторы `getValue`/`setValue` позволяют подменить реализацию свойства: +```kotlin +import kotlin.reflect.KProperty + +class Trimmed { + private var value: String = "" + operator fun getValue(thisRef: Any?, property: KProperty<*>): String = value + operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) { + value = newValue.trim() + } +} + +class Form { + var login: String by Trimmed() +} + +fun main() { + val f = Form() + f.login = " ivan " + println("[${f.login}]") // [ivan] +} +``` +Подробнее о делегатах и готовых реализациях (`lazy`, `observable`, `map`): [Делегирование](../ООП/Делегирование.md) + +## Когда перегружать оператор +| Ситуация | Стоит ли | +|---|---| +| математический тип (вектор, матрица, деньги, дата) | да | +| коллекция-подобный тип (`[]`, `in`, `for`) | да | +| сравнение по естественному порядку | да, через `Comparable` | +| «красивее выглядит» | нет | +| смысл оператора неочевиден (`user + role`) | нет | +| операция может бросить исключение неожиданно | нет | + +Критерий простой: оператор должен означать то же, что для встроенных типов. `+` обязан быть коммутативным по смыслу и не иметь побочных эффектов; `==` - соответствовать `hashCode`. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | перегрузки операторов нет | `operator fun` | +> | `a.add(b)` | `a + b` | +> | `list.get(i)` | `list[i]` | +> | `map.put(k, v)` | `map[k] = v` | +> | `a.compareTo(b) < 0` | `a < b` | +> | `a.equals(b)` | `a == b` | +> | `BigDecimal.add().multiply()` | `a + b * c` | +> | инфиксной нотации нет | `infix fun` | +> +> **Java не поддерживает перегрузку операторов принципиально** - кроме `+` для строк, который встроен в компилятор. Это сознательное решение дизайнеров Java: операторы с произвольным смыслом усложняют чтение. Kotlin выбрал компромисс: перегрузка есть, но набор операторов фиксирован и имена функций заданы жёстко, так что `plus` всегда означает `+` и нельзя придумать свой оператор. +> +> **Разница заметнее всего на числовых типах.** `BigDecimal` в Java: +> ```java +> BigDecimal result = a.multiply(b).add(c).divide(d, RoundingMode.HALF_UP); +> ``` +> В Kotlin те же операторы уже объявлены в stdlib как расширения: +> ```kotlin +> import java.math.BigDecimal +> +> fun main() { +> val a = BigDecimal("10.5") +> val b = BigDecimal("2") +> println(a + b) // 12.5 +> println(a * b) // 21.0 +> println(a > b) // true +> } +> ``` +> Для денежных расчётов это главный практический выигрыш: формула в коде выглядит как формула. +> +> **`==` в Kotlin - это `equals`, а не сравнение ссылок.** Самое важное отличие на практике: в Java `a == b` для объектов сравнивает ссылки, и забытый `.equals()` - классическая ошибка со строками. В Kotlin `==` вызывает `equals` (с корректной обработкой null), а для ссылок есть отдельный `===`: +> ```kotlin +> fun main() { +> val a = StringBuilder("x").toString() +> val b = StringBuilder("x").toString() +> println(a == b) // true — по значению +> println(a === b) // false — разные объекты +> } +> ``` +> +> **`infix` даёт читаемость без DSL-библиотек.** В Java тестовые фреймворки имитируют инфиксную запись цепочкой методов: `assertThat(x).isEqualTo(y)`. В Kotlin это записывается как `x shouldBe y` - подробнее: [Kotest](../Тестирование/Kotest.md) +> +> **`invoke` аналога не имеет.** В Java объект нельзя вызвать как функцию; ближайшее - интерфейс с методом `apply`, вызываемый явно. +> +> Сравнить: [Операторы и выражения в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовый%20синтаксис/Операторы%20и%20выражения.md), [Класс Object](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Базовые%20типы%20ООП/Класс%20Object.md) + +> [!warning] Подводные камни +> **Приоритет `infix` ниже арифметики:** +> ```kotlin +> infix fun Int.append(x: Int) = "$this$x" +> +> fun main() { +> println(1 append 2 + 3) // 15 — это 1 append (2+3) +> println((1 append 2) + 3) // 123 +> } +> ``` +> Выражение выглядит как последовательность слева направо, но арифметика вычисляется первой. +> +> **`infix` нельзя использовать без получателя и с `this` неявно:** +> ```kotlin +> class Calc { +> infix fun add(x: Int) = x + 1 +> fun demo(): Int { +> // return add 5 // ошибка +> return this add 5 // так можно +> } +> } +> +> fun main() { println(Calc().demo()) } // 6 +> ``` +> +> **`plus` не меняет объект, `plusAssign` меняет - и перепутать легко:** +> ```kotlin +> fun main() { +> var list1 = listOf(1, 2) +> list1 += 3 // новый список: plus + присваивание +> println(list1) // [1, 2, 3] +> +> val list2 = mutableListOf(1, 2) +> list2 += 3 // изменение на месте: plusAssign +> println(list2) // [1, 2, 3] +> } +> ``` +> Результат одинаковый, стоимость разная: первый вариант копирует весь список. +> +> **Объявление и `plus`, и `plusAssign` даёт ошибку компиляции при `var`:** +> ```kotlin +> class Both(val v: Int) { +> operator fun plus(x: Int) = Both(v + x) +> operator fun plusAssign(x: Int) { } +> } +> +> fun main() { +> val b = Both(1) +> b += 1 // выбран plusAssign — для val это единственный вариант +> println(b.v) // 1 +> } +> ``` +> Для `var`-переменной компилятор не сможет выбрать между `b = b + 1` и `b.plusAssign(1)`. +> +> **`inc`/`dec` должны возвращать новый объект:** +> ```kotlin +> class Counter4(val value: Int) { +> operator fun inc() = Counter4(value + 1) +> } +> +> fun main() { +> var c = Counter4(0) +> c++ +> println(c.value) // 1 +> } +> ``` +> Если `inc` изменит объект и вернёт `this`, постфиксный `c++` сломается: он должен вернуть **старое** значение, а вернёт изменённое. +> +> **`equals` нельзя объявить расширением:** +> ```kotlin +> class NoEquals(val v: Int) +> +> // operator fun NoEquals.equals(other: Any?) = ... // не заработает +> +> fun main() { +> println(NoEquals(1) == NoEquals(1)) // false — сравнение ссылок из Any +> } +> ``` +> `equals` должен быть `override` в классе: он объявлен в `Any`, а расширение не переопределяет член. Подробнее: [equals, hashCode и toString](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/equals,%20hashCode%20и%20toString.md) +> +> **`compareTo` обязан быть согласован с `equals`.** Если `a.compareTo(b) == 0`, но `a != b`, то `TreeSet` и `sortedSetOf` начнут терять элементы - они используют сравнение, а не равенство. +> +> **`get` без `set` создаёт асимметричный API:** +> ```kotlin +> class ReadOnlyBox(private val items: List) { +> operator fun get(i: Int) = items[i] +> } +> +> fun main() { +> val b = ReadOnlyBox(listOf(1, 2)) +> println(b[0]) // 1 +> // b[0] = 5 // ошибка: нет set +> } +> ``` +> Это нормально (так работает `List`), но пользователь увидит `[]` и может ожидать присваивания. +> +> **Оператор на расширении не виден, если есть член класса** - действует то же правило затенения, что у обычных расширений. +> +> **Операторы с неочевидным смыслом ухудшают код:** +> ```kotlin +> data class User13(val name: String, val roles: List) +> +> // operator fun User13.plus(role: String) = copy(roles = roles + role) +> fun User13.withRole(role: String) = copy(roles = roles + role) +> +> fun main() { +> println(User13("Иван", emptyList()).withRole("админ")) +> // User13(name=Иван, roles=[админ]) +> } +> ``` +> `user + "админ"` короче, но непонятно: что значит «сложить пользователя со строкой»? Именованный метод честнее. +> +> **`invoke` на обычном классе запутывает:** `service()` не говорит, что делает объект. Оправдано для функциональных обёрток (`UseCase`, `Validator`), где у объекта ровно одна операция. +> +> **Нельзя придумать новый оператор.** Набор фиксирован: `+`, `-`, `*`, `/`, `%`, `[]`, `()`, `in`, `..`, сравнения, `++`/`--`, унарные. Произвольные символы вроде `<*>` или `|>` недоступны - только `infix`-функция со словесным именем. + +Далее: [Receivers и context parameters](Receivers%20и%20context%20parameters.md) diff --git a/Kotlin/Функциональный Kotlin/Паттерны проектирования.md b/Kotlin/Функциональный Kotlin/Паттерны проектирования.md new file mode 100644 index 0000000..ccb2d88 --- /dev/null +++ b/Kotlin/Функциональный Kotlin/Паттерны проектирования.md @@ -0,0 +1,577 @@ +--- +title: Паттерны проектирования +description: Какие паттерны GoF встроены в язык, какие упростились и какие остались нужны +tags: + - kotlin + - functional + - patterns +--- + +Многие паттерны проектирования - это обходные приёмы для того, чего не хватает языку. Kotlin часть этих возможностей встроил, поэтому соответствующие паттерны сводятся к одной конструкции. + +| Паттерн | В Kotlin | +|---|---| +| Singleton | `object` | +| Builder | именованные аргументы, `apply`, DSL | +| Factory Method | функция верхнего уровня или `invoke` в companion | +| Strategy | функциональный тип | +| Decorator | делегирование `by` | +| Observer | `Flow`, `StateFlow` или список лямбд | +| Template Method | функция высшего порядка | +| Command | лямбда | +| Adapter | функции-расширения | +| Visitor | `sealed` + `when` | +| Prototype | `copy()` у `data class` | +| Null Object | nullable-тип и `?:` | +| Facade, Repository, Proxy | остаются как есть | + +## Singleton +```kotlin +object Settings { + private val values = mutableMapOf() + + var theme: String = "light" + + fun put(key: String, value: String) { values[key] = value } + fun get(key: String): String? = values[key] +} + +fun main() { + Settings.theme = "dark" + Settings.put("lang", "ru") + println("${Settings.theme} ${Settings.get("lang")}") // dark ru +} +``` +`object` - потокобезопасный ленивый синглтон: экземпляр создаётся при первом обращении, инициализация защищена загрузчиком классов. Никакого `getInstance()`, `volatile` и двойной проверки блокировки. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) + +Цена та же, что у любого синглтона: глобальное состояние, которое трудно подменить в тестах. Если объект нужно мокировать - он должен реализовать интерфейс и внедряться параметром. + +## Builder +Три варианта по возрастанию сложности. Первый - именованные аргументы с значениями по умолчанию: +```kotlin +data class HttpRequest( + val url: String, + val method: String = "GET", + val timeoutMs: Int = 5_000, + val headers: Map = emptyMap(), + val retries: Int = 0, +) + +fun main() { + val r = HttpRequest(url = "https://example.com", method = "POST", retries = 3) + println(r) + // HttpRequest(url=https://example.com, method=POST, timeoutMs=5000, headers={}, retries=3) +} +``` +Это закрывает подавляющее большинство случаев, ради которых пишут builder. Плюс к этому `copy` даёт модификацию: +```kotlin +data class HttpRequest( + val url: String, + val method: String = "GET", + val headers: Map = emptyMap(), +) + +fun main() { + val base = HttpRequest("https://example.com") + val withAuth = base.copy(headers = mapOf("Authorization" to "Bearer x")) + println(withAuth.headers) // {Authorization=Bearer x} + println(base.headers) // {} — оригинал цел +} +``` + +Второй вариант - `apply` для классов с изменяемыми свойствами (включая чужие): +```kotlin +fun main() { + val sb = StringBuilder().apply { + append("строка") + append(" ") + append(42) + } + println(sb) // строка 42 +} +``` + +Третий - DSL, когда структура вложенная: +```kotlin +class Form2 { + private val fields = mutableListOf() + fun field(name: String, required: Boolean = false) { + fields.add(if (required) "$name*" else name) + } + fun render() = fields.joinToString(", ") +} + +fun form(block: Form2.() -> Unit) = Form2().apply(block).render() + +fun main() { + println(form { + field("имя", required = true) + field("телефон") + }) + // имя*, телефон +} +``` +Подробнее: [DSL](DSL.md) + +## Factory Method +```kotlin +sealed interface Shape2 { + data class Circle2(val radius: Double) : Shape2 + data class Square(val side: Double) : Shape2 +} + +fun shapeOf(type: String, size: Double): Shape2? = when (type) { + "circle" -> Shape2.Circle2(size) + "square" -> Shape2.Square(size) + else -> null +} + +fun main() { + println(shapeOf("circle", 2.0)) // Circle2(radius=2.0) + println(shapeOf("unknown", 1.0)) // null +} +``` +Отдельный класс-фабрика не нужен: функция верхнего уровня делает то же. Когда фабрика должна выглядеть как конструктор, помогает `invoke` в companion: +```kotlin +class PositiveNumber private constructor(val value: Int) { + companion object { + operator fun invoke(raw: Int): PositiveNumber? = + if (raw > 0) PositiveNumber(raw) else null + } + override fun toString() = "+$value" +} + +fun main() { + println(PositiveNumber(5)) // +5 + println(PositiveNumber(-1)) // null +} +``` +Настоящий конструктор не может вернуть `null` или кешированный экземпляр - здесь это возможно, а вызов выглядит как `PositiveNumber(5)`. Подробнее: [Инфиксные и операторные функции](Инфиксные%20и%20операторные%20функции.md) + +## Strategy +Классический паттерн - интерфейс с одним методом и набор реализаций. В Kotlin это функциональный тип: +```kotlin +class Checkout(private val discount: (Long) -> Long) { + fun total(amount: Long) = discount(amount) +} + +fun main() { + val none = Checkout { it } + val tenPercent = Checkout { it * 90 / 100 } + val fixed = Checkout { (it - 100).coerceAtLeast(0) } + + println("${none.total(1000)} ${tenPercent.total(1000)} ${fixed.total(1000)}") + // 1000 900 900 +} +``` +Стратегии становятся значениями: их можно хранить в `Map`, выбирать по ключу, комбинировать: +```kotlin +fun main() { + val strategies: Map Long> = mapOf( + "none" to { it }, + "ten" to { it * 90 / 100 }, + "half" to { it / 2 }, + ) + + val chosen = strategies["ten"] ?: strategies.getValue("none") + println(chosen(1000)) // 900 + + val combined: (Long) -> Long = { strategies.getValue("ten")(strategies.getValue("half")(it)) } + println(combined(1000)) // 450 +} +``` +Когда у стратегии больше одной операции - интерфейс остаётся правильным выбором. + +## Decorator +```kotlin +interface Repository8 { + fun find(id: Int): String? +} + +class DatabaseRepository : Repository8 { + override fun find(id: Int): String? = if (id == 1) "Иван" else null +} + +class CachingRepository(private val delegate: Repository8) : Repository8 by delegate { + private val cache = mutableMapOf() + + override fun find(id: Int): String? = cache.getOrPut(id) { + println("запрос в БД: $id") + delegate.find(id) + } +} + +class LoggingRepository(private val delegate: Repository8) : Repository8 by delegate + +fun main() { + val repo = LoggingRepository(CachingRepository(DatabaseRepository())) + println(repo.find(1)) // запрос в БД: 1 \n Иван + println(repo.find(1)) // Иван — из кеша +} +``` +`by delegate` генерирует делегирующие реализации для всех методов интерфейса, а переопределить нужно только изменённые. В Java каждый декоратор обязан вручную прописать все методы интерфейса. Подробнее: [Делегирование](../ООП/Делегирование.md) + +## Observer +Простая версия - список лямбд: +```kotlin +class Publisher { + private val subscribers = mutableListOf<(T) -> Unit>() + + fun subscribe(handler: (T) -> Unit): () -> Unit { + subscribers.add(handler) + return { subscribers.remove(handler) } // функция отписки + } + + fun publish(event: T) = subscribers.forEach { it(event) } +} + +fun main() { + val publisher = Publisher() + val unsubscribe = publisher.subscribe { println("получено: $it") } + publisher.subscribe { println("второй: $it") } + + publisher.publish("событие 1") + // получено: событие 1 + // второй: событие 1 + + unsubscribe() + publisher.publish("событие 2") + // второй: событие 2 +} +``` +Возврат функции отписки вместо метода `unsubscribe(handler)` избавляет от хранения ссылки на обработчик - частая причина утечек в Java. + +Для реальных задач вместо своей реализации берут `StateFlow` или `SharedFlow`: там уже решены обратное давление, конкурентность и жизненный цикл подписки. Подробнее: [StateFlow и SharedFlow](../Корутины/StateFlow%20и%20SharedFlow.md) + +Для одного наблюдаемого свойства есть готовый делегат: +```kotlin +import kotlin.properties.Delegates + +class Model { + var status: String by Delegates.observable("новый") { _, old, new -> + println("статус: $old -> $new") + } +} + +fun main() { + val m = Model() + m.status = "в работе" // статус: новый -> в работе + m.status = "готов" // статус: в работе -> готов +} +``` + +## Template Method +Паттерн, где базовый класс задаёт скелет алгоритма, а подклассы заполняют шаги. В Kotlin это функция с лямбдами: +```kotlin +fun processFile( + lines: List, + parse: (String) -> T?, + onError: (String) -> Unit = { println("пропущено: $it") }, +): List = lines.mapNotNull { line -> + parse(line) ?: null.also { onError(line) } +} + +fun main() { + val result = processFile( + lines = listOf("1", "два", "3"), + parse = { it.toIntOrNull() }, + ) + println(result) + // пропущено: два + // [1, 3] +} +``` +Наследование не нужно: шаги передаются параметрами. Это и проще (нет иерархии), и гибче (шаги можно комбинировать в рантайме). + +## Command +```kotlin +class History { + private val undoStack = mutableListOf<() -> Unit>() + + fun execute(action: () -> Unit, undo: () -> Unit) { + action() + undoStack.add(undo) + } + + fun undo() { undoStack.removeLastOrNull()?.invoke() } +} + +fun main() { + val text = StringBuilder() + val history = History() + + history.execute({ text.append("Привет") }, { text.setLength(0) }) + println(text) // Привет + history.undo() + println("[$text]") // [] +} +``` +Интерфейс `Command` с методом `execute` заменяется лямбдой. Если у команды есть состояние или несколько операций - `data class` с методами честнее. + +## Visitor +Паттерн нужен там, где над закрытой иерархией выполняют разные операции. В Kotlin это `sealed` + `when`: +```kotlin +sealed interface Expr { + data class Num(val value: Int) : Expr + data class Add(val left: Expr, val right: Expr) : Expr + data class Mul(val left: Expr, val right: Expr) : Expr +} + +fun eval(e: Expr): Int = when (e) { + is Expr.Num -> e.value + is Expr.Add -> eval(e.left) + eval(e.right) + is Expr.Mul -> eval(e.left) * eval(e.right) +} + +fun render(e: Expr): String = when (e) { + is Expr.Num -> e.value.toString() + is Expr.Add -> "(${render(e.left)} + ${render(e.right)})" + is Expr.Mul -> "${render(e.left)} * ${render(e.right)}" +} + +fun main() { + val expr = Expr.Add(Expr.Num(2), Expr.Mul(Expr.Num(3), Expr.Num(4))) + println(render(expr)) // (2 + 3 * 4) + println(eval(expr)) // 14 +} +``` +Новая операция - новая функция, менять классы не нужно. Компилятор проверяет полноту `when`, поэтому добавление варианта в `sealed` сразу покажет все места, требующие правки - то, чего Visitor добивается ценой двойной диспетчеризации. Подробнее: [Sealed class и interface](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) + +## Prototype и Null Object +```kotlin +data class Config4(val host: String, val port: Int, val debug: Boolean = false) + +fun main() { + val prod = Config4("example.com", 443) + val staging = prod.copy(host = "staging.example.com", debug = true) + println(staging) + // Config4(host=staging.example.com, port=443, debug=true) +} +``` +`copy` - это Prototype, встроенный в `data class`. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) + +Null Object создавался, чтобы избежать проверок на `null`. Nullable-типы решают ту же задачу явнее: +```kotlin +interface Logger3 { fun log(m: String) } + +fun process6(data: String, logger: Logger3? = null) { + logger?.log("начало") + println("обработано: $data") + logger?.log("конец") +} + +fun main() { + process6("x") // обработано: x + process6("y", object : Logger3 { override fun log(m: String) = println("[$m]") }) + // [начало] + // обработано: y + // [конец] +} +``` +Пустая реализация-заглушка не нужна: `?.` не вызывает метод при `null`. Подробнее: [Операторы null-безопасности](../Синтаксис%20и%20типы/Null-безопасность/Операторы%20null-безопасности.md) + +## Что остаётся нужным +Паттерны, описывающие **архитектуру**, а не обход ограничений языка, остаются актуальными: Repository, Facade, Adapter между слоями, Strategy с несколькими методами, State-машина, Chain of Responsibility. + +```kotlin +sealed interface OrderState { + data object New : OrderState + data object Paid : OrderState + data class Shipped(val track: String) : OrderState + data object Cancelled : OrderState +} + +fun next(state: OrderState, event: String): OrderState = when (state) { + OrderState.New -> when (event) { + "pay" -> OrderState.Paid + "cancel" -> OrderState.Cancelled + else -> state + } + OrderState.Paid -> if (event == "ship") OrderState.Shipped("TRACK-1") else state + is OrderState.Shipped -> state + OrderState.Cancelled -> state +} + +fun main() { + var state: OrderState = OrderState.New + for (event in listOf("pay", "ship", "cancel")) { + state = next(state, event) + println("$event -> $state") + } + // pay -> Paid + // ship -> Shipped(track=TRACK-1) + // cancel -> Shipped(track=TRACK-1) +} +``` +Здесь `sealed` + `when` не отменяют паттерн State, а дают ему надёжную реализацию: невозможное состояние не создать, а забытый переход не скомпилируется. + +> [!info] Отличия от Java +> | Паттерн в Java | В Kotlin | +> |---|---| +> | `private static final INSTANCE` + `getInstance()` | `object` | +> | класс `Builder` с 10 методами и `build()` | именованные аргументы | +> | `interface Strategy` + 3 класса | `(T) -> R` | +> | декоратор с ручным проксированием всех методов | `by delegate` | +> | `List` + `addListener`/`removeListener` | лямбды или `Flow` | +> | `abstract class` с `protected abstract` шагами | функция с лямбдами | +> | `interface Visitor` с `visitX` на каждый тип | `sealed` + `when` | +> | `clone()` или конструктор копирования | `copy()` | +> | `NullLogger implements Logger {}` | `Logger?` + `?.` | +> +> **Главная разница - в количестве кода на тот же смысл.** Builder для класса с пятью полями в Java - это ~60 строк (вложенный класс, поле на каждый параметр, метод на каждый параметр, `build()`). В Kotlin это объявление `data class` с значениями по умолчанию, то есть ноль дополнительных строк. То же с Singleton (было: приватный конструктор, статическое поле, `getInstance`, возможно двойная проверка блокировки; стало: `object`) и Strategy (было: интерфейс плюс класс на каждую реализацию; стало: лямбда). +> +> **Декоратор - самый показательный случай.** В Java, чтобы задекорировать интерфейс с 15 методами, нужно написать 15 делегирующих методов, из которых интересен один. Отсюда классы вроде `HttpServletRequestWrapper` в стандартной библиотеке - их пишут специально, чтобы наследоваться. В Kotlin `by delegate` делает это автоматически: +> ```kotlin +> interface Big { +> fun a(): Int +> fun b(): Int +> fun c(): Int +> } +> +> class Impl : Big { +> override fun a() = 1 +> override fun b() = 2 +> override fun c() = 3 +> } +> +> class OnlyBChanged(d: Big) : Big by d { +> override fun b() = 99 +> } +> +> fun main() { +> val x = OnlyBChanged(Impl()) +> println("${x.a()} ${x.b()} ${x.c()}") // 1 99 3 +> } +> ``` +> +> **Паттерны не исчезли - исчез их boilerplate.** Смысл Strategy (вынести изменяемое поведение) остался и в Kotlin, просто выражается функциональным типом. Полезно знать паттерны именно как словарь: «здесь Strategy» понятнее, чем «здесь лямбда в конструкторе». +> +> **Некоторые паттерны в Kotlin не нужны вообще.** Null Object существовал, чтобы не писать `if (x != null)`; при наличии `?.` он только добавляет класс. Double-Checked Locking для синглтона не нужен, потому что `object` и `lazy` уже потокобезопасны. +> +> Сравнить: [Singleton в Java](../../Программирование%20на%20Java/Паттерны%20проектирования/Singleton.md), [Builder](../../Программирование%20на%20Java/Паттерны%20проектирования/Builder.md) + +> [!warning] Подводные камни +> **`object` - глобальное состояние, которое не подменить в тестах:** +> ```kotlin +> object Clock2 { +> fun now(): Long = System.currentTimeMillis() +> } +> +> interface Clock3 { fun now(): Long } +> +> class Service4(private val clock: Clock3) { +> fun timestamp() = clock.now() +> } +> +> fun main() { +> val fake = object : Clock3 { override fun now() = 1_000L } +> println(Service4(fake).timestamp()) // 1000 — тест детерминирован +> println(Clock2.now() > 0) // true — а это не подменить +> } +> ``` +> Между `object` и интерфейсом с внедрением выбирайте второе, если поведение нужно будет подменить. +> +> **`object` с изменяемым состоянием не потокобезопасен.** Создание экземпляра защищено, а доступ к полям - нет: +> ```kotlin +> object Counter5 { +> var count = 0 // гонка при обращении из нескольких потоков +> } +> +> fun main() { +> Counter5.count++ +> println(Counter5.count) // 1 +> } +> ``` +> +> **Стратегия-лямбда теряет имя в стеке и логах:** +> ```kotlin +> fun main() { +> val strategies: Map Int> = mapOf("double" to { it * 2 }) +> val f = strategies.getValue("double") +> println(f::class.simpleName?.contains("Lambda")) // true — имя сгенерировано +> } +> ``` +> Вместо осмысленного имени получается что-то вроде `MainKt$$Lambda/0x00001e0001040620` - с адресом, меняющимся от запуска к запуску. Отладка «какая стратегия сработала» требует хранить имя рядом. Если это важно - интерфейс или `data class` с полем `name`. +> +> **`by delegate` делегирует только методы интерфейса.** `equals`, `hashCode` и `toString` берутся из `Any`, а не из делегата: +> ```kotlin +> interface Named5 { val name: String } +> data class Impl2(override val name: String) : Named5 +> +> class Wrapper(d: Named5) : Named5 by d +> +> fun main() { +> val w = Wrapper(Impl2("x")) +> println(w.name) // x +> println(w.toString().startsWith("Wrapper")) // true — не делегировано +> println(w == Wrapper(Impl2("x"))) // false +> } +> ``` +> +> **Делегат фиксируется при создании:** +> ```kotlin +> interface Source { fun value(): Int } +> +> class Changing(private var inner: Source) : Source by inner { +> fun replace(newInner: Source) { inner = newInner } +> } +> +> fun main() { +> val first = object : Source { override fun value() = 1 } +> val second = object : Source { override fun value() = 2 } +> val c = Changing(first) +> c.replace(second) +> println(c.value()) // 1 — делегирует первому! +> } +> ``` +> `by` запоминает ссылку в момент конструирования. Для подменяемого делегата нужно явное проксирование метода. +> +> **Свой Observer течёт по памяти:** +> ```kotlin +> class Leaky3 { +> private val handlers = mutableListOf<(Int) -> Unit>() +> fun subscribe(h: (Int) -> Unit) { handlers.add(h) } +> fun count() = handlers.size +> } +> +> fun main() { +> val p = Leaky3() +> repeat(3) { p.subscribe { } } +> println(p.count()) // 3 — и никогда не уменьшится +> } +> ``` +> Без механизма отписки подписчики живут до конца жизни издателя и держат свои замыкания. Поэтому для событий лучше `Flow` с привязкой к области корутины. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md) +> +> **Лямбда в коллекции не сравнивается:** +> ```kotlin +> fun main() { +> val handlers = mutableListOf<() -> Unit>() +> val h = { println("x") } +> handlers.add(h) +> println(handlers.remove(h)) // true — та же ссылка +> handlers.add { println("x") } +> println(handlers.remove { println("x") }) // false — другой объект +> } +> ``` +> Отписка по «такой же» лямбде не работает - нужна именно та ссылка или токен отписки. +> +> **Builder через `apply` на `val`-полях невозможен** - нужны `var`, а значит объект изменяем после сборки. Для неизменяемого результата используйте `data class` с конструктором или отдельный билдер, возвращающий immutable-объект. +> +> **Именованные аргументы не защищают от неверного сочетания.** `HttpRequest(url = "x", retries = -5)` скомпилируется. Валидацию нужно писать в `init`: +> ```kotlin +> data class Retry(val attempts: Int) { +> init { require(attempts >= 0) { "attempts не может быть отрицательным" } } +> } +> +> fun main() { +> println(Retry(3)) +> try { Retry(-1) } catch (e: IllegalArgumentException) { println(e.message) } +> // Retry(attempts=3) +> // attempts не может быть отрицательным +> } +> ``` +> Подробнее: [Проверки и контракты](../Исключения/Проверки%20и%20контракты.md) +> +> **Применение паттерна там, где он не нужен, остаётся главной проблемой** - и язык от этого не спасает. Фабрика для класса с одним конструктором, Strategy с единственной реализацией и Repository поверх одного SQL-запроса добавляют уровни без выгоды. Критерий прежний: паттерн оправдан, когда вариативность уже существует, а не когда она предполагается. + +Далее: [Корутины](../Корутины/Корутины.md) diff --git a/Kotlin/Функциональный Kotlin/Функции высшего порядка.md b/Kotlin/Функциональный Kotlin/Функции высшего порядка.md new file mode 100644 index 0000000..a4bcc69 --- /dev/null +++ b/Kotlin/Функциональный Kotlin/Функции высшего порядка.md @@ -0,0 +1,392 @@ +--- +title: Функции высшего порядка +description: Функции, принимающие и возвращающие функции, композиция, каррирование и мемоизация +tags: + - kotlin + - functional +--- + +Функция высшего порядка - это функция, которая принимает функцию в параметре или возвращает функцию. В Kotlin функциональный тип (`(Int) -> String`) полноправный, поэтому ничего особенного для этого не нужно. + +```kotlin +fun applyTwice(x: Int, f: (Int) -> Int): Int = f(f(x)) + +fun main() { + println(applyTwice(3) { it * 2 }) // 12 + println(applyTwice(3) { it + 10 }) // 23 +} +``` + +Возврат функции: +```kotlin +fun multiplier(factor: Int): (Int) -> Int = { it * factor } + +fun main() { + val triple = multiplier(3) + println(triple(5)) // 15 + println(multiplier(10)(7)) // 70 +} +``` +`multiplier(3)` возвращает лямбду, которая **захватила** `factor`. Это замыкание, и оно работает так же, как в любом функциональном языке. Подробнее о синтаксисе: [Лямбды и функциональные типы](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) + +## Зачем это нужно +Функция высшего порядка позволяет вынести **изменяемую часть** алгоритма в параметр. Классический пример - повторяющаяся обвязка: + +```kotlin +fun retry(times: Int, block: () -> T): T { + var lastError: Throwable? = null + repeat(times) { attempt -> + try { + return block() + } catch (e: Exception) { + lastError = e + println("попытка ${attempt + 1} не удалась") + } + } + throw IllegalStateException("все $times попыток провалились", lastError) +} + +fun main() { + var counter = 0 + val result = retry(3) { + counter++ + if (counter < 3) error("ещё не готово") + "успех на попытке $counter" + } + println(result) // успех на попытке 3 +} +``` +Логика повторов написана один раз; что именно повторять - решает вызывающий. Без функциональных типов пришлось бы заводить интерфейс с одним методом и анонимный класс. + +Та же идея для измерения, транзакций и логирования: +```kotlin +inline fun measured(name: String, block: () -> T): T { + val start = System.nanoTime() + try { + return block() + } finally { + println("$name: ${(System.nanoTime() - start) / 1_000} мкс") + } +} + +fun main() { + val sum = measured("сумма") { (1..100_000).sumOf { it.toLong() } } + println(sum) // 5000050000 +} +``` +`finally` здесь важен: время выводится и при исключении. `inline` убирает объект лямбды - подробнее: [Inline-функции](Inline-функции.md) + +## Функциональные типы как параметры +```kotlin +fun processNumbers( + numbers: List, + filter: (Int) -> Boolean = { true }, + transform: (Int) -> String = { it.toString() }, +): List = numbers.filter(filter).map(transform) + +fun main() { + println(processNumbers(listOf(1, 2, 3))) // [1, 2, 3] + println(processNumbers(listOf(1, 2, 3), filter = { it > 1 })) // [2, 3] + println(processNumbers(listOf(1, 2), transform = { "№$it" })) // [№1, №2] +} +``` +Функциональный параметр может иметь значение по умолчанию - это заменяет перегрузки. + +Nullable функциональный тип требует скобок: +```kotlin +fun handle(value: Int, onSuccess: ((Int) -> Unit)? = null): Int { + onSuccess?.invoke(value) + return value +} + +fun main() { + println(handle(1)) // 1 + println(handle(2) { println("получено $it") }) // получено 2 \n 2 +} +``` +`(Int) -> Unit?` - это функция, возвращающая `Unit?`. Скобки меняют смысл принципиально. + +## Композиция +```kotlin +infix fun ((A) -> B).then(next: (B) -> C): (A) -> C = { next(this(it)) } + +fun main() { + val parse: (String) -> Int = { it.trim().toInt() } + val double: (Int) -> Int = { it * 2 } + val format: (Int) -> String = { "результат: $it" } + + val pipeline = parse then double then format + println(pipeline(" 21 ")) // результат: 42 +} +``` +В stdlib готовой композиции нет - её пишут сами в три строки. Направление `then` (слева направо) читается лучше математического `compose`, но встречаются оба: +```kotlin +infix fun ((B) -> C).compose(before: (A) -> B): (A) -> C = { this(before(it)) } + +fun main() { + val inc: (Int) -> Int = { it + 1 } + val double: (Int) -> Int = { it * 2 } + + println((inc compose double)(5)) // 11 — сначала double, потом inc + println((double compose inc)(5)) // 12 — наоборот +} +``` + +## Каррирование и частичное применение +```kotlin +fun curry(f: (A, B) -> C): (A) -> (B) -> C = { a -> { b -> f(a, b) } } + +fun main() { + val sum: (Int, Int) -> Int = { a, b -> a + b } + val curried = curry(sum) + + println(curried(2)(3)) // 5 + val add10 = curried(10) + println(add10(5)) // 15 + println(listOf(1, 2, 3).map(add10)) // [11, 12, 13] +} +``` +На практике частичное применение чаще делают прямо лямбдой - это короче и понятнее: +```kotlin +fun log(level: String, module: String, message: String) = "[$level][$module] $message" + +fun main() { + val errorInAuth: (String) -> String = { log("ERROR", "auth", it) } + println(errorInAuth("неверный пароль")) // [ERROR][auth] неверный пароль +} +``` +Ещё проще - значения по умолчанию и именованные аргументы, которые в Kotlin закрывают большинство задач, решаемых каррированием в других языках. Подробнее: [Функции](../Синтаксис%20и%20типы/Работа%20с%20данными/Функции.md) + +## Мемоизация +```kotlin +fun memoize(f: (A) -> R): (A) -> R { + val cache = mutableMapOf() + return { arg -> cache.getOrPut(arg) { f(arg) } } +} + +fun main() { + var calls = 0 + val slowSquare = memoize { calls++; it * it } + + println(slowSquare(4)) // 16 + println(slowSquare(4)) // 16 + println(slowSquare(5)) // 25 + println("вызовов: $calls") // вызовов: 2 +} +``` +`getOrPut` делает это в одну строку. Ограничения такого кеша: он не ограничен по размеру и не потокобезопасен - для продакшена нужен `ConcurrentHashMap` или готовый кеш. + +Рекурсия с мемоизацией требует ссылки на саму функцию: +```kotlin +fun main() { + val cache = mutableMapOf(0 to 0L, 1 to 1L) + fun fib(n: Int): Long = cache.getOrPut(n) { fib(n - 1) + fib(n - 2) } + + println(fib(50)) // 12586269025 + println(cache.size) // 51 +} +``` +Локальная функция может вызывать себя, а лямбда - нет: внутри `val f = { ... f(...) }` имя `f` ещё не инициализировано. + +## Функции, возвращающие функции +Типичный случай - настраиваемый валидатор: +```kotlin +fun lengthBetween(min: Int, max: Int): (String) -> String? = { value -> + when { + value.length < min -> "короче $min символов" + value.length > max -> "длиннее $max символов" + else -> null + } +} + +fun validateAll(value: String, vararg rules: (String) -> String?): List = + rules.mapNotNull { it(value) } + +fun main() { + val notBlank: (String) -> String? = { if (it.isBlank()) "пустое значение" else null } + + println(validateAll("ab", notBlank, lengthBetween(3, 10))) // [короче 3 символов] + println(validateAll("привет", notBlank, lengthBetween(3, 10))) // [] + println(validateAll(" ", notBlank)) // [пустое значение] +} +``` +Правила - обычные значения: их можно хранить в списке, передавать и комбинировать. Это основная практическая выгода функций высшего порядка. + +## Ссылки на функции вместо лямбд +```kotlin +fun isEven(x: Int) = x % 2 == 0 + +fun main() { + val numbers = listOf(1, 2, 3, 4) + + println(numbers.filter { isEven(it) }) // [2, 4] — лямбда-обёртка + println(numbers.filter(::isEven)) // [2, 4] — ссылка напрямую + println(numbers.map(Int::toString)) // [1, 2, 3, 4] — ссылка на метод + println(listOf("1", "2").map(String::toInt)) // [1, 2] + + val strings = listOf("bb", "a", "ccc") + println(strings.sortedBy(String::length)) // [a, bb, ccc] +} +``` +Ссылка `::isEven` короче и не создаёт лишнего уровня вложенности. Подробнее: [Лямбды и функциональные типы](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) + +## Лямбда с получателем +Особый вид функционального типа - `T.() -> R`. Внутри такой лямбды `this` - это `T`: +```kotlin +fun buildMessage(build: StringBuilder.() -> Unit): String { + val sb = StringBuilder() + sb.build() + return sb.toString() +} + +fun main() { + val text = buildMessage { + append("Привет") // this == StringBuilder + append(", ") + append("мир") + } + println(text) // Привет, мир +} +``` +На этом построены все билдеры Kotlin, включая `buildList` и `apply`. Подробнее: [Receivers и context parameters](Receivers%20и%20context%20parameters.md), [DSL](DSL.md) + +## Карта раздела +| Заметка | О чём | +|---|---| +| [Inline-функции](Inline-функции.md) | как убрать накладные расходы лямбд | +| [Функции-расширения](Функции-расширения.md) | добавление методов к готовым типам | +| [Scope-функции](Scope-функции.md) | `let`, `run`, `apply`, `also`, `with` | +| [Инфиксные и операторные функции](Инфиксные%20и%20операторные%20функции.md) | свой синтаксис для своих типов | +| [Receivers и context parameters](Receivers%20и%20context%20parameters.md) | неявный `this` и контекстные зависимости | +| [DSL](DSL.md) | сборка всего перечисленного в язык под задачу | +| [Паттерны проектирования](Паттерны%20проектирования.md) | что от паттернов осталось в Kotlin | + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `Function`, `BiFunction`, `Supplier`, `Consumer` | один синтаксис `(T) -> R` | +> | `Predicate` | `(T) -> Boolean` | +> | `Runnable` | `() -> Unit` | +> | `IntFunction`, `ToIntFunction`, ... (40+ интерфейсов) | нет, но есть упаковка | +> | `f.apply(x)` | `f(x)` | +> | `f.andThen(g)` | своя `then` | +> | лямбда - объект | лямбда инлайнится при `inline` | +> | нет аналога | лямбда с получателем `T.() -> R` | +> +> **Функциональный тип вместо интерфейса.** В Java функция передаётся как реализация интерфейса, и интерфейс нужно выбрать: `Function` для одного аргумента, `BiFunction` для двух, `Supplier` без аргументов, `Consumer` без результата, `Predicate` для `boolean`, плюс примитивные специализации - в `java.util.function` их больше сорока. В Kotlin тип записывается напрямую: +> ```kotlin +> fun demo( +> supplier: () -> Int, +> consumer: (Int) -> Unit, +> predicate: (Int) -> Boolean, +> biFunction: (Int, Int) -> Int, +> ) { +> val v = supplier() +> consumer(v) +> println("${predicate(v)} ${biFunction(v, v)}") +> } +> +> fun main() { demo({ 2 }, { }, { it > 1 }, { a, b -> a + b }) } // true 4 +> ``` +> Под капотом это всё равно интерфейсы `Function0`, `Function1`, ... `Function22`, но в коде их не видно. Три с лишним аргумента в Java требуют собственного интерфейса - в Kotlin предел 22. +> +> **Вызов через `invoke`, а не `apply`.** В Java функциональный объект вызывают методом интерфейса: `f.apply(x)`, `p.test(x)`, `c.accept(x)`, `s.get()` - имя зависит от интерфейса. В Kotlin оператор `()` работает для любого функционального типа, потому что все они объявляют `operator fun invoke`. +> +> **Композиции в stdlib нет.** Java даёт `Function.andThen`, `Function.compose`, `Predicate.and/or/negate`. Kotlin их не предоставляет сознательно: обычно короче написать одну лямбду, чем собирать её из частей. Когда композиция действительно нужна, она пишется в одну строку - как `then` выше. +> +> **Лямбда с получателем аналога в Java не имеет.** Это основа билдеров и DSL в Kotlin; в Java эквивалента нет даже близко, поэтому билдеры там пишут методами, возвращающими `this`. +> +> Сравнить: [Функции в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md), [Интерфейсы в Java](../../Программирование%20на%20Java/ООП/Интерфейсы.md) + +> [!warning] Подводные камни +> **Лямбда без `inline` создаёт объект на каждый вызов:** +> ```kotlin +> fun notInlined(block: () -> Unit) { block() } +> inline fun inlined(block: () -> Unit) { block() } +> +> fun main() { +> repeat(3) { notInlined { } } // объект Function0 на каждый вызов +> repeat(3) { inlined { } } // объектов нет +> println("готово") +> } +> ``` +> Лямбда без захвата переменных кешируется компилятором в синглтон, а лямбда с захватом - нет. В горячем цикле это заметно. Подробнее: [Inline-функции](Inline-функции.md) +> +> **`return` внутри лямбды возвращает из внешней функции - но только в inline:** +> ```kotlin +> inline fun forEachInline(items: List, action: (Int) -> Unit) { for (i in items) action(i) } +> +> fun findFirstEven(items: List): Int? { +> forEachInline(items) { if (it % 2 == 0) return it } // выход из findFirstEven +> return null +> } +> +> fun main() { println(findFirstEven(listOf(1, 3, 4, 6))) } // 4 +> ``` +> В не-inline функции такой `return` не скомпилируется - нужен `return@label`. Это разное поведение для внешне одинакового кода. +> +> **Nullable функциональный тип требует скобок:** +> ```kotlin +> fun main() { +> val a: ((Int) -> Unit)? = null // функция может быть null +> val b: (Int) -> Unit? = { } // функция возвращает Unit? +> println("${a == null} ${b(1)}") // true kotlin.Unit +> } +> ``` +> Без скобок получается совсем другой тип, и ошибка проявится далеко от объявления. +> +> **Вызов nullable-функции только через `invoke`:** +> ```kotlin +> fun main() { +> val f: ((Int) -> Int)? = { it * 2 } +> // println(f?(1)) // синтаксическая ошибка +> println(f?.invoke(1)) // 2 +> } +> ``` +> +> **Захваченная `var` меняется после создания лямбды:** +> ```kotlin +> fun main() { +> var counter = 0 +> val show = { println("counter = $counter") } +> counter = 10 +> show() // counter = 10, а не 0 +> } +> ``` +> В отличие от Java, Kotlin разрешает захват изменяемых переменных - они оборачиваются в объект-ссылку. Удобно, но лямбда перестаёт быть чистой функцией и становится зависимой от момента вызова. +> +> **Лямбды в цикле захватывают переменную, а не значение:** +> ```kotlin +> fun main() { +> val actions = mutableListOf<() -> Int>() +> var i = 0 +> while (i < 3) { actions.add { i }; i++ } +> println(actions.map { it() }) // [3, 3, 3] — все видят финальное i +> +> val correct = (0 until 3).map { v -> { v } } +> println(correct.map { it() }) // [0, 1, 2] — параметр цикла свой у каждой +> +> val fixed = mutableListOf<() -> Int>() +> for (j in 0 until 3) fixed.add { j } +> println(fixed.map { it() }) // [0, 1, 2] — переменная for неизменяема +> } +> ``` +> +> **Мемоизация течёт по памяти.** `mutableMapOf` в замыкании растёт без ограничений и держит все аргументы и результаты. Если функция живёт всё время работы приложения, это утечка; нужен кеш с ограничением размера. +> +> **Мемоизация не потокобезопасна.** `getOrPut` на обычном `MutableMap` при одновременном доступе может повредить структуру. Минимальная защита - `ConcurrentHashMap`, но и у него `getOrPut` не атомарен: используйте `computeIfAbsent`. +> +> **Лямбда не может ссылаться на себя:** +> ```kotlin +> fun main() { +> // val fact: (Int) -> Int = { if (it <= 1) 1 else it * fact(it - 1) } // ошибка +> fun fact(n: Int): Int = if (n <= 1) 1 else n * fact(n - 1) // локальная функция может +> println(fact(5)) // 120 +> } +> ``` +> +> **Больше двух-трёх функциональных параметров делают вызов нечитаемым.** `process(list, { }, { }, { })` ничего не говорит о смысле аргументов. Варианты: именованные аргументы, объект-конфигурация или интерфейс с несколькими методами. +> +> **Функциональные типы упаковывают примитивы.** `(Int) -> Int` в байт-коде - это `Function1`. Специализаций вроде `IntUnaryOperator` в Kotlin нет, поэтому в численно-интенсивном коде `inline` важен не только из-за объекта лямбды, но и из-за упаковки. + +Далее: [Inline-функции](Inline-функции.md) diff --git a/Kotlin/Функциональный Kotlin/Функции-расширения.md b/Kotlin/Функциональный Kotlin/Функции-расширения.md new file mode 100644 index 0000000..c8b790f --- /dev/null +++ b/Kotlin/Функциональный Kotlin/Функции-расширения.md @@ -0,0 +1,361 @@ +--- +title: Функции-расширения +description: Добавление методов к готовым типам, статическое разрешение и свойства-расширения +tags: + - kotlin + - functional +--- + +Функция-расширение добавляет метод к типу, который вы не контролируете - без наследования и без обёрток. + +```kotlin +fun String.isPalindrome(): Boolean { + val clean = lowercase().filter { it.isLetter() } + return clean == clean.reversed() +} + +fun main() { + println("Довод".isPalindrome()) // true + println("А роза упала на лапу Азора".isPalindrome()) // true + println("Привет".isPalindrome()) // false +} +``` +Внутри расширения `this` - это объект, на котором вызвали функцию (его называют **получателем**, receiver). `this` можно опускать: `lowercase()` - это `this.lowercase()`. + +## Это обычная статическая функция +Расширение не меняет класс и не попадает в него. Компилятор превращает его в статический метод с получателем первым параметром: +```kotlin +fun Int.double(): Int = this * 2 + +fun main() { + println(5.double()) // 10 — выглядит как метод + // В байт-коде: Файл.double(5) +} +``` +Из этого следуют все особенности расширений - и удобные, и опасные. + +**Нет доступа к `private`:** +```kotlin +class Account(private val balance: Long, val owner: String) + +// fun Account.canWithdraw(x: Long) = balance >= x // ошибка: balance недоступен +fun Account.describe() = "счёт $owner" // public — можно + +fun main() { + println(Account(100, "Иван").describe()) // счёт Иван +} +``` +Расширение - внешний код, инкапсуляция работает. Это принципиальное ограничение: расширение не заменяет метод класса, когда нужен доступ к состоянию. + +**Разрешение статическое, не виртуальное:** +```kotlin +open class Shape +class Circle : Shape() + +fun Shape.name() = "фигура" +fun Circle.name() = "круг" + +fun main() { + val shape: Shape = Circle() + println(shape.name()) // фигура — тип переменной, не объекта! + println(Circle().name()) // круг +} +``` +Это главная ловушка расширений. Выбор функции делается компилятором по **объявленному** типу, поэтому полиморфизма здесь нет. Подробнее: [Полиморфизм](../ООП/Полиморфизм.md) + +**Метод класса всегда выигрывает:** +```kotlin +class Printer { + fun show() = "метод класса" +} + +fun Printer.show() = "расширение" // warning: extension is shadowed by a member + +fun main() { + println(Printer().show()) // метод класса +} +``` +Это гарантия совместимости: добавление метода в класс не ломает код, но молча меняет поведение вызова. Компилятор предупреждает. + +## Расширения на nullable-типах +```kotlin +fun String?.orPlaceholder(): String = if (isNullOrBlank()) "(пусто)" else this + +fun main() { + println("текст".orPlaceholder()) // текст + println(null.orPlaceholder()) // (пусто) + println(" ".orPlaceholder()) // (пусто) +} +``` +Расширение на `T?` можно вызвать на `null` без `?.` - внутри `this` просто может быть `null`. Так устроены `isNullOrEmpty`, `orEmpty` и `toString` в stdlib: +```kotlin +fun main() { + val s: String? = null + println(s.isNullOrEmpty()) // true — без ?. ! + println(s.orEmpty().length) // 0 + println(s.toString()) // null — строка "null" +} +``` + +## Свойства-расширения +```kotlin +val String.firstWord: String get() = substringBefore(" ") +val List.secondOrNull: T? get() = getOrNull(1) +val Int.isEven: Boolean get() = this % 2 == 0 + +fun main() { + println("привет мир".firstWord) // привет + println(listOf(1, 2, 3).secondOrNull) // 2 + println(4.isEven) // true +} +``` +У свойства-расширения **не может быть поля** - только геттер (и сеттер, если получатель изменяемый): +```kotlin +var StringBuilder.lastChar: Char + get() = this[length - 1] + set(value) { setCharAt(length - 1, value) } + +fun main() { + val sb = StringBuilder("Котлин") + println(sb.lastChar) // н + sb.lastChar = '!' + println(sb) // Котли! +} +``` +Хранить состояние в расширении нельзя: `val Int.counter = 0` не скомпилируется, потому что хранить его негде. + +## Расширения на дженериках +```kotlin +fun List.penultimate(): T? = getOrNull(size - 2) + +fun > List.isSorted(): Boolean = zipWithNext().all { (a, b) -> a <= b } + +fun T.alsoPrint(label: String): T = also { println("$label: $it") } + +fun main() { + println(listOf(1, 2, 3).penultimate()) // 2 + println(listOf(1, 2, 3).isSorted()) // true + val x = 42.alsoPrint("значение") // значение: 42 + println(x + 1) // 43 +} +``` +Расширение на `T` без ограничений применимо к любому типу - именно так объявлены все [scope-функции](Scope-функции.md). + +Расширение можно объявить на конкретной параметризации: +```kotlin +fun List.sumOfSquares(): Int = sumOf { it * it } +fun List.longest(): String? = maxByOrNull { it.length } + +fun main() { + println(listOf(1, 2, 3).sumOfSquares()) // 14 + println(listOf("a", "ccc", "bb").longest()) // ccc +} +``` +Так же работает `Iterable.sum()` в stdlib - одна функция на каждый числовой тип. + +## Расширения на функциональных типах +```kotlin +fun ((T) -> R).withLogging(name: String): (T) -> R = { arg -> + println("$name($arg)") + this(arg) +} + +fun main() { + val double: (Int) -> Int = { it * 2 } + val logged = double.withLogging("double") + println(logged(21)) + // double(21) + // 42 +} +``` +Функциональный тип - обычный тип, расширения на нём работают так же. + +## Расширения на объектах-компаньонах +```kotlin +class Celsius(val value: Double) { + companion object {} + override fun toString() = "$value°C" +} + +fun Celsius.Companion.fromFahrenheit(f: Double) = Celsius((f - 32) * 5 / 9) + +fun main() { + println(Celsius.fromFahrenheit(68.0)) // 20.0°C +} +``` +Так к типу добавляют фабричный метод, вызываемый через имя класса. Два условия: `companion object` должен быть объявлен в классе (пустого объявления достаточно) и конструктор должен быть доступен - расширение на `Companion` остаётся внешним кодом и приватный конструктор не увидит. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) + +## Где объявлять +```kotlin +class Order6(val items: List, val total: Long) { + // расширение внутри класса — видно только внутри + private fun String.shorten() = take(3) + + fun summary() = items.joinToString { it.shorten() } +} + +fun main() { + println(Order6(listOf("молоко", "хлеб"), 100).summary()) // мол, хле +} +``` +Расширение, объявленное внутри класса, имеет два получателя: `this` расширения и `this` класса. Это основа DSL - подробнее: [Receivers и context parameters](Receivers%20и%20context%20parameters.md) + +Для расширений верхнего уровня принято заводить файл по теме: +```kotlin +// StringExtensions.kt +fun String.toSlug() = lowercase().replace(Regex("[^a-zа-я0-9]+"), "-").trim('-') + +fun main() { + println("Привет, Мир!".toSlug()) // привет-мир +} +``` + +## Когда расширение, когда метод +| Ситуация | Выбор | +|---|---| +| нужен доступ к `private`-состоянию | метод класса | +| поведение - часть сути типа | метод класса | +| нужен полиморфизм (`override`) | метод класса | +| тип чужой (stdlib, библиотека) | расширение | +| утилита, нужная не всем пользователям типа | расширение | +| адаптация типа к своему слою (DTO -> модель) | расширение | +| логика зависит от внешних сервисов | расширение или отдельный класс | + +Практическое правило: расширение не должно притворяться частью типа, если оно меняет его инварианты. Для чистых преобразований и утилит это идеальный инструмент. + +> [!info] Отличия от Java +> | Java | Kotlin | +> |---|---| +> | `StringUtils.isPalindrome(s)` | `s.isPalindrome()` | +> | класс `*Utils` со статикой | расширения в файле | +> | `default`-метод в интерфейсе | расширение на интерфейсе | +> | наследование или декоратор | расширение | +> | нет аналога | свойства-расширения | +> +> **Расширение - это и есть статический утилитный метод, но с синтаксисом метода.** В Java накопление утилит приводит к классам вроде `StringUtils`, `CollectionUtils`, `DateUtils`, и вызов читается наизнанку: `StringUtils.capitalize(StringUtils.trim(s))`. В Kotlin то же самое читается слева направо: +> ```kotlin +> fun String.capitalizeFirst() = replaceFirstChar { it.uppercase() } +> +> fun main() { +> println(" привет ".trim().capitalizeFirst()) // Привет +> } +> ``` +> Разницы в байт-коде нет: и там и там статический метод. Отличается только порядок чтения и то, что IDE подсказывает расширения в автодополнении по типу. +> +> **`default`-методы требуют владения интерфейсом.** В Java добавить поведение к `List` можно только если вы автор `List`. Расширение объявляется где угодно - поэтому `List` в Kotlin имеет ~150 операций, не входящих в интерфейс. +> +> **Вызов из Java выглядит как статика.** Расширение `fun String.toSlug()` в файле `StringExtensions.kt` компилируется в `StringExtensionsKt.toSlug(String)`: +> ```kotlin +> @file:JvmName("StringUtils") +> package demo +> +> fun String.toSlug() = lowercase() +> ``` +> Java-код вызовет `StringUtils.toSlug(s)`. То есть для Java-клиентов это ровно привычный утилитный класс. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md) +> +> **Lombok-подобных расширений в Java нет вообще.** Попытки добиться похожего (манифольд, плагины компилятора) остаются экзотикой, потому что требуют нестандартного компилятора. +> +> Сравнить: [Функции в Java](../../Программирование%20на%20Java/Синтаксис%20и%20типы/Работа%20с%20данными%20и%20типами/Функции.md), [Интерфейсы в Java](../../Программирование%20на%20Java/ООП/Интерфейсы.md) + +> [!warning] Подводные камни +> **Разрешение статическое - полиморфизма нет:** +> ```kotlin +> open class Animal +> class Dog : Animal() +> +> fun Animal.sound() = "..." +> fun Dog.sound() = "гав" +> +> fun makeSound(a: Animal) = a.sound() +> +> fun main() { +> println(Dog().sound()) // гав +> println(makeSound(Dog())) // ... — выбрано по типу параметра! +> } +> ``` +> Это самая частая ошибка с расширениями. Если поведение должно зависеть от фактического типа - нужен метод класса или `when` по типу. +> +> **Метод класса перекрывает расширение молча для вызывающего:** +> ```kotlin +> class Box8 { +> fun size() = 1 +> } +> +> fun Box8.size() = 999 +> +> fun main() { println(Box8().size()) } // 1 +> ``` +> Опаснее другое: если библиотека добавит метод `size()` в новой версии, ваше расширение перестанет вызываться после обновления - без ошибки компиляции. +> +> **Нет доступа к `private` и `protected`:** +> ```kotlin +> class Counter2 { +> private var count = 0 +> fun increment() { count++ } +> fun value() = count +> } +> +> // fun Counter2.reset() { count = 0 } // ошибка +> fun Counter2.incrementTwice() { increment(); increment() } +> +> fun main() { +> val c = Counter2() +> c.incrementTwice() +> println(c.value()) // 2 +> } +> ``` +> +> **Расширение на `T?` скрывает проверку на null:** +> ```kotlin +> fun String?.safeLength(): Int = this?.length ?: 0 +> +> fun main() { +> val s: String? = null +> println(s.safeLength()) // 0 — без ?. и без предупреждений +> } +> ``` +> Читатель видит `s.safeLength()` и может решить, что `s` не может быть `null`. Для такой функции нужно очень явное имя. +> +> **Свойство-расширение не хранит состояние:** +> ```kotlin +> // val Int.cached: Int = compute() // ошибка: initializer not allowed +> val Int.computed: Int get() = this * 2 // только геттер +> +> fun main() { println(2.computed) } // 4 +> ``` +> Геттер вызывается на каждое обращение. Для дорогих вычислений это неожиданная стоимость - `val` выглядит как поле, но является функцией. +> +> **Расширения не участвуют в `when` по типу и в рефлексии:** +> ```kotlin +> fun Any.describe5() = "объект" +> +> fun main() { +> println("x".describe5()) // объект +> println(String::class.members.any { it.name == "describe5" }) // false +> } +> ``` +> Для фреймворков, работающих через рефлексию (сериализация, ORM), расширения невидимы. +> +> **Расширение на `Any` засоряет автодополнение:** +> ```kotlin +> fun Any?.debug(): String = "значение: $this" +> +> fun main() { println(1.debug()) } // значение: 1 +> ``` +> Такая функция появится в подсказках для **каждого** типа в проекте. Расширения на `Any` оправданы только в stdlib-подобных случаях. +> +> **Одинаковые расширения из разных пакетов конфликтуют.** Если два импорта дают `String.toSlug()`, компилятор потребует явного импорта или алиаса (`import a.toSlug as aSlug`). Для библиотек это аргумент за специфичные имена. +> +> **Расширение не наследуется и не переопределяется.** `override fun String.foo()` невозможно: у расширений нет виртуальной таблицы. +> +> **Расширение на платформенном типе может получить `null` вопреки сигнатуре:** +> ```kotlin +> fun String.shout() = uppercase() + "!" +> +> fun main() { +> println("да".shout()) // ДА! +> } +> ``` +> Если такое расширение вызвать на значении, пришедшем из Java-метода без аннотаций, проверка на `null` сработает на входе и даст NPE внутри расширения - со стеком, указывающим не на источник проблемы. Подробнее: [Платформенные типы](../Синтаксис%20и%20типы/Null-безопасность/Платформенные%20типы.md) + +Далее: [Scope-функции](Scope-функции.md) -- 2.52.0