Kotlin убирает целые классы багов и бойлерплейта Java (nullability в системе типов, data-классы, корутины) и при этом работает на той же JVM и напрямую вызывает Java; плата за это: время сборки, скрытые накладные расходы в рантайме и слабое место на границе с Java.
Преимущества
Null safety: String и String? являются разными типами, поэтому большинство NullPointerException превращается в ошибки компиляции.
Меньше бойлерплейта: свойства, вывод типов, data class, аргументы по умолчанию и именованные аргументы, строковые шаблоны.
Выразительные типы: sealed-иерархии с исчерпывающим when, smart cast, функции-расширения.
Корутины и Flow: асинхронный код пишется сверху вниз, со структурированной отменой.
Экосистема: Android с 2019 года развивается по принципу Kotlin-first; Jetpack Compose существует только для Kotlin, а Kotlin Multiplatform позволяет делить код с iOS.
Interop: Kotlin- и Java-классы живут в одном модуле и вызывают друг друга, поэтому кодовую базу можно переводить файл за файлом.
Недостатки
Скорость сборки: чистая сборка медленнее, чем на Java; компилятор K2 (по умолчанию с Kotlin 2.0) и KSP вместо kapt сокращают разрыв.
Скрытые издержки: захватывающие лямбды, боксинг Int? и дженериков, аксессоры companion object: по отдельности дёшево, но заметно в горячих циклах.
Платформенные типы: значения из Java-кода без аннотаций обходят проверки на null и всё равно могут упасть в рантайме.
Много способов написать одно и то же: scope-функции и DSL при злоупотреблении портят читаемость, поэтому команде нужны соглашения.
AndroidJunior
Основы Kotlin
val/var, типы, null safety, control flow, функции, лямбды
Закрыто вопросов
0 / 24
- 0Повторить
- 6Часто спрашивают
Вопросы
val объявляет ссылку только для чтения, присваиваемую один раз, var можно переприсвоить, а const val объявляет константу времени компиляции, значение которой компилятор подставляет в каждое место использования.
**val**: как final-переменная в Java. Ссылка зафиксирована, но объект за ней может меняться: при val list = mutableListOf(1) по-прежнему можно вызвать list.add(2).
**var**: можно переприсваивать. По умолчанию пишите val и переходите на var, только когда значение действительно меняется: чем меньше изменяемого состояния, тем меньше багов и безопаснее работа из нескольких потоков.
**const val**: только String и примитивные типы, только на верхнем уровне или внутри object / companion object, и значение должно быть известно на этапе компиляции. В отличие от обычного val, его можно использовать в аргументах аннотаций.
Обычный val вычисляется в рантайме, поэтому val startedAt = System.currentTimeMillis() допустим, но const он быть не может.
Поскольку вызывающий код хранит копию значения, изменение const val в библиотеке подействует только после перекомпиляции модулей, которые его используют.
Nullability входит в тип: String никогда не содержит null, String? может, и компилятор не даст использовать String? как String, пока вы не обработали случай null.
**?.** безопасный вызов: если получатель null, вызова не будет, результатом станет null (user?.address?.city).
**?:** Elvis подставляет запасное значение, когда слева null; справа может стоять return или throw: val id = intent.getStringExtra("id") ?: return.
**!!**: утверждает, что значение не null, иначе бросает NullPointerException. Каждый !! означает краш, на который вы согласились; лучше Elvis или requireNotNull(x) { "message" }.
**?.let { }**: выполняет блок только для не-null значения и передаёт его как it.
Smart cast: после if (x != null) компилятор считает x не-null внутри этой ветки.
Java interop: значение из Java-кода без аннотаций имеет платформенный тип (String!), который компилятор не проверяет; размечайте Java-сторону @Nullable / @NonNull или считайте такое значение nullable на границе.
Код напечатает null, -1, true и Hello, null, а затем последняя строка бросит NullPointerException: падает только !!, все остальные операторы здесь обрабатывают null намеренно.
name?.length: безопасный вызов пропускает length, всё выражение равно null, и println печатает null.
name?.length ?: -1: Elvis заменяет этот null на -1.
name.isNullOrEmpty(): расширение, объявленное на String?, поэтому ?. не нужен, а для null оно возвращает true.
"Hello, $name": строковый шаблон вызывает toString() у null и вставляет текст null; именно так «Hello, null» попадает в реальный UI. Пишите ${name ?: "guest"}.
name!!.length утверждает, что значение не null, поэтому бросает NullPointerException.
lateinit var нужен для non-null значения, которое позже присвоит кто-то другой, например Hilt или onCreate; by lazy нужен для val, который вычисляет себя при первом чтении и кеширует результат. Проверка для lateinit: ::prop.isInitialized.
lateinit var
Только var, только non-null и не примитивный тип вроде Int или Boolean; для них есть Delegates.notNull().
Чтение до присваивания бросает UninitializedPropertyAccessException; ::binding.isInitialized позволяет сначала проверить.
Типичные случаи: поле, которое инжектит Hilt, view binding, присваиваемый в onCreate, тестируемый объект, создаваемый в @Before.
by lazy { }
Только val. Блок выполняется один раз, при первом обращении, результат кешируется.
По умолчанию потокобезопасен (LazyThreadSafetyMode.SYNCHRONIZED); LazyThreadSafetyMode.NONE убирает блокировку, когда свойство читает только один поток, например главный.
Типичные случаи: дорогой объект, который экрану может и не понадобиться, например форматтер, парсер, большой адаптер.
Не используйте lateinit для значения, которого законно может не быть: nullable var делает это состояние видимым для компилятора, а не превращает его в краш.
== проверяет структурное равенство через вызов equals(), а === проверяет ссылочное: указывают ли обе ссылки на один и тот же объект.
a == b компилируется в a?.equals(b) ?: (b === null), поэтому безопасно по null и никогда не бросает исключение. === перегрузить нельзя.
Два экземпляра data class с одинаковыми свойствами равны по ==, но не по ===. Для вашего класса == означает то, что говорит equals(); переопределяйте вместе с ним hashCode(), иначе HashSet и HashMap перестанут находить ваши объекты.
Массивы: == для массивов сравнивает ссылки; нужен contentEquals().
Боксированные числа: значения Int? от -128 до 127 берутся из кеша Integer в JVM, поэтому === случайно даёт true для 127 и false для 128. Никогда не сравнивайте числа через ===; компилятор об этом предупреждает.
when играет в Kotlin роль switch, но он является выражением и возвращает значение сработавшей ветки; если это значение используется, ветки должны покрывать все входные данные: через else или перечислением всех вариантов enum, sealed-типа или Boolean.
Ветка может сопоставлять значение (0 ->), несколько значений (200, 204 ->), диапазон (in 1..9 ->), тип (is String ->, заодно со smart cast) или, в when без аргумента, любое булево условие.
if/else и try/catch тоже работают как выражения, поэтому val label = if (ok) "Done" else "Retry" заменяет тернарный оператор, которого в Kotlin нет.
Для sealed- или enum-типа не пишите else: когда кто-то добавит новый подтип, каждый when, который его не учёл, перестанет компилироваться. Ветка else это бы скрыла.
С Kotlin 1.7 when-оператор по sealed-, enum- или Boolean-аргументу тоже обязан быть исчерпывающим.
С Kotlin 2.2 у ветки может быть guard-условие: is UiState.Content if state.items.isEmpty() -> "Nothing here".
Диапазон start..end включает оба конца; ..< и until исключают конец, downTo считает в обратную сторону, а step меняет шаг.
for (i in 0..<list.size) даёт безопасный цикл по индексам; 0..list.size выходит на один шаг за последний индекс и бросает IndexOutOfBoundsException. Ещё понятнее писать list.indices или withIndex().
..< стабилен с Kotlin 1.9; until является более старым написанием того же самого.
Обратный .. даёт пустой диапазон, а не ошибку: for (i in 5..1) не выполнится ни разу. Используйте 5 downTo 1.
in и !in проверяют принадлежность без итерации: if (code in 200..299). С in работает любой Comparable, но перебирать в цикле можно только целочисленные диапазоны и диапазоны Char.
В заголовке for диапазон компилируется в обычный счётный цикл без аллокаций; если счётчик не нужен вовсе, используйте repeat(n) { }.
Лямбдой называют функциональный литерал вроде { x: Int -> x * 2 }, который можно сохранить или передать; функция высшего порядка принимает функцию параметром или возвращает её, как filter, map или ваша собственная retry(block: () -> Boolean).
Синтаксис: параметры до ->; единственный параметр доступен как it; последнее выражение становится результатом.
Функциональные типы: (Int) -> String, () -> Unit, suspend () -> Unit; nullable записывается как ((Int) -> Unit)?.
Trailing lambda: если последний параметр имеет функциональный тип, лямбда выносится за скобки: items.filter { it.isActive }.
Ссылки на функции: ::isValid, String::trim, viewModel::onClick передают существующую функцию вместо новой лямбды.
Замыкания: лямбда может читать и менять переменные окружающей области.
Во что она компилируется
На JVM лямбда становится объектом, реализующим функциональный интерфейс вроде Function1. С Kotlin 2.0 он генерируется через invokedynamic, как Java-лямбда, а не отдельным классом на каждую лямбду.
Лямбда, которая ничего не захватывает, создаётся один раз и переиспользуется; захватывающая аллоцирует объект при каждом вызове, а захваченный var оборачивается в объект Ref.
inline-функция высшего порядка (forEach, map, let) копирует тело лямбды в место вызова, поэтому объект не создаётся вовсе.
Код напечатает 1, 2, 1: каждый вызов makeCounter() создаёт свой count, а возвращённая лямбда его захватывает, поэтому у first и second отдельные счётчики, которые переживают вызов функции.
Лямбда работает как замыкание: она держит ссылку на count, а не копию, поэтому ++count меняет захваченную переменную, и следующий вызов видит новое значение.
Обычный локальный var исчезает, когда функция возвращает управление. Раз лямбда захватывает и меняет эту переменную, компилятор переносит её в маленький объект в куче (Ref.IntRef), общий для функции и лямбды.
second получен отдельным вызовом, поэтому у него свой IntRef, начинающийся с 0.
Захваченное изменяемое состояние не потокобезопасно: одновременные вызовы first() из двух потоков могут потерять инкременты. Если лямбда используется из нескольких потоков, берите AtomicInteger.
Параметр может объявить значение по умолчанию, и вызывающий код передаёт только то, что отличается, а любой аргумент можно передать по имени; вместе они заменяют телескопические конструкторы и большинство рукописных Builder-ов.
Именованные аргументы делают место вызова читаемым (createUser(name = "Ada", isAdmin = true) вместо голого true) и позволяют пропустить параметр в середине.
Значения по умолчанию вычисляются при каждом вызове и могут использовать предыдущие параметры: fun load(page: Int = 0, size: Int = if (page == 0) 50 else 20).
data class со значениями по умолчанию плюс copy(timeoutMs = 60_000) покрывают сценарий «тот же объект с одним изменением» без Builder.
Переопределяющая функция наследует значения по умолчанию и не имеет права объявлять свои.
Java interop: Java видит только полную сигнатуру; @JvmOverloads генерирует укороченные перегрузки. Кастомному View она нужна, потому что инфлейт из XML вызывает конструктор (Context, AttributeSet).
Добавляйте новые параметры в конец: параметр того же типа, вставленный в середину, оставляет позиционные вызовы компилируемыми, но меняет их смысл.
List является интерфейсом только для чтения без add и remove, MutableList их добавляет; val или var остаётся отдельным выбором: он решает, может ли переменная указывать на другой список, а не может ли меняться сам список.
val items = mutableListOf<Int>(): содержимое меняется (items.add(1)), ссылка не меняется никогда.
С var items = listOf<Int>() сам список не меняется; каждое обновление создаёт новый список и переприсваивает его: items = items + 1. Именно такую форму ждут StateFlow и стейт в Compose: они реагируют, только когда новое значение не равно старому, а список, изменённый на месте, всегда равен самому себе.
var с MutableList даёт два способа мутации и почти всегда ошибка.
Только для чтения не значит неизменяемый: MutableList можно передать туда, где ожидается List, и тот, у кого есть изменяемая ссылка, всё ещё может его поменять. Отдавайте из класса List, держите MutableList приватным, а для снимка копируйте через toList().
Для настоящей гарантии используйте kotlinx.collections.immutable (persistentListOf()).
Те же пары есть для Set / MutableSet и Map / MutableMap.
Это расширения стандартной библиотеки, которые строят новую коллекцию или значение, не меняя исходную: map преобразует каждый элемент, fold/reduce сворачивают все элементы в одно значение, а остальные перегруппировывают элементы в списки, мапы или пары.
**map / flatMap**: map даёт один результат на элемент; flatMap ждёт список на каждый элемент и сплющивает их в один: orders.flatMap { it.items }.
**filter / mapNotNull**: filter оставляет подходящие элементы; mapNotNull преобразует и выбрасывает null за один проход.
**fold и reduce**: fold(initial) начинает с вашего значения и работает на пустом списке; reduce начинает с первого элемента и бросает исключение на пустом списке. Для сумм проще всего sumOf { }.
**groupBy**: Map<K, List<V>>: каждый элемент сохраняется под своим ключом.
**associateBy**: Map<K, V>: один элемент на ключ, и более поздний дубликат молча затирает предыдущий.
**partition**: делит на Pair из подходящих и неподходящих элементов за один проход.
**zip**: составляет пары по позиции и останавливается на конце более короткого списка.
Каждый шаг аллоцирует новый список; asSequence() делает длинную цепочку ленивой.
Массивы компилируются в JVM-массивы фиксированного размера, которые сравниваются по ссылке, поэтому == для двух массивов с одинаковым содержимым даёт false; по умолчанию берите List, а массивы берите только там, где их требует API, vararg или производительность на примитивах.
Массивы не переопределяют equals, поэтому == сводится к сравнению ссылок; сравнивайте через contentEquals(), печатайте через contentToString().
data class со свойством-массивом получает equals и hashCode, которые игнорируют содержимое; храните List.
Array<Int> компилируется в Integer[], каждый элемент боксируется; IntArray, LongArray, ByteArray и остальные соответствуют примитивным int[], long[], byte[].
**vararg**-параметры приходят массивом; существующий массив передаётся spread-оператором: log(*parts).
Типичные случаи для массивов: ByteArray для файлов, сети и криптографии, числовые горячие циклы, Java- и Android-API, которые принимают массивы.
Конвертация: toList(), toIntArray() и toTypedArray().
val (a, b) = obj по порядку вызывает obj.component1() и obj.component2(); имена, которые вы пишете, не важны, важна только позиция.
data class генерирует componentN() для каждого свойства основного конструктора; они есть и у Pair, Triple и Map.Entry.
Частые случаи: for ((key, value) in map), for ((index, item) in list.withIndex()), параметры лямбды вроде map.forEach { (k, v) -> } и возврат двух значений через небольшой data-класс.
_ пропускает ненужный компонент.
Любой класс может поддержать деструктуризацию, объявив operator fun component1(), component2() и так далее.
Ловушка: раз всё позиционно, перестановка двух свойств одного типа в конструкторе оставляет все места деструктуризации компилируемыми, но молча присваивает не те значения. Деструктурируйте только небольшие типы с очевидным порядком, а широкие модели читайте по именам свойств.
String неизменяем, поэтому каждый += создаёт новую строку и копирует все символы, накопленные к этому моменту, так что n добавлений стоят O(n²); StringBuilder, обычно через buildString { } или joinToString(), дописывает в один растущий буфер.
Один шаблон или a + b + c в одном выражении писать нормально: компилятор превращает его в одну эффективную конкатенацию. Цена появляется только между итерациями цикла.
**buildString { append(...) }**: даёт StringBuilder внутри блока и возвращает готовую строку.
**joinToString(", ") { it.name }**: типичный случай склейки списка, с параметрами prefix, postfix и limit вместо ручной возни с разделителями.
**StringBuffer**: синхронизированный устаревший близнец StringBuilder; если собирать строку в одном потоке, он не нужен.
После проверки вроде x is String или x != null компилятор считает x более узким типом в этой ветке без явного приведения, но только если может доказать, что значение не изменится между проверкой и использованием.
Где работает
Локальные val подходят всегда; локальные var подходят, если их не переприсваивают между проверкой и использованием.
val-свойства, объявленные в том же модуле, не open и без кастомного геттера.
В when (x) { is Foo -> ... }, справа от && и после раннего return или throw в противоположной ветке.
Где не работает
var-свойство: другой поток или вызов посередине может его переприсвоить. Сначала скопируйте его в локальную переменную.
val с кастомным геттером, open-свойство или свойство из другого модуля: каждое чтение может вернуть другое значение.
Компилятор K2 (по умолчанию с Kotlin 2.0) принимает больше случаев, чем старый, например общий супертип после || и локальные переменные, используемые в лямбдах.
as и as?
x as String приводит тип и бросает ClassCastException при несовпадении.
x as? String вместо этого возвращает null, поэтому хорошо сочетается с Elvis: (x as? String) ?: "".
?.let { } ?: showGuestBanner() проверяет результат лямбды, а не user: lastSeenName.put() возвращает предыдущее значение, а при первом заходе оно null, поэтому баннер для гостя видит и залогиненный пользователь.
Elvis смотрит на всю левую часть: user?.let { … } равно null, когда null сам user или когда блок вернул null. MutableMap.put() возвращает старое значение по ключу, поэтому null получается ровно при первом заходе.
Почему баг проскакивает: у всех, кто уже открывал экран, всё работает, так что быстрая ручная проверка проходит. Та же ловушка встречается в любом блоке, последний вызов которого возвращает T?.
Исправление: для двух веток логики нужен настоящий if (user != null) … else …: smart cast делает user не-null внутри, а else зависит только от user. ?: оставьте для выбора значения, как в user?.name ?: "guest".
Если хочется сохранить цепочку: user?.also { … } ?: showGuestBanner() работает, потому что also возвращает свой получатель, но читателю нужно это знать; if/else понять неправильно невозможно.
Как поймать: unit-тест с пустой мапой и не-null пользователем, который проверяет, что баннера нет; на ревью считайте ?.let { … } ?: … в роли управления потоком тревожным сигналом.
Да, val означает лишь отсутствие сеттера; у val с кастомным геттером нет backing field, и геттер выполняется при каждом чтении, так что он может каждый раз возвращать новое значение.
Свойство в Kotlin состоит из геттера, необязательного сеттера и необязательного backing field. Поле существует, только если используется аксессор по умолчанию или аксессор обращается к field.
val isEmpty get() = items.isEmpty() ничего не хранит и пересчитывается при каждом чтении; val createdAt = now() сохраняет значение один раз.
Внутри кастомного сеттера пишите в field; присваивание самому свойству снова вызывает сеттер и заканчивается StackOverflowError.
var count = 0 с последующим private set даёт публичное чтение и приватную запись; это обычный способ отдать состояние из класса.
Вычисляемые свойства должны быть дешёвыми и без побочных эффектов, потому что вызывающий код считает чтение почти бесплатным; тяжёлую работу выносите в функцию или by lazy.
По этой же причине smart cast не работает для val с кастомным геттером.
by lazy вычисляет authHeader один раз, при первом запросе, и кеширует его на всё время жизни единственного ApiClient, поэтому после повторного входа каждый запрос по-прежнему уходит с токеном предыдущего пользователя, и сервер отвечает 401.
**Что обещает lazy**: инициализатор выполняется при первом чтении, а результат сохраняется навсегда; последующие изменения session.token он не видит. lazy подходит для значений, которые не меняются после вычисления, например форматтера или распарсенного конфига.
Вариант хуже: если первый запрос ушёл до логина, в кеше окажется "Bearer null": строковый шаблон превращает null в текст null, и клиент остаётся сломанным, пока процесс не умрёт.
Исправление: кастомный геттер get() = "Bearer ${session.token}" не имеет backing field и читает актуальный токен при каждом обращении; собрать одну строку на запрос ничего не стоит по сравнению с сетевым вызовом.
В реальном приложении: добавляйте токен в OkHttp Interceptor, который читает сессию в момент запроса, и обрабатывайте отсутствие токена явно, а не отправляйте Bearer null.
Как поймать: тест, который меняет токен между двумя вызовами request() и проверяет заголовок второго; с подозрением относитесь к by lazy для значений, которые могут измениться после логаута.
Отдельных примитивных типов в языке Kotlin нет (Int выглядит как обычный класс), но компилятор хранит non-null Int, Long, Double или Boolean как JVM-примитив везде, где может, и боксирует его, только когда нужен объект.
Боксируется: nullable Int?, аргумент дженерика (List<Int>, Map<String, Int>, T), значение, присвоенное Any, и Array<Int>.
Не боксируется: локальные переменные, свойства, параметры и возвращаемые значения типа Int, а также IntArray.
Боксинг стоит аллокации (вне кеша -128..127) и лишнего перехода по указателю. Это важно в горячих циклах, больших коллекциях и коде, выполняемом каждый кадр: используйте IntArray или мапы из androidx.collection, например MutableIntIntMap.
Нет неявного расширения: val total: Long = count не компилируется, если count имеет тип Int; пишите count.toLong(). Литерал получает тип из контекста, поэтому val x: Long = 1 работает.
Арифметика остаётся типизированной: Int / Int выполняет целочисленное деление (1 / 2 == 0), а переполнение Int молча заворачивается (Int.MAX_VALUE + 1 отрицательно); для таймстемпов и денег в минимальных единицах используйте Long.
Any служит корнем всех non-null типов, экземпляров у него сколько угодно, у Unit ровно один экземпляр и он играет роль void из Java, а у Nothing экземпляров нет вовсе: это тип выражения, которое никогда не завершается.
**Any**: соответствует java.lang.Object и объявляет только equals(), hashCode() и toString(). Any? стоит на вершине всей иерархии, включая null.
**Unit**: синглтон object. Функция без объявленного типа возврата возвращает Unit; на JVM это компилируется в void, но как настоящий тип он работает в качестве аргумента дженерика: () -> Unit, Result<Unit>.
**Nothing**: тип throw, TODO(), error() и бесконечного цикла. Он подтип любого типа, поэтому val id: String = raw ?: throw IllegalStateException() проходит проверку типов, а код после него помечается недостижимым.
Функцию, объявленную как fun fail(msg: String): Nothing, можно поставить в любую ветку, не расширяя тип результата.
У Nothing? ровно одно значение (null): val x = null выводится как Nothing?, а List<Nothing> подходит к любому List<T>, потому что List ковариантен.
Нет, ничто в сигнатуре Kotlin-функции не заставляет вызывающий код ловить исключение, поэтому ожидаемые ошибки уходят в тип возврата (sealed-результат или Result<T>), а исключения остаются для багов и действительно исключительных ситуаций.
try/catch работает как выражение: val port = try { raw.toInt() } catch (e: NumberFormatException) { 8080 }.
require(cond) бросает IllegalArgumentException для неверного аргумента, check(cond) бросает IllegalStateException для нарушенного состояния, error("msg") бросает всегда.
Ожидаемые исходы: неверный пароль, нет сети, платёж отклонён. Моделируйте их как sealed interface вроде Success / WrongPassword / Offline, чтобы when заставил вызывающий код обработать каждый случай.
runCatching { } возвращает Result<T>, но ловит любой Throwable, включая CancellationException; внутри корутин пробрасывайте его дальше, иначе отмена перестанет работать.
Java-код не знает, что Kotlin-функция бросает исключение: добавьте @Throws(IOException::class), чтобы Java была обязана его поймать, а Swift (через Kotlin/Native) видел throws.
Почему Kotlin от них отказался: в больших Java-проектах checked exceptions в основном порождали пустые catch и цепочки throws Exception, а ещё плохо сочетаются с лямбдами.
Код напечатает только a: forEach объявлена как inline-функция, поэтому голый return внутри её лямбды возвращает из самой printVisible, и ни b, ни done не печатаются. Запись return@forEach (а начиная с Kotlin 2.2 также обычный continue) пропускает только текущий элемент.
Инлайнинг копирует тело лямбды в вызывающую функцию, поэтому у return нет лямбды, из которой можно выйти, и он завершает внешнюю функцию. Это нелокальный возврат (non-local return).
У каждой лямбды есть неявная метка по имени функции, в которую она передана: return@forEach работает как continue (на Kotlin 2.2+ то же делает и обычный continue), а return@map value возвращает значение для одного элемента.
Циклам можно ставить явные метки: outer@ for (row in grid) { for (cell in row) if (cell == 0) break@outer }.
Лямбда, переданная в не-inline функцию или в crossinline-параметр, может выполниться уже после завершения внешней функции, поэтому компилятор запрещает там голый return и принимает только возврат с меткой.
До Kotlin 2.2 у forEach не было break; начиная с 2.2 break и continue работают прямо внутри лямбд inline-функций вроде forEach и any. На более старых версиях используйте обычный цикл for с break или firstOrNull { }, any { }, takeWhile { }. Внутри лямбды не-inline функции они по-прежнему запрещены.