1. Введение
В этой статье эксперт сообщества Spring АйО – Михаил Поливаха рассмотрит новый компилятор К2 для Kotlin. Сначала он расскажет о том, какие проблемы K2 призван решить, а затем о других минорных улучшениях, которые были сделаны. Гайд по обновлению на новую версию будет опубликован в следующей части этой статьи.
2. Причины создания новой версии компилятора
Для понимания мотивации за новым K2 компилятором, необходимо начать с истории вопроса. А именно, важно понять, какую цель Kotlin преследовал изначально.
2.1 Создание Kotlin
Kotlin существует уже достаточно продолжительное время. Разработка началась в 2010, так что мы можем с уверенностью сказать, что это было достаточно давно. Причина появления Kotlin заключается в том, что в те времена принято было считать, что Java развивается довольно медленно, стагнирует. Скорость разработки языка была довольно медленной, а новые фичи были редким явлением, в сравнении, к примеру с C#, в котором эволюция языка шла значительно быстрее.
И Kotlin создавался как язык, который, как предполагалось, заполнит этот пробел. Первоначально язык предлагался только как улучшенная версия Java, он не был предназначен для того, чтобы превзойти или каким-то образом заменить Java, так как Kotlin работает поверх JVM, он только предлагал дополнительные возможности языка. И этих возможностей было достаточно много, к примеру
и многие, многие другие. И все эти фичи невероятно востребованы у обычных разработчиков.
2.2 Проблемы Kotlin
Однако мы должны понимать, что чем больше фич есть в языке программирования, тем сложнее он будет, и, следовательно, компилятор должен будет совершить больше действий для компиляции проекта. На данный момент в Kotlin насчитывается более 30 глобальных (жестких) ключевых слов и около 20 контекстных (мягких) ключевых слов, не считая десятка других модификаторов. К примеру, в языке Go всего 25 ключевых слов. Причина этого кроется в том, что Go был спроектирован таким образом, чтобы быть настолько простым, насколько это только возможно, именно с точки зрения функционала. Поэтому программы на Go компилируются невероятно быстро, в сравнении с программами на других языках программирования.
Ну а как обстоят дела с Kotlin? Из-за множества фич, которыми обладает Kotlin, а также из-за сложности системы типов Kotlin, время компиляции всегда было его слабым местом. Например, в общем случае Kotlin компилируется намного медленнее Java.
Эти цифры, конечно, довольно спорные и сильно зависят от конфигурации процесса сборки, от сложности написанного нами кода и т.д. Разница в скорости компиляции была особенно заметна на более ранних версиях Kotlin. Опытные разработчики, вероятно, помнят, насколько медленным было автодополнение кода в свое время в IntelliJ IDEA. Конечно же со временем ситуация улучшилась, но проблема все еще существует.
3. Компилятор K2. Улучшения
Начиная с версии языка Kotlin 2.0.0 компилятор К2 используется по умолчанию. Это означает, что Maven и Gradle Kotlin плагины теперь будут компилировать код используя компилятор К2. Давайте более детально обсудим улучшения связанные с процессом компиляции. Начнем с главного - с улучшения производительности.
3.1. Скорость компиляции
Разработчики языка конечно же знали о проблеме со скоростью компиляции и в версии Kotlin 1.7.0 была выпущена альфа-версия компилятора К2. Его основной целью было улучшение скорости компиляции программ и снижение сложности компилятора в целом. По наблюдениям JetBrains, улучшение скорости компиляции может достигать 200% и даже больше. Важно понимать, что эти тесты относятся только к конкретным проектам JetBrains и могут отличаться от наших.
3.2. Smart Casts
Поскольку в K2 компиляторе была переписана Front-End часть, функциональность умного приведения типов улучшилась. Позже мы обсудим, что конкретно изменилось в компиляторе K2, а пока сосредоточимся на изменениях, заметных пользователю. Рассмотрим пример
fun translateExecution(exception: Throwable?) : Throwable? {
val isInvocationTargetException = exception is InvocationTargetException
if (isInvocationTargetException) {
return (exception as InvocationTargetException).targetException
}
return exception
}
Это довольно простой код, не будем пояснять его детально. Он компилируется и новым и старым компилятором. Проблема заключается в том, что нам необходимо сделать явное приведение типов внутри выражения if. На самом деле, это избыточно, так как на этом этапе мы уже должны знать, что переменная exception имеет тип InvocationTargetException. Однако Kotlin компилятор K1 (в данном конкретном случае), не сможет вывести тип локальной переменной exception. Связано это с тем, что K1 не может связать значение isInvocationTargetException
и типа локальной переменной exception
. Иными словами, если isInvocationTargetException == true
, то тип локальной переменной exception
точно InvocationTargetException
, но К1 не может понять эту взаимосвязь. Но в случае компилятора К2 это явное приведение типов внутри выражения if будет излишним и код будет выглядеть следующим образом:
fun translateExecutionV2(exception: Throwable?) : Throwable? {
val isInvocationTargetException = exception is InvocationTargetException
if (isInvocationTargetException) {
return exception.targetException
}
return exception
}
Этот код успешно скомпилируется компилятором К2. Но нужно заметить, что следующий код все равно не будет работать:
fun translateExecutionV2(exception: Throwable?) : Throwable? {
if (isInvocationTargetException(exception)) {
return exception.targetException
}
return exception
}
private fun isInvocationTargetException(exception: Throwable?) =
exception is InvocationTargetException
Компилятор K2 не отслеживает проверки внутри функций. Чтобы решить эту проблему, у нас есть Contracts API.
В области умного приведения типов сделано много других улучшений, но все они сводятся к одной простой вещи: теперь компилятор K2 способен лучше выводить типы, а явные приведения типов стали встречаться реже.
4. Компилятор К2. Основные изменения
Давайте теперь обсудим, какие изменения привели к заявленному росту производительности. Чтобы понять это, нужно сначала выяснить, какая именно часть компилятора была изменена - в основном это Front-End часть компилятора Kotlin.
Фронтенд компилятор отвечает за построение PSI (Program Structure Interface) - конкретного синтаксического дерева (Concrete Syntax Tree, CST) - начальную структуру данных, которую собирает Front-End компилятор Kotlin. Позже Front-End компилятор Kotlin строит семантическое дерево FIR (Front-End Intermediate Representation), которое является абстрактным синтаксическим деревом (Abstract Syntax Tree, AST).
4.1 PSI vs FIR
Первая проблема со старым компилятором заключалась в том, что он слишком полагался на структуру PSI, которая по своей конструкции намного больше и сложнее, чем структура данных FIR. Это связано с тем, что PSI содержит всю информацию, имеющуюся в исходном коде, в то время как FIR представляет собой более разреженную версию. Поэтому работа с FIR в целом быстрее.
Еще одной проблемой старого компилятора был BindingContext. Это огромная коллекция хэш-таблиц, в которой хранится семантическая информация о программе. Так, к примеру, если мы хотим найти переменную, на которую ссылаются в интерполяции строк, старому компилятору нужно выполнить 2 поиска в хеш таблицах внутри BindingContext. Новый компилятор K2 этого не делает, а вместо этого полагается на древовидную структуру данных в FIR. Доступ к значению узла дерева выполняется быстрее, чем 2 поиска внутри огромной хэш-таблицы.
4.2 Уменьшение количества jump между классами
И наконец, компилятор К2 значительно сократил количество jump, необходимых для определения, например, возвращаемого значения функции. Чтобы понять, что означает "jump", давайте рассмотрим следующий пример:
// in ChildClass.kt
class ChildClass : ParentClass() {
fun greeting(s: String) = hello(s)
}
// in ParentClass.kt
open class ParentClass {
internal fun hello(s: String) = println("Hello, $s!")
}
Итак, здесь тип возвращаемого значения функции greeting() соответствует типу возвращаемого значения функции hello(). Поэтому нам нужно сначала определить тип возвращаемого значения функции hello() для того, чтобы определить тип возвращаемого значения функции greetings(). Однако на самом деле мы не знаем, какую функцию hello() имел в виду пользователь, поэтому нам нужно сначала ее найти. Это может быть локальная функция (в том же классе, например), это может быть функция родительского класса, который скорее всего находится в другом файле .kt, поэтому нам нужно выполнить jump в родительский файл .kt. Искомой функции может не оказаться в родительском файле. Кроме того, это может быть функция, включенная через импорт со звездочкой, поэтому нам нужно искать функцию верхнего уровня hello() в этих файлах и т.д. Все это и будет jump в другие исходные файлы.
Jump относительно дорогие в процессе разрешения ссылок. Старый компилятор Kotlin выполнял множество jump практически на каждом этапе процесса компиляции. В отличие от этого, новый компилятор Kotlin K2 имеет всего 2 этапа, на которых присутствуют jump - вывод родительских типов (как в приведенном выше примере) и неявные объявления типов.
5. Модуль Multiplatform
Также были внесены два изменения в модуль Kotlin Multiplatform.
5.1 Разделение модулей
В прошлом компилятор Kotlin требовал наличие как common, так и платформенного кода на этапе компиляции. В некоторых сценариях это могло привести к ситуациям, когда код на Kotlin common вызывал платформенный код. Теперь возможно компилировать платформенный код отдельно от common модуля. Такой подход менее подвержен ошибкам и предсказуем в поведении кода на различных платформах.
5.2 Расширение видимости
Теперь мы также можем изменить модификатор видимости для элемента actual
так, чтобы он отличался от expect
. Важно отметить, что мы можем либо расширить уровень видимости actual декларации, по сравнению с expected, либо оставить таким же, но не сузить. Ранее компилятор требовал, чтобы модификатор видимости был одинаковым как для expect
элемента, так и для actual
. Рассмотрим пример:
// Common module
expect fun getPlatform(): Platform
// Android Platform
actual fun getPlatform(): Platform = AndroidPlatform()
В приведенном выше примере обе функции неявно имеют модификатор public. Теперь можно сделать это:
// Common module
internal expect fun getPlatform(): Platform
// Android Platform
actual fun getPlatform(): Platform = AndroidPlatform()
Здесь expect
функция имеет модификатор видимости internal. В то же время actual
реализация для платформы Android, например, имеет неявный модификатор public.
6. Выводы
Kotlin уже существует достаточно долгое время. За это время в нем накопилось множество возможностей. Поскольку Kotlin в итоге компилируется в байт-код Java (не учитывая KMM и Kotlin Native), все его возможности реализованы компилятором. По этой причине процесс компиляции был довольно сложным. Это привело к медленной компиляции проектов на Kotlin. Компилятор K2 решает именно эту проблему. Он стремится упростить процесс компиляции, сделав его значительно быстрее. В то же время он улучшает умное приведение типов и позволяет разделять общий и платформенный код в KMM.
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
Ждем всех, присоединяйтесь!