typealias задаёт просто второе имя для существующего типа: компилятор везде подставляет настоящий тип, поэтому в байткоде ничего нового не появляется, и UserId полностью взаимозаменяем с Long.
Где полезен: сократить длинные generic- или функциональные типы (typealias Reducer<S, A> = (S, A) -> S) и дать читаемое имя типу колбэка.
Безопасности нет: fun ban(id: UserId) спокойно примет любой Long, в том числе id заказа. Если боитесь именно перепутать два id, используйте @JvmInline value class.
Невидим за пределами компилятора: Java, рефлексия и стектрейсы видят только исходный тип.
**Против fun interface**: алиас для (Int) -> Unit и есть этот функциональный тип, поэтому подойдёт любая подходящая лямбда, а Java видит Function1. fun interface представляет собой настоящий именованный тип: Java может его реализовать, у него могут быть дополнительные члены, и посторонний функциональный тип молча не подставится.
AndroidMiddle
Продвинутый Kotlin
Дженерики, inline/reified, делегаты, DSL builder-ы, type alias
Закрыто вопросов
0 / 23
- 0Повторить
- 5Часто спрашивают
Вопросы
По умолчанию generic-типы инвариантны; out T делает тип ковариантным, потому что он только отдаёт T, а in T делает его контравариантным, потому что он только принимает T. Это котлиновская версия правила Producer-Extends, Consumer-Super из Java.
Инвариантность: MutableList<Dog> не является MutableList<Animal>, иначе в список собак можно было бы добавить Cat.
**out T (ковариантность)**: List<out E> только возвращает элементы, поэтому List<Dog> можно передать туда, где ждут List<Animal>. T может стоять только в выходных позициях.
**in T (контравариантность)**: Comparator<in T> только принимает T, поэтому Comparator<Animal> может сортировать собак.
На месте объявления и на месте использования: в Kotlin out/in пишут один раз на классе, а в Java ? extends повторяют при каждом использовании. Для инвариантного класса можно сделать проекцию на месте использования: fun copy(from: Array<out Any>, to: Array<in Any>).
Star projection: List<*> означает «список чего-то неизвестного»: элементы читаются как Any?, добавить ничего нельзя. Это аналог List<?> в Java.
Соответствие PECS: out = ? extends, in = ? super, * = ?.
Nothing считается подтипом любого типа, а с out T это отношение переносится на контейнер, поэтому UiState<Nothing> является подтипом любого UiState<T>, и один синглтон Loading обслуживает все экраны.
Без out интерфейс инвариантен: UiState<Nothing> и UiState<User> становятся несвязанными типами, и оба присваивания падают с type mismatch. Пришлось бы сделать Loading generic-классом и создавать отдельный экземпляр под каждый тип.
**Nothing**: тип без значений, на дне иерархии; это тип выражения throw и возвращаемый тип TODO(), а emptyList() возвращает один общий List<Nothing>.
**Цена out**: T может стоять только в выходных позициях, поэтому в UiState<T> нельзя объявить fun with(value: T); Error в порядке, потому что не упоминает T.
На JVM аргументы дженериков стираются: в рантайме объект знает только, что он List, поэтому is List<String> проверить невозможно (это ошибка компиляции), а as List<String> проверяет лишь часть List и пропускает элементы не того типа.
Непроверяемое приведение: для listOf(1, 2, 3) каст проходит успешно. ClassCastException («Integer cannot be cast to String») возникает при первом чтении элемента как String, далеко от настоящей ошибки. Именно об этом отложенном падении и предупреждает компилятор.
Что разрешено: payload is List<*>: star projection означает «список неизвестных элементов», которые читаются как Any?.
Безопасный вариант: (payload as? List<*>).orEmpty().filterIsInstance<String>() проверяет каждый элемент и возвращает настоящий List<String>; он молча отбрасывает не-String элементы, а если неверный элемент должен ронять код, используйте map { it as String }.
**reified не отменяет стирание**: он даёт inline-функции класс в месте вызова, поэтому is T работает для T = String, но для T = List<String> проверка по-прежнему только «это List».
Правильный дизайн: превращать Any в типизированные модели один раз, на границе (JSON, Bundle, extras из Intent), а не кастовать внутри бизнес-логики.
Тело inline-функции и переданные ей лямбды копируются в каждое место вызова, поэтому объект Function не создаётся и лишнего вызова нет; noinline оставляет конкретную лямбду настоящим объектом, а crossinline запрещает нелокальный return из лямбды, которая выполняется в другом контексте.
Что даёт: нет аллокации лямбд на горячих участках (поэтому map, filter, forEach, let и apply объявлены inline), нелокальный return из лямбды и reified-параметры типа.
**noinline**: нужен, когда лямбду надо сохранить или передать в не-inline функцию, например pending += task.
**crossinline**: нужен, когда лямбда вызывается из другой лямбды или объекта внутри тела (handler.post { block() }). Тогда вызывающий код может использовать только return@name с меткой.
Когда вредит: большое тело, скопированное в сотни мест вызова, раздувает байткод и dex. Функция без параметров функционального типа и без reified ничего не выигрывает, и компилятор предупреждает: «expected performance impact from inlining is insignificant».
Ограничения: публичная inline-функция может использовать только публичные объявления и @PublishedApi, а любая inline-функция не может рекурсивно вызывать саму себя.
Лямбда встраивается в save(), где голый return означает «выйти из save», но post выполнит её позже внутри Runnable, когда save давно завершилась, поэтому компилятор отказывает: «Can't inline 'block' here: it may contain non-local returns».
**Способ 1: crossinline block: () -> Unit**: лямбда по-прежнему встраивается, теперь в тело Runnable, а вызывающий код может писать только return@runOnUi с меткой, который выходит лишь из лямбды. Обычно правильный вариант.
**Способ 2: noinline block: () -> Unit**: лямбда остаётся обычным объектом, который передаётся в post. Ограничение на return то же, плюс аллокация, а раз встраиваемых лямбд не осталось, inline ничего не даёт, и его стоит убрать.
Правило: любая встраиваемая лямбда, которая вызывается из вложенной лямбды или анонимного объекта (например, Runnable или тела корутины), должна быть crossinline: у нелокального return просто не будет кадра стека, из которого выходить.
В вызывающем коде: после исправления компилируется runOnUi { if (isFinishing) return@runOnUi }.
reified делает параметр типа доступным в рантайме: тело inline-функции копируется в каждое место вызова, и компилятор подставляет туда конкретный тип, поэтому работают T::class, is T и as T.
Почему только inline: обычная generic-функция компилируется один раз, и T стирается, так что тип просто некуда положить. Встраивание создаёт копию для каждого места вызова, где T известен.
Типичное применение: context.launch<DetailsActivity>() вместо передачи DetailsActivity::class.java, filterIsInstance<T>(), by viewModels<T>(), Json.decodeFromString<T>().
Java: функцию с reified-параметром из Java вызвать нельзя вообще, поэтому для Java делают перегрузку, принимающую Class<T>. Обычную inline-функцию без reified из Java вызвать можно, но это будет обычный вызов: ничего не встраивается.
Ограничения: reified даёт класс, но не его generic-аргументы (T = List<User> превращается просто в List), и передать в такую функцию не-reified T из другой generic-функции нельзя.
val x by d передаёт геттер и сеттер свойства объекту d: компилятор хранит d в скрытом поле и превращает каждое чтение в d.getValue(this, ::x), а каждую запись превращает в d.setValue(this, ::x, value).
**lazy { }: вычисляется при первом чтении и кешируется; по умолчанию синхронизирован.
Delegates.observable(initial) { prop, old, new -> }: вызывает колбэк после каждого присваивания.
Delegates.vetoable(initial) { prop, old, new -> Boolean }: вызывается до присваивания и может отклонить новое значение.
Delegates.notNull()**: как lateinit, но работает и для Int, Boolean и других примитивных типов, для которых lateinit запрещён.
**by map**: читает свойство из Map по его имени; удобно для динамического конфига.
Свой делегат: реализуйте ReadOnlyProperty / ReadWriteProperty или просто operator fun getValue / setValue.
На Android делегаты используются каждый день: by viewModels(), by navArgs() и в Compose by remember { mutableStateOf(...) }, где State читается через extension getValue.
По умолчанию да: lazy { } использует SYNCHRONIZED, поэтому инициализатор выполняется ровно один раз, даже если несколько потоков читают свойство одновременно; два других режима жертвуют этой гарантией ради меньшего числа блокировок.
**SYNCHRONIZED (по умолчанию): double-checked locking; остальные потоки ждут, пока завершится первая инициализация. Если инициализатор бросил исключение, ничего не кешируется, и следующее чтение попробует снова.
PUBLICATION: без блокировки; инициализатор может выполниться в нескольких потоках параллельно, но сохраняется только первое возвращённое значение, и все видят именно его. Подходит для чистого и дешёвого инициализатора.
NONE: никакой синхронизации; при конкурентном доступе инициализатор может выполниться дважды, а потоки могут увидеть разные экземпляры. Используйте, только когда доступ ограничен одним потоком, например объект читается только на main-потоке.
Стоимость**: после инициализации SYNCHRONIZED-lazy читает @Volatile-поле без захвата блокировки, так что режим по умолчанию редко становится узким местом.
Ловушка: два lazy, инициализаторы которых читают друг друга из двух разных потоков, могут получить deadlock: каждый поток держит одну блокировку и ждёт другую.
Delegates.observable срабатывает только при переприсваивании свойства, а items.add(item) меняет тот же список через геттер, поэтому колбэк не вызывается и экран пропускает каждое добавление; clear() работает лишь потому, что присваивает новый список.
Что отслеживает делегат: observable оборачивает сеттер: setValue сохраняет новую ссылку и затем вызывает колбэк. items.add() вызывает getValue, получает список и меняет его на месте, так что делегат об этом не узнаёт.
Исправление: отдавать наружу read-only List<String> и заменять его целиком: items = items + item создаёт новый список и проходит через сеттер, поэтому каждое изменение даёт один колбэк с полным снимком.
Зачем read-only тип: private set запрещает снаружи только присваивание, но не store.items.add(). С типом List<String> мутация в обход колбэка просто не скомпилируется.
Тот же баг в других местах: MutableStateFlow<MutableList<T>>, mutableStateOf(mutableListOf()) в Compose и LiveData тоже молчат, когда вы меняете хранимый объект на месте; StateFlow и вовсе отбрасывает повторное присваивание того же экземпляра, потому что сравнивает значения через equals. Храните неизменяемые значения и пишите _state.update { it + item }.
@JvmInline value class UserId(val raw: Long) задаёт отдельный тип на этапе компиляции, но в рантайме компилятор везде, где может, использует исходный Long напрямую, так что вы получаете типобезопасность без объекта на каждое значение.
Правила: ровно один val в первичном конструкторе, нет идентичности (=== запрещён), нет дополнительных backing-полей; блоки init, методы и реализация интерфейсов разрешены.
Когда происходит боксинг: при nullable-типе поверх примитива (UserId?), в качестве generic-аргумента (List<UserId>, любой T) и при использовании как реализуемого интерфейса или как Any.
Манглинг имён: функция, принимающая value class, получает изменённое JVM-имя вроде ban-<hash>, поэтому из Java её нормально не вызвать. В Kotlin 2.2 появилась экспериментальная @JvmExposeBoxed, которая генерирует удобные для Java версии.
**Против typealias: алиас даёт только другое имя; value class создаёт новый тип, который проверяет компилятор.
Против data class**: data class хранит несколько полей, но всегда создаёт объект.
Где полезен: id, Email, деньги в минимальных единицах, единицы измерения. Dp и Color в Compose реализованы как value class.
Они меняют JVM-форму Kotlin-объявления, чтобы Java могла вызывать его естественно: настоящий статический метод, перегрузки, которые подразумевают аргументы по умолчанию, обычное поле или другое JVM-имя.
**@JvmStatic**: член companion object или object становится настоящим статическим методом; без него в Java пишут Config.Companion.load() или Analytics.INSTANCE.track().
**@JvmOverloads**: генерирует цепочку перегрузок для аргументов по умолчанию. Классический случай касается конструктора кастомной View (context, attrs = null, defStyleAttr = 0), который XML-инфлейтер вызывает с двумя аргументами.
**@JvmField**: открывает свойство как публичное поле без геттера и сеттера; const val делает то же для констант времени компиляции.
**@JvmName**: переименовывает функцию или файловый фасад (StringUtils вместо StringUtilsKt) и разрешает конфликт платформенных объявлений, когда две функции после стирания получают одинаковую JVM-сигнатуру.
**@Throws**: в Kotlin нет checked-исключений, поэтому Java не сможет написать catch (IOException e), пока функция их не объявит.
**@JvmSuppressWildcards**: Kotlin превращает Set<Tracker> в параметре в Set<? extends Tracker>; Dagger и Hilt сравнивают типы буквально, поэтому для multibinding-инъекций Set/Map эта аннотация часто нужна.
Не забывайте и про use-site targets: чтобы аннотация из Java-библиотеки на свойстве конструктора попала на нужный сгенерированный элемент, может понадобиться @field: или @get:.
Null safety в Kotlin покрывает только код, который проверил компилятор Kotlin; null просачивается через платформенные типы Java, десериализацию на рефлексии, !! и lateinit, непроверяемые дженерики и порядок инициализации.
Платформенные типы: значение из неаннотированной Java имеет тип String!; его можно присвоить в String без проверки, и падение случится при первом использовании. Аннотированная Java (@Nullable, @NonNull, JSpecify) даёт нормальные типы; AndroidX и большая часть Android SDK аннотированы.
Gson: класс без конструктора без аргументов создаётся через Unsafe без вызова конструктора, поэтому при отсутствии поля в JSON val name: String остаётся null, а значения по умолчанию игнорируются (если у всех параметров есть значения по умолчанию, конструктор без аргументов появляется и проблемы нет). Moshi с кодогенерацией и kotlinx.serialization вызывают настоящий конструктор и либо подставляют значение по умолчанию, либо явно падают.
**!! и lateinit: это утверждения, а не доказательства.
Дженерики**: неограниченный <T> означает <T : Any?>, так что T может быть null; пишите <T : Any>, когда null недопустим. Непроверяемые приведения тоже могут протащить null в List<String>.
Порядок инициализации: open-метод, вызванный из конструктора суперкласса, читает свойства подкласса до того, как они присвоены.
Проверка на границе: Kotlin вставляет Intrinsics.checkNotNullParameter в публичные функции, поэтому Java-код, передавший null, падает сразу, с именем параметра.
Решение архитектурное: валидировать на границе (сетевые DTO, строки из базы, Java API) и маппить в доменные типы, nullability которых можно доверять.
Аннотация представляет собой метаданные на объявлении; её retention определяет, где она сохранится (только в исходниках, в class-файле или для рефлексии в рантайме), а annotation processors читают её на этапе компиляции и генерируют код. Так работают Room, Hilt и кодогенерация Moshi.
Retention: AnnotationRetention.SOURCE существует только для компилятора и процессоров, BINARY сохраняется в class-файле, но не видна рефлексии, а RUNTIME (значение по умолчанию в Kotlin) доступна через рефлексию; так Retrofit читает @GET.
Targets: @Target ограничивает, где можно поставить аннотацию; на свойстве use-site targets (@field:, @get:, @param:) решают, на какой сгенерированный элемент она попадёт.
kapt: запускает Java annotation processors, предварительно генерируя Java-стабы для каждого Kotlin-файла, поэтому медленный; находится в режиме поддержки.
KSP: Kotlin Symbol Processing читает символы Kotlin напрямую, без стабов, и обычно примерно вдвое быстрее; текущая реализация называется KSP2. Room, Dagger/Hilt, Moshi и Glide его поддерживают, поэтому новый код использует ksp(...), а старые модули уходят с kapt.
Генерировать, а не рефлексировать: на Android рефлексия в рантайме медленная, kotlin-reflect добавляет мегабайты, а R8 вырезает или переименовывает то, что ищет рефлексия, если не добавить keep-правила.
Opt-in помечает API как экспериментальный или опасный: автор вешает на него аннотацию-маркер, и каждый вызывающий должен явно подтвердить этот маркер в коде, так что ломающее изменение затронет только тех, кто принял риск.
Маркер: annotation class с аннотацией @RequiresOptIn(message = "...", level = ...); ERROR ломает компиляцию, WARNING только предупреждает.
Принять локально: @OptIn(ExperimentalMaterial3Api::class) на функции, классе или файле; требование дальше не распространяется.
Пробросить дальше: пометить своё объявление самим маркером, и тогда opt-in придётся делать уже вашим вызывающим.
На весь модуль: compilerOptions { optIn.add("kotlinx.coroutines.ExperimentalCoroutinesApi") } в Gradle-блоке kotlin.
Маркеры, которые встречаются постоянно: ExperimentalMaterial3Api, ExperimentalCoroutinesApi (например, на flatMapLatest) и DelicateCoroutinesApi на GlobalScope, который стабилен, но им легко навредить.
Это не deprecation: @Deprecated говорит, что API уйдёт; opt-in говорит, что он работает сейчас, но может измениться.
Свой маркер стоит заводить для публичного, но нестабильного API общего модуля или для опасного вызова, который должен быть осознанным решением в каждом месте использования.
Тело inline-функции копируется в каждое место вызова, поэтому становится частью опубликованной бинарной поверхности: оно может обращаться только к тому, что видно вызывающему, а вызывающие хранят ту копию, с которой были скомпилированы, пока их не пересоберут.
Последствия
Видимость: публичная inline-функция не может ссылаться на private- или internal-объявления: «Public-API inline function cannot access non-public-API». Выходом служит @PublishedApi internal: публично в байткоде, но по-прежнему internal для исходников Kotlin. Если потом переименовать или удалить такое объявление, это будет бинарно несовместимое изменение.
Бинарная совместимость: исправление бага внутри inline-функции не доходит до уже скомпилированных вызывающих. Приложение, зависящее от библиотеки A, собранной с вашей 1.0, продолжит выполнять тело из 1.0 даже после выхода 1.1. Тело inline-функции в библиотеке является API.
Размер кода: тело оказывается в каждом месте вызова и раздувает методы и dex; держите inline-тела маленькими, а основную работу выносите в обычную (@PublishedApi internal) функцию, которую они вызывают.
Без лямбд выигрыша нет: без параметра функционального типа и без reified встраивание ничего не экономит, и компилятор об этом предупреждает.
Когда это всё же оправдано
Нужен reified-параметр типа или нелокальный return из лямбды: и то и другое существует только благодаря встраиванию.
Крошечный хелпер высшего порядка на горячем пути.
Правило: в опубликованной библиотеке inline означает обязательство, а не переключатель оптимизации.
guarded отпускает лок, только если block завершился нормально. Ранний return в refresh() оказывается нелокальным возвратом из заинлайненной лямбды: он сразу выходит из refresh(), пропускает unlock(), и лок остаётся захваченным навсегда. Исключение из fetchToken() даёт то же самое.
**Почему return пропускает разблокировку**: тело лямбды встраивается в refresh(), поэтому return сразу выходит из refresh(), и .also { unlock() } после block() не выполняется. Это происходит при каждом вызове, когда токен уже свежий, то есть начиная со второго.
Почему зависают только другие потоки: ReentrantLock считает захваты для каждого потока: поток-владелец спокойно входит повторно (его счётчик захватов просто растёт), а все остальные потоки навсегда блокируются в lock().
Исправление: try { return block() } finally { unlock() }: finally выполняется и при обычном выходе, и при нелокальном возврате, и при исключении. В реальном коде достаточно вызвать withLock из стандартной библиотеки; он написан ровно так. Пометка block как crossinline запретила бы return, но лок всё равно утекал бы при исключении.
Как поймать такое: на ревью любая inline-функция, которая что-то захватывает, вызывает лямбду и освобождает ресурс, должна использовать try/finally (или use). Тест, который проходит по ветке с ранним возвратом и затем проверяет lock.holdCount == 0, сразу упадёт.
reified захватывает класс из места вызова, но не его аргументы типа: для T = List<User> T::class даёт просто List, поэтому Gson получает указание собрать список и наполняет его своими LinkedTreeMap по умолчанию, а ClassCastException вылетает там, где вы впервые читаете поле.
Что reified видит и чего не видит
Компилятор подставляет конкретный тип в каждом месте вызова, поэтому компилируются T::class, is T и as T. Все три относятся к вопросам уровня класса, а User внутри List<User> стирается, как любой другой generic-аргумент.
typeOf<T>() (kotlin.reflect.typeOf, стабилен с 1.6, требует reified T) возвращает полный KType с аргументами: typeOf<List<User>>() сохраняет аргумент: kotlin.collections.List<User>.
Любой API с параметром Class<T> или KClass<T> выбрасывает аргументы по определению. Настоящий дефект сидит в хелпере, а не в Gson.
Исправления, по одному на библиотеку
kotlinx.serialization: serializer(typeOf<T>()); именно так устроен её собственный reified decodeFromString<List<User>>(), поэтому встроенная перегрузка корректна, а самописная на Class некорректна.
Gson: super-type token, object : TypeToken<T>() {}.type, который кодирует полный тип в супертипе анонимного подкласса.
Moshi: Types.newParameterizedType(List::class.java, User::class.java).
Ментальная модель: reified отвечает на вопрос «какой класс», а typeOf отвечает на вопрос «какой тип». Для generic-нагрузки нужен второй.
by expr вычисляет выражение один раз для каждого свойства и сохраняет результат в скрытом поле, поэтому два свойства с одним и тем же объектом-делегатом делят все его поля; в момент вызова их различает только KProperty, переданный в getValue/setValue.
Механизм
Для var theme: String by Setting() компилятор создаёт синтетическое поле theme$delegate и переписывает чтения в theme$delegate.getValue(this, ::theme), а записи переписывает в setValue(this, ::theme, value).
thisRef хранит экземпляр-владелец, а property описывает свойство, к которому обращаются. Ключ настроек, ключ бандла или имя колонки нужно брать из property.name, а не из состояния, закешированного внутри делегата.
Состояние делегата принадлежит объекту делегата, а не свойству. Один общий экземпляр даёт одно общее значение; исправляется это новым делегатом в каждом by или ключами по property.name.
by lazy { } избегает ловушки по построению: каждый вызов lazy создаёт свой Lazy. На свойстве класса это по экземпляру; на object или свойстве верхнего уровня получается синглтон на весь процесс.
provideDelegate
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) выполняется один раз, при инициализации свойства, и возвращает настоящий делегат; это точка, где можно проверить ключ, где-то зарегистрировать свойство или собрать делегат, зависящий от имени.
PropertyDelegateProvider: интерфейс stdlib для того же оператора.
Правило: состояние живёт в объекте делегата, поэтому дайте каждому свойству свой делегат или стройте все ключи от property.name.
DSL на Kotlin пишется обычным кодом на лямбдах с ресивером: функция принимает block: Builder.() -> Unit, создаёт билдер, выполняет блок с билдером в роли this и возвращает результат, а @DslMarker не даёт вложенным блокам незаметно вызывать внешний билдер.
Строительные блоки
Лямбда с ресивером: внутри Builder.() -> Unit члены билдера доступны без квалификатора, поэтому item("Share") { icon = ... } читается как конфигурация.
**apply**: создать и настроить за один вызов: MenuBuilder().apply(block).build().
Extension-функции: добавляют «глаголы» в билдеры, которые вам не принадлежат, как это делают Gradle-плагины в build.gradle.kts.
Infix-функции и операторы: шлифовка вроде Users.id eq 1 в Exposed; используйте умеренно, они ухудшают обнаруживаемость API.
Зачем @DslMarker
Во вложенных лямбдах все внешние ресиверы остаются неявно доступными, поэтому item(...), написанный внутри блока item { }, разрешится во внешний MenuBuilder и молча добавит соседний пункт.
Если пометить все билдеры одним маркером, такие неявные вызовы становятся ошибкой компиляции; к внешней области по-прежнему можно обратиться явно, например this@menu.item(...).
Советы по дизайну
Билдеры должны быть изменяемыми только во время блока; возвращайте неизменяемый результат и валидируйте его в build().
Известные примеры: Gradle Kotlin DSL, роутинг Ktor, kotlinx.html, модули Koin. Compose использует ту же идею областей с ресивером для RowScope.weight и BoxScope.align.
Функция стандартной библиотеки объявляет контракт (обещание компилятору о том, что её возвращаемое значение говорит об аргументах), а ваш хелпер для компилятора непрозрачен, поэтому он не знает, что false означает «не null», и отбрасывает smart cast.
Что выражают контракты
returns(false) implies (this@isNullOrEmpty != null): именно такую форму использует isNullOrEmpty; есть также returns(), returns(true) и returnsNotNull().
callsInPlace(block, InvocationKind.EXACTLY_ONCE) сообщает компилятору, что лямбда выполняется прямо здесь и ровно один раз, поэтому val можно присвоить внутри run { } и использовать после.
require, check, requireNotNull и checkNotNull построены на контрактах, поэтому require(x != null) сужает тип x до конца функции.
Правила и опасность
Вызов contract { } должен быть первой инструкцией функции верхнего уровня с блочным телом, а API требует @OptIn(ExperimentalContracts::class).
Компилятор ДОВЕРЯЕТ контракту и никогда его не проверяет. Неверный implies равносилен лжи компилятору, и итоговый NullPointerException вылетит далеко от ошибки.
Контракты оправданы для широко используемого предиката или хелпера наподобие scope-функции, а не для разового кода.
Context parameter задаёт зависимость, которую функция объявляет, но которую никто не передаёт явно: компилятор подставляет её из подходящего контекста, уже доступного в области видимости, поэтому «фоновые» вещи вроде логгера или транзакции больше не нужно протаскивать через каждую сигнатуру.
Объявление: context(logger: Logger) fun sync(). Вызов компилируется, только если доступен контекст Logger; иначе ошибка сообщит, что не хватает context argument. Если нужно лишь потребовать контекст, в качестве имени используйте _.
Как передать: открыть область через with(logger) { sync() } или объявить такой же context parameter у вызывающей функции, и он пробросится дальше.
Несколько сразу и ресивер: context(logger: Logger, analytics: Analytics) fun User.deactivate(). В отличие от extension-ресивера, context parameter не занимает this.
Возможности в пределах области: context(tx: Transaction) fun UserDao.deleteAll() можно вызвать только внутри блока транзакции; DSL используют ту же идею.
Границы
Это разрешение на этапе компиляции, а не внедрение зависимостей: оно заменяет протаскивание параметров, а не Hilt или Koin, и ничего не ищется в контейнере.
Оно заменяет старые context receivers, которые так и не стали стабильными; туториал с context(Logger) без имени параметра устарел.
Context parameters появились как preview в Kotlin 2.2 (флаг -Xcontext-parameters) и стали стабильными в Kotlin 2.4; на более старых компиляторах флаг всё ещё нужен. Явная передача контекста в месте вызова пока экспериментальная.
Kotlin 2.0 сделал K2 (переписанный фронтенд компилятора) версией по умолчанию: более быстрый анализ и более умные smart cast почти без изменений в исходниках, а релизы 2.x с тех пор используют его, чтобы регулярно выпускать новые возможности языка.
Компилятор
Скорость: анализ во фронтенде заметно быстрее, а K2-режим Android Studio использует тот же анализ в IDE.
Более умные smart cast: теперь они переживают проверку, сохранённую в локальный val, || из sealed-подтипов (приведение к общему супертипу) и лямбды inline-функций.
Compose compiler: переехал в репозиторий Kotlin: подключается Gradle-плагином org.jetbrains.kotlin.plugin.compose, и его версия совпадает с версией Kotlin, так что старая таблица совместимости больше не нужна.
Настройка сборки: kotlinOptions { } объявлен устаревшим в пользу compilerOptions { }; kapt по-прежнему работает, но медленно, и путь вперёд лежит через KSP2.
Возможности языка в 2.x
Стабильны с 2.2: guard conditions в when (is Failure if r.retryable ->), нелокальные break/continue внутри inline-лямбд и multi-dollar интерполяция ($$"...").
Стабильны с 2.3: вложенные type alias и проверка исчерпываемости when на основе потока данных.
Стабильны с 2.4: context parameters (заменяют context receivers) и explicit backing fields (val items: StateFlow<T> с field = MutableStateFlow(...)).
Всё ещё экспериментальные или только предложенные: rich errors (ошибки в виде union-типов), для продакшен-кода не подходят.
Обновление на практике
Ломаются обычно плагины компилятора и процессоры, а не ваш код: поднимайте версии Compose, KSP, Hilt и плагина сериализации вместе с Kotlin.