![](https://habrastorage.org/getpro/habr/upload_files/6fb/759/1f6/6fb7591f611b10770942a200f86839ef.png)
Привет, меня зовут Мялкин Максим, я занимаюсь мобильной разработкой в KTS.
Не за горами выпуск новой версии Kotlin 2.0, основной частью которого является изменение компилятора на K2.
По замерам JB, K2 ускоряет компиляцию на 94%. Также он позволит ускорить разработку новых языковых фич и унифицировать все платформы, предоставляя улучшенную архитектуру для мультиплатформенных проектов.
Но мало кто погружался в то, как работает K2, и чем он отличается от K1.
Эта статья более освещает нюансы работы компилятора, которые будут полезны разработчикам для понимания, что же JB улучшают под капотом, и как это работает.
Контент статьи основывается на выступлении и овервью, но с добавлением дополнительной информации.
Оглавление
Как работает компилятор
Для начала поговорим о компиляторах в целом. По сути, компилятор — это программа. Она принимает код на языке программирования и превращает его в машинный код, который компьютер может исполнить.
Чтобы превратить исходный код в машинный, компилятор проводит несколько промежуточных преобразований. Если очень упростить, то компилятор делает две вещи:
меняет формат данных (compiling)
упрощает его (lowering)
![](https://habrastorage.org/getpro/habr/upload_files/270/87c/d74/27087cd740cefa8036228a3fabb6ca55.png)
Иногда стандартных функций Kotlin недостаточно. В таких случаях разработчики выходят за рамки стандартных инструментов и используют плагины компилятора.
Плагины встраиваются в работу компилятора Kotlin на различных фазах компиляции и меняют стандартное преобразование кода компилятором.
![](https://habrastorage.org/getpro/habr/upload_files/202/f74/7c5/202f747c54865c34df0132635279f51c.png)
Плагины компилятора используют такие библиотек как Jetpack Compose, Kotlinx serialization.
Kotlin компилятор состоит из 2 частей:
Frontend
отвечает за построение синтаксического дерева (структуры кода) и добавление семантической информации (смысл кода)
Backend
отвечает за генерацию кода для целевой (target) платформы: JVM, JS, Native, WASM (экспериментальный)
![](https://habrastorage.org/getpro/habr/upload_files/56e/8b2/853/56e8b28531b3b0326d360f788f139ccb.png)
Остановимся подробнее на каждой из частей.
Frontend
У компилятора Kotlin есть две фронтенд-реализации:
K1 (FE10-)
K2 (Fir-)
Упрощенно весь процесс работы фронтенда выглядит так:
Компилятор принимает исходный код
Производит синтаксический и семантический анализ кода
Отправляет данные на бэкенд для последующей генерации IR (Intermediate representation) и целевого кода платформы (target)
![](https://habrastorage.org/getpro/habr/upload_files/1f5/6c4/f1e/1f56c4f1e203bf04bb04fb29d8663a18.png)
Обе реализации похожи, но K2 вводит дополнительное форматирование данных перед тем, как отправить преобразованный код на бэкенд.
Реализация K1
![](https://habrastorage.org/getpro/habr/upload_files/2af/bfd/412/2afbfd412cd2a88b69f5b706e1c82cd8.png)
Реализация K1 работает c:
PSI (Programming Structure Interface)
PSI — это слой платформы IntelliJ, который занимается парсингом файлов и созданием синтаксических и семантических моделей кода (что такое синтаксис и семантика рассмотрим позже)
В зависимости от типа элемента дескрипторы могут содержать разную информацию о нем. Например дескриптор класса: контент класса, overrides, companion object и тд.
BindingContext — это Map, которая содержит информацию о программе и дополняет PSI.
Во время обработки K1 отправляет PSI и BindingContext на Backend, который на входе использует преобразование PSI в IR (Psi2Ir) для дальнейшей работы.
Как работает K1
Общая схема работы выглядит следующим образом
![](https://habrastorage.org/getpro/habr/upload_files/572/7f9/705/5727f97056517eff98516ca70f34d2bb.png)
Есть 2 фазы:
Parsing Phase — разбирает что именно написал разработчик. На этой фазе еще неизвестно, будет ли компилироваться написанный код. Здесь проверяется, что код написан синтаксически верно.
Resolution Phase — производит анализ, необходимый для понимания, будет ли код компилироваться и является ли он семантически правильным (более детальный разбор Resolution Phase).
Разберём дальше разницу между синтаксисом и семантикой.
Чтобы разобраться в работе компилятора K1, предположим, что у нас есть исходный код
fun two() = 2
Пока что для компилятора это только строка текста.
Parsing Phase
Задача синтаксического парсинга
Задача парсинга — проверить структуру программы и убедиться, что она верная и следует грамматическим правилам языка.
Пример:
![](https://habrastorage.org/getpro/habr/upload_files/adb/053/2c7/adb0532c76d3dd7f4af419179adb371a.png)
Этапы Parsing phase
![](https://habrastorage.org/getpro/habr/upload_files/33e/6ab/daa/33e6abdaadc3bcc914c9994631c794b6.png)
Все начинается с лексического анализа (Lexer Analysis). Сначала Kotlin Parser сканирует исходный код (строку) и разбивает его на лексические части (KtTokens). Например:
(
—>KtSingleValueToken.LPAR
)
—>KtSingleValueToken.RPAR
=
—>KtSingleValueToken.EQ
В нашем примере разбиение на токены выглядит так:
Пробелы тоже превращаются в токены. Но они не обозначены, чтобы не перегружать лишней информацией.
Далее начинается синтаксический анализ (Syntax Analysis). Его первый этап — это построение абстрактного синтаксического дерева (AST), которое состоит из лексических токенов. Важная особенность: дерево уже представляет из себя не последовательность символов, а имеет структуру.
Обратите внимание, что структура этого дерева совпадает со структурой нашего исходного кода. В нашем примере это выглядит так:
Следующим этапом мы накладываем PSI на узлы абстрактного синтаксического дерева. Теперь у нас есть PSI-дерево, которое содержит дополнительную информацию по каждому элементу:
На этом этапе мы ищем только синтаксические ошибки. Другими словами, мы видим правильно написанный код, но пока не можем предсказать, скомпилируется он или нет и самое главное не понимаем смысл этого кода.
В IntelliJ IDEA, вы можете загрузить плагин PSIViewer чтобы смотреть PSI для кода, написанного вами.
![](https://habrastorage.org/getpro/habr/upload_files/165/821/e8b/165821e8b1855a9d51d6dbf38e93d51e.png)
Resolution Phase
Задача семантического парсинга
Чтобы понять смысл написанного кода используется Resolution Phase.
Здесь PSI-дерево проходит набор этапов, в процессе которых генерируется семантическая информация, позволяющая понять (resolving) имена функций, типов, переменных и откуда они взялись.
Все мы в коде видели ошибки из этой стадии.
![](https://habrastorage.org/getpro/habr/upload_files/11b/a47/d11/11ba47d11cf39386fc081444fd0a1c73.png)
Семантическая информация содержит данные о:
Переменных и параметрах
![](https://habrastorage.org/getpro/habr/upload_files/990/679/b56/990679b5695fc4eb454e6f5f69b88d14.jpg)
На стадии семантического анализа нужно понять, что, используя pet внутри функции play, мы используем параметр функции, объявленный в первой строке
Типах
![](https://habrastorage.org/getpro/habr/upload_files/b06/012/0a7/b060120a757af3fd4cb173261860d6d3.png)
Семантическая информация содержит данные о том, на какие типы ссылается код, в каком месте они объявлены.
Вызовах функций
![](https://habrastorage.org/getpro/habr/upload_files/4c0/7be/40d/4c07be40d527d5ac1c9c54e9d211135e.png)
Может существовать много функций meow, и требуется определить, какую именно функцию нужно вызвать. Эта стадия занимает большую часть семантического анализа. Тк meow может быть: обычной функцией внутри класса, extension, property. Функция может находиться внутри текущего модуля, другого модуля или в библиотеке. На этапе семантического анализа нужно найти все функции с нужным названием и выбрать наиболее подходящую функцию для конкретной ситуации.
Эта стадия отвечает на похожие вопросы:
Откуда появилась эта функция/переменная/тип для использования
Точно ли две строки относятся к одной и той же переменной или это разные переменные в зависимости от контекста где они используются?
Какой тип у конкретного объекта?
Производится выведение типов
val list1 = listOf(1, 2, 3)
val list2 = listOf("one", "two", "three")
Представим, что у нас есть такая запись.
Тут используется generic-функция listOf
public fun <T> listOf(vararg elements: T): List<T>
Задача семантического анализа — вывести аргументы типа для каждого использования
val list1 = listOf(1, 2, 3)
В первом случае T = Int
val list2 = listOf("one", "two", "three")
Во втором — T = String.
Этапы Resolution Phase
Реализация K1 выполняет вычисление на PSI-дереве, создает дескрипторы и BindingContext map, а затем передает все это на бэкенд. В итоге бэкенд преобразует полученную информацию в IR с помощью Psi2Ir.
Вся эта информация будет использоваться дальше на бэкенде:
![](https://habrastorage.org/getpro/habr/upload_files/ece/610/25f/ece61025f9a00c4e4606f0e8d9bb1c46.png)
Пример с деревом PSI и семантической информацией BindingContext
В этом примере можно увидеть как исходный код (строка) преобразуется в структуру (дерево) и по каждому узлу дерева добавляется дополнительная информация (семантика).
![](https://habrastorage.org/getpro/habr/upload_files/836/1fd/265/8361fd265640179683030531eb4af37f.png)
Проблемы K1
Мы разобрались с тем, как работает фронтенд-реализация K1. Обозначим этот процесс на схеме K1:
![](https://habrastorage.org/getpro/habr/upload_files/745/b50/65d/745b5065de514b84b60da07976b05ad0.png)
У этой реализации есть свои недостатки (объяснение Дмитрия Новожилова в kotlin slack). Преобразование через PSI и BindingContext приводит к проблемам с производительностью компилятора.
Тк компилятор работает с ленивыми дескрипторами, то выполнение постоянно прыгает между разными частями кода, это сводит на нет некоторые JIT-оптимизации. Кроме этого много информации resolution phase хранится в большом BindingContext, из-за этого ЦП не может хорошо кэшировать объекты.
Все это ведет к проблемам с производительностью.
Реализация K2
Чтобы повысить производительность компилятора и улучшить архитектуру решения, JB создала новую фронтенд-реализацию K2 (Fir frontend).
Изменение произошло в структурах данных. Вместо старых структур (PSI + BindingContext) реализация K2 использует Fir (Frontend Intermediate Representation). Это семантическое дерево (дерево, в узлах которого к структуре кода добавлена семантическая информация). Оно представляет исходный код.
![](https://habrastorage.org/getpro/habr/upload_files/20e/340/2a3/20e3402a3355374bcbf03cf3a99c238e.png)
Как работает K2
Вместо того, чтобы как раньше отправлять PSI и BindingContext в Backend, в K2 на Frontend происходят дополнительные преобразования:
В случае с K2, мы рассмотрим более сложный случай и пропустим те фазы, в которых K1 и K2 работают одинаково.
Для примера возьмем исходный код
private val kotlinFiles = mutableListOf(1, 2, 3)
![](https://habrastorage.org/getpro/habr/upload_files/d51/12a/97d/d5112a97d9581d6e903dc8ccb1e27fdf.png)
Parsing
Начнем с уровня PSI (до этого K1 и K2 схожи):
![](https://habrastorage.org/getpro/habr/upload_files/a9b/89f/a7b/a9b89fa7bb1d10a0c160659817323df8.png)
В этом случае, реализация K1 просто генерировала бы дополнительные данные для отправки PSI на бэкенд.
Psi2Fir
В отличие от нее, K2 компилирует этот промежуточный формат данных перед созданием Ir. В итоге получается Fir — это mutable-дерево, построенное из результатов парсера (PSI-дерева):
![](https://habrastorage.org/getpro/habr/upload_files/f65/1b1/0c0/f651b10c054bc941fb3516f04817e779.png)
Тут видно сходство между PSI и raw Fir.
![](https://habrastorage.org/getpro/habr/upload_files/1e8/868/4e7/1e88684e78583306c60d3baa8cc79948.png)
Resolution
Итак, мы создаем raw FIR и пересылаем его нескольким обработчикам. Они представляют собой различные этапы пайплайна компилятора и заполняют дерево семантической информацией:
![](https://habrastorage.org/getpro/habr/upload_files/10c/ab4/2c1/10cab42c14a33d2d388845583ddf0049.png)
Все файлы в модуле проходят эти этапы одновременно.
В это время выполняются такие действия, как:
Вычисление import’ов
Поиск идентификаторов класса для супертипов и наследников sealed классов
Вычисление модификаторов видимости
Вычисление контрактов функций
Выведение типов возвращаемого значения
Анализ тел функций и свойств
На стадии Resolution компилятор преобразует сгенерированное raw Fir, заполняя дерево семантической информацией:
![](https://habrastorage.org/getpro/habr/upload_files/9ea/d4b/700/9ead4b70078c70c14a4d19c1d8d62f96.png)
На этих этапах компилятор выполняет desugaring. Например, `if` можно заменить на `when` или `for` — на `while`.
Раньше мы делали все это на Backend, но благодаря K2 то же самое можно делать на Frontend.
Checking
![](https://habrastorage.org/getpro/habr/upload_files/6dc/4ba/282/6dc4ba2823a579a6e58c74e18ed50ed4.png)
Мы пришли к последней стадии — стадии проверки. Здесь компилятор берет Fir и проводит диагностику на предмет предупреждений и ошибок.
Если ошибки появились, данные отправляются плагину IDEA — именно он подчеркивает ошибку красной линией. Компиляция останавливается, чтобы не отправлять неверный код на бэкенд.
Fir2Ir
Если ошибок нет
![](https://habrastorage.org/getpro/habr/upload_files/128/67e/571/12867e5715f0fa5c6650a4a3715f9eb7.png)
FIR трансформируется в IR
![](https://habrastorage.org/getpro/habr/upload_files/5f5/0f6/810/5f50f68104937a2fbcb4f6cb3a90c1a1.png)
В итоге мы получаем входные данные для бэкенда
Визуальное сравнение результатов K1 и K2
![](https://habrastorage.org/getpro/habr/upload_files/57d/17a/34d/57d17a34de9a0b9eef198ce7dcd0216e.png)
Итого в K2 мы получили Fir (одну структуру данных вместо двух в K1 версии)
Backend
Цели улучшения Backend
Изначально при разработке Kotlin компилятор не использовал промежуточное представление (IR). Тк это необязательная стадия при разработке компиляторов.
Каноничная архитектура компилятора
![](https://habrastorage.org/getpro/habr/upload_files/48b/6a1/0e4/48b6a10e4b25df66253d762008826cc0.png)
Отсутствие IR позволило Kotlin быстрее эволюционировать на ранних стадиях. Kotlin компилировался не только в JVM, но так же и в JS. JS высокоуровневый язык и он похож больше на Kotlin, чем на JVM байткод. Поэтому IR бы только тормозило разработку преобразования.
![](https://habrastorage.org/getpro/habr/upload_files/06b/521/b96/06b521b96545144de6567e249ac0d863.png)
Но вскоре появился еще 1 native backend.
![](https://habrastorage.org/getpro/habr/upload_files/863/3a4/4e3/8633a44e35800c2fdc31298245f43fe8.png)
Стало понятно что все 3 бэкенда могут использовать общую логику по трансформации и упрощению кода, что позволит поддерживать фичи для разных бекендов проще. Поэтому начиная с native-backend команда Kotlin решила использовать IR, сначала только для него самого.
![](https://habrastorage.org/getpro/habr/upload_files/2e9/14e/f6f/2e914ef6feb4ab75068486290e87bc95.png)
Итак, в качестве целей разработки новой версии можно выделить:
переиспользование логики между разными бекендами
упрощение поддержки новых языковых возможностей
Необходимо отметить, что ускорение Backend не являлось целью. В отличие от ускорения Frontend-части.
Новая реализация Backend позволила расширять компилятор с помощью компиляторных плагинов. В частности реализация Jetpack Compose полагается на новую версию бекенда c использованием IR.
Как работает Backend
![](https://habrastorage.org/getpro/habr/upload_files/c78/534/63e/c7853463ed3b31b4e99c4698a6f4b4ab.png)
Сейчас в Kotlin есть четыре реализации backend:
JVM
JS
Native
WASM
Какой бы бэкенд вы ни использовали, обработка в нем всегда начинается с генератора IR и оптимизатора. Затем выбранная конфигурация запускает сгенерированный код:
![](https://habrastorage.org/getpro/habr/upload_files/98b/e2d/008/98be2d00820accf6e676d0332f0f4f90.png)
Для примера выберем бэкенд JVM и продолжим работу с mutableList
IR
![](https://habrastorage.org/getpro/habr/upload_files/991/90e/0a2/99190e0a2a44ffce6f512fe4a0aa4163.png)
IR это также дерево. В IR добавлена вся семантическая информация. Для человека IR на выглядит не очень читабельно, но зато такой вид гораздо понятнее для компилятора. В момент анализа IR создаются поток управления и стеки вызовов.
Можно рассматривать IR как представление кода в виде дерева, которое разработано специально для генерации кода target-платформы. В отличие от Fir на фронтенде, который проверяет смысл написанного кода.
IR lowering
Затем на IR выполняются оптимизации и упрощения это называется lowering. Таким образом упрощается сложность Kotlin. Например в IR отсутствуют local и internal классы, они заменены отдельными классами со специальными именами. С помощью упрощений разным backend’ам не нужно бороться со сложностью языка Kotlin и реализация становится проще.
![](https://habrastorage.org/getpro/habr/upload_files/674/cfd/3bd/674cfd3bd2547984874c6d26b1b7c661.png)
Также на этом этапе упрощаются операции, улучшается производительность и качество машинного кода, особенно с точки зрения многопоточности.
Target code
Теперь у нас есть сгенерированный код целевой платформы. В нашем примере это JVM байт-код. Далее он отправляется на виртуальную машину, где он и будет исполняться:
![](https://habrastorage.org/getpro/habr/upload_files/1e8/495/dda/1e8495dda92dc53af904e3a5756b14ee.png)
На этом задачи компилятора считаются выполненными.
Выводы
Что дает новый компилятор:
Единая структура данных Fir для представления кода и семантики
Возможность добавления плагинов компилятора
Упрощение поддержки новых языковых возможностей для разных target-платформ.
Улучшение производительности компилятора и IDE (плагин Kotlin в IDE использует Frontend компилятора). По результатам замеров JB:
ускорение компиляции на 94%
ускорение фазы инициализации на 488%
ускорение фазы анализа на 376%
В этой статье мы рассмотрели как работают разные версии компилятора Kotlin. Непосредственно в эту тему редко углубляются разработчики. Я надеюсь, что материал позволил заглянуть вам под капот и узнать, что же там происходит, когда вы каждый день собираете приложения на работе.
Дополнительные материалы
Видео Crash Course on the Kotlin Compiler by Amanda Hinchman-Dominguez
Видео KotlinConf 2018 - Writing Your First Kotlin Compiler Plugin by Kevin Most
Видео What Everyone Must Know About The NEW Kotlin K2 Compiler
Видео A Hitchhiker's Guide to Compose Compiler: Composers, Compiler Plugins, and Snapshots
Репозиторий Kotlin-Compiler-Crash-Course с заметками об отладке, тестировании и настройке компилятора Kotlin
Цикл статей Crash course on the Kotlin compiler
Книга “Compilers: Principles, Techniques, and Tools” by Alfred Aho, Jeffrey Ullman, Ravi Sethi, Monica Lam
![](https://habrastorage.org/getpro/habr/upload_files/76d/a8f/ea9/76da8fea949241a9417e3c6013d57c2a.png)
Другие наши статьи по Android разработке: