Бэкенды компилятора: JVM, Native, JS, Wasm

Один язык — четыре способа превратиться в исполняемый код; разбираем, что это значит на практике.

Compiler backend — часть компилятора Kotlin, которая переводит общее промежуточное представление в конкретный целевой формат: байткод JVM, нативный объектный код, JavaScript или WebAssembly.

Зачем разбираться в бэкендах

На первый взгляд это деталь, но именно бэкенды объясняют, почему какой-то код «работает на Android, но не собирается под iOS». Понимание целей экономит часы недоумения над ошибками компиляции.

Kotlin/JVM

Самый зрелый бэкенд. Kotlin превращается в .class-файлы — байткод, исполняемый виртуальной машиной Java. Это то, на чём работает Android и серверный Kotlin. Здесь доступна вся экосистема JVM: библиотеки Java, рефлексия, привычный сборщик мусора. На Android именно сюда попадает androidMain.

Kotlin/Native

Компилирует Kotlin в нативный машинный код через LLVM — без виртуальной машины. Это путь на iOS, macOS, watchOS, а также Linux и Windows. У Native свой рантайм со сборщиком мусора (современный — конкурентный, без старых ограничений «замораживания» объектов). Результат для iOS — это либо .framework, либо XCFramework, который Xcode подключает как обычную зависимость.

commonMain  --(JVM backend)-->  байткод .class      (Android)
            --(Native/LLVM)-->  нативный .framework (iOS)
            --(JS backend)--->  .js                 (Web)
            --(Wasm backend)->  .wasm               (Web)

Kotlin/JS и Kotlin/Wasm

Kotlin/JS транслирует код в JavaScript для запуска в браузере или Node.js. Kotlin/Wasm — более новая цель, компилирующая в WebAssembly; на ней, в частности, работает веб-вариант Compose Multiplatform. Для мобильной разработки эти цели часто не нужны, но они показывают широту охвата.

Как работает под капотом

До бэкендов есть общий фронтенд: компилятор разбирает исходник, проверяет типы и строит промежуточное представление (IR — intermediate representation). Это IR — единая «правда» о вашем коде, из которой каждый бэкенд генерирует свой результат. Поэтому семантика языка одинакова везде: List, корутина, data class ведут себя одинаково на Android и iOS, хотя байты под ними совершенно разные.

Различается стандартная библиотека по целям. Общий kotlin.* (коллекции, строки, базовые типы) есть везде. А вот kotlin.io для файлов или конкретные обёртки над платформенным API — нет. Поэтому проект делит код на исходные наборы (source sets): общий и платформенные.

Частые ошибки

Сборка под Android идёт быстро, и появляется иллюзия, что «всё работает». Но первая же сборка под iOS через Kotlin/Native может вскрыть, что в общий код просочился JVM-только класс. Правило: проверяйте сборку под все цели регулярно, а не только под Android. Вторая ошибка — считать Native «медленнее» из-за отсутствия JIT; на практике для мобильных задач разница незаметна, а старта приложения даже выигрывает.

Итоги

  • JVM — Android и сервер; Native — iOS и десктоп; JS/Wasm — веб.
  • Единый фронтенд строит общий IR, бэкенды генерируют из него разные форматы.
  • Семантика языка одинакова, но стандартная библиотека по целям различается.
  • Регулярно собирайте под iOS, а не только под Android.
Проверьте себя
1. Через что Kotlin/Native компилирует код для iOS?
AЧерез JVM
BЧерез LLVM в нативный машинный код
CЧерез JavaScript
DЧерез Docker-контейнер
2. Почему data class ведёт себя одинаково на Android и iOS?
AСлучайное совпадение
BЕдиный фронтенд строит общий IR, из которого бэкенды генерируют код
CПотому что используется JVM на iOS
DИз-за реплики JavaScript