1. Слои версий в Kotlin‑проекте
Когда вы слышите «давай обновим Kotlin до 2.x», очень хочется представить себе одну ручку “Kotlin version”, которую мы повернули — и всё стало современным и красивым. На практике Kotlin в проекте живёт сразу в нескольких слоях: компилятор, Gradle‑плагин, стандартная библиотека, зависимости и IDE. И сломаться может любой слой, причём иногда он ломается очень вежливо: просто начинает ругаться предупреждениями, которые вы проигнорируете, а через полгода они станут ошибками.
Чтобы не путаться, давайте введём понятную «карту местности»: где какая версия живёт и за что отвечает. Самое полезное — научиться разделять: версия языка (какой синтаксис и правила доступны), версия компилятора (как это анализируется и во что превращается), и версия инструментов сборки (как проект вообще компилируется в CI и на вашей машине).
Таблица: какие версии есть в Kotlin‑проекте
| Слой | Что это такое человеческим языком | Где обычно настраивается | Типичные симптомы проблем |
|---|---|---|---|
| Версия Kotlin Gradle Plugin (KGP) | Плагин, который добавляет задачи компиляции Kotlin в Gradle | build.gradle.kts (plugins { kotlin("jvm") version "…" }) | Сборка не стартует, падают Gradle‑таски, странные ошибки конфигурации |
| Версия компилятора Kotlin | То, что реально компилирует код (включая K2‑фронтенд в Kotlin 2.x) | Приходит вместе с KGP | Код, который “вчера компилился”, сегодня не компилируется, диагностика меняется |
| Language/API version | “Правила языка” и “уровень API”, с которым вы совместимы | compilerOptions { languageVersion… apiVersion… } | Неожиданные запреты/разрешения фич; несовпадение ожиданий между модулями |
| Стандартная библиотека (stdlib) | Базовые функции/типы Kotlin | Обычно подтягивается автоматически KGP, но может фиксироваться зависимостью | Конфликты версий, NoSuchMethodError при запуске (редко, но бывает) |
| Плагины и кодогенерация (KSP и др.) | Инструменты, которые участвуют в компиляции | Gradle плагины + зависимости | Генерация перестала работать, «несовместимая версия», падение на этапе сборки |
| IDE + Kotlin plugin в IDE | То, что подсвечивает код и запускает сборку из кнопки | IntelliJ IDEA | «В IDE красное, но Gradle собирает» или наоборот |
Эта таблица — как аптечка: может выглядеть скучно, но в момент боли она внезапно становится любимой.
2. K2, languageVersion и совместимость обновлений
Что такое K2 и почему после обновления «краснеет» код
Kotlin 2.x — это не только новые возможности языка, но и большой внутренний сдвиг: появился новый фронтенд компилятора (его часто называют K2). Если очень упростить, фронтенд — это часть компилятора, которая читает ваш код, понимает его смысл, строит внутреннее представление и решает: «вот это можно, а вот это нельзя». Когда фронтенд меняется, меняются и диагностики, и трактовка некоторых “угловых” случаев — иногда ваш код не стал хуже, просто компилятор стал строже и предсказуемее.
Важная мысль для начинающего (и для уставшего опытного тоже): если после обновления Kotlin у вас вылезли новые ошибки — это не обязательно “всё сломали”. Иногда это компилятор перестал терпеть то, что раньше молча проглатывал.
Пример: open val и инициализация в init
Вот показательный пример из миграции на Kotlin 2.0: раньше некоторые варианты отложенной инициализации “прокатывали”, теперь — нет. В Kotlin 2.0 open‑свойства с backing field должны иметь инициализацию сразу, иначе компилятор ругается.
open class Base {
open val a: Int
open var b: Int
init {
this.a = 1 // В Kotlin 2.0: ошибка, open val должен иметь initializer
this.b = 1 // И это всегда было ошибкой для open var
}
}
class Derived : Base() {
override val a: Int = 2
override var b: Int = 2
}
Почему это важно именно в лекции про «версии»? Потому что это классический случай: вы обновили Kotlin — и внезапно «сломался» код, который давно не трогали. А на самом деле просто изменилась трактовка правила, и проект теперь требует более явных гарантий.
Как проекту задать правила языка через languageVersion
Когда проект становится больше одной папки “с уроками”, вы начинаете жить в реальности, где часть кода уже написана “как привыкли”, часть — “как модно”, а часть — “как подсказала IDE в 2 часа ночи”. И в этот момент особенно важно уметь управлять правилами компиляции: иногда вы хотите обновить Gradle‑плагин и инструменты, но временно оставить старые правила языка, чтобы миграция прошла мягче. Или наоборот: хотите включить более новые правила там, где уже всё готово.
Ключевая идея: версия Kotlin‑плагина и версия правил языка — не одно и то же. Да, чаще всего они идут рядом, но иногда полезно временно “зафиксировать” languageVersion.
Пример: временно включить правила 1.9
В официальном гайде по миграции есть прямой рецепт: чтобы использовать предыдущий компилятор в Kotlin 2.0.0+ можно выставить languageVersion в 1.9 или передать -language-version 1.9.
Смысл этого шага не в том, чтобы “сидеть на 1.9 вечно”. Смысл — разделить проблему на куски: сначала обновили инструменты (Gradle, плагины), затем включили новые правила языка и разобрали ошибки.
Ниже — пример, как это может выглядеть в build.gradle.kts (упрощённо, идея важнее точного места в файле):
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
languageVersion.set(KotlinVersion.KOTLIN_1_9)
apiVersion.set(KotlinVersion.KOTLIN_1_9)
}
}
Такой переключатель — как режим “временная запаска”. Не очень быстро, но позволяет доехать до сервиса, а не чинить колесо посреди трассы.
Почему Kotlin 2.x часто тянет за собой «паровоз» зависимостей
Есть два типа обновлений. Первый тип — “поменял цифру в одном месте и всё собралось”. Второй тип — “поменял цифру, и внезапно узнал новые слова: ABI, frontend, binary compatibility, plugin API”. Kotlin 2.x чаще относится ко второму типу, особенно если в проекте есть KSP, kotlinx.serialization или другие инструменты, которые завязаны на компиляцию.
Самая важная привычка, которую стоит выработать: если зависимость участвует в компиляции (а не только в рантайме), она чувствительнее к версии Kotlin. Это относится и к кодогенерации, и к компиляторным плагинам, и даже к тому, как IDE анализирует проект.
Схема зависимостей версий
flowchart TD
A["Версия Kotlin Gradle Plugin (KGP)"] --> B["Версия компилятора Kotlin (K2/K1)"]
A --> C["Задачи Gradle: compileKotlin, test, etc."]
B --> D["Правила языка (languageVersion/apiVersion)"]
B --> E["Инструменты компиляции: KSP/плагины"]
E --> F["Сгенерированные .kt файлы"]
F --> C
Идея проста: если вы обновили Kotlin, но забыли, что у вас есть KSP, который генерирует код, то вы обновили “двигатель”, но оставили старый “карбюратор”. Машина (сборка) может начать кашлять.
Практичный принцип совместимости
Если сформулировать максимально по‑человечески, то правило такое: обновляйте то, что связано с компиляцией, согласованно. Если вы обновляете Kotlin, посмотрите на:
- Gradle‑плагин Kotlin (он же приносит компилятор);
- KSP (если есть);
- плагины сериализации и другие compile‑time инструменты (если есть);
- и только потом — обычные библиотеки.
Здесь важно не “знать все версии наизусть”, а иметь дисциплину: обновляете один слой — проверяете сборку, фиксируете результат, идёте дальше.
3. Дисциплина обновлений и предупреждения компилятора
Пошаговый сценарий обновления без «всё горит»
В какой-то момент вы начнёте обновлять Kotlin не потому что “хочется новенького”, а потому что проекту нужен патч безопасности, или библиотека перестала поддерживать старую версию, или коллега прислал PR со словами “у меня локально всё работает” (что является древним заклинанием хаоса). И тут выигрывают не те, кто быстрее меняет цифры, а те, кто делает обновления предсказуемыми.
Дальше будет не “идеальный процесс на 40 страниц”, а жизненный, для небольшого учебного или pet‑проекта. И, что приятно, он масштабируется: те же идеи работают и в больших командах.
Сначала вы создаёте отдельную ветку. Это банально, но это момент, когда вы покупаете себе право на ошибку. Потом вы обновляете Kotlin Gradle plugin (обычно это строка версии в plugins { … }). После этого вы сразу запускаете сборку из Gradle (не только из IDE), потому что IDE иногда живёт “в своём мире”, а Gradle — это тот мир, где живёт CI.
Если сборка упала, вы не бежите чинить всё подряд. Вы смотрите: это ошибка конфигурации Gradle (не собрался build‑скрипт), это ошибка компиляции Kotlin (не компилируется код), или это ошибка тестов (код компилируется, но поведение изменилось). Это три разных типа проблем, и их полезно разделять хотя бы у себя в голове.
Дальше вы включаете новые правила языка не сразу, а осознанно. Если при переходе на Kotlin 2.x проект сразу в K2‑режиме начинает ругаться на много мест, вы можете временно выставить languageVersion на 1.9, собрать проект, а затем вернуть 2.x и чинить уже “чистые” проблемы языка, а не одновременно язык + сборку. Сам факт возможности отката описан в гайде миграции.
И наконец — вы фиксируете изменения маленькими коммитами. Это не бюрократия, а помощь себе будущему: “обновили Kotlin”, “починили X”, “починили Y”. Тогда откат и анализ становятся простыми.
Почему предупреждения в Kotlin 2.x лучше не «глушить»
У компилятора есть два режима общения с вами: “всё хорошо” и “я тебя предупреждал”. Предупреждения, особенно deprecation‑предупреждения, часто воспринимаются как шум: мешают, подчёркивают код, портят настроение. Но в реальности это один из самых дешёвых способов узнать о будущих проблемах заранее.
Kotlin и его экосистема регулярно переводят предупреждения в ошибки по мере развития языка. Поэтому привычка “ой, да ладно, потом” иногда превращается в “почему CI красный, если мы ничего не меняли?”. Ответ обычно такой: вы не меняли код, но мир вокруг (версии) поменял правила.
Практичная стратегия здесь звучит скучно, но работает: если предупреждение можно исправить за 5–10 минут без потери смысла — исправляйте. Если нельзя, тогда хотя бы сделайте это решение осознанным: оставьте комментарий, заведите задачу, или примените подавление (@Suppress(...)) максимально локально. Иначе через полгода вы будете смотреть на подавления как археолог: “кто это сделал и почему здесь похоронен мамонт?”.
4. Миграция своего API через @Deprecated
Когда вы обновляете Kotlin, почти всегда всплывает второй пласт работы: ваш собственный код тоже развивается. Вы переименовываете функции, меняете контракты, убираете “костыли”, улучшаете дизайн API. И тут легко сделать больно пользователям вашего кода (или самому себе через месяц): вы переименовали метод — и всё приложение развалилось.
Kotlin даёт очень удобный механизм мягкой миграции: @Deprecated. Это буквально дорожный знак “тут ремонт, объезжай через новую улицу”. И самое приятное: IDE умеет подсказывать замену автоматически через ReplaceWith.
Пример: миграция функции через ReplaceWith
Представим, что в нашем учебном консольном приложении (условно назовём его StudyTracker) была функция formatMoneyOld(...), а мы хотим заменить её на formatMoney(...), потому что старое имя звучит как признание в грехах.
@Deprecated(
message = "Используйте formatMoney(amountCents)",
replaceWith = ReplaceWith("formatMoney(amountCents)")
)
fun formatMoneyOld(amountCents: Int): String = formatMoney(amountCents)
fun formatMoney(amountCents: Int): String {
val dollars = amountCents / 100
val cents = amountCents % 100
return "$dollars.${cents.toString().padStart(2, '0')}"
}
Здесь важно, что старую функцию мы не удалили мгновенно: мы сделали её тонкой обёрткой, чтобы проект продолжал собираться, а IDE помогала переписать вызовы.
Пример: миграция свойства через делегирование
Иногда вы переименовываете не функцию, а свойство. Kotlin позволяет делегировать одно свойство другому, и это прямо рекомендуется как сценарий совместимого переименования: вводим новое свойство, старое помечаем @Deprecated и делегируем на новое.
class Settings {
var currencyCode: String = "USD"
@Deprecated("Use 'currencyCode' instead", ReplaceWith("currencyCode"))
var currency: String by this::currencyCode
}
fun main() {
val s = Settings()
s.currency = "EUR"
println(s.currencyCode) // EUR
}
Это выглядит почти как магия, но по смыслу очень просто: старое имя остаётся жить как “псевдоним”, а данные на самом деле хранятся в новом месте.
5. Типичные ошибки при обновлении Kotlin до 2.x
Ошибка №1: обновить Kotlin и ещё двадцать зависимостей “заодно”, а потом искать источник поломки.
Так делать очень хочется, потому что “раз уж страдать — то по полной программе”. Но это делает диагностику почти невозможной: вы не знаете, сломалось из‑за Kotlin, из‑за Gradle, из‑за плагина или из‑за библиотеки. Гораздо стабильнее обновлять слоями и фиксировать маленькие шаги.
Ошибка №2: путать “версию Kotlin” и “версию languageVersion”.
Версия Kotlin Gradle plugin приносит компилятор, но правила языка можно временно зафиксировать. В гайде миграции прямо упоминается вариант отката на languageVersion 1.9 или -language-version 1.9, чтобы использовать предыдущий компилятор. Это полезно как временная мера, но важно помнить, что это именно временный мост, а не “новая норма”.
Ошибка №3: игнорировать изменения семантики и правил, которые компилятор стал проверять строже.
На Kotlin 2.x можно внезапно получить ошибку там, где раньше был “серый” код. Пример с open val, который раньше мог инициализироваться в init, а теперь требует initializer, хорошо показывает природу таких изменений. Здесь неправильно злиться на компилятор — правильнее принять, что проекту нужны более явные гарантии.
Ошибка №4: ломать своё API “резко”, без мягкой миграции.
Удалить старую функцию, переименовать свойство и надеяться “ну я же всё найду поиском” — работает до первого пропущенного места. Kotlin даёт отличный механизм миграции через @Deprecated и даже через делегирование старого свойства на новое. Если вы этим не пользуетесь, вы добровольно отказываетесь от бесплатной помощи IDE и компилятора.
Ошибка №5: воспринимать предупреждения как мусор и глушить их глобально.
Предупреждения — это часто “ранняя версия ошибки из будущего”. Если их бездумно подавлять, проект становится хрупким: он компилируется “сейчас”, но не объясняет, что именно нужно поправить перед следующим обновлением. Лучше либо исправлять, либо документировать, почему подавление оправдано, и держать его локально.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ