Kotlin и autoboxing
А зря, т.к. недавно общаясь с одним из своих коллег, который как раз прочитал одну из статей по Котлину с обзором основных фич, доказывал мне что null-safety зло и реализовано через обработку исключения, т.е. выполняя код:
name?.length
компилятор просто оборачивает вызов в try-catch, пытаясь поймать NullPointerException.
Аналогично другой товарищ после очередного обзора считал, что раз var есть в Kotline, как и в JS, то типизация и там и там динамическая, да и вообще «все эти ваши var/val зло, ничего не понятно, хорошо, что их в Java нет». Say hello, JEP286!
Еще один неудачный пример популяризации языка случился недавно, когда на одной из презентаций по Котлину сам автор доклада не совсем корректно описал работу языка, связанную с примитивами из Java, рассказывая о том, что в Котлине всегда будут использоваться ссылочные типы. Об этом и хотелось бы рассказать поподробней.
Сама суть проблемы с autoboxing/unboxing в Java известна: есть примитивные типы, есть ссылочные классы обертки. При использовании обобщенных типов мы не можем использовать примитивы, т.к. сами generics в runtime затираются (да-да, через отражение мы все равно можем вытащить эту информацию), а вместо них живет обычный Object и привидение к типу, которое добавляет компилятор. Однако Java позволяет приводить из примитивного типа к ссылочному, т.е. из int к java.lang.Integer и наоборот, что и называется autoboxing и unboxing соответственно. Помимо всех тех очевидных проблем, вытекающих отсюда, сейчас нам интересна одна — то что при таких преобразованиях создается новый ссылочный объект, что в целом не очень хорошо влияет на производительность (да-да, на самом деле объект создается не всегда, а только если он не попадет в кеш).
Так как же ведет себя Котлин?
Сначала стоит напомнить, что у Котлина свой набор типов kotlin.Int, kotlin.Long и т.д. И на первый взгляд может показаться, что ситуация тут еще хуже, чем в Java, т.к. создание объекта происходит всегда. Однако это не так. Базовые классы в стандартной библиотеке Котлина виртуальные. Это значит, что сами классы существуют только на этапе написания кода, дальше компилятор транслирует их на целевые классы платформы, в частности для JVM kotlin.Int транслируется в int. Т.е. код на Котлине:
val tmp = 3.0
println(tmp)
После компиляции:
double tmp = 3.0D;
System.out.println(tmp);
Null-типы Котлин транслирует уже в ссылочные, т.е. kotlin.Int? → java.lang.Integer, что вполне логично:
val tmp: Double? = 3.0
println(tmp)
После компиляции:
Double tmp = Double.valueOf(3.0D);
System.out.println(tmp);
Точно также для extension методов и свойств. Если мы укажем не null тип, то компилятор подставит примитив в качестве ресивера, если же nullable то ссылочный класс обертки.
fun Int.example() {
println(this)
}
После компиляции:
public final void example(int receiver) {
System.out.println(receiver);
}
В общем основная идея понятна: компилятор где это возможно старается использовать java примитивы, в остальных случаях ссылочные классы.
Все это хорошо, но что на счет массивов из примитивов?
Тут ситуация похожа: для массивов из примитивов есть свои аналоги в Котлине, например, IntArray → int[] и т.д. Для всех остальных типов используется обобщенный класс Array → T[]. Причем массивы в Котлине поддерживают все те же «функциональные» операции, что и коллекции, т.е. map, fold, reduce и т.д. Опять же можно предположить, что под капотом лежат обобщенные функции, которые вызываются для каждой из операций, в следствии чего на уровне байт кода будет срабатывать тот самый boxing на каждой итерации:
val intArr = intArrayOf(1, 2, 3)
println(intArr.fold(0, { acc, cur -> acc + cur }))
Однако этого не происходит, потому что для каждой такой операции у Котлина есть соответствующий метод с нужным типом. Понятно, что получается много похожих функций, которые отличаются лишь типом массива, но для решения этой проблемы внутри используется кодогенерация. К тому же сама функция и передаваемая лямбда будут заинлайнены в точке вызова, поэтому весь код выше развернется в простой цикл:
int initial = 0;
int accumulator = initial;
for(int i = 0; i < receiver.length; ++i) {
int element = receiver[i];
accumulator += element;
}
System.out.println(accumulator);
Стоит также учесть, что многие функции (например, map) у массивов возвращают не новый массив, а список, в результате чего autoboxing таки будет срабатывать, как это было бы для любого кода с обобщениями в Java.
Очень многих скептиков сильно волнует вопрос производительности «всех этих новых языков». Из всего описанного выше можно сделать вывод (даже не прибегая к бенчмаркам, т.к. результирующий код, генерируемый Котлином и написанный вручную на Java, практически идентичен), что производительность в примерах связанных с autoboxin/unboxing будет как минимум похожа. Однако никто не отменяет того факта, что Котлином, как и любым другим инструментом или библиотекой нужно уметь пользоваться и разбираться в том, что происходит под капотом.