Kotlin і autoboxing

останнім часом досить багато презентацій і статей на тему Котлина. Водночас більша їх частина зводиться до перерахування основних достоїнств мови, без розгляду як це працює всередині і в якій байт-код транслюється.

А даремно, оскільки нещодавно спілкуючись з одним із своїх колег, який як раз прочитав одну з статей за Котліну з оглядом основних фіч, доводив мені що null-safety зло і реалізовано через обробку виключення, тобто виконуючи код:

name?.length

компілятор просто обертає виклик try-catch, намагаючись зловити NullPointerException.

Аналогічно інший товариш після чергового огляду вважав, що раз var є в Kotline, як і в JS, то типізація і там і там динамічна, та й взагалі «всі ці ваші var/val зло, нічого не зрозуміло, добре, що їх у Java немає». Say hello, JEP286!

Ще один невдалий приклад популяризації мови трапився нещодавно, коли на одній з презентацій по Котліну сам автор доповіді не зовсім коректно описав роботу мови, пов'язану з примітивами з Java, розповідаючи про те, що в Котлине завжди будуть використовуватися посилальні типи. Про це і хотілося б розповісти детальніше.

Сама суть проблеми з autoboxing/unboxing в Java відома: є примітивні типи, є смітники класи обгортки. При використанні узагальнених типів ми не можемо використовувати примітиви, т. к. самі узагальнення в 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.0 D;
System.out.println(tmp);

Null-типи Котлін транслює вже в смітники, тобто kotlin.Int? -> java.lang.Integer, що цілком логічно:

val tmp: Double? = 3.0
println(tmp)

Після компіляції:

Double tmp = Double.valueOf(3.0 D);
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 буде як мінімум схожа. Однак ніхто не скасовує того факту, що Котлином, як і будь-яким іншим інструментом або бібліотекою треба вміти користуватися і розбиратися в тому, що відбувається під капотом.
Джерело: Хабрахабр
  • avatar
  • 0

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.