А зря, т.к. недавно общаясь с одним из своих коллег, который как раз прочитал одну из статей по Котлину с обзором основных фич, доказывал мне что 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 будет как минимум похожа. Однако никто не отменяет того факта, что Котлином, как и любым другим инструментом или библиотекой нужно уметь пользоваться и разбираться в том, что происходит под капотом.
Комментарии (19)
etozhegdr
11.09.2016 11:15С массивами не всё так однозначно, на самом деле. Есть большая проблема с vararg'ами.
Sirikid
11.09.2016 22:19Можно подробнее? У меня проблема была в запоминании синтаксиса.
etozhegdr
12.09.2016 00:25В первом случае все нормально, а во втором получается compilation error. И проблема в дизайне языка.
interface Foo<T> { fun bar(vararg a: T) } class NormalDouble: Foo<java.lang.Double> { override fun bar(vararg a: java.lang.Double) { throw UnsupportedOperationException() } } class BadDouble: Foo<kotlin.Double> { override fun bar(vararg a: kotlin.Double) { throw UnsupportedOperationException() } }
Для vararg a: T генерируется Object[] a, и для vararg a: Int это int[] a , что не является переопределением.
Есть один вариант пофиксить это: для bar компилятор будет генерировать Integer[], вместо int[].
Но другая проблема возникает здесь:
val bi = BadInt() val arr = intArrayOf(1, 2) bi.bar(*arr) // Здесь проблема
Вообще можно сделать отдельный оператор, который будет боксить каждый элемент массива, но это будет очень медленно. Единственный вариант для решения этой проблемы без изменения дизайна языка — ждать релиза проекта Valhalla.bean
12.09.2016 01:00Причем тут дизайн языка, да и vararg вообще, проблема как раз в самой платформе: в Java в принципе нельзя сделать метод с примитивом в качестве generic параметра, что вы и пытаетесь осуществить. Попробуйте переписать пример выше на обычной джаве и скормить там в качестве параметра типа обычный int — на текущей версии Java так сделать нельзя, почему это происходит кидал ссылку в статье про боксинг.
Так и Котлин не может kotlin.Double сконвертить в int для дженериков, поскольку сами дженерики и там и там практически идентичны. Да, он может это не пишет явно в сообщение об ошибке, но тут догадаться и так можно почему это происходит.
Чтобы исправить данный пример можно отметить в самом дженерике тип как kotlin.Double? тогда все будет успешно. Или просто убрать дженерики вообще и написать:
fun bar(vararg a: Double) { throw UnsupportedOperationException("not implemented") }
тогда он сгенерит:
public final void bar(double... a) { throw (Throwable)(new UnsupportedOperationException("not implemented")); }
что вероятно и хотелось изначально.etozhegdr
12.09.2016 01:54-1kotlin.Double тоже не работает.
Но идея выкинуть дженерики из языка хорошая, мне нравится.
А вообще таск открыт.bean
12.09.2016 02:16kotlin.Double тоже не работает.
kotlin.Double? — выделил важное жирным
Но идея выкинуть дженерики из языка хорошая, мне нравится.
Не знаю откуда вы это взяли вообще.
А вообще таск открыт.
Читайте внимательней комментарий к таску, там как раз все развернуто описано.etozhegdr
12.09.2016 02:24-1Вижу это:
Meanwhile, the questions is what's better:
- (the present behavior) we refuse to compile something that intuitively should be compilable;
- we compile it, but introduce a performance penalty that the user has little chance to be aware of at the time of writing the code.
bean
12.09.2016 02:46И?
В текущей ситуации, вам кажется что код выше, т.е.:
class BadDouble: Foo<kotlin.Double> { override fun bar(vararg a: kotlin.Double) { throw UnsupportedOperationException() } }
Должен скомпилироваться в:
public final class BadDouble implements Foo<double> { public void bar(double... a) { throw ((Throwable)new UnsupportedOperationException("not implemented")); } }
Но такая конструкция в текущей версии Java просто не поддерживается, как это уже писал выше.
Какие есть варианты решения проблемы?
1. (как сейчас) выдавать ошибку компиляции
2. не явно преобразовывать Integer[] и вставлять хаки при конвертации между int[] и bar(vararg a: kotlin.Double).
Sirikid
12.09.2016 07:56В принципе согласен, случай не самый частый, думаю поэтому его пропустили или даже намеренно оставили таким как есть. В разделе про vararg (https://kotlinlang.org/docs/reference/functions.html#variable-number-of-arguments-varargs) не написано что nonnull примитивы становятся массивами примитивов, в остальном не вижу ничего фатального.
vektory79
11.09.2016 17:47+2А ещё в IDEA есть специальный инструмент, показывающий во что на уровне байткода превращается код нп Kotlin. Чтобы не надо было гадать.
И да. Смотреть туда довольно полезно, если вы действительно заботитесь о производительности. Иногда довольно занятные перлы обнаруживаются. Которые, впрочем исправляются с минорными релизами, если об этом сообщать разработчикам.
vladimir_dolzhenko
Во что же всё-таки преобразуется
name?.length
? Не склонен считать авторов Kotlin такими невежами, выбрасывающими каждый раз NPE — но хотелось бы знать, что же в итогеbean
lany
Попробовал скомпилировать такую функцию:
Кодогенератор до сих пор как-то хромает. Получается что-то в духе:
Вроде можно было и без временной переменной обойтись. А самое главное — непонятно, зачем nop. Не следует думать, что они безобидны. Легко можно превысить какой-нибудь эвристический предел в JIT-компиляторе (например, на длину байткода метода) и лишним нопом отключить какую-нибудь оптимизацию.
bean
Мне кажется достаточно трудно делать какие-то выводы об оптимизациях на уровне JVM в данном случае без отрыва от тестов. Вообще зачем действительно там nop мне трудно сказать, могу лишь предположить, что возможно это как-то связано с дебагом.
adelier
Раз проверка не атомарна, значит ли это что null-safety в котлине не гарантируется при многопоточности?
bean
Естественно нет, он не будет за вас создавать синхронизацию и прочее. Для большинства случаев это просто не нужно, например если null переменная объявляется внутри метода.
lany
Никто не мешает перегрузить значение из кучи в локальную переменную или на стек однократно. А локальные переменные и стек в Java не могут измениться из других потоков — это закон. Так что, думаю, здесь всё хорошо.