- 🟢 Уровень 1: Синтаксис и Фундамент (Часть 1)
- 🟢 Уровень 1: Синтаксис и Фундамент (Часть 2)
- 🟡 Уровень 2: Idiomatic Kotlin (Часть 1)
- 🟡 Уровень 2: Idiomatic Kotlin (Часть 2)
- 🔴 Уровень 3: Архитектура и Проектирование (Часть 1)
- 🔴 Уровень 3: Архитектура и Проектирование (Часть 2)
- 🔴 Уровень 3: Архитектура и Проектирование (Часть 3)
- 🟣 Уровень 4: Асинхронность (Часть 1)
- 🟣 Уровень 4: Асинхронность (Часть 2)
- 🟣 Уровень 4: Асинхронность (Часть 3) — Kotlin Flow
- ⚫ Уровень 5: Expert (Часть 1) — Reflection & Interop
- ⚫ Уровень 5: Expert (Часть 2) — Performance & Bytecode
- ⚫ Уровень 5: Expert (Часть 3) — Обработка ошибок
- 🛠️ Уровень 6: Практическая Магия (DSL)
- 💡 Резюме уровня 6
- 🧪 Уровень 7: Тестирование по-Котлиновски (Mockk & Coroutines)
В Java мы привыкли указывать типы явно (String name = ...). В Kotlin компилятор гораздо умнее. Кроме того, Kotlin подталкивает нас к неизменяемости (immutability).
В Kotlin две ключевых слова для объявления переменных:
val(от value): Неизменяемая ссылка. Аналогfinalв Java. Используйте по умолчанию.var(от variable): Изменяемая переменная. Используйте только тогда, когда это действительно необходимо.
Если компилятор может понять тип из контекста (из правой части выражения), писать его явно не нужно.
Код (Сравнение):
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.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?).
В Java NullPointerException (NPE) — самая частая ошибка. Kotlin решает её на уровне системы типов. Это самая важная концепция для понимания.
Типы в Kotlin делятся на два лагеря:
- Non-nullable (
String,User,Int): В такую переменную нельзя положитьnull. Компилятор просто не даст собрать код. - 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.
}
Как же работать с String?? Не писать же постоянно if (text != null)?
"Если объект не null, выполни действие/верни свойство. Если null — верни null".
val input: String? = null
// Java подход:
// int length = (input != null) ? input.length() : null;
// Kotlin подход:
val length: Int? = input?.length
println(length) // Выведет "null", программа НЕ упадет.
"Если слева 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")
}
"Я мамой клянусь, здесь нет null. Преврати String? в String или выбрось NPE".
val input: String? = "Data"
val forcedData: String = input!! // Если input окажется null, приложение упадет с NPE.
// СОВЕТ: Избегайте !! в продакшн коде.
// Используйте его только если вы 100% уверены (но лучше используйте ?:)
В Java приведение типов (String) obj выбрасывает ClassCastException, если тип не совпадает.
В Kotlin as? вернет null, если каст не удался.
val obj: Any = 123
val str: String? = obj as? String // str будет null, так как 123 — это Int, а не String.
// Ошибки не будет.
Представьте задачу: У нас есть 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"
- Пишите
valвсегда, пока компилятор не потребуетvar. - Если переменная может принимать значение "отсутствия", ставьте
?к типу (String?). - Если видите ошибку "Only safe calls are allowed...", используйте
?.или?:. - Никогда не используйте
!!просто чтобы "заткнуть" компилятор.
В Java создание простого класса для хранения данных (Person) требует: приватных полей, конструктора, геттеров, сеттеров, equals, hashCode и toString. Это около 50 строк кода.
В Kotlin это часто занимает одну строку.
В 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")
}
}
Для классов, чья цель просто хранить данные (DTO, POJO), используйте data class.
Добавив одно слово data, вы бесплатно получаете:
toString()— красивый вывод:User(name=Alex, age=25).equals()иhashCode()— корректное сравнение по полям.copy()— создание копии объекта с изменением части полей.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)
}
В 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()
В 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-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
}
Обычный 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")
}
}
- Поля приватны по умолчанию: Когда вы пишете
val name: String, поле создаетсяprivate, а геттерpublic. Вам не нужно писать геттеры руками. newумер: Конструкторы вызываются как обычные функции.- **
extendsиimplementsзаменены на:**:class Manager : Employee(), InterfaceName. - Файловая структура: В Java один класс = один файл. В Kotlin можно (и нужно) класть несколько маленьких классов (например, Data classes) в один файл, если они логически связаны.
В Java методы часто перегружены (overloaded). Вы создаете print(), print(String), print(String, Int) и т.д. В Kotlin это решается элегантнее.
Если функция состоит из одной строки, фигурные скобки {} и 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 код документирует сам себя.
}
Это одна из самых любимых фич 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
}
Extension function — это статическая магия. Она не меняет исходный класс байт-кода. В Java этот код компилируется в:
// То, во что превращает это компилятор:
public static boolean isEmail(String $this) {
return $this.contains("@") && $this.contains(".");
}Следствие: Extension функции не имеют доступа к private или protected полям класса. Они видят только то, что публично (public).
В 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())
}
}
В 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()).
В Java вы часто пишете так:
User user = new User();
user.setName("Alex");
user.setAge(25);
user.setCity("Moscow");
process(user);Имя переменной user повторяется 5 раз. Scope Functions позволяют выполнить блок кода в контексте объекта, временно избавившись от повторения имени переменной.
Их всего пять. Они делают почти одно и то же, но различаются двумя параметрами:
- Как ссылаются на объект внутри блока:
thisилиit. - Что возвращает функция: Сам объект (Context Object) или Результат лямбды (Lambda Result).
Разберем каждую подробно.
Суть: "Примени настройки к этому объекту и верни его же".
- Ссылка:
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
Суть: "Возьми объект, сделай с ним что-то и верни результат (последнюю строку)".
- Ссылка:
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"
}
Суть: "С этим объектом сделай следующее...".
- Ссылка:
this. - Возврат: Результат лямбды.
- Отличие: Это не extension-функция, а обычная функция.
Используется, когда результат не нужен, но нужно вызвать много методов у одного объекта.
val numbers = mutableListOf("One", "Two", "Three")
val firstAndLast = with(numbers) {
// this ссылается на numbers
"First is $first(), last is $last()" // Методы List вызываются напрямую
}
Суть: "Запусти блок кода на объекте и верни результат".
- Ссылка:
this. - Возврат: Результат лямбды.
Часто используется для вычисления значения на основе свойств объекта + инициализация.
val service = MultiLayerService()
// Инициализируем сервис И сразу вычисляем результат
val result = service.run {
port = 8080
init() // метод сервиса
queryData() // метод сервиса, возвращает String
} // result будет иметь тип String (результат queryData)
Суть: "А также сделай вот это (не меняя контекст)".
- Ссылка:
it. - Возврат: Сам объект.
Идеально для логирования или промежуточных действий в цепочке вызовов. Он говорит: "Я не трогаю объект, я просто смотрю".
val book = Book("Kotlin Guide")
.apply { price = 100 } // Настроили
.also { println("Book created: $it") } // Залогировали (it = Book)
.apply { price = 90 } // Снова настроили (скидка)
// В переменную попадет книга с ценой 90.
// also не повлиял на цепочку возврата.
Я составил для вас матрицу решений. Задайте себе два вопроса:
- Нужен ли мне результат вычислений (новая переменная)?
- Нет (нужен сам объект): Выбирайте
applyилиalso. - Да (нужен результат блока): Выбирайте
let,runилиwith.
- Как удобнее обращаться к объекту?
- Как
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 удобнее) |
Новички часто начинают вкладывать их друг в друга. Это делает код нечитаемым.
// ПЛОХО:
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 друг в друга, если это возможно. Лучше разбить на обычные переменные.
В Java у нас есть Enum для перечисления констант.
- Проблема Enum: У всех констант должна быть одинаковая структура данных. Вы не можете сказать: "В статусе SUCCESS у меня лежит список данных, а в статусе ERROR — текст ошибки".
- Решение Kotlin:
Sealed Class(Запечатанный класс).
Это иерархия классов, где все наследники известны на этапе компиляции.
Представьте экран, который грузит список новостей. У него может быть 3 состояния:
- Loading: Просто крутится спиннер (данных нет).
- Success: Данные пришли (нужен список новостей).
- 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()
}
Поскольку компилятор знает, что у 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})")
}
}
}
Начиная с Kotlin 1.5, появились sealed interface.
Разница такая же, как между абстрактным классом и интерфейсом.
- Используйте
sealed class, если наследникам нужно общее состояние (поля) или логика в конструкторе. - Используйте
sealed interface, если общей логики нет (или класс уже наследует другой класс, так как множественное наследование классов запрещено).
sealed interface ClickEvent {
data class OpenArticle(val id: Int) : ClickEvent
object BackPressed : ClickEvent
}
| Характеристика | Abstract Class | Sealed Class |
|---|---|---|
| Наследники | Кто угодно, где угодно | Строго фиксированы в том же пакете/модуле |
when |
Требует else |
**Не требует else** (исчерпывающая проверка) |
| Смысл | "Есть общая логика" | "Это закрытый набор вариантов" (Вариант А ИЛИ Вариант Б) |
- MVI / MVVM Architectures: Состояния экрана (
LCE- Loading/Content/Error). - Обработка ошибок: Вместо выбрасывания
Exception, функция возвращает sealed classResult:
sealed class LoginResult {
object Success : LoginResult()
object InvalidPassword : LoginResult()
object NetworkError : LoginResult()
}
- View Types в списках (RecyclerView): Если у вас в списке есть разные элементы (Заголовок, Карточка товара, Реклама), опишите их как sealed class, и в адаптере
whenпоможет ничего не забыть.
Думайте о Sealed Class как об "Enums with Data" (Энамах с данными).
- Если у статусов нет данных — это обычный
Enum. - Если хоть у одного статуса есть данные (как список новостей в
Success) — этоSealed Class.
В 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) и реализации сложного поведения без наследования.
Это фича, которой нет в Java. Она позволяет вынести логику get() и set() свойства в отдельный класс.
Синтаксис: val/var имя by Делегат.
В 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).
Если вы хотите реагировать на изменение переменной (например, обновлять 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'
}
Часто используется при парсинге 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
}
Это не магия, а контракт. Делегат — это любой класс, у которого есть оператор getValue (и setValue для 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
}
- Android:
val viewModel by viewModels()— получение ViewModel через делегат. - Koin / Kodein (DI):
val repository by inject()— внедрение зависимостей. - БД (Exposed): Описание колонок в базе данных:
val name by varchar("name", 50).
- Используйте Class Delegation (
by list), чтобы не писать методы-прокси вручную при реализации паттерна Декоратор. - Используйте
by lazyдля тяжелых объектов. Это стандарт. - Делегаты позволяют переиспользовать логику геттеров/сеттеров.
Проблема Java (Type Erasure):
В Java (и в Kotlin по умолчанию) дженерики существуют только до компиляции. В байт-коде List<String> и List<Integer> превращаются просто в List.
Поэтому нельзя написать: if (obj instanceof T) или new T(). JVM не знает, что такое T во время выполнения.
Решение Kotlin: inline функции + reified.
Если функция помечена как inline, компилятор вставляет её код прямо в место вызова. А значит, он знает, какой конкретно тип вы туда передали.
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()
}
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>.
Это самая сложная часть для понимания, но самая важная для архитектуры библиотек.
Вспомним правило PECS из Java:
- Producer Extends (Если коллекция отдает данные ->
? extends T) - Consumer Super (Если коллекция принимает данные ->
? super T)
В Kotlin эти слова заменили на понятные out и in.
"Я только отдаю 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>.
"Я только принимаю 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<T> и принимает, и отдает T. Там нет ни in, ни out.
Поэтому MutableList<String> нельзя присвоить в MutableList<Any>. Иначе в список строк можно было бы положить Int через ссылку на Any.
Аналог Java ? (Raw type или Wildcard).
Используется, когда вам неважен тип дженерика.
// Мы принимаем список чего угодно
fun printListSize(list: List<*>) {
// Мы не можем писать в этот список (кроме null)
// Мы не знаем, какой там тип, но можем достать Any?
val item: Any? = list.get(0)
println(list.size)
}
Отличие от Any?:
MutableList<Any?>— список, куда можно положить что угодно.MutableList<*>— список какого-то конкретного типа, но мы не знаем какого. Поэтому класть туда ничего нельзя (безопасность).
Если вам нужно, чтобы 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() }
}
reified— киллер-фича. Используйтеinline fun <reified T>, когда нужно узнать типTвнутри метода (например, для парсинга, логгирования, DI).- **
List<String>— этоList<Any>**, потому что он неизменяемый (out). - **
MutableList<String>— это НЕMutableList<Any>**, потому что он изменяемый. - **Java
? extends**= Kotlinout. - **Java
? super**= Kotlinin.
В Java поток (Thread) — это дорогая сущность. Он занимает 1-2 МБ памяти стека, и его создание требует системного вызова в ОС. Вы не можете создать 100 000 потоков — вы получите OutOfMemoryError.
Корутина (Coroutine) — это легковесный поток.
- Она не привязана к конкретному потоку ОС (может начать выполняться на одном, приостановиться и продолжить на другом).
- Она занимает байты памяти, а не мегабайты.
- Вы можете легко создать миллион корутин на одной машине.
Главная магия Kotlin — это возможность приостановить (suspend) выполнение функции, не блокируя поток, на котором она выполняется.
Представьте, что поток — это рабочий на заводе.
- Java (Thread.sleep / IO blocking): Рабочий садится на стул и ждет 5 секунд, пока деталь остынет. Он ничего не делает, но зарплату (ресурсы CPU/RAM) получает.
- Kotlin (delay / suspend): Рабочий вешает на деталь таймер, откладывает её в сторону и тут же берет следующую задачу. Когда таймер сработает, он вернется к первой детали.
Чтобы функция могла "приостановиться", она должна быть помечена модификатором suspend.
// Обычная функция
fun regularFunction() {
// Thread.sleep(1000) // ПЛОХО: Это заблокирует весь поток!
}
// Suspend функция
suspend fun downloadData(): String {
// delay - это "неблокирующий сон"
delay(1000)
return "Data"
}
Правило: suspend функцию можно вызвать только из другой suspend функции или из корутины. Из обычного Java-метода её вызвать нельзя (без специальных хаков).
Мы не можем просто вызвать downloadData() из main. Нам нужен мост между обычным миром и миром корутин. Это делают Coroutines Builders.
Блокирует текущий поток (как в 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,".
Аналог executor.execute(Runnable).
- Запускает корутину и сразу возвращает управление.
- Возвращает объект
Job(можно отменить корутину). - Не возвращает результат вычислений (возвращает
Job).
val job = scope.launch {
// Делаем что-то долгое
processImage()
}
// job.cancel() // Можно отменить, если не нужно
В 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 не доработают. Это гарантирует отсутствие утечек.
Как 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.
Попробуйте запустить этот код. В 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:
- Корутины — это задачи, которые могут ставиться на паузу, не блокируя поток.
suspend— маркер такой функции.launch— запуск задачи ("выстрелил и забыл").- Structured Concurrency — корутины всегда привязаны к Scope.
В 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 может проснуться на другом. Редко используется в продакшене. |
В Java/Android переключение потоков часто выглядит как ад ("Callback Hell"):
// Java Style (Pseudo-code)
executor.execute(() -> {
Data data = database.load(); // Фоновый поток
runOnUiThread(() -> {
show(data); // Главный поток
});
});В Kotlin мы используем функцию withContext. Она:
- Переключает корутину на указанный диспетчер.
- Выполняет блок кода.
- Приостанавливает внешнюю корутину, пока блок не закончится.
- Возвращает результат и автоматически возвращается на исходный диспетчер.
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)
}
Итог: Линейный код, который прыгает по потокам, но читается как синхронный.
launch возвращает Job (просто "ручка" для управления). А что, если нам нужно вернуть значение, как Future или Callable в Java?
Для этого есть билдер async.
- Он запускает корутину.
- Возвращает объект
Deferred<T>(АналогCompletableFuture<T>). - Чтобы получить результат, нужно вызвать
.await().
Допустим, нам нужно получить данные пользователя и его список друзей. Каждый запрос длится 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() // Ждем пока закончится первый
Вы видели, что мы пишем launch(Dispatchers.IO). Но иногда можно увидеть:
launch(Dispatchers.IO + CoroutineName("MyWorker") + Job()).
CoroutineContext — это набор элементов, который определяет поведение корутины. Он работает как Map (ключ-значение). Основные элементы контекста:
- Dispatcher: Где выполнять (
Dispatchers.IO). - Job: Управление жизнью (ссылка на саму задачу).
- CoroutineName: Имя для отладки (полезно в логах).
- CoroutineExceptionHandler: Обработчик ошибок (аналог
Thread.UncaughtExceptionHandler).
Оператор + объединяет контексты, перезаписывая дубликаты.
val scope = CoroutineScope(Dispatchers.Main + Job())
scope.launch(Dispatchers.IO + CoroutineName("BackgroundLoad")) {
// Здесь контекст будет:
// Dispatcher: IO (переопределил Main)
// Job: Новый дочерний Job (создан автоматически launch'ем)
// Name: BackgroundLoad
}
Если вы пишете под 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:
- Используйте
Dispatchers.IOдля сети/БД иDispatchers.Defaultдля вычислений. withContext— безопасный способ сменить поток и вернуться обратно с результатом.asyncзапускает задачу параллельно и возвращаетDeferred. Не забывайте вызыватьawait().
Представьте кран с водой.
- 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) }
}
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)
}
}
В RxJava вы использовали subscribeOn. В Flow используется flowOn. Он меняет диспетчер для всего, что находится выше него по цепочке.
flow {
// Тяжелая работа (загрузка БД)
emit(loadData())
}
.flowOn(Dispatchers.IO) // <-- Весь код выше (flow block) выполнится на IO
.map { process(it) } // Выполнится на Default (если добавим еще flowOn)
.collect { updateUi(it) } // Выполнится на Main (там, где вызвали collect)
Обычный Flow — холодный (cold). Нет подписчика — нет работы.
Но в UI (Android/Web) нам часто нужны "горячие" источники, которые хранят состояние независимо от подписчиков.
Это поток, который всегда хранит последнее значение. Идеально подходит для хранения состояния экрана (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)
}
}
}
Это поток событий. Используется для "разовых" эффектов: показать Toast, навигация, снекбар.
- Не имеет начального значения (по умолчанию).
- Можно настроить буфер и replay (сколько старых событий отдавать новым подписчикам).
- Если подписчика нет, событие может уйти "в никуда" (или остаться в буфере).
val events = MutableSharedFlow<String>()
// Отправка события (suspend функция, т.к. буфер может быть переполнен)
events.emit("Click detected")
В 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, вот словарь перевода:
| 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 побеждает?
- Simplicity: Нет монстров типа
Single<List<Maybe<T>>>. - Suspend: Внутри операторов Flow можно делать
delay, вызывать БД и т.д. без блокировок. В RxJava внутриmapнельзя вызвать асинхронный код просто так. - Structured Concurrency: Flow умирает вместе с экраном (Scope) автоматически. Никаких утечек памяти из-за забытого
dispose().
Резюме Уровня 4: Вы теперь владеете полным арсеналом современной асинхронности:
- Coroutines для разовых задач.
- Dispatchers для управления потоками.
- Flow для работы с потоками данных.
В Java рефлексия встроена в rt.jar. В Kotlin, чтобы не раздувать размер приложений (особенно критично для Android), полноценная рефлексия вынесена в отдельную библиотеку kotlin-reflect.jar.
Без этой библиотеки вы можете делать простые вещи (получить имя класса), но не сможете интроспектировать свойства или вызывать методы по имени.
Это самый частый источник путаницы.
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")
}
}
Kotlin отлично вызывает Java. А вот Java вызывает Kotlin иногда "криво". Чтобы ваш Kotlin-код выглядел в Java как родной, используйте JVM Аннотации.
По умолчанию методы в object или companion object не являются статическими в байт-коде. Это методы специального инстанса INSTANCE.
Kotlin:
object Utils {
fun doMagic() {}
}
Java (Без аннотации - Уродливо):
Utils.INSTANCE.doMagic();Решение:
object Utils {
@JvmStatic // Генерирует реальный static метод в байт-коде
fun doMagic() {}
}
Java (С аннотацией - Красиво):
Utils.doMagic();Kotlin поддерживает параметры по умолчанию. Java — нет. По умолчанию Kotlin генерирует для Java только один метод со всеми аргументами.
Kotlin:
class MyView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
)
Что видит Java (Без аннотации):
Только MyView(Context, AttributeSet, int). Вы не можете вызвать его с одним аргументом.
С @JvmOverloads:
Компилятор сгенерирует перегрузки:
MyView(Context)MyView(Context, AttributeSet)MyView(Context, AttributeSet, int)
В Kotlin val/var — это свойства (property). Это значит, что поле приватное, а доступ через геттер/сеттер.
Иногда (например, для тестов или специфичных фреймворков) нужно простое public поле.
class Data {
@JvmField
val id = 100 // В Java это будет: public final int id = 100;
}
В 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
}Это классическая проблема Enterprise-разработки.
В Kotlin **все классы и методы по умолчанию final** (закрыты для наследования).
Библиотеки вроде Spring (для @Transactional прокси) и Hibernate (для Lazy Loading прокси) должны создавать наследников ваших классов на лету. С final классами они падают.
Решение 1 (Ручное - Утомительно):
Писать open перед каждым классом Entity и Service.
open class UserService { ... }
open class UserEntity { ... }
Решение 2 (Правильное - Compiler Plugins): Использовать официальные плагины компилятора в Gradle.
- Plugin
kotlin-spring(All-open): Автоматически делает классы с аннотациями Spring (@Component,@Serviceи т.д.) открытыми (open). Вы в коде этого не видите, но в байт-коде они открыты. - 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"
}
- Если нужна сложная рефлексия — подключайте
kotlin-reflect. - Пишете библиотеку для Java? Используйте
@JvmStaticи@JvmOverloadsщедро. - Используете Spring/JPA? Обязательно настройте плагины
all-openиno-arg, иначе устанете писатьopen.
В Kotlin мы обожаем лямбды. Мы используем их везде: в filter, map, forEach, в колбэках.
Но в JVM (до Project Valhalla) любая лямбда — это объект (экземпляр анонимного класса FunctionN).
Проблема: Если вы вызываете функцию с лямбдой внутри цикла (например, обработка картинки по пикселям), вы создаете миллионы мусорных объектов. GC (Garbage Collector) сойдет с ума.
Решение: Ключевое слово inline.
Если вы помечаете функцию как inline, компилятор не создает объект функции. Вместо этого он копирует тело функции и тело лямбды прямо в место вызова.
// Обычная функция (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();
}- Всегда, когда функция принимает лямбду как аргумент (как
filter,map,run,let— они все inline). - Никогда, если функция большая и не принимает лямбд (вы просто раздуете размер байт-кода приложения, так как код скопируется во все места вызова).
noinline: Если вы хотите заинлайнить одну лямбду, а вторую передать куда-то как переменную (инлайн-лямбды не существуют как объекты, их нельзя сохранить в поле).crossinline: Запрещает делатьreturn(выход из внешней функции) внутри лямбды, чтобы не поломать логику потока управления.
В хорошей архитектуре мы любим создавать типы-обертки, чтобы не путать данные.
Например, 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) { ... }Никаких лишних объектов. Оверхед — ноль.
Рекурсия — это красиво, но опасно (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;
}
}Это позволяет писать алгоритмы в функциональном стиле без страха переполнить стек.
В 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)
inline— ваш лучший друг при написании утилитных функций с лямбдами. Используйте его, чтобы избежать создания объектовFunction.value class— используйте для DDD (Domain Driven Design), чтобы создавать безопасные типы (Email,Id) без нагрузки на GC.tailrec— превращает рекурсию в безопасный цикл.IntArray— используйте вместоArray<Int>, чтобы работать с "голым" железом JVM.
В Java, если метод делает throws IOException, компилятор бьет вас по рукам, пока вы не обернете вызов в try-catch.
В Kotlin все исключения — Runtime (как RuntimeException в Java).
Плюсы:
- Код чище.
- Лямбды работают проще (в Java лямбды не могут бросать Checked Exceptions без "танцев с бубном").
Минусы:
- Вы можете забыть обработать ошибку, и приложение упадет.
Поэтому в Kotlin идиоматичным считается не выбрасывать исключения в бизнес-логике, а возвращать специальный тип результата.
Начиная с 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)
}
}
Теперь клиентский код может обрабатывать результат цепочкой вызовов, не прерывая поток выполнения.
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" }
}
Вам не нужно писать try-catch и вручную создавать Result.success/failure каждый раз. Для этого есть встроенная функция runCatching.
Она принимает блок кода, выполняет его и:
- Если все ок — возвращает
Result.success. - Если вылетел 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, но в функциональном стиле.
Это особенно полезно в корутинах, чтобы не "валить" весь 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)
}
}
Стандартный 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+, но знать об этом нужно.
- Reflection: Используйте
::class(KClass) и библиотекуkotlin-reflectдля глубокого анализа. - Interop: Уважайте Java-разработчиков. Ставьте
@JvmStatic,@JvmOverloadsи@Throws. - Performance: Используйте
inlineдля лямбд,value classдля оберток иIntArrayдля примитивов. - Errors: Забудьте про
try-catchв бизнес-логике. ИспользуйтеrunCatchingи возвращайтеResult<T>.
Вспомним обычную лямбду. Она принимает аргумент.
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()
}
Именно эта фича позволяет открывать фигурные скобки { ... } и писать код в контексте конкретного объекта, создавая вложенность.
Мы хотим написать код, который генерирует HTML, но проверяется компилятором:
val text = html {
head { title("My Site") }
body {
h1("Hello")
p("Kotlin is power")
}
}
Нам нужен класс, который умеет хранить имя тега, атрибуты и список детей.
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()
}
}
Описываем структуру 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")
fun html(init: HTML.() -> Unit): String {
val root = HTML() // Создаем корневой объект
root.init() // Применяем к нему лямбду пользователя
return root.toString() // Рендерим
}
Теперь ваш код из начала примера работает! И самое главное: внутри head вы не сможете вызвать h1, если не добавите соответствующий метод в класс Head. Компилятор следит за структурой.
Здесь мы используем 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 строк.
И напоследок, пример того, как создавать 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()
}
Вы прошли путь от новичка, который пишет public static void, до эксперта, который может:
- Управлять тысячами потоков через Coroutines.
- Строить архитектуру на Sealed Classes.
- Оптимизировать память через Inline & Value Classes.
- Создавать свои языки разметки через DSL
Цель: Научиться писать тесты, которые читаются как документация, правильно мокать final-классы и тестировать асинхронный код.
В 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 (там лучше по старинке).
Почему Java-разработчики отказываются от Mockito в Kotlin?
when— это ключевое слово в Kotlin. В Mockito приходится писатьMockito.when(mock.method()). Ужасно.- Final классы: В Kotlin все классы
final. Mockito не умеет их мокать без настройкиmock-maker-inline. - Null Safety:
Mockito.any()возвращаетnull. Если ваш метод принимаетString(неString?), Mockito кинет NPE еще до начала теста.
Встречайте Mockk — библиотеку, написанную на Kotlin для Kotlin.
// Зависимость: 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)
}
}
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)
}
Как тестировать suspend функцию, в которой есть delay(10000)? Ждать 10 секунд? Нет.
Используем библиотеку kotlinx-coroutines-test.
Этот билдер пропускает все 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)
}
В реальном коде вы используете 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()
}
В 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)
}
}
Стоит упомянуть, что в 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.
- Имена: Используйте
backticks with spacesдля читаемости. - Mockk: Забудьте Mockito. Используйте
mockk(),every { }иverify { }. - Coroutines: Используйте
runTestиStandardTestDispatcherдля пропуска времени. - Architecture: Всегда передавайте
Dispatcherв конструктор классов, чтобы в тестах его можно было подменить.
Теперь ваш roadmap действительно полон. Вы умеете не только писать код, но и гарантировать его качество профессиональными инструментами.