Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save dmitry-osin/b833abb6f175fd221c290770ef805642 to your computer and use it in GitHub Desktop.

Select an option

Save dmitry-osin/b833abb6f175fd221c290770ef805642 to your computer and use it in GitHub Desktop.
Kotlin для Java разработчика от новичка до эксперта

🟢 Уровень 1: Синтаксис и Фундамент (Часть 1)

1. Переменные и Типы: Смена парадигмы

В Java мы привыкли указывать типы явно (String name = ...). В Kotlin компилятор гораздо умнее. Кроме того, Kotlin подталкивает нас к неизменяемости (immutability).

val vs var

В Kotlin две ключевых слова для объявления переменных:

  • val (от value): Неизменяемая ссылка. Аналог final в Java. Используйте по умолчанию.
  • var (от variable): Изменяемая переменная. Используйте только тогда, когда это действительно необходимо.

Вывод типов (Type Inference)

Если компилятор может понять тип из контекста (из правой части выражения), писать его явно не нужно.

Код (Сравнение):

fun main() {
    // --- Java style (но в Kotlin) ---
    // Тип указан явно. Это работает, но считается избыточным.
    val explicitlyTypedName: String = "Java Developer"

    // --- Kotlin style (Idiomatic) ---
    // Компилятор видит строку справа -> присваивает тип String переменной name.
    val name = "Kotlin Learner" 
    
    // name = "New Name" // ОШИБКА КОМПИЛЯЦИИ! val нельзя переназначить.

    var age = 25 // Тип Int выведен автоматически
    age = 26     // var можно менять
    
    // age = "Twenty six" // ОШИБКА! Тип зафиксирован как Int при создании.
    // Kotlin — статически типизированный язык.
}

Строковые шаблоны (String Templates)

Забудьте про конкатенацию через + или String.format().

val firstName = "Ivan"
val lastName = "Petrov"
val hours = 5

// $variable для простой вставки
println("User: $firstName $lastName") 

// ${expression} для вычислений или доступа к свойствам внутри строки
println("Name length: ${firstName.length + lastName.length}") 
println("Is working hard? ${if (hours > 8) "Yes" else "No"}")

Базовые типы: Где примитивы?

В Java есть жесткое разделение: int (примитив) и Integer (объект-обертка). Это создает боль с автобоксингом и дженериками (List<Integer>, но массив int[]).

В Kotlin в коде нет примитивов. Всё выглядит как объект: Int, Double, Boolean, Char.

  • Вы можете вызвать метод у числа: 10.toString().
  • Но! Компилятор Kotlin умный. На этапе компиляции в байт-код он превращает Int в примитив int везде, где это возможно, для производительности. В объект Integer он превращается только если вы кладете его в коллекцию (List<Int>) или используете Nullable тип (Int?).

2. Null Safety: Главная защита от выстрела в ногу

В Java NullPointerException (NPE) — самая частая ошибка. Kotlin решает её на уровне системы типов. Это самая важная концепция для понимания.

Nullable vs Non-nullable

Типы в Kotlin делятся на два лагеря:

  1. Non-nullable (String, User, Int): В такую переменную нельзя положить null. Компилятор просто не даст собрать код.
  2. Nullable (String?, User?, Int?): Тип с вопросительным знаком. Сюда можно положить null.
fun main() {
    // 1. Non-nullable (По умолчанию)
    var text: String = "Hello"
    // text = null // ОШИБКА КОМПИЛЯЦИИ. 
    // Вы гарантированно знаете: если у вас есть переменная типа String, там есть строка.
    println(text.length) // Безопасно, проверка на null не нужна.

    // 2. Nullable (Нужно явно добавить ?)
    var nullableText: String? = "Hello"
    nullableText = null // Теперь это разрешено

    // println(nullableText.length) // ОШИБКА КОМПИЛЯЦИИ!
    // Kotlin запрещает обращаться к методам nullable переменной напрямую,
    // так как там может быть null.
}

Инструменты работы с Null

Как же работать с String?? Не писать же постоянно if (text != null)?

1. Safe Call Operator ?.

"Если объект не null, выполни действие/верни свойство. Если null — верни null".

val input: String? = null

// Java подход:
// int length = (input != null) ? input.length() : null;

// Kotlin подход:
val length: Int? = input?.length 

println(length) // Выведет "null", программа НЕ упадет.

2. Elvis Operator ?:

"Если слева null, возьми то, что справа". Аналог Optional.orElse().

val input: String? = null

// Логика: Попробуй получить длину. Если input == null, то верни 0.
val length: Int = input?.length ?: 0 

// Часто используется для early return (выхода из функции)
fun processUser(name: String?) {
    // Если name == null, функция завершается
    val validName = name ?: return 
    println("Processing $validName")
}

3. Not-null Assertion !! (Оператор для любителей риска)

"Я мамой клянусь, здесь нет null. Преврати String? в String или выбрось NPE".

val input: String? = "Data"
val forcedData: String = input!! // Если input окажется null, приложение упадет с NPE.

// СОВЕТ: Избегайте !! в продакшн коде. 
// Используйте его только если вы 100% уверены (но лучше используйте ?:)

4. Safe Cast as?

В Java приведение типов (String) obj выбрасывает ClassCastException, если тип не совпадает. В Kotlin as? вернет null, если каст не удался.

val obj: Any = 123
val str: String? = obj as? String // str будет null, так как 123 — это Int, а не String.
// Ошибки не будет.

Практический пример: Java vs Kotlin

Представьте задачу: У нас есть User, у него есть Address, у адреса есть City. Все поля могут быть null. Нам нужно получить название города или "Unknown".

Java (Old school):

String cityName = "Unknown";
if (user != null) {
    if (user.getAddress() != null) {
        if (user.getAddress().getCity() != null) {
            cityName = user.getAddress().getCity();
        }
    }
}

Kotlin (Idiomatic):

// В одну строку, читается слева направо:
val cityName = user?.address?.city ?: "Unknown"

🧠 Что нужно усвоить на этом этапе:

  1. Пишите val всегда, пока компилятор не потребует var.
  2. Если переменная может принимать значение "отсутствия", ставьте ? к типу (String?).
  3. Если видите ошибку "Only safe calls are allowed...", используйте ?. или ?:.
  4. Никогда не используйте !! просто чтобы "заткнуть" компилятор.

🟢 Уровень 1: Синтаксис и Фундамент (Часть 2)

3. Классы и Объекты

В Java создание простого класса для хранения данных (Person) требует: приватных полей, конструктора, геттеров, сеттеров, equals, hashCode и toString. Это около 50 строк кода. В Kotlin это часто занимает одну строку.

Классы и Свойства (Properties)

В Kotlin нет "полей" (fields) в понимании Java (мы их почти не видим). Есть Свойства (Properties).

  • val свойство = поле + геттер.
  • var свойство = поле + геттер + сеттер.

Primary Constructor (Первичный конструктор) Конструктор объявляется прямо в заголовке класса.

// class - ключевое слово
// User - имя класса
// (val name: String, var age: Int) - Первичный конструктор
class User(val name: String, var age: Int) 

fun main() {
    // New не нужно! Просто вызов конструктора как функции
    val user = User("Alex", 25) 

    // Обращение к свойствам (под капотом вызываются геттеры/сеттеры)
    println(user.name) // user.getName()
    user.age = 26      // user.setAge(26)
    // user.name = "Bob" // Ошибка! name объявлено как val (immutable)
}

Блок init и кастомная логика Если нужно выполнить код при создании объекта (валидация, логирование), используйте блок init.

class User(val name: String, var age: Int) {
    // init выполняется сразу после создания объекта
    init {
        if (age < 0) println("Error: Age cannot be negative")
    }
}

Data Classes (Убийца Lombok)

Для классов, чья цель просто хранить данные (DTO, POJO), используйте data class.

Добавив одно слово data, вы бесплатно получаете:

  1. toString() — красивый вывод: User(name=Alex, age=25).
  2. equals() и hashCode() — корректное сравнение по полям.
  3. copy() — создание копии объекта с изменением части полей.
  4. componentN() — для деструктуризации.
data class Book(val title: String, val price: Int)

fun main() {
    val b1 = Book("Kotlin in Action", 100)
    val b2 = Book("Kotlin in Action", 100)
    
    // В Java это было бы false (сравнение ссылок). 
    // В Kotlin data class сравниваются по содержимому.
    println(b1 == b2) // true 

    // Метод copy() - критически важен для immutability
    // Мы не меняем b1, мы создаем новую книгу на основе старой
    val b3 = b1.copy(price = 120) 
    println(b3) // Book(title=Kotlin in Action, price=120)
}

Object и Companion Object (Где static?)

В Kotlin **нет ключевого слова static**.

1. object (Синглтон из коробки) Если вам нужен класс, который существует в единственном экземпляре.

object DatabaseConfig {
    val url = "jdbc:mysql://localhost:3306"
    fun connect() { /*...*/ }
}

// Использование:
DatabaseConfig.connect()

2. companion object (Замена статическим методам) Если вы хотите, чтобы метод был привязан к классу, а не к экземпляру (например, Factory метод), поместите его в companion object.

class User private constructor(val name: String) {
    
    // Все, что внутри, выглядит как static для внешнего мира
    companion object {
        fun createDefault(): User {
            return User("Guest")
        }
    }
}

// Вызов метода как статического:
val user = User.createDefault()

4. Управление потоком (Control Flow)

if как выражение

В Kotlin if возвращает значение. Это убивает необходимость в тернарном операторе (condition ? true : false). В Kotlin тернарного оператора нет.

val a = 10
val b = 20

// Java: int max = (a > b) ? a : b;
// Kotlin:
val max = if (a > b) a else b

// Можно даже использовать блоки кода (возвращается последняя строка)
val maxWithLog = if (a > b) {
    println("A is greater")
    a
} else {
    println("B is greater")
    b
}

when (Супер-сильный Switch)

when заменяет switch-case, но он намного мощнее. Он умеет проверять значения, типы, диапазоны и произвольные условия. break писать не нужно.

fun describe(obj: Any): String = when (obj) {
    1          -> "One"             // Проверка на значение
    "Hello"    -> "Greeting"        // Проверка на строку
    is Long    -> "Long Number"     // Проверка типа (instanceof)
    !is String -> "Not a string"    // Отрицание типа
    in 10..20  -> "Between 10-20"   // Проверка вхождения в диапазон
    else       -> "Unknown"         // Аналог default
}

Циклы и Диапазоны (Ranges)

Обычный for (int i=0; i<10; i++) в Kotlin считается устаревшим (хотя возможным через while). Идиоматичный способ — использовать диапазоны.

fun main() {
    // 1..5 включает 5 (1, 2, 3, 4, 5)
    for (i in 1..5) { 
        print(i) 
    }

    // until исключает последнюю границу (1, 2, 3, 4) - удобно для массивов
    for (i in 0 until 5) { 
        print(i) 
    }

    // Шаг и обратный порядок
    for (i in 10 downTo 1 step 2) {
        print("$i ") // 10, 8, 6, 4, 2
    }
    
    // Итерация по мапе
    val map = mapOf("key1" to 1, "key2" to 2)
    for ((key, value) in map) {
        println("$key -> $value")
    }
}

💡 Важные ментальные сдвиги для Java-разработчика

  1. Поля приватны по умолчанию: Когда вы пишете val name: String, поле создается private, а геттер public. Вам не нужно писать геттеры руками.
  2. new умер: Конструкторы вызываются как обычные функции.
  3. **extends и implements заменены на :**: class Manager : Employee(), InterfaceName.
  4. Файловая структура: В Java один класс = один файл. В Kotlin можно (и нужно) класть несколько маленьких классов (например, Data classes) в один файл, если они логически связаны.

🟡 Уровень 2: Idiomatic Kotlin (Часть 1)

1. Функции 2.0: Гибкость и Краткость

В Java методы часто перегружены (overloaded). Вы создаете print(), print(String), print(String, Int) и т.д. В Kotlin это решается элегантнее.

Single-Expression Functions (Функции-выражения)

Если функция состоит из одной строки, фигурные скобки {} и return можно выбросить.

// Java style (Стандартный блок)
fun sum(a: Int, b: Int): Int {
    return a + b
}

// Kotlin Idiomatic style
// Тип возвращаемого значения (Int) выводится автоматически
fun sum(a: Int, b: Int) = a + b

// Даже с логикой
fun max(a: Int, b: Int) = if (a > b) a else b

Именованные аргументы и значения по умолчанию

Это "убийца" паттерна Builder и бесконечных перегрузок методов.

// Объявляем значения по умолчанию (=)
fun buildRequest(
    url: String, 
    method: String = "GET", 
    timeout: Int = 5000,
    headers: Map<String, String> = emptyMap()
) {
    println("Connecting to $url via $method with timeout $timeout")
}

fun main() {
    // 1. Используем все дефолтные значения
    buildRequest("https://google.com") 
    // Вывод: Connecting to https://google.com via GET with timeout 5000

    // 2. Именованные аргументы (порядок не важен!)
    // Мы пропустили method, но переопределили timeout
    buildRequest(
        url = "https://myserver.com", 
        timeout = 10000
    )

    // 3. Читаемость
    // Сравните с Java: buildRequest("url", null, 0, null) — что значат эти null?
    // В Kotlin код документирует сам себя.
}

2. Extension Functions (Функции расширения)

Это одна из самых любимых фич Java-разработчиков при переходе. Она позволяет добавить новый метод в любой класс (даже в String, List или класс из сторонней библиотеки), не наследуясь от него и не используя паттерн "Декоратор".

Как это работает?

Вы пишете функцию, указывая перед именем тип, который расширяете (Receiver Type).

// Задача: Научить String проверять, является ли она email-ом.

// String - это Receiver Type (Тип-получатель)
// this - ссылка на сам объект строки
fun String.isEmail(): Boolean {
    return this.contains("@") && this.contains(".")
}

// Можно сделать еще короче (single-expression)
fun String.removeSpaces(): String = this.replace(" ", "")

fun main() {
    val input = "user@example.com"
    
    // Вызываем как родной метод класса String!
    if (input.isEmail()) { 
        println("Valid email")
    }
    
    println("Hello World".removeSpaces()) // HelloWorld
}

⚠️ Важно понимать (Under the hood)

Extension function — это статическая магия. Она не меняет исходный класс байт-кода. В Java этот код компилируется в:

// То, во что превращает это компилятор:
public static boolean isEmail(String $this) {
    return $this.contains("@") && $this.contains(".");
}

Следствие: Extension функции не имеют доступа к private или protected полям класса. Они видят только то, что публично (public).


3. Лямбды и High-Order Functions

В Java 8 появились лямбды, но в Kotlin они возведены в абсолют. Функции — это "граждане первого класса". Их можно присваивать переменным, передавать в аргументы и возвращать.

Синтаксис лямбд

Лямбда — это просто кусок кода в фигурных скобках { ... }.

// Тип переменной: (Int, Int) -> Int
// Значение: { x, y -> x + y }
val sumLambda: (Int, Int) -> Int = { x, y -> x + y }

println(sumLambda(2, 3)) // 5

Главные фишки синтаксиса

1. it: неявное имя единственного аргумента Если у лямбды один аргумент, его не нужно объявлять. Он доступен как it.

val numbers = listOf(1, 2, 3, 4, 5)

// Java style: numbers.stream().filter(n -> n > 2)...
// Kotlin:
val res = numbers.filter { it > 2 } 

2. Trailing Lambda (Лямбда в хвосте) Если функция принимает лямбду последним аргументом, её можно вынести за скобки (). Это критически важно для создания DSL.

// Функция принимает строку и действие (функцию)
fun processString(str: String, operation: (String) -> Unit) {
    operation(str)
}

fun main() {
    // Вариант 1 (Некрасиво, лямбда внутри скобок):
    processString("Hello", { s -> println(s.uppercase()) })

    // Вариант 2 (Idiomatic Kotlin):
    // Скобка закрылась после "Hello", лямбда пошла следом.
    processString("Hello") { 
        println(it.uppercase()) 
    }
}

Практика: Коллекции без Stream API

В Java, чтобы отфильтровать список и преобразовать его, нужно открывать стрим: list.stream().filter(...).map(...).collect(...). В Kotlin функции filter, map и другие встроены в коллекции как Extension Functions.

data class User(val name: String, val age: Int)

fun main() {
    val users = listOf(
        User("Alice", 25),
        User("Bob", 17),
        User("Charlie", 30)
    )

    // Задача: Получить список имен совершеннолетних пользователей в верхнем регистре.

    val names = users
        .filter { it.age >= 18 } // Оставляет только взрослых
        .map { it.name.uppercase() } // Преобразует User -> String
        // .toList() и .collect() НЕ НУЖНЫ. map уже возвращает новый List.

    println(names) // [ALICE, CHARLIE]
}

💡 Важные нюансы производительности

Вы можете спросить: "Если я использую filter и map, не создаются ли лишние списки на каждом шаге?" Ответ: Да, создаются. Для маленьких коллекций это неважно.

Если у вас список на 100 000 элементов, цепочка filter().map() создаст два промежуточных списка. В этом случае нужно использовать Sequences (аналог Java Streams).

val names = users.asSequence() // <-- Превращаем в ленивую последовательность
    .filter { it.age >= 18 }   // Ничего не выполняется
    .map { it.name.uppercase() } 
    .toList() // <-- Терминальная операция, только тут все выполнится

Домашнее задание (мысленное)

Попробуйте взять любой свой Utility-класс из Java (где методы типа StringUtils.isEmpty(str)) и представьте, как бы он выглядел на Kotlin с использованием Extension Functions (str.isEmpty()).


🟡 Уровень 2: Idiomatic Kotlin (Часть 2)

Scope Functions: let, run, with, apply, also

В Java вы часто пишете так:

User user = new User();
user.setName("Alex");
user.setAge(25);
user.setCity("Moscow");
process(user);

Имя переменной user повторяется 5 раз. Scope Functions позволяют выполнить блок кода в контексте объекта, временно избавившись от повторения имени переменной.

Их всего пять. Они делают почти одно и то же, но различаются двумя параметрами:

  1. Как ссылаются на объект внутри блока: this или it.
  2. Что возвращает функция: Сам объект (Context Object) или Результат лямбды (Lambda Result).

Разберем каждую подробно.


1. apply (Настройщик)

Суть: "Примени настройки к этому объекту и верни его же".

  • Ссылка: this (можно опускать).
  • Возврат: Сам объект.

Самый частый сценарий — инициализация объектов. Замена паттерна Builder.

// Java
File file = new File("path/to/file.txt");
file.setExecutable(true);
file.setReadable(true);
file.setWritable(false);

// Kotlin
// Создаем объект и сразу настраиваем его внутри блока
val file = File("path/to/file.txt").apply {
    // Внутри блока мы как будто внутри класса File
    setExecutable(true) // это this.setExecutable(true)
    setReadable(true)
    setWritable(false)
} // <-- apply возвращает тот же объект file

2. let (Трансформатор / Null-check)

Суть: "Возьми объект, сделай с ним что-то и верни результат (последнюю строку)".

  • Ссылка: it (можно переименовать).
  • Возврат: Результат лямбды.

Сценарий 1: Null-Safety. Выполнение кода только если переменная не null.

val str: String? = "Hello"

// str?.let { ... } выполнится ТОЛЬКО если str != null
str?.let { 
    // Внутри it - это String (уже не nullable!)
    println("Length is ${it.length}")
}

Сценарий 2: Трансформация. Превращение одного объекта в другой внутри локальной области.

val user = User("Alex", 25)

val message = user.let { u -> // переименовали it в u для ясности
    // Делаем вычисления
    val isAdult = u.age >= 18
    // Возвращаем строку (результат блока)
    "User ${u.name} is adult: $isAdult" 
}

3. with (Группировка вызовов)

Суть: "С этим объектом сделай следующее...".

  • Ссылка: this.
  • Возврат: Результат лямбды.
  • Отличие: Это не extension-функция, а обычная функция.

Используется, когда результат не нужен, но нужно вызвать много методов у одного объекта.

val numbers = mutableListOf("One", "Two", "Three")

val firstAndLast = with(numbers) {
    // this ссылается на numbers
    "First is $first(), last is $last()" // Методы List вызываются напрямую
}

4. run (Гибрид with и let)

Суть: "Запусти блок кода на объекте и верни результат".

  • Ссылка: this.
  • Возврат: Результат лямбды.

Часто используется для вычисления значения на основе свойств объекта + инициализация.

val service = MultiLayerService()

// Инициализируем сервис И сразу вычисляем результат
val result = service.run {
    port = 8080
    init()       // метод сервиса
    queryData()  // метод сервиса, возвращает String
} // result будет иметь тип String (результат queryData)

5. also (Побочные эффекты)

Суть: "А также сделай вот это (не меняя контекст)".

  • Ссылка: it.
  • Возврат: Сам объект.

Идеально для логирования или промежуточных действий в цепочке вызовов. Он говорит: "Я не трогаю объект, я просто смотрю".

val book = Book("Kotlin Guide")
    .apply { price = 100 } // Настроили
    .also { println("Book created: $it") } // Залогировали (it = Book)
    .apply { price = 90 } // Снова настроили (скидка)

// В переменную попадет книга с ценой 90. 
// also не повлиял на цепочку возврата.

⚡ Шпаргалка: Как выбрать?

Я составил для вас матрицу решений. Задайте себе два вопроса:

  1. Нужен ли мне результат вычислений (новая переменная)?
  • Нет (нужен сам объект): Выбирайте apply или also.
  • Да (нужен результат блока): Выбирайте let, run или with.
  1. Как удобнее обращаться к объекту?
  • Как this (методы без префикса): Используйте apply, run, with. (Для настройки объекта).
  • Как it (как аргумент): Используйте let, also. (Если объект передается дальше в функцию или нужен лог).
Функция Ссылка Возвращает Для чего обычно используется?
apply this Obj Настройка объекта (Config/Builder)
also it Obj Логирование, валидация, побочные эффекты
let it Result Null-check (?.let), конвертация, локальная переменная
run this Result Вычисление чего-то на основе свойств объекта
with this Result Группировка вызовов методов (редко нужен, run удобнее)

Пример "Ада из Scope Functions" (Как не надо делать)

Новички часто начинают вкладывать их друг в друга. Это делает код нечитаемым.

// ПЛОХО:
val result = File("text.txt").apply {
    setReadable(true)
}.let { file ->
    file.readLines().also { lines ->
        println("Read ${lines.size} lines")
    }.map { line ->
        line.uppercase()
    }
}
// Здесь теряется нить: что такое it? где this?

Правило: Не вкладывайте Scope Functions друг в друга, если это возможно. Лучше разбить на обычные переменные.


🔴 Уровень 3: Архитектура и Проектирование (Часть 1)

Sealed Classes & Interfaces: Супер-энамы

В Java у нас есть Enum для перечисления констант.

  • Проблема Enum: У всех констант должна быть одинаковая структура данных. Вы не можете сказать: "В статусе SUCCESS у меня лежит список данных, а в статусе ERROR — текст ошибки".
  • Решение Kotlin: Sealed Class (Запечатанный класс).

Это иерархия классов, где все наследники известны на этапе компиляции.

Пример: Загрузка данных из сети

Представьте экран, который грузит список новостей. У него может быть 3 состояния:

  1. Loading: Просто крутится спиннер (данных нет).
  2. Success: Данные пришли (нужен список новостей).
  3. Error: Ошибка (нужен текст ошибки и, возможно, иконка).

В Java вы бы создали класс с кучей полей List data, String error, boolean isLoading и проверяли бы их комбинации. В Kotlin мы моделируем это через типы.

// Ключевое слово sealed
sealed class UiState {
    
    // 1. Состояние загрузки - данных нет, используем object (синглтон)
    object Loading : UiState()
    
    // 2. Успех - храним список. Используем data class
    data class Success(val news: List<String>) : UiState()
    
    // 3. Ошибка - храним сообщение и код ошибки
    data class Error(val message: String, val code: Int) : UiState()
}

Главная фишка: Исчерпывающий when

Поскольку компилятор знает, что у UiState может быть только 3 наследника (и никто другой не может унаследоваться от него за пределами этого модуля/пакета), он позволяет использовать when **без ветки else**.

Это гарантия безопасности: если вы добавите новый статус, компилятор подсветит красным все места, где вы забыли его обработать.

fun updateScreen(state: UiState) {
    // else не нужен! Компилятор знает все варианты.
    when (state) {
        is UiState.Loading -> {
            showSpinner()
        }
        is UiState.Success -> {
            hideSpinner()
            // Smart Cast: здесь state автоматически приведен к типу Success
            // Мы можем обращаться к .news напрямую
            showList(state.news) 
        }
        is UiState.Error -> {
            hideSpinner()
            // Smart Cast к типу Error
            showErrorToast("${state.message} (Code: ${state.code})")
        }
    }
}

Sealed Interface

Начиная с Kotlin 1.5, появились sealed interface. Разница такая же, как между абстрактным классом и интерфейсом.

  • Используйте sealed class, если наследникам нужно общее состояние (поля) или логика в конструкторе.
  • Используйте sealed interface, если общей логики нет (или класс уже наследует другой класс, так как множественное наследование классов запрещено).
sealed interface ClickEvent {
    data class OpenArticle(val id: Int) : ClickEvent
    object BackPressed : ClickEvent
}

Чем это отличается от Abstract Class?

Характеристика Abstract Class Sealed Class
Наследники Кто угодно, где угодно Строго фиксированы в том же пакете/модуле
when Требует else **Не требует else** (исчерпывающая проверка)
Смысл "Есть общая логика" "Это закрытый набор вариантов" (Вариант А ИЛИ Вариант Б)

Где это использовать? (Real-world cases)

  1. MVI / MVVM Architectures: Состояния экрана (LCE - Loading/Content/Error).
  2. Обработка ошибок: Вместо выбрасывания Exception, функция возвращает sealed class Result:
sealed class LoginResult {
    object Success : LoginResult()
    object InvalidPassword : LoginResult()
    object NetworkError : LoginResult()
}
  1. View Types в списках (RecyclerView): Если у вас в списке есть разные элементы (Заголовок, Карточка товара, Реклама), опишите их как sealed class, и в адаптере when поможет ничего не забыть.

💡 Лайфхак для Java-разработчика

Думайте о Sealed Class как об "Enums with Data" (Энамах с данными).

  • Если у статусов нет данных — это обычный Enum.
  • Если хоть у одного статуса есть данные (как список новостей в Success) — это Sealed Class.

🔴 Уровень 3: Архитектура и Проектирование (Часть 2)

1. Делегирование реализации (Class Delegation)

В Java, если вы хотите использовать паттерн Декоратор (обернуть класс, изменить пару методов, а остальные вызовы пробросить внутрь), вам придется вручную переопределять все методы интерфейса.

В Kotlin компилятор сделает это за вас.

Задача: "Список с логгером"

Представьте, что мы хотим создать свой ArrayList, который ведет себя как обычный список, но при добавлении элемента пишет лог в консоль. Наследоваться от ArrayList — плохая практика (нарушение инкапсуляции). Лучше реализовать интерфейс MutableList и хранить внутри настоящий список.

Java (Боль и страдания): Вам придется реализовать 20+ методов (size(), get(), clear(), iterator()...), и в каждом писать return innerList.method().

Kotlin (Магия by):

// Мы реализуем интерфейс MutableList<T>
// Но говорим: "Все методы интерфейса возьми у объекта innerList"
class LoggingList<T>(
    private val innerList: MutableList<T> = ArrayList()
) : MutableList<T> by innerList { 

    // Переопределяем ТОЛЬКО то, что хотим изменить
    override fun add(element: T): Boolean {
        println("Adding element: $element")
        // Вызываем метод у реального списка
        return innerList.add(element) 
    }
    
    // Все остальные методы (get, remove, size) сгенерирует компилятор.
    // Они просто будут вызывать innerList.метод()
}

fun main() {
    val list = LoggingList<String>()
    list.add("Hello") // Напечатает: Adding element: Hello
    println(list.size) // 1 (вызов проброшен в innerList)
}

Это невероятно мощный инструмент для создания оберток (Wrappers) и реализации сложного поведения без наследования.


2. Делегированные свойства (Property Delegation)

Это фича, которой нет в Java. Она позволяет вынести логику get() и set() свойства в отдельный класс.

Синтаксис: val/var имя by Делегат.

Самый важный делегат: by lazy

В Java ленивая инициализация (создание тяжелого объекта только при первом обращении) — это громоздкий код с if (field == null) и synchronized (Double-checked locking).

В Kotlin это одна строчка:

class Database {
    // connect() - тяжелая операция
    fun connect() { println("Connected!") }
}

class App {
    // db не будет создана здесь!
    val db by lazy {
        println("Initializing Database...")
        Database() // Этот блок выполнится только 1 раз
    }
}

fun main() {
    val app = App()
    println("App created")
    
    // Первый вызов: выполнится блок lazy, создастся объект, вернется результат
    app.db.connect() 
    
    // Второй вызов: просто вернется уже созданный объект
    app.db.connect() 
}
/* Вывод:
App created
Initializing Database...
Connected!
Connected!
*/

По умолчанию lazy потокобезопасен (synchronized).

by observable (Наблюдатель)

Если вы хотите реагировать на изменение переменной (например, обновлять UI или писать лог при изменении настройки).

import kotlin.properties.Delegates

class User {
    var name: String by Delegates.observable("NoName") { prop, old, new ->
        println("Name changed from '$old' to '$new'")
    }
}

fun main() {
    val user = User()
    user.name = "Alex" // Вывод: Name changed from 'NoName' to 'Alex'
    user.name = "Bob"  // Вывод: Name changed from 'Alex' to 'Bob'
}

by map (Хранение свойств в Map)

Часто используется при парсинге JSON или динамических конфигурациях. Вы можете делегировать поля класса прямо в карту.

class User(val map: Map<String, Any?>) {
    val name: String by map
    val age: Int     by map
}

fun main() {
    val data = mapOf(
        "name" to "John Doe",
        "age"  to 25
    )
    
    val user = User(data)
    println(user.name) // John Doe
}

3. Как написать свой делегат? (Under the hood)

Это не магия, а контракт. Делегат — это любой класс, у которого есть оператор getValuesetValue для var).

Пример: Делегат, который всегда возвращает строку в верхнем регистре, что бы вы туда ни положили.

import kotlin.reflect.KProperty

class UpperCaseDelegate {
    private var value: String = ""

    // Оператор чтения
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return value
    }

    // Оператор записи
    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
        value = newValue.uppercase() // Логика трансформации здесь
    }
}

class Example {
    var text: String by UpperCaseDelegate()
}

fun main() {
    val ex = Example()
    ex.text = "hello"
    println(ex.text) // HELLO
}

Где это используется в реальности?

  1. Android: val viewModel by viewModels() — получение ViewModel через делегат.
  2. Koin / Kodein (DI): val repository by inject() — внедрение зависимостей.
  3. БД (Exposed): Описание колонок в базе данных: val name by varchar("name", 50).

💡 Резюме

  1. Используйте Class Delegation (by list), чтобы не писать методы-прокси вручную при реализации паттерна Декоратор.
  2. Используйте by lazy для тяжелых объектов. Это стандарт.
  3. Делегаты позволяют переиспользовать логику геттеров/сеттеров.

🔴 Уровень 3: Архитектура и Проектирование (Часть 3)

1. Reified Type Parameters (Победа над стиранием типов)

Проблема Java (Type Erasure): В Java (и в Kotlin по умолчанию) дженерики существуют только до компиляции. В байт-коде List<String> и List<Integer> превращаются просто в List. Поэтому нельзя написать: if (obj instanceof T) или new T(). JVM не знает, что такое T во время выполнения.

Решение Kotlin: inline функции + reified. Если функция помечена как inline, компилятор вставляет её код прямо в место вызова. А значит, он знает, какой конкретно тип вы туда передали.

Пример: JSON десериализация

Java (Old school): Вам приходится передавать Class<T> аргументом, чтобы библиотека знала, что создавать.

// Java
User user = gson.fromJson(json, User.class);

Kotlin (Reified): Мы можем сделать так, чтобы тип T был доступен.

// Ключевое слово inline обязательно!
// reified T - теперь T существует в рантайме внутри этой функции
inline fun <reified T> String.toObject(): T {
    // Представим, что у нас есть Gson
    return Gson().fromJson(this, T::class.java) 
}

fun main() {
    val json = """{"name":"Alex"}"""
    
    // ВЫЗОВ: Мы не передаем User::class.java явно!
    // Kotlin сам подставляет User благодаря выводу типов.
    val user: User = json.toObject() 
}

Пример 2: Безопасная фильтрация списка по типу

val list = listOf(1, "Hello", 2, "World", 3.0)

// filterIsInstance использует reified T под капотом
val strings: List<String> = list.filterIsInstance<String>()

println(strings) // [Hello, World]

В Java такой метод написать невозможно без передачи Class<String>.


2. Variance: in и out (Вместо ? super и ? extends)

Это самая сложная часть для понимания, но самая важная для архитектуры библиотек.

Вспомним правило PECS из Java:

  • Producer Extends (Если коллекция отдает данные -> ? extends T)
  • Consumer Super (Если коллекция принимает данные -> ? super T)

В Kotlin эти слова заменили на понятные out и in.

out (Ковариантность) — Producer

"Я только отдаю T (возвращаю из функций), но не принимаю".

Если класс Producer<out T>, то Producer<String> можно присвоить в переменную Producer<Any>. Это безопасно, так как мы только читаем оттуда.

// Интерфейс только ВОЗВРАЩАЕТ T (Producer)
interface Source<out T> {
    fun nextT(): T
}

fun demo(strings: Source<String>) {
    // Это работает! Потому что String - это Any.
    // Мы можем читать String и считать их Any.
    val objects: Source<Any> = strings 
}

В стандартной библиотеке: List<T> (неизменяемый) объявлен как List<out E>. Поэтому List<String> является подтипом List<Any>.

in (Контравариантность) — Consumer

"Я только принимаю T (аргумент функций), но не отдаю".

Если класс Consumer<in T>, то Consumer<Number> можно присвоить в Consumer<Int>. Звучит нелогично? Смотрите: если потребитель умеет есть любые Number, то он точно справится и с Int.

// Интерфейс только ПРИНИМАЕТ T (Consumer)
interface Comparable<in T> {
    fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    // Если объект умеет сравнивать любые Числа...
    // То он умеет сравнивать и Double.
    val y: Comparable<Double> = x 
}

Инвариантность (MutableList)

Изменяемый список MutableList<T> и принимает, и отдает T. Там нет ни in, ни out. Поэтому MutableList<String> нельзя присвоить в MutableList<Any>. Иначе в список строк можно было бы положить Int через ссылку на Any.


3. Star Projection * (Звездная проекция)

Аналог Java ? (Raw type или Wildcard). Используется, когда вам неважен тип дженерика.

// Мы принимаем список чего угодно
fun printListSize(list: List<*>) {
    // Мы не можем писать в этот список (кроме null)
    // Мы не знаем, какой там тип, но можем достать Any?
    val item: Any? = list.get(0)
    println(list.size)
}

Отличие от Any?:

  • MutableList<Any?> — список, куда можно положить что угодно.
  • MutableList<*> — список какого-то конкретного типа, но мы не знаем какого. Поэтому класть туда ничего нельзя (безопасность).

4. where (Ограничения нескольких типов)

Если вам нужно, чтобы T наследовался сразу от нескольких интерфейсов.

fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
    where T : CharSequence, // T должно быть строкой
          T : Comparable<T> // И T должно быть сравниваемым
{
    return list.filter { it > threshold }.map { it.toString() }
}

💡 Резюме для Java-разработчика

  1. reified — киллер-фича. Используйте inline fun <reified T>, когда нужно узнать тип T внутри метода (например, для парсинга, логгирования, DI).
  2. **List<String> — это List<Any>**, потому что он неизменяемый (out).
  3. **MutableList<String> — это НЕ MutableList<Any>**, потому что он изменяемый.
  4. **Java ? extends** = Kotlin out.
  5. **Java ? super** = Kotlin in.

🟣 Уровень 4: Асинхронность (Часть 1)

1. Корутины vs Потоки: В чем разница?

В Java поток (Thread) — это дорогая сущность. Он занимает 1-2 МБ памяти стека, и его создание требует системного вызова в ОС. Вы не можете создать 100 000 потоков — вы получите OutOfMemoryError.

Корутина (Coroutine) — это легковесный поток.

  • Она не привязана к конкретному потоку ОС (может начать выполняться на одном, приостановиться и продолжить на другом).
  • Она занимает байты памяти, а не мегабайты.
  • Вы можете легко создать миллион корутин на одной машине.

Ключевая концепция: Suspend (Приостановка)

Главная магия Kotlin — это возможность приостановить (suspend) выполнение функции, не блокируя поток, на котором она выполняется.

Представьте, что поток — это рабочий на заводе.

  • Java (Thread.sleep / IO blocking): Рабочий садится на стул и ждет 5 секунд, пока деталь остынет. Он ничего не делает, но зарплату (ресурсы CPU/RAM) получает.
  • Kotlin (delay / suspend): Рабочий вешает на деталь таймер, откладывает её в сторону и тут же берет следующую задачу. Когда таймер сработает, он вернется к первой детали.

2. Ключевое слово suspend

Чтобы функция могла "приостановиться", она должна быть помечена модификатором suspend.

// Обычная функция
fun regularFunction() {
    // Thread.sleep(1000) // ПЛОХО: Это заблокирует весь поток!
}

// Suspend функция
suspend fun downloadData(): String {
    // delay - это "неблокирующий сон"
    delay(1000) 
    return "Data"
}

Правило: suspend функцию можно вызвать только из другой suspend функции или из корутины. Из обычного Java-метода её вызвать нельзя (без специальных хаков).


3. Запуск корутин: Builders

Мы не можем просто вызвать downloadData() из main. Нам нужен мост между обычным миром и миром корутин. Это делают Coroutines Builders.

runBlocking (Мост для тестов и main)

Блокирует текущий поток (как в Java), пока все корутины внутри не выполнятся. В реальном коде (Backend/Android) используется крайне редко, но для обучения и скриптов — это точка входа.

import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
    println("Start: ${Thread.currentThread().name}")

    // Запускаем корутину
    launch {
        // Имитируем работу 1 сек
        delay(1000) 
        println("World! : ${Thread.currentThread().name}")
    }

    println("Hello,")
    // runBlocking будет ждать завершения launch перед выходом
}

/* Вывод:
Start: main
Hello,
(пауза 1 сек)
World! : main
*/

Обратите внимание: Всё выполняется на ОДНОМ потоке main, но асинхронно! Пока корутина "спала" (delay), поток пошел дальше и напечатал "Hello,".

launch (Fire and Forget)

Аналог executor.execute(Runnable).

  • Запускает корутину и сразу возвращает управление.
  • Возвращает объект Job (можно отменить корутину).
  • Не возвращает результат вычислений (возвращает Job).
val job = scope.launch {
    // Делаем что-то долгое
    processImage()
}
// job.cancel() // Можно отменить, если не нужно

4. Structured Concurrency (Структурированная конкурентность)

В Java, если вы запустите поток и потеряете ссылку на него, он может работать вечно (утечка памяти), даже если основной объект умер.

В Kotlin введена Структурированная конкурентность. Правило: Корутина не может быть запущена "в вакууме". Она всегда должна быть запущена внутри какой-то Области видимости (CoroutineScope).

  • Если умирает Родитель (Scope), то автоматически отменяются все Дети (Coroutines).
  • Родитель не завершится, пока не завершатся все его Дети.
fun main() = runBlocking {
    // Создаем новую область видимости
    launch { 
        println("Parent started")
        
        // Вложенная корутина (Child)
        launch {
            delay(2000)
            println("Child 1 finished")
        }
        
        launch {
            delay(1000)
            println("Child 2 finished")
        }
        
        println("Parent logic finished")
    }
    println("Main finished")
}

Даже если "Parent logic finished" напечатается быстро, программа не закроется, пока Child 1 и Child 2 не доработают. Это гарантирует отсутствие утечек.


5. Under the Hood (Как это работает в байт-коде?)

Как Java-разработчику, вам будет интересно: как JVM выполняет suspend, если в байт-коде нет такой инструкции?

Компилятор Kotlin превращает suspend функции в State Machine (Конечный автомат). Это называется CPS (Continuation Passing Style).

Исходный код:

suspend fun task() {
    print("A")
    delay(1000) // Точка приостановки 1
    print("B")
    delay(1000) // Точка приостановки 2
    print("C")
}

Во что примерно это компилируется (псевдокод):

void task(Continuation cont) {
    switch (cont.label) {
        case 0:
            print("A");
            cont.label = 1;
            // delay возвращает метку "COROUTINE_SUSPENDED"
            if (DelayKt.delay(1000, cont) == SUSPENDED) return; 
            // Функция РЕАЛЬНО завершается (return), стек освобождается!
            
        case 1:
            // Спустя 1 сек, система вызывает task() снова, но с label=1
            print("B");
            cont.label = 2;
            if (DelayKt.delay(1000, cont) == SUSPENDED) return;
            
        case 2:
            print("C");
            return;
    }
}

Суть: Когда корутина доходит до delay, функция task физически завершается (return). Поток освобождается для других дел. А через секунду таймер вызывает эту же функцию снова, но прыгает сразу на case 1.


💡 Демонстрация силы (100k Coroutines)

Попробуйте запустить этот код. В Java он бы убил JVM. В Kotlin это займет секунду.

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

fun main() = runBlocking {
    val time = measureTimeMillis {
        val jobs = List(100_000) { // Создаем лист из 100 000 элементов
            launch {
                delay(1000) // Каждая ждет 1 секунду
                print(".")
            }
        }
        // joinAll() не нужен, так как runBlocking ждет всех детей
    }
    println("\nCompleted in $time ms")
}

Вы увидите, что это займет ~1100 мс. 100 000 "потоков" отработали параллельно практически без оверхеда.


Резюме Части 1:

  1. Корутины — это задачи, которые могут ставиться на паузу, не блокируя поток.
  2. suspend — маркер такой функции.
  3. launch — запуск задачи ("выстрелил и забыл").
  4. Structured Concurrency — корутины всегда привязаны к Scope.

🟣 Уровень 4: Асинхронность (Часть 2)

1. Dispatchers: На каком потоке мы работаем?

В Java вы создавали ThreadPoolExecutor или использовали ForkJoinPool. В Kotlin пулы потоков уже созданы и оптимизированы за вас. Они называются Диспетчеры.

Мы указываем диспетчер при запуске корутины (launch(Dispatchers.IO)) или при переключении контекста.

Основные виды диспетчеров:

Диспетчер Аналог в Java Для чего использовать?
Dispatchers.Main Android Main Looper / JavaFX / Swing Thread Только работа с UI. Обновление текста, анимации. На Бэкенде обычно недоступен.
Dispatchers.IO CachedThreadPool (безлимитный, растет по нужде) Ввод-вывод. Сеть, База данных, Чтение файлов. Вмещает 64+ потоков.
Dispatchers.Default ForkJoinPool (размер = кол-во ядер CPU) Тяжелые вычисления. JSON парсинг, сортировка списков, криптография.
Dispatchers.Unconfined Нет прямого аналога "Где придется". Стартует на текущем потоке, после suspend может проснуться на другом. Редко используется в продакшене.

2. withContext: Элегантное переключение

В Java/Android переключение потоков часто выглядит как ад ("Callback Hell"):

// Java Style (Pseudo-code)
executor.execute(() -> {
    Data data = database.load(); // Фоновый поток
    runOnUiThread(() -> {
        show(data); // Главный поток
    });
});

В Kotlin мы используем функцию withContext. Она:

  1. Переключает корутину на указанный диспетчер.
  2. Выполняет блок кода.
  3. Приостанавливает внешнюю корутину, пока блок не закончится.
  4. Возвращает результат и автоматически возвращается на исходный диспетчер.
suspend fun loadAndShowData() {
    // 1. Мы находимся, например, на Main потоке
    showProgressBar()

    // 2. Переключаемся на IO для загрузки
    // Внешний код ЖДЕТ (suspend), но поток UI НЕ ЗАБЛОКИРОВАН
    val data = withContext(Dispatchers.IO) {
        println("Loading on: ${Thread.currentThread().name}")
        // Имитация тяжелого запроса к БД
        Thread.sleep(1000) // Тут можно даже блокировать поток, это IO-диспетчер
        "My Data" // Возвращаем результат
    }

    // 3. Мы автоматически вернулись на исходный поток (Main)
    println("Back on: ${Thread.currentThread().name}")
    showData(data)
}

Итог: Линейный код, который прыгает по потокам, но читается как синхронный.


3. async & await: Параллелизм

launch возвращает Job (просто "ручка" для управления). А что, если нам нужно вернуть значение, как Future или Callable в Java?

Для этого есть билдер async.

  • Он запускает корутину.
  • Возвращает объект Deferred<T> (Аналог CompletableFuture<T>).
  • Чтобы получить результат, нужно вызвать .await().

Последовательно vs Параллельно

Допустим, нам нужно получить данные пользователя и его список друзей. Каждый запрос длится 1 сек.

Вариант 1: Последовательно (Плохо)

val time = measureTimeMillis {
    val user = fetchUser() // ждем 1 сек
    val friends = fetchFriends() // ждем 1 сек
} 
// Итого: 2 секунды

Вариант 2: Параллельно с async (Хорошо)

fun main() = runBlocking {
    val time = measureTimeMillis {
        // Запускаем обе задачи одновременно
        // Deferred<String>
        val userDeferred = async(Dispatchers.IO) { fetchUser() } 
        // Deferred<List<String>>
        val friendsDeferred = async(Dispatchers.IO) { fetchFriends() }

        // Код идет дальше... задачи уже крутятся...

        // В этой точке нам нужны результаты. Ждем (suspend).
        val user = userDeferred.await()
        val friends = friendsDeferred.await()
        
        println("User: $user, Friends: $friends")
    }
    println("Time: $time ms") // Итого: ~1000 мс (самая длинная задача)
}

suspend fun fetchUser(): String { delay(1000); return "Alex" }
suspend fun fetchFriends(): List<String> { delay(1000); return listOf("Bob", "Alice") }

⚠️ Частая ошибка новичков

// ТАК ПАРАЛЛЕЛЬНОСТИ НЕ БУДЕТ:
val user = async { fetchUser() }.await() // Запустили и сразу заблокировались
val friends = async { fetchFriends() }.await() // Ждем пока закончится первый

4. CoroutineContext: Магия плюса +

Вы видели, что мы пишем launch(Dispatchers.IO). Но иногда можно увидеть: launch(Dispatchers.IO + CoroutineName("MyWorker") + Job()).

CoroutineContext — это набор элементов, который определяет поведение корутины. Он работает как Map (ключ-значение). Основные элементы контекста:

  1. Dispatcher: Где выполнять (Dispatchers.IO).
  2. Job: Управление жизнью (ссылка на саму задачу).
  3. CoroutineName: Имя для отладки (полезно в логах).
  4. CoroutineExceptionHandler: Обработчик ошибок (аналог Thread.UncaughtExceptionHandler).

Оператор + объединяет контексты, перезаписывая дубликаты.

val scope = CoroutineScope(Dispatchers.Main + Job())

scope.launch(Dispatchers.IO + CoroutineName("BackgroundLoad")) {
    // Здесь контекст будет:
    // Dispatcher: IO (переопределил Main)
    // Job: Новый дочерний Job (создан автоматически launch'ем)
    // Name: BackgroundLoad
}

💡 Практический совет: ViewModelScope (Android)

Если вы пишете под Android, вам редко нужно создавать Scope руками. В ViewModel есть готовый viewModelScope.

class MyViewModel : ViewModel() {
    fun loadData() {
        // Автоматически отменится, если экран закроется!
        viewModelScope.launch {
            try {
                val data = api.getData() // suspend вызов (Retrofit)
                _liveData.value = data
            } catch (e: Exception) {
                // Обработка ошибок try-catch работает для корутин!
                showError(e)
            }
        }
    }
}

Заметьте: Retrofit (начиная с 2.6.0) поддерживает suspend функции и сам переключается на IO-поток под капотом, так что withContext писать не обязательно.


Резюме Части 2:

  1. Используйте Dispatchers.IO для сети/БД и Dispatchers.Default для вычислений.
  2. withContext — безопасный способ сменить поток и вернуться обратно с результатом.
  3. async запускает задачу параллельно и возвращает Deferred. Не забывайте вызывать await().

🟣 Уровень 4: Асинхронность (Часть 3) — Kotlin Flow

1. Что такое Flow? (Cold Streams)

Представьте кран с водой.

  • List: Это ведро воды. Данные уже вычислены и лежат в памяти.
  • Flow: Это труба. Вода (данные) течет только тогда, когда вы открываете кран (collect).

В отличие от RxJava, где нужно помнить про subscribeOn/observeOn и Disposable, Flow построен на базе корутин и Structured Concurrency.

Простой пример:

import kotlinx.coroutines.flow.*
import kotlinx.coroutines.*

// Функция не suspend! Она просто создает описание потока.
fun simpleFlow(): Flow<Int> = flow {
    println("Flow started")
    for (i in 1..3) {
        delay(100) // Асинхронная пауза (имитация работы)
        emit(i)    // Эмиссия значения в поток
    }
}

fun main() = runBlocking {
    val myFlow = simpleFlow()
    
    println("Calling collect...")
    // Поток запускается ТОЛЬКО здесь (Cold stream)
    myFlow.collect { value -> 
        println(value) 
    }
    
    println("Calling collect again...")
    // Если вызвать еще раз, поток перезапустится с нуля
    myFlow.collect { value -> println(value) }
}

2. Операторы (Как Collections, только асинхронно)

Flow имеет богатый набор операторов (map, filter, take, zip), которые очень похожи на работу с коллекциями. Главное отличие — внутри операторов можно вызывать suspend функции!

suspend fun performRequest(i: Int): String {
    delay(100)
    return "Response $i"
}

fun main() = runBlocking {
    (1..5).asFlow() // Превращаем диапазон в Flow
        .filter { it % 2 == 0 } // Оставляем четные (2, 4)
        .map { 
            // Внутри map можно делать асинхронные вызовы!
            performRequest(it) 
        }
        .collect { response ->
            println(response)
        }
}

flowOn (Переключение контекста)

В RxJava вы использовали subscribeOn. В Flow используется flowOn. Он меняет диспетчер для всего, что находится выше него по цепочке.

flow {
    // Тяжелая работа (загрузка БД)
    emit(loadData()) 
}
.flowOn(Dispatchers.IO) // <-- Весь код выше (flow block) выполнится на IO
.map { process(it) }    // Выполнится на Default (если добавим еще flowOn)
.collect { updateUi(it) } // Выполнится на Main (там, где вызвали collect)

3. Hot Streams: StateFlow и SharedFlow

Обычный Flow — холодный (cold). Нет подписчика — нет работы. Но в UI (Android/Web) нам часто нужны "горячие" источники, которые хранят состояние независимо от подписчиков.

StateFlow (Аналог LiveData / BehaviorSubject)

Это поток, который всегда хранит последнее значение. Идеально подходит для хранения состояния экрана (MVI/MVVM).

  • Всегда имеет начальное значение.
  • При подписке сразу отдает текущее значение.
  • Conflation: Если значения приходят слишком быстро, подписчик получит только самое последнее (пропустит промежуточные).
class MainViewModel {
    // Private mutable (можем менять внутри)
    private val _uiState = MutableStateFlow("Loading")
    
    // Public immutable (снаружи только читаем)
    val uiState: StateFlow<String> = _uiState.asStateFlow()

    fun updateData() {
        _uiState.value = "Success" // Обновление состояния
    }
}

// В UI (Android Activity / Compose)
fun setup() {
    lifecycleScope.launch {
        viewModel.uiState.collect { state ->
            // Сработает сразу при подписке ("Loading")
            // И каждый раз при изменении
            render(state)
        }
    }
}

SharedFlow (Аналог PublishSubject)

Это поток событий. Используется для "разовых" эффектов: показать Toast, навигация, снекбар.

  • Не имеет начального значения (по умолчанию).
  • Можно настроить буфер и replay (сколько старых событий отдавать новым подписчикам).
  • Если подписчика нет, событие может уйти "в никуда" (или остаться в буфере).
val events = MutableSharedFlow<String>()

// Отправка события (suspend функция, т.к. буфер может быть переполнен)
events.emit("Click detected")

4. Обработка ошибок

В Flow не нужно использовать специальные колбэки onError. Используйте обычный try-catch внутри корутины или декларативный оператор catch.

Вариант 1 (Императивный):

try {
    myFlow.collect { println(it) }
} catch (e: Exception) {
    println("Caught $e")
}

Вариант 2 (Декларативный):

myFlow
    .map { check(it > 0); it }
    .catch { e -> 
        emit(-1) // При ошибке выдать дефолтное значение
        // или залогировать
    }
    .collect { println(it) }

💡 RxJava vs Kotlin Flow

Если вы знаете RxJava, вот словарь перевода:

RxJava Kotlin Flow Комментарий
Single suspend fun Одно значение
Observable / Flowable Flow Холодный поток
BehaviorSubject StateFlow Хранит состояние
PublishSubject SharedFlow Трансляция событий
map, filter, flatMap map, filter, flatMapConcat Операторы почти идентичны
subscribeOn flowOn Управление потоками
dispose() job.cancel() Отмена через Scope корутины

Почему Flow побеждает?

  1. Simplicity: Нет монстров типа Single<List<Maybe<T>>>.
  2. Suspend: Внутри операторов Flow можно делать delay, вызывать БД и т.д. без блокировок. В RxJava внутри map нельзя вызвать асинхронный код просто так.
  3. Structured Concurrency: Flow умирает вместе с экраном (Scope) автоматически. Никаких утечек памяти из-за забытого dispose().

Резюме Уровня 4: Вы теперь владеете полным арсеналом современной асинхронности:

  1. Coroutines для разовых задач.
  2. Dispatchers для управления потоками.
  3. Flow для работы с потоками данных.

⚫ Уровень 5: Expert (Часть 1) — Reflection & Interop

1. Reflection API: KClass vs Class

В Java рефлексия встроена в rt.jar. В Kotlin, чтобы не раздувать размер приложений (особенно критично для Android), полноценная рефлексия вынесена в отдельную библиотеку kotlin-reflect.jar.

Без этой библиотеки вы можете делать простые вещи (получить имя класса), но не сможете интроспектировать свойства или вызывать методы по имени.

KClass vs Java Class

Это самый частый источник путаницы.

  • java.lang.Class: Это стандартный класс Java. Нужен для Gson, Jackson, Spring.
  • kotlin.reflect.KClass: Это мета-описание класса Kotlin. Оно знает про val/var, data class, nullability и suspend.
// 1. Получение ссылки на KClass (Kotlin)
val kClass: KClass<User> = User::class

// 2. Получение ссылки на Class (Java)
// .java - это свойство-расширение для конвертации
val jClass: Class<User> = User::class.java 

// 3. Обратное преобразование (требует kotlin-reflect)
val kClassBack: KClass<User> = jClass.kotlin

Практика: Доступ к свойствам через рефлексию

Допустим, нам нужно прочитать значение свойства, зная только его имя строкой (динамический доступ).

import kotlin.reflect.full.memberProperties

data class User(val name: String, var age: Int)

fun printProperty(instance: Any, propName: String) {
    // Получаем KClass
    val kClass = instance::class 
    
    // Ищем свойство по имени в коллекции memberProperties
    val prop = kClass.memberProperties.find { it.name == propName }
    
    if (prop != null) {
        // getter вызывается через call
        println("Value: ${prop.getter.call(instance)}") 
    } else {
        println("Property not found")
    }
}

2. Java Interop: Делаем код красивым для Java

Kotlin отлично вызывает Java. А вот Java вызывает Kotlin иногда "криво". Чтобы ваш Kotlin-код выглядел в Java как родной, используйте JVM Аннотации.

@JvmStatic: Настоящая статика

По умолчанию методы в object или companion object не являются статическими в байт-коде. Это методы специального инстанса INSTANCE.

Kotlin:

object Utils {
    fun doMagic() {}
}

Java (Без аннотации - Уродливо):

Utils.INSTANCE.doMagic();

Решение:

object Utils {
    @JvmStatic // Генерирует реальный static метод в байт-коде
    fun doMagic() {}
}

Java (С аннотацией - Красиво):

Utils.doMagic();

@JvmOverloads: Конструкторы и методы

Kotlin поддерживает параметры по умолчанию. Java — нет. По умолчанию Kotlin генерирует для Java только один метод со всеми аргументами.

Kotlin:

class MyView @JvmOverloads constructor(
    context: Context, 
    attrs: AttributeSet? = null, 
    defStyle: Int = 0
)

Что видит Java (Без аннотации): Только MyView(Context, AttributeSet, int). Вы не можете вызвать его с одним аргументом.

С @JvmOverloads: Компилятор сгенерирует перегрузки:

  1. MyView(Context)
  2. MyView(Context, AttributeSet)
  3. MyView(Context, AttributeSet, int)

@JvmField: Открытые поля

В Kotlin val/var — это свойства (property). Это значит, что поле приватное, а доступ через геттер/сеттер. Иногда (например, для тестов или специфичных фреймворков) нужно простое public поле.

class Data {
    @JvmField
    val id = 100 // В Java это будет: public final int id = 100;
}

@Throws: Проверяемые исключения

В Kotlin нет Checked Exceptions. Но если вы пишете код, который будет вызываться из Java, и хотите заставить Java-разработчика обработать ошибку (try-catch), используйте @Throws.

Kotlin:

@Throws(IOException::class)
fun readFile() {
    throw IOException("Error")
}

Java:

try {
    KtFile.readFile();
} catch (IOException e) { // Без @Throws компилятор Java скажет, что exception не бросается
    // handle
}

3. Проблема final (Spring & Hibernate)

Это классическая проблема Enterprise-разработки. В Kotlin **все классы и методы по умолчанию final** (закрыты для наследования).

Библиотеки вроде Spring (для @Transactional прокси) и Hibernate (для Lazy Loading прокси) должны создавать наследников ваших классов на лету. С final классами они падают.

Решение 1 (Ручное - Утомительно): Писать open перед каждым классом Entity и Service.

open class UserService { ... }
open class UserEntity { ... }

Решение 2 (Правильное - Compiler Plugins): Использовать официальные плагины компилятора в Gradle.

  1. Plugin kotlin-spring (All-open): Автоматически делает классы с аннотациями Spring (@Component, @Service и т.д.) открытыми (open). Вы в коде этого не видите, но в байт-коде они открыты.
  2. Plugin kotlin-jpa (No-arg): Hibernate требует пустой конструктор. Data-классы его не имеют. Этот плагин генерирует синтетический пустой конструктор специально для Hibernate.
// build.gradle.kts
plugins {
    kotlin("plugin.spring") version "1.9.0"
    kotlin("plugin.jpa") version "1.9.0"
}

💡 Резюме Части 1

  1. Если нужна сложная рефлексия — подключайте kotlin-reflect.
  2. Пишете библиотеку для Java? Используйте @JvmStatic и @JvmOverloads щедро.
  3. Используете Spring/JPA? Обязательно настройте плагины all-open и no-arg, иначе устанете писать open.

⚫ Уровень 5: Expert (Часть 2) — Performance & Bytecode

1. inline функции: Убийца оверхеда

В Kotlin мы обожаем лямбды. Мы используем их везде: в filter, map, forEach, в колбэках. Но в JVM (до Project Valhalla) любая лямбда — это объект (экземпляр анонимного класса FunctionN).

Проблема: Если вы вызываете функцию с лямбдой внутри цикла (например, обработка картинки по пикселям), вы создаете миллионы мусорных объектов. GC (Garbage Collector) сойдет с ума.

Решение: Ключевое слово inline.

Если вы помечаете функцию как inline, компилятор не создает объект функции. Вместо этого он копирует тело функции и тело лямбды прямо в место вызова.

Пример: Свой "synchronized"

// Обычная функция (High-order function)
fun lock(lock: Lock, body: () -> Unit) {
    lock.lock()
    try {
        body()
    } finally {
        lock.unlock()
    }
}

// В месте вызова создается объект new Function() { invoke() { ... } }
lock(l) { doAction() } 

Если добавить inline:

inline fun lock(lock: Lock, body: () -> Unit) {
    lock.lock()
    try {
        body()
    } finally {
        lock.unlock()
    }
}

Во что это скомпилируется (Java псевдокод): Никаких вызовов функций. Код просто вставится внутрь:

l.lock();
try {
    doAction(); // Тело лямбды вставлено сюда!
} finally {
    l.unlock();
}

Когда использовать inline?

  1. Всегда, когда функция принимает лямбду как аргумент (как filter, map, run, let — они все inline).
  2. Никогда, если функция большая и не принимает лямбд (вы просто раздуете размер байт-кода приложения, так как код скопируется во все места вызова).

Тонкости: noinline и crossinline

  • noinline: Если вы хотите заинлайнить одну лямбду, а вторую передать куда-то как переменную (инлайн-лямбды не существуют как объекты, их нельзя сохранить в поле).
  • crossinline: Запрещает делать return (выход из внешней функции) внутри лямбды, чтобы не поломать логику потока управления.

2. Value Classes (ex. Inline Classes)

В хорошей архитектуре мы любим создавать типы-обертки, чтобы не путать данные. Например, Password и Email — это строки, но мы не хотим случайно передать пароль в метод, который ждет email.

Проблема: Каждая обертка — это лишний объект в куче (Header + Reference + само значение). Это бьет по памяти.

Решение: value class (с аннотацией @JvmInline).

@JvmInline
value class Password(val s: String)

@JvmInline
value class UserId(val id: Int)

fun login(uid: UserId, pwd: Password) {
    println("User ${uid.id} logged in")
}

fun main() {
    val uid = UserId(100)
    val pwd = Password("secret")
    
    login(uid, pwd)
}

Магия под капотом: В Kotlin-коде вы работаете с типами UserId и Password. Типобезопасность гарантирована. Но в байт-коде (в скомпилированном Java классе) эти типы исчезают! Метод login будет выглядеть так:

// Статический метод с манглированным именем
public static void login_FuncHash(int uid, String pwd) { ... }

Никаких лишних объектов. Оверхед — ноль.


3. Хвостовая рекурсия (tailrec)

Рекурсия — это красиво, но опасно (StackOverflowError). Каждый вызов функции занимает место в стеке.

Если ваш алгоритм построен так, что рекурсивный вызов является последней операцией в функции, Kotlin может оптимизировать его в обычный цикл while.

// Вычисление факториала
// tailrec говорит компилятору: "Разверни это в цикл, если можешь"
tailrec fun factorial(n: Int, run: Int = 1): Int {
    if (n == 1) return run
    // Рекурсивный вызов - ПОСЛЕДНЯЯ операция.
    // Мы не делаем n * factorial(...) — это было бы умножение ПОСЛЕ вызова.
    return factorial(n - 1, run * n)
}

Байт-код (эквивалент):

public int factorial(int n, int run) {
    while (true) {
        if (n == 1) return run;
        run = run * n;
        n = n - 1;
    }
}

Это позволяет писать алгоритмы в функциональном стиле без страха переполнить стек.


4. Primitive Arrays (Где теряется память)

В Java есть int[] и Integer[].

  • int[] — это непрерывный кусок памяти (очень быстро, мало памяти).
  • Integer[] — это массив ссылок на объекты, разбросанные по куче (медленно, много памяти).

В Kotlin:

  • Array<Int> == Integer[] (Массив объектов!). Избегайте этого, если нужна производительность.
  • IntArray == int[] (Массив примитивов). Используйте это.

Также существуют: BooleanArray, ByteArray, DoubleArray и т.д.

// ПЛОХО (для high-load):
val boxed: Array<Int> = arrayOf(1, 2, 3) 

// ХОРОШО:
val primitives: IntArray = intArrayOf(1, 2, 3)

💡 Резюме Части 2

  1. inline — ваш лучший друг при написании утилитных функций с лямбдами. Используйте его, чтобы избежать создания объектов Function.
  2. value class — используйте для DDD (Domain Driven Design), чтобы создавать безопасные типы (Email, Id) без нагрузки на GC.
  3. tailrec — превращает рекурсию в безопасный цикл.
  4. IntArray — используйте вместо Array<Int>, чтобы работать с "голым" железом JVM.

⚫ Уровень 5: Expert (Часть 3) — Обработка ошибок

1. Жизнь без Checked Exceptions

В Java, если метод делает throws IOException, компилятор бьет вас по рукам, пока вы не обернете вызов в try-catch. В Kotlin все исключения — Runtime (как RuntimeException в Java).

Плюсы:

  • Код чище.
  • Лямбды работают проще (в Java лямбды не могут бросать Checked Exceptions без "танцев с бубном").

Минусы:

  • Вы можете забыть обработать ошибку, и приложение упадет.

Поэтому в Kotlin идиоматичным считается не выбрасывать исключения в бизнес-логике, а возвращать специальный тип результата.


2. Тип Result<T>

Начиная с Kotlin 1.3, в стандартную библиотеку встроен класс Result<T>. Это контейнер, который хранит либо успешное значение T, либо исключение Throwable.

Представьте это как две железнодорожные колеи: "Успех" и "Провал". Код движется по ним параллельно.

Пример: Парсинг и Сеть

// Традиционный подход (Java style)
fun parseIdOld(input: String): Int {
    return input.toInt() // Может выбросить NumberFormatException
}

// Kotlin Functional Style
// Мы явно говорим сигнатурой: "Я верну Int или Ошибку"
fun parseId(input: String): Result<Int> {
    return try {
        Result.success(input.toInt())
    } catch (e: NumberFormatException) {
        Result.failure(e)
    }
}

Использование Result

Теперь клиентский код может обрабатывать результат цепочкой вызовов, не прерывая поток выполнения.

fun main() {
    val result: Result<Int> = parseId("invalid")

    result
        .onSuccess { id -> 
            println("User ID: $id") 
        }
        .onFailure { e -> 
            println("Parsing failed: ${e.message}") 
        }

    // Или получение с дефолтным значением
    val id = result.getOrDefault(0)
    
    // Или преобразование (map)
    // Если была ошибка, map пропустится
    val stringResult = result.map { "ID is $it" }
}

3. runCatching: Универсальная обертка

Вам не нужно писать try-catch и вручную создавать Result.success/failure каждый раз. Для этого есть встроенная функция runCatching.

Она принимает блок кода, выполняет его и:

  1. Если все ок — возвращает Result.success.
  2. Если вылетел exception — ловит его и возвращает Result.failure.
fun loadData(): Result<Data> = runCatching {
    // Здесь опасный код (например, Java библиотека или сеть)
    apiClient.fetchData() // Может упасть
}

fun main() {
    loadData()
        .map { it.process() } // Выполнится только если loadData успешна
        .onFailure { sendErrorLog(it) } // Выполнится только при ошибке
        .fold(
            onSuccess = { showData(it) },
            onFailure = { showPlaceholder() }
        )
}

fold — это терминальный оператор. Он "схлопывает" две ветки (успех и ошибку) в одно итоговое значение или действие. Аналог if-else, но в функциональном стиле.


4. runCatching внутри Корутин

Это особенно полезно в корутинах, чтобы не "валить" весь CoroutineScope из-за одной ошибки.

viewModelScope.launch {
    // Если api.getUser() упадет, приложение не крашнется.
    // Мы просто получим объект Result с ошибкой внутри.
    val result = runCatching { api.getUser() }
    
    result.onSuccess { user ->
        _uiState.value = UiState.Content(user)
    }
    result.onFailure { e ->
        _uiState.value = UiState.Error(e.message)
    }
}

5. Для настоящих гуру: Библиотека Arrow

Стандартный Result<T> имеет одно ограничение: ошибка всегда должна быть наследником Throwable. Но что, если ошибка — это не исключение, а просто доменное событие (например, UserNotFound, NotEnoughMoney)? Выбрасывать исключения для бизнес-логики — дорого (из-за StackTrace) и неправильно.

Здесь на сцену выходят библиотеки функционального программирования, самая популярная — Arrow. Тип Either<Left, Right>:

  • Right (Право): Правильный результат (Успех).
  • Left (Лево): Ошибка (Любой объект, не обязательно Exception).
// (Пример с Arrow, не входит в stdlib)
fun buyItem(price: Int): Either<BuyError, Item> {
    if (balance < price) return Either.Left(BuyError.NotEnoughMoney)
    return Either.Right(Item("Sword"))
}

Это уже уровень Senior+, но знать об этом нужно.


💡 Резюме Уровня 5

  1. Reflection: Используйте ::class (KClass) и библиотеку kotlin-reflect для глубокого анализа.
  2. Interop: Уважайте Java-разработчиков. Ставьте @JvmStatic, @JvmOverloads и @Throws.
  3. Performance: Используйте inline для лямбд, value class для оберток и IntArray для примитивов.
  4. Errors: Забудьте про try-catch в бизнес-логике. Используйте runCatching и возвращайте Result<T>.

🛠️ Уровень 6: Практическая Магия (DSL)

1. Секретный ингредиент: Lambda with Receiver

Вспомним обычную лямбду. Она принимает аргумент.

val printIt: (String) -> Unit = { println(it) } // it - это аргумент
printIt("Hello")

А теперь магия. Мы можем сказать компилятору: "Считай, что эта лямбда — это метод класса String". Это называется Lambda with Receiver (Лямбда с получателем). Тип записывается так: String.() -> Unit.

// Тип: String.() -> Unit
// Внутри лямбды "this" — это строка, к которой мы её применили!
val printThis: String.() -> Unit = { 
    // Мы внутри строки! Можем вызывать методы String напрямую.
    println(this.uppercase()) 
    // или просто
    println(length) 
}

fun main() {
    val str = "hello"
    // Вызываем лямбду так, будто это метод объекта str
    str.printThis() 
}

Именно эта фича позволяет открывать фигурные скобки { ... } и писать код в контексте конкретного объекта, создавая вложенность.


2. Проект: Пишем свой HTML Builder

Мы хотим написать код, который генерирует HTML, но проверяется компилятором:

val text = html {
    head { title("My Site") }
    body {
        h1("Hello")
        p("Kotlin is power")
    }
}

Шаг 1: Базовый класс

Нам нужен класс, который умеет хранить имя тега, атрибуты и список детей.

abstract class Tag(val name: String) {
    private val children = mutableListOf<Tag>()
    private val content = StringBuilder()

    // Магический метод для вложенности
    // <T : Tag> - создаем тег определенного типа
    // init: T.() -> Unit - лямбда с получателем этого тега
    protected fun <T : Tag> initTag(tag: T, init: T.() -> Unit): T {
        tag.init() // Выполняем лямбду в контексте нового тега
        children.add(tag) // Добавляем в список детей
        return tag
    }

    // Метод для простого текста
    fun text(s: String) {
        content.append(s)
    }

    // Рекурсивный рендер в строку
    override fun toString(): String {
        val builder = StringBuilder()
        builder.append("<$name>")
        if (content.isNotEmpty()) builder.append(content)
        for (c in children) builder.append(c.toString())
        builder.append("</$name>")
        return builder.toString()
    }
}

Шаг 2: Конкретные теги

Описываем структуру HTML.

class HTML : Tag("html") {
    // Внутри HTML может быть HEAD и BODY
    fun head(init: Head.() -> Unit) = initTag(Head(), init)
    fun body(init: Body.() -> Unit) = initTag(Body(), init)
}

class Head : Tag("head") {
    fun title(text: String) = initTag(Title(), { text(text) })
}

class Title : Tag("title")

class Body : Tag("body") {
    // Внутри BODY могут быть H1, P, A...
    fun h1(text: String) = initTag(H1(), { text(text) })
    fun p(text: String) = initTag(P(), { text(text) })
}

class H1 : Tag("h1")
class P : Tag("p")

Шаг 3: Точка входа

fun html(init: HTML.() -> Unit): String {
    val root = HTML() // Создаем корневой объект
    root.init()       // Применяем к нему лямбду пользователя
    return root.toString() // Рендерим
}

Теперь ваш код из начала примера работает! И самое главное: внутри head вы не сможете вызвать h1, если не добавите соответствующий метод в класс Head. Компилятор следит за структурой.


3. Проект: SQL Builder (Fluent Interface)

Здесь мы используем Infix functions, чтобы код читался как предложение.

Цель:

val sql = select("name", "age") from "users" where { "age" eq 18 }

Реализация

class SqlQuery {
    private val columns = mutableListOf<String>()
    private var table: String = ""
    private var condition: String = ""

    // 1. SELECT
    fun select(vararg cols: String): SqlQuery {
        columns.addAll(cols)
        return this // Возвращаем this для цепочки
    }

    // 2. FROM (Infix!)
    infix fun from(tableName: String): SqlQuery {
        table = tableName
        return this
    }

    // 3. WHERE (Принимает лямбду с контекстом Condition)
    infix fun where(block: Condition.() -> Unit): SqlQuery {
        val cond = Condition()
        cond.block()
        condition = cond.toString()
        return this
    }

    override fun toString(): String {
        return "SELECT ${columns.joinToString(", ")} FROM $table WHERE $condition"
    }
}

// Вспомогательный класс для условий
class Condition {
    private val parts = mutableListOf<String>()

    // infix функция для сравнения: "age" eq 18
    infix fun String.eq(value: Any) {
        // Если строка - добавляем кавычки
        val v = if (value is String) "'$value'" else value
        parts.add("$this = $v")
    }
    
    infix fun String.like(value: String) {
        parts.add("$this LIKE '$value'")
    }

    override fun toString() = parts.joinToString(" AND ")
}

// Точка входа (чтобы не писать new SqlQuery)
fun select(vararg columns: String) = SqlQuery().select(*columns)

Вуаля! Вы написали мини-ORM за 30 строк.


4. Проект: JSON DSL и Sealed Classes

И напоследок, пример того, как создавать JSON структуру. Здесь мы используем оператор UnaryPlus (+) для добавления элементов, чтобы это выглядело необычно и стильно.

Цель:

val myJson = json {
    "name" to "Kotlin"
    "versions" to array(1.0, 1.1, 1.4)
    "meta" to obj {
        "author" to "JetBrains"
    }
}

Реализация

class JsonObject {
    private val map = mutableMapOf<String, Any>()

    // infix функция "key" to "value"
    infix fun String.to(value: Any) {
        map[this] = value
    }

    // Метод для вложенного объекта
    fun obj(init: JsonObject.() -> Unit): JsonObject {
        val nested = JsonObject()
        nested.init()
        return nested
    }
    
    // Вспомогательный для массива
    fun array(vararg args: Any) = args.toList()

    override fun toString(): String {
         // Простая, наивная реализация сериализации
        val pairs = map.map { (k, v) -> 
            val valueStr = if (v is String) "\"$v\"" else v.toString()
            "\"$k\": $valueStr" 
        }
        return "{ ${pairs.joinToString(", ")} }"
    }
}

fun json(init: JsonObject.() -> Unit): String {
    val root = JsonObject()
    root.init()
    return root.toString()
}

💡 Резюме уровня 6

Вы прошли путь от новичка, который пишет public static void, до эксперта, который может:

  1. Управлять тысячами потоков через Coroutines.
  2. Строить архитектуру на Sealed Classes.
  3. Оптимизировать память через Inline & Value Classes.
  4. Создавать свои языки разметки через DSL

🧪 Уровень 7: Тестирование по-Котлиновски (Mockk & Coroutines)

Цель: Научиться писать тесты, которые читаются как документация, правильно мокать final-классы и тестировать асинхронный код.

1. Имена методов в обратных кавычках

В Java мы писали: shouldReturnTrue_whenUserIsValid(). В Kotlin мы можем использовать пробелы в названиях функций, если заключим их в обратные кавычки ```.

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals

class MathTest {

    @Test
    fun `should return sum when adding two positive numbers`() {
        val result = 2 + 2
        assertEquals(4, result)
    }
    
    @Test
    fun `список должен быть пустым при инициализации`() { // Да, можно даже по-русски
        val list = emptyList<String>()
        assertEquals(0, list.size)
    }
}

Примечание: Это работает на JVM, но может давать сбои на Android API < 30 (там лучше по старинке).


2. Mockito vs Mockk

Почему Java-разработчики отказываются от Mockito в Kotlin?

  1. when — это ключевое слово в Kotlin. В Mockito приходится писать Mockito.when(mock.method()). Ужасно.
  2. Final классы: В Kotlin все классы final. Mockito не умеет их мокать без настройки mock-maker-inline.
  3. Null Safety: Mockito.any() возвращает null. Если ваш метод принимает String (не String?), Mockito кинет NPE еще до начала теста.

Встречайте Mockk — библиотеку, написанную на Kotlin для Kotlin.

Основы Mockk DSL

// Зависимость: io.mockk:mockk:1.13.x

class UserServiceTest {
    // Создаем мок (аналог @Mock)
    // relaxed = true значит "возвращай дефолтные значения, если я не задал поведение"
    private val userRepository = mockk<UserRepository>(relaxed = true)
    
    private val userService = UserService(userRepository)

    @Test
    fun `should register user when email is unique`() {
        // GIVEN (Настройка поведения)
        // Аналог: when(repo.exists("email")).thenReturn(false)
        every { userRepository.exists("alex@mail.com") } returns false
        
        // Slot - чтобы захватить аргумент (ArgumentCaptor)
        val userSlot = slot<User>()
        // just Runs - для методов, возвращающих Unit (void)
        every { userRepository.save(capture(userSlot)) } just Runs 

        // WHEN
        userService.register("alex@mail.com")

        // THEN (Проверка вызовов)
        // Аналог: verify(repo).save(any())
        verify(exactly = 1) { userRepository.save(any()) }
        
        // Проверка захваченного аргумента
        assertEquals("alex@mail.com", userSlot.captured.email)
    }
}

Мокинг object и Static (Магия)

Mockito это делает со скрипом. Mockk делает это в одну строку.

// У нас есть синглтон
object Calculator {
    fun complexOp(x: Int) = x * 100
}

@Test
fun `test object mocking`() {
    // Мокаем объект
    mockkObject(Calculator)
    
    every { Calculator.complexOp(5) } returns 999
    
    assertEquals(999, Calculator.complexOp(5))
    
    // Не забываем размокать после теста!
    unmockkObject(Calculator) 
}

3. Тестирование Корутин (runTest)

Как тестировать suspend функцию, в которой есть delay(10000)? Ждать 10 секунд? Нет. Используем библиотеку kotlinx-coroutines-test.

runTest — управление временем

Этот билдер пропускает все delay. 10 секунд пройдут за 1 миллисекунду виртуального времени.

// Класс, который хотим протестировать
class DataFetcher {
    suspend fun fetchData(): String {
        delay(5000) // Долгая операция
        return "Data"
    }
}

@Test
fun `should fetch data instantly`() = runTest {
    // Внутри runTest мы находимся в StandardTestDispatcher
    val fetcher = DataFetcher()
    
    val result = fetcher.fetchData() // delay(5000) пропустится мгновенно
    
    assertEquals("Data", result)
    // currentTime покажет, что виртуально прошло 5000мс
    assertEquals(5000, currentTime) 
}

Подмена Dispatchers (Injecting Dispatchers)

В реальном коде вы используете withContext(Dispatchers.IO). В тестах Dispatchers.IO нужно заменить на тестовый диспетчер, иначе тесты будут нестабильны.

Правильный паттерн: Внедрять диспетчеры через конструктор.

// Prod код
class Repository(
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO // По дефолту IO
) {
    suspend fun save() = withContext(ioDispatcher) { /* ... */ }
}

// Test код
@Test
fun `test repository`() = runTest {
    // Подсовываем UnconfinedTestDispatcher (выполняет всё сразу на текущем потоке)
    val repo = Repository(ioDispatcher = UnconfinedTestDispatcher())
    repo.save()
}

4. Data Driven Testing (Параметризованные тесты)

В JUnit 5 есть @ParameterizedTest. Но в Kotlin можно сделать проще и нагляднее с помощью списков и лямбд.

@Test
fun `validator should accept valid emails`() {
    // Список тестовых данных
    listOf(
        "simple@example.com",
        "very.common@example.com",
        "disposable.style.email+symbol@example.com"
    ).forEach { email ->
        // Проверяем каждый email
        assertTrue(Validator.isValid(email), "Failed for: $email")
    }
}

Для более сложных случаев (вход -> ожидаемый результат) используйте data classes:

@Test
fun `calculator should sum correctly`() {
    data class TestCase(val a: Int, val b: Int, val expected: Int)
    
    listOf(
        TestCase(1, 1, 2),
        TestCase(2, 3, 5),
        TestCase(-1, 1, 0)
    ).forEach { (a, b, expected) ->
        assertEquals(expected, a + b)
    }
}

5. Kotest (Альтернативная вселенная)

Стоит упомянуть, что в Kotlin есть мощнейший фреймворк Kotest. Он позволяет писать тесты совсем не так, как в JUnit.

// Стиль StringSpec (один из 10 стилей Kotest)
class MyTests : StringSpec({
    
    "length of Hello should be 5" {
        "Hello".length shouldBe 5
    }
    
    "collections check" {
        val list = listOf("a", "b", "c")
        list shouldContain "b"
        list shouldHaveSize 3
    }
})

Совет: Начните с JUnit 5 + Mockk. Это стандарт индустрии. Kotest — это выбор для тех, кто хочет максимального DSL.


💡 Резюме Уровня 7

  1. Имена: Используйте backticks with spaces для читаемости.
  2. Mockk: Забудьте Mockito. Используйте mockk(), every { } и verify { }.
  3. Coroutines: Используйте runTest и StandardTestDispatcher для пропуска времени.
  4. Architecture: Всегда передавайте Dispatcher в конструктор классов, чтобы в тестах его можно было подменить.

Теперь ваш roadmap действительно полон. Вы умеете не только писать код, но и гарантировать его качество профессиональными инструментами.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment