Kotlin, компиляция в байткод и производительность (часть 1)
О Kotlin последнее время уже очень много сказано (особенно в совокупности с последними новостями c Google IO 17), но в то же время не очень много такой нужной информации, во что же компилируется Kotlin. Давайте подробнее рассмотрим на примере компиляции в байткод JVM.
Это первая часть публикации. Вторую можно посмотреть тут
Процесс компиляции это довольно обширная тема и чтобы лучше раскрыть все ее нюансы я взял большую часть примеров компиляции из выступления Дмитрия Жемерова: Caught in the Act: Kotlin Bytecode Generation and Runtime Performance. Из этого же выступления взяты все бенчмарки. Помимо ознакомления с публикацией, настоятельно рекомендую вам еще и посмотреть его выступление. Некоторые вещи там рассказаны более подробно. Я же больше внимания акцентирую именно на компиляции языка.
Содержание:
Но прежде чем рассмотрим основные конструкции языка и то, в какой байткод они компилируются, нужно упомянуть о том, как непосредственно происходит сама компиляция языка:
На вход компилятора kotlinc поступают исходные файлы, причем не только файлы kotlin, но и файлы java. Это нужно чтобы можно было свободно ссылаться на Java из Kotlin, и наоборот. Сам компилятор прекрасно понимает исходники Java, но не занимается их компиляцией, на этом этапе происходит только компиляция файлов Kotlin. После полученные *.class файлы передаются компилятору javaс вместе с исходными файлами *.java. На этом этапе компилируются все java файлы, после чего становится возможным собрать вместе все файлы в jar (либо каким другим образом).
Для того чтобы посмотреть в какой байткод генерируется Kotlin, в Intellij IDEA можно открыть специальное окно из Tools -> Kotlin -> Show Kotlin Bytecode. И после, при открытие любого файла *.kt, в этом окне будет виден его байткод. Если в нем не будет ничего такого, что нельзя представить в Java, то также будет доступна возможность декомпилировать его в Java код кнопкой Decompile.
Если посмотреть на любой *.class файл kotlin, то там можно увидеть большую аннотацию @Metadata:
Давайте теперь перейдем к примерам, в которых рассмотрим основные конструкции и то, в каком виде они представлены в байткоде. Но чтобы не разбираться в громоздких записях байткода в большинстве случаев рассмотрим декомпилированный вариант в Java:
Функции на уровне файла
Начнем с самого простого примера: функция на уровне файла.
В Java нет аналогичной конструкции. В байткоде она реализуется с помощью создания дополнительного класса.
В качестве названия для такого класса используется имя исходного файла с суффиксом *Kt (в данном случае Example1Kt). Существует также возможность поменять имя класса с помощью аннотации file:JvmName:
Primary конструкторы
В Kotlin есть возможность прямо в заголовке конструктора объявить свойства (property).
Они будут параметрами конструктора, для них будут сгенерированы поля и, соответственно, значения, переданные в конструктор, будут записаны в эти поля. Также будут созданы getter, позволяющие эти поля прочитать. Декомпилированный вариант примера выше будет выглядеть так:
Если в объявлении класса A у переменной x изменить val на var, то тогда еще будет сгенерированы setter. Стоит также обратить внимание на то, что класс A будет объявлен с модификатором final и public. Это связано с тем что все классы в Kotlin по умолчанию final и имеют область видимости public.
data классы
В Kotlin есть специальный модификатор для класса data.
Это ключевое слово говорит компилятору о том, чтобы он сгенерировал для класса методы equals, hashCode, toString, copy и componentN функции. Последние нужны для того, чтобы класс можно было использовать в destructing объявлениях. Посмотрим на декомпилированный код:
На практике модификатор data очень часто используется, особенно для классов, которые участвуют во взаимодействии между компонентами, либо хранятся в коллекциях. Также data классы позволяют быстро создать иммутабельный контейнер для данных.
Свойства в теле класса
Свойства также могут быть объявлены в теле класса.
В данном примере в классе С мы объявили свойство x типа String, которое еще к тому же может быть null. В этом случае в коде появляются дополнительные аннотации @Nullable:
В этом случае в декомпилированном варианте мы увидим getter, setter (так как переменная объявлена с модификатором var).Аннотация @Nullable необходима для того, чтобы те статические анализаторы, которые понимают данную аннотацию, могли проверять по ним код и сообщать о каких-либо возможных ошибках.
Если же нам не нужны getter и setter, а просто нужно публичное поле, то мы можем добавить аннотацию @JvmField:
Тогда результирующий Java код будет следующий:
Not-null типы в публичных и приватных методах
В Kotlin существует небольшая разница между тем, какой байткод генерируется для public и private методов. Посмотрим на примере двух методов, в которые передаются not-null переменные.
В обоих методах передается параметр s типа String, и в обоих случаях этот параметр не может быть null.
В таком случае для публичного метода генерируется дополнительная проверка типа (Intrinsics.checkParameterIsNotNull), которая проверяет что переданный параметр действительно не null. Это сделано для того, чтобы публичные методы можно было вызывать из Java. И если вдруг в них передается null, то этот метод должен падать в этом же месте, не передавая переменную дальше по коду. Это необходимо для раннего диагностирования ошибок. В приватных методах такой проверки нет. Из Java его просто так нельзя вызвать, только если через reflection. Но с помощью reflection можно вообще много чего сломать при желании. Из Kotlin же компилятор сам следит за вызовами и не даст передать null в такой метод.
Такие проверки, конечно, не могут совсем не влиять на быстродействие. Довольно интересно померить на сколько же они ее ухудшают, но простыми бенчмарками это сделать тяжело. Поэтому посмотрим на данные, которые удалось получить Дмитрию Жемерову:
Проверка параметров на nullДля одного параметра стоимость такой проверки на NotNull вообще пренебрежимо мала. Для метода с восемью параметрами, который больше ничего не делает, кроме как проверяет на null, уже получается что какая-то заметная стоимость есть. Но в любом случае в обычной жизни эту стоимость (приблизительно 3 наносекунды) можно не учитывать. Более вероятна ситуация, что это последнее, что придется оптимизировать в коде. Но если все же нужно убрать излишние проверки, то на данный момент это возможно с помощью дополнительный опций компилятора kotlinc: -Xno-param-assertions и -Xno-call-assertions (важно!: прежде чем отключать проверки, действительно подумайте, в этом ли причина ваших бед, и не будет ли такого, что это принесет больше вреда чем пользы)
Функции расширения (extension functions)
Kotlin позволяет расширять API существующих классов, написанных не только на Kotlin, но и на Java. Для любого класса можно написать объявление функции и дальше в коде ее можно использовать у этого класса так, как будто эта функция была при его объявлении.
В Java генерируется класс, в котором будет просто статический метод с именем, как у функции расширения. В этот метод передается инстанс расширяемого класса. Таким образом, когда мы вызываем функцию расширения, мы на самом деле передаем в стическую функцию сам элемент, на котором вызываем метод.
Почти вся стандартная библиотека Kotlin состоит из функций расширений для классов JDK. В Kotlin очень маленькая своя стандартная библиотека и нет объявления своих классов коллекций. Все коллекции, объявляемые через listOf, setOf, mapOf, которые в Kotlin выглядят на первый взгляд своими, на самом деле обычные Java коллекции ArrayList, HashSet, HashMap. И если нужно передать такую коллекцию в библиотеку (или из библиотеки), то нет никаких накладных расходов на конвертацию к своим внутренним классам (в отличие от Scala <-> Java) или копирование.
Тела методов в интерфейсах
В Kotlin есть возможность добавить реализацию для методов в интерфейсах.
В Java 8 такая возможность также появилась, но по причине того, что Kotlin должен работать и на Java 6, результирующий код в Java выглядит следующим образом:
В Java создается обычный интерфейс, с декларацией метода, и появляется декларация класса DefaultImpls с реализацией по умолчанию для нужных методов. В местах же использования методов появляется вызов реализаций из объявленного в интерфейсе класса, в методы которого передается сам объект вызова.
У команды Kotlin есть планы для перехода на реализацию этой функциональности с помощью методов по умолчанию (default method) из Java 8, но на данный момент присутствуют трудности с сохранением бинарной совместимости с уже скомпилированными библиотеками. Можно посмотреть обсуждение этой проблемы на youtrack. Конечно большой проблемы это не создает, но если в проекте планируется создание api для Java, то нужно учитывать эту особенность.
Аргументы по умолчанию
В отличие от Java, в Kotlin есть аргументы по умолчанию. Но их реализация сделана достаточно интересно.
Для реализации аргументов по умолчанию в байткоде Java используется синтетический метод, в который передается битовая маска mask с информацией о том, какие аргументы отсутствуют в вызове.
Единственный интересный момент, зачем генерируется аргумент var4? Сам он нигде не используется, а в местах использования передается null. Информацию по назначению этого аргумента я не нашел, может yole сможет прояснить ситуацию.
Ниже показаны оценки затрат на такие манипуляции:
Аргументы по умолчаниюСтоимость аргументов по умолчанию уже становится немного заметной. Но все равно потери измеряются в наносекундах и при обычной работе такими потерями можно пренебречь. Существует также способ заставить компилятор Kotlin по другому сгенерировать в байткоде аргументы по умолчанию. Для этого нужно добавить аннотацию @JvmOverloads:
В таком случае, помимо методов из предыдущего примера, еще будут сгенерированы перегрузки метода first под различные варианты передачи аргументов.
Лямбды
Лямбды в Kotlin представляются практически также как и в Java (за исключением того что они являются объектами первого класса)
В данном случае функция runLambda принимает инстанс интерфейса Function0 (объявление которого находится в стандартной библиотеке Kotlin), в котором есть функция invoke(). И соответственно это все совместимо с тем, как это работает в Java 8, и, конечно, работает SAM-конверсия из Java. Результирующий байткод будет выглядеть следующим образом:
Компиляция в байткод сильно зависит от того, если ли захват значения из окружающего контекста или нет. Рассмотрим пример, когда есть глобальная переменная value и лямбда, которая просто возвращает ее значение.
В Java в данном случае, по сути, создается синглтон. Сама лямбда ничего из контекста не использует и соотвественно не нужно создавать разные инстансы под все вызовы. Поэтому просто компилируется класс, который реализует интерфейс Function0, и, как результат, вызов лямбды происходит без аллокации и весьма дешево.
Рассмотрим другой пример с использованием локальных переменных с контекстами.
В данном случае синглтоном уже не обойтись, так как каждый конкретный инстанс лямбды должен иметь свое значение параметра.
Лямбды в Kotlin также умеют менять значение не локальных переменных (в отличие от лямбд Java).
В этом случае создается обертка для изменяемой переменной. Сама обертка, аналогично предыдущему примеру, передается в создаваемую лямбду, внутри которой и происходит изменение исходной переменной через обращение к обертке.
Попробуем сравнить производительность решений на Kotlin, с аналогами на Java:
ЛямбдаКак видно, возня с обертками (последний пример) занимает заметное время, но, с другой стороны, в Java такое не поддерживается из коробки, а если делать руками подобную реализацию, то и затраты будут аналогичные. В остальном разница не так заметна.
Также в Kotlin есть возможность передавать ссылки на методы (method reference) в лямбды, причем они, в отличие от лямбд, сохраняют информацию о том, на что же указывают методы. Ссылки на методы компилируется похожим образом на то, как выглядят лямбды без захвата контекста. Создается синглтон, который помимо значения еще знает на что же эта лямбда ссылается.
У лямбд в Kotlin есть еще одна интересная особенность: их можно объявить с модификатором inline. В этом случае компилятор сам найдет все места использования функции в коде и заменит их на тело функции. JIT тоже умеет инлайнить некоторые вещи и сам, но никогда нельзя быть уверенным в том, что он будет инлайнить, а что пропустит. Поэтому иметь свой управляемый механизм инлайна никогда не помешает.
В примере выше не происходит никакой аллокации, никаких вызовов. По сути, код функции просто “схлопывается”. Это позволяет очень эффективно реализовывать всякие filter, map и т.п. Тот же оператор synchronized тоже инлайнится.
Спасибо за внимание! Надеюсь вам понравилась статья. Прошу всех тех, кто заметил какие-либо ошибки или неточность, написать об этом мне в личном сообщении.