Files
obsidian/Kotlin/Инструментарий/Компилятор K2.md
T

24 KiB
Raw Blame History

title, description, tags
title description tags
Компилятор K2 Фазы компиляции, что изменилось по сравнению с K1, умные приведения, экспериментальные флаги
kotlin
tooling
compiler

K2 - фронтенд компилятора Kotlin, стабильный с версии 2.0 и единственный в линии 2.x. Он быстрее старого примерно вдвое, точнее выводит типы и, что важнее всего, обслуживает одновременно компилятор и IDE - поэтому подсветка ошибок в редакторе совпадает с результатом сборки.

interface Base { fun f(): String }
class A : Base { override fun f() = "A" }
class B : Base { override fun f() = "B" }

fun describe(x: Any) {
    if (x is A || x is B) {
        // K2 выводит общий супертип Base для объединения типов
        println(x.f())
    }
}

fun main() {
    describe(A())        // A
    describe(B())        // B
}

В K1 этот код не компилировался: после is A || is B переменная оставалась Any, и x.f() требовал явного приведения.

Фазы компиляции

Посмотреть реальные фазы можно флагами -verbose -Xreport-perf:

$ kotlinc -verbose -Xreport-perf Demo.kt -d out
PERF:                 INIT     271 ms
PERF:              ANALYZE     530 ms      11.311 loc/s
PERF:    TRANSLATION to IR     144 ms      41.453 loc/s
PERF:         KLIB WRITING       0 ms
PERF:          IR LOWERING     122 ms      48.799 loc/s
PERF:              BACKEND      75 ms      79.456 loc/s
graph LR
    A[".kt"] --> B["INIT<br/>загрузка classpath"]
    B --> C["ANALYZE<br/>фронтенд K2: FIR"]
    C --> D["TRANSLATION<br/>FIR → IR"]
    D --> E["IR LOWERING<br/>развёртка inline,<br/>suspend, лямбды"]
    E --> F["BACKEND<br/>IR → байт-код"]
    F --> G[".class + Metadata"]
Фаза Что происходит
INIT чтение classpath, инициализация компилятора
ANALYZE разбор, разрешение имён, вывод типов, проверки - фронтенд K2 строит FIR
TRANSLATION to IR FIR превращается в промежуточное представление, общее для всех бэкендов
IR LOWERING развёртка inline, CPS для suspend, генерация лямбд, data class-методов
BACKEND IR → байт-код JVM (или JS, Native, Wasm)

Ключевое архитектурное решение - разделение на FIR (фронтенд) и IR (общий бэкенд). Фронтенд один для всех платформ, бэкенды разные. Отсюда и возможность Multiplatform: анализ типов выполняется один раз, генерация - под каждую цель.

Что изменилось в умных приведениях

K2 переписал анализ потока данных, и это самое заметное для повседневной работы улучшение.

Приведение через переменную с результатом проверки:

fun viaVariable(x: Any?) {
    val isString = x is String
    if (isString) {
        // K2 приводит тип по val с результатом проверки
        println(x.length)
    }
}

fun main() {
    viaVariable("шесть!")        // 6
}

Приведение проникает в лямбду:

fun intoLambda(x: Any) {
    if (x is String) {
        run { println(x.length) }        // приведение видно внутри лямбды
    }
}

fun main() {
    intoLambda("семь!!!")        // 7
}

Nullable-функция вызывается после проверки:

fun callable(f: (() -> Unit)?) {
    if (f != null) {
        f()        // без ?.invoke()
    }
}

fun main() {
    callable { println("лямбда вызвана") }        // лямбда вызвана
}

Исчерпывающий when по данным потока:

sealed interface State
data object Loading : State
data class Done(val value: String) : State

fun render(s: State) = when (s) {
    Loading -> "загрузка"
    is Done -> "готово: ${s.value}"
}

fun main() {
    println(render(Loading))              // загрузка
    println(render(Done("данные")))         // готово: данные
}

else не нужен - компилятор знает все варианты sealed. Подробнее: Умные приведения

Чего K2 не делает - приведения для изменяемого свойства класса:

$ kotlinc Demo.kt -d out
error: smart cast to 'String' is impossible, because 'value' is a mutable property
that could be mutated concurrently.

Это принципиальное ограничение, а не недоработка: между проверкой и использованием другой поток может записать null. Решение - локальная копия:

class Holder { var value: String? = "есть" }

fun main() {
    val h = Holder()
    val snapshot = h.value
    if (snapshot != null) {
        println(snapshot.length)        // 4
    }
}

Полезные флаги анализа

-Wextra

Дополнительные проверки K2: неиспользуемые значения, подозрительные конструкции.

kotlinc -Wextra Demo.kt -d out

-Xreturn-value-checker

Предупреждает о проигнорированном результате функции - полезно для функций, чей результат нельзя терять:

$ kotlinc -Xreturn-value-checker=full Demo.kt -d out
warning: unused return value of 'compute'.
    compute()
    ^^^^^^^

Режимы: check (только помеченные @MustUseReturnValue), full (все), disable. Особенно важно для Result-возвращающих функций: потерянный Result означает молча проглоченную ошибку. Подробнее: Result и runCatching

-Xexplicit-api

Для библиотек: требует явно указывать видимость и типы в публичном API.

$ kotlinc -Xexplicit-api=strict Lib.kt -d out
error: visibility must be specified in explicit API mode.
class Public {
^^^^^^^^^^^^
error: visibility must be specified in explicit API mode.
    fun noExplicitType() = 42
    ^^^^^^^^^^^^^^^^^^
// так проходит explicit API mode
public class Service {
    public fun compute(): Int = 42
    private fun internalHelper(): Int = 1
}

fun main() {
    println(Service().compute())        // 42
}

В Gradle - kotlin { explicitApi() }. Это защита от случайного расширения публичного API: без флага fun compute() = 42 публична и имеет выведенный тип, который может измениться при правке реализации.

Производительность

Флаг Что делает
-Xbackend-threads=N кодогенерация в N потоков
-Xreport-perf статистика по фазам (с -verbose)
-Xlambdas={class|indy} схема компиляции лямбд
-Xstring-concat={indy|indy-with-constants|inline} схема конкатенации строк

-Xlambdas=indy (по умолчанию для JVM-таргета 8+) компилирует лямбды через invokedynamic, как в Java. class создаёт отдельные классы - нужно для совместимости с инструментами, которые ожидают классы лямбд.

Экспериментальные возможности языка

K2 - точка входа для новых конструкций. Полный список - kotlinc -X.

Флаг Возможность
-Xcontext-parameters context parameters (в 2.4 работают без флага)
-Xwhen-guards when с охранными условиями
-Xnon-local-break-continue break/continue из inline-лямбды
-Xmulti-dollar-interpolation $$ для литералов с $
-Xname-based-destructuring деструктуризация по именам
-Xnested-type-aliases typealias внутри класса
-Xexplicit-backing-fields отдельный тип backing field и свойства
-Xcontext-sensitive-resolution разрешение имён по ожидаемому типу
-Xdata-flow-based-exhaustiveness исчерпывающий when по анализу потока
-Xannotation-target-all @all: для аннотаций на свойствах
// context parameters — в 2.4 уже без флага
interface Logger { fun log(message: String) }

context(logger: Logger)
fun process(data: String) {
    logger.log("обработка: $data")
}

fun main() {
    val logger = object : Logger {
        override fun log(message: String) = println(message)
    }
    with(logger) {
        process("запись")        // обработка: запись
    }
}

Подробнее: Receivers и context parameters

Три версии, которые путают

// build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion

kotlin {
    jvmToolchain(21)

    compilerOptions {
        jvmTarget.set(JvmTarget.JVM_21)                   // версия байт-кода
        languageVersion.set(KotlinVersion.KOTLIN_2_4)       // возможности языка
        apiVersion.set(KotlinVersion.KOTLIN_2_2)             // доступный API stdlib
    }
}
Опция Отвечает за Типичная ошибка
jvmTarget формат .class оставить дефолтный 1.8
languageVersion синтаксис и семантика ожидать новые возможности при старом значении
apiVersion какие функции stdlib видны указать устаревшую версию

apiVersion ниже languageVersion - легальная комбинация: можно писать на новом языке, но не использовать новый API stdlib (чтобы библиотека работала со старым рантаймом).

Важно: устаревшая apiVersion сама даёт предупреждение. В Kotlin 2.4 значение 2.1 выдаёт «API version 2.1 is deprecated», и с allWarningsAsErrors это ломает сборку. Подробнее: Gradle и Kotlin DSL

Progressive mode

// build.gradle.kts
kotlin {
    compilerOptions {
        progressiveMode.set(true)
    }
}

Обычно исправления некорректного поведения проходят цикл миграции: предупреждение в одной версии, ошибка через две. Progressive mode применяет их сразу. Код, написанный в этом режиме, совместим с обычным, но не наоборот. Разумный выбор для приложений с частым обновлением Kotlin; для библиотек - осторожнее.

Плагины компилятора

K2 поддерживает плагины, работающие на уровне FIR/IR - они видят типы и могут генерировать код:

Плагин Что делает
kotlinx-serialization генерирует сериализаторы при компиляции
all-open делает классы open (для Spring, Mockito)
no-arg добавляет конструктор без аргументов (JPA)
kotlin-spring предустановленный all-open для Spring-аннотаций
kotlin-jpa предустановленный no-arg для JPA
kotlin-lombok понимание Lombok-геттеров в смешанном коде
power-assert подробные сообщения в assert
compose оптимизация composable-функций
// build.gradle.kts
plugins {
    kotlin("jvm") version "2.4.0"
    kotlin("plugin.serialization") version "2.4.0"
    kotlin("plugin.spring") version "2.4.0"
    kotlin("plugin.jpa") version "2.4.0"
}

Отличие плагина компилятора от KSP: плагин модифицирует IR напрямую (может менять существующий код), KSP только генерирует новые файлы. Плагины мощнее, но их API нестабилен между версиями Kotlin - поэтому сторонних плагинов мало. Подробнее: KSP

Инкрементальная компиляция

# gradle.properties
kotlin.incremental=true
kotlin.incremental.useClasspathSnapshot=true
kotlin.daemon.jvmargs=-Xmx2g

Компилятор отслеживает, какие файлы зависят от изменённых, и пересобирает только их. Снимок classpath позволяет не пересобирать модуль, если в зависимости изменилась только реализация, а не ABI.

Что ломает инкрементальность:

Причина Следствие
изменение inline-функции пересборка всех её вызовов
изменение публичного API пересборка зависимых модулей
api вместо implementation пересборка потребителей
правка в buildSrc пересборка всего проекта
clean полная пересборка

Диагностика проблем

# подробный лог компиляции
./gradlew compileKotlin --info

# время по фазам
kotlinc -verbose -Xreport-perf Demo.kt -d out

# что попало в байт-код
kotlinc Demo.kt -d out && javap -p -cp out DemoKt

# проверить без записи результата
./gradlew compileKotlin --rerun-tasks

# сбросить кеши при странных ошибках
./gradlew clean && rm -rf .gradle .kotlin

Если IDE показывает ошибку, а сборка проходит (или наоборот) - это признак расхождения версий компилятора в IDE и Gradle. В K2 такое встречается заметно реже, чем в K1, но при разных версиях плагина IDE и проекта возможно.

[!info] Отличия от Java

Java Kotlin
javac - один фронтенд и бэкенд FIR (фронтенд) + IR (бэкенды)
компилятор только для JVM JVM, JS, Native, Wasm
нет анализа потока для приведений умные приведения
instanceof с pattern matching (16+) is + автоматическое приведение
аннотационные процессоры (APT) плагины компилятора + KSP
-Xlint -Wextra, -Xreturn-value-checker
IDE имеет свой анализатор K2 общий для IDE и сборки

Общий фронтенд для IDE и компилятора - главное инфраструктурное отличие. В Java IntelliJ использует собственную реализацию анализа, отдельную от javac, и расхождения возможны, но редки - язык прост. Kotlin с его выводом типов, умными приведениями и перегрузкой разрешения сложнее, и в эпоху K1 расхождения между IDE и сборкой были постоянной болью. K2 устранил причину: это один и тот же код.

Умные приведения заменяют то, что в Java решается pattern matching. Java 16 добавил if (o instanceof String s) - явное связывание переменной. Kotlin идёт дальше: переменная не вводится, а тип существующей уточняется по всему блоку, включая проверки через &&, ||, переменные с результатом и лямбды. Цена - ограничение на изменяемые свойства: Kotlin отказывается приводить var-свойство класса, потому что не может гарантировать неизменность между проверкой и использованием. Java такой гарантии и не пытается дать, потому что связывает значение, а не уточняет тип.

Плагины компилятора - то, чего в Java осознанно нет. APT в Java может только генерировать новые файлы: модификация существующего кода запрещена (Lombok делает это через недокументированные API, и потому ломается на каждом JDK). Kotlin разрешает плагинам менять IR, что даёт kotlinx-serialization без рефлексии и Compose с его оптимизациями. Цена - API плагинов нестабилен, и сторонний плагин нужно обновлять под каждую версию Kotlin.

Многоплатформенность определила архитектуру. javac компилирует в байт-код JVM - это его единственная задача. Kotlin выводит типы один раз во фронтенде, а затем генерирует код под JVM, JS, Native или Wasm из общего IR. Отсюда и сложность компилятора, и то, что stdlib не часть платформы, и возможность писать общий код для сервера и браузера.

Сравнить: javac, JIT

[!warning] Подводные камни Умное приведение не работает для var-свойства класса:

class Holder { var value: String? = "есть" }

fun main() {
    val h = Holder()
    // if (h.value != null) println(h.value.length)   // ошибка компиляции
    val snapshot = h.value
    println(if (snapshot != null) snapshot.length else 0)        // 4
    }

Сообщение: «smart cast is impossible, because 'value' is a mutable property that could be mutated concurrently». Это не обойти флагом - нужна локальная копия.

-Xexplicit-api обнаруживает проблему поздно. Включение на существующей библиотеке даёт сотни ошибок сразу. Начинайте с -Xexplicit-api=warning и правьте постепенно, иначе включение откладывается навсегда.

Экспериментальные флаги -X не гарантируют совместимость. Возможность может измениться или исчезнуть в следующей версии. Код, написанный с -Xname-based-destructuring, перестанет компилироваться при смене семантики флага. Для production-кода используйте только стабильные возможности.

progressiveMode - односторонний переход. Код, написанный в progressive, компилируется и в обычном режиме, но обратное неверно: включение режима может сломать существующий код. Для библиотек, собираемых пользователями, это риск.

Устаревшая apiVersion вместе с -Werror роняет сборку. В Kotlin 2.4 apiVersion = 2.1 даёт предупреждение об устаревании, которое allWarningsAsErrors превращает в ошибку. Поддерживается около трёх предыдущих версий.

Изменение inline-функции обнуляет инкрементальность её вызовов. Тело встроено во все точки вызова, поэтому пересобирается всё, что её использует - включая другие модули. inline в публичном API библиотеки фиксирует реализацию в ABI.

Нехватка памяти демона маскируется под медленную сборку. Без kotlin.daemon.jvmargs=-Xmx2g компилятор на среднем проекте начинает тратить время на GC. Ошибки нет - просто сборка идёт в разы дольше.

-Xbackend-threads не всегда ускоряет. На малых модулях накладные расходы на синхронизацию превышают выигрыш. Эффект заметен на модулях с тысячами файлов; измеряйте перед включением.

Расхождение IDE и сборки означает разные версии компилятора. Плагин Kotlin в IDE и версия в build.gradle.kts должны соответствовать. Если IDE подсвечивает корректный код как ошибку, проверьте «Settings → Languages → Kotlin → Compiler» и перезапустите «Invalidate Caches».

-Xreport-perf без -verbose ничего не печатает. Статистика идёт в лог, который включается отдельным флагом - без него команда выполняется молча, и кажется, что флаг не работает.

Плагин компилятора фиксирует версию Kotlin. Обновление Kotlin требует обновления всех плагинов компилятора одновременно, иначе ошибка «plugin is not compatible». Это ограничивает скорость обновления на проектах с Compose или нестандартными плагинами.

Далее: KSP