Kotlin під капотом — дивимося декомпилированный байткод
Перегляд декомпилированного в Java байткода Kotlin чи не найкращий спосіб зрозуміти, як він все-таки працює і як деякі конструкції мови впливають на перфоманс. Багато хто саме собою вже давно це зробили, так що особливо актуальною дана стаття буде для новачків і тих, хто вже давно подужав Java і вирішив використовувати Kotlin недавно.
Я спеціально втрачу досить побиті і відомі моменти так як, напевно, немає сенсу в сотий раз писати про генерації геттеров/сеттерів для var і подібних речах. Отже почнемо.
Як подивитися декомпилированный байткод в Intellij Idea?
Досить просто — достатньо відкрити потрібний файл і вибрати в меню Tools -> Kotlin -> Show Kotlin Bytecode
Далі у вікні просто натискаємо Decompile
Для перегляду буде використовуватися версія Kotlin 1.3-RC.
Тепер, наприкінці, перейдемо до основної частини.
object
Kotlin
object Test
Decompiled Java
public final class Test {
public static final Test INSTANCE;
static {
Test var0 = new Test();
INSTANCE = var0;
}
}
Я думаю всі, хто має справу з Kotlin знає, що object створює сінглтон. Однак, далеко не всім очевидно якою саме сінглтон створюється та є він потокобезопасным.
За декомпилированному кодом видно, що отриманий сінглтон схожий на eager реалізацію сінглтона, він створюється в той момент, коли класслоудер завантажує клас. Потокобезопасным він є лише умовно — з одного боку static блок виконується при завантаженні класслоудером, що саме по собі потокобезопасно. З іншого боку, якщо класслоудеров більше одного, то і одним примірником можна не відбутися.
extensions
Kotlin
fun String.getEmpty(): String {
return"
}
Decompiled Java
public final class TestKt {
@NotNull
public static final String getEmpty(@NotNull String $receiver) {
Intrinsics.checkParameterIsNotNull($receiver, "receiver$0");
return";
}
}
Тут загалом все зрозуміло — экстеншны є просто синтаксичним сахарком і компілюються у звичайний статичний метод.
Якщо когось збентежила рядок з Intrinsics.checkParameterIsNotNull, то і там все прозоро — у всіх функціях з не nullable аргументами Kotlin додає перевірку на null і кидає виняток якщо ви підсунули свиню null, хоча в аргументах обіцяли цього не робити. Виглядає це так:
public static void checkParameterIsNotNull(Object value, String paramName) {
if (value == null) {
throwParameterIsNullException(paramName);
}
}
Що характерно, якщо написати функцію, а extension property
val String.empty: String
get() {
return"
}
То в результаті ми отримаємо рівно те ж саме, що отримали для методу String.getEmpty()
inline
Kotlin
inline fun something() {
println("hello")
}
class Test {
fun test() {
something()
}
}
Decompiled Java
public final class Test {
public final void test() {
String var1 = "hello";
System.out.println(var1);
}
}
public final class TestKt {
public static final void something() {
String var1 = "hello";
System.out.println(var1);
}
}
З инлайном все досить просто — функція, позначена як inline просто цілком і повністю вставляється в те місце, звідки її викликали. Що цікаво — вона також сама по собі компилится в статиці, ймовірно, для можливості interoperability з Java.
Вся міць инлайна розкривається в той момент, коли в аргументах значиться лямбда:
Kotlin
inline fun something(action: () -> Unit) {
action()
println("world")
}
class Test {
fun test() {
something {
println("hello")
}
}
}
Decompiled Java
public final class Test {
public final void test() {
String var1 = "hello";
System.out.println(var1);
var1 = "world";
System.out.println(var1);
}
}
public final class TestKt {
public static final void something(@NotNull Function0 action) {
Intrinsics.checkParameterIsNotNull(action, "action");
action.invoke();
String var2 = "world";
System.out.println(var2);
}
}
У нижній частині знову видно статика, а у верхній видно, що лямбда в аргументі функції також инлайнится, а не створює додатковий анонімний клас, як це було б в Java.
Приблизно на цьому пізнання inline в Kotlin у багатьох закінчуються, але є ще 2 цікавих моменту, а саме noinline і crossinline. Це ключові слова, які можна приставити до лямбда є аргументом на інлайн функції.
Kotlin
inline fun something(noinline action: () -> Unit) {
action()
println("world")
}
class Test {
fun test() {
something {
println("hello")
}
}
}
Decompiled Java
public final class Test {
public final void test() {
Function0 action$iv = (Function0)null.INSTANCE;
action$iv.invoke();
String var2 = "world";
System.out.println(var2);
}
}
public final class TestKt {
public static final void something(@NotNull Function0 action) {
Intrinsics.checkParameterIsNotNull(action, "action");
action.invoke();
String var2 = "world";
System.out.println(var2);
}
}
При такій запису IDE починає вказувати, що такий інлайн марний трохи менше ніж повністю. А компілює рівно те ж, що і Java — створює Function0. Чому декомпилировалось з дивним (Function0)null.INSTANCE; — я без поняття, найімовірніше це баг декомпилятора.
crossinline в свою чергу робить рівно те ж, що і звичайний inline (тобто якщо перед лямбдой в аргументі не писати взагалі нічого), за невеликим винятком — лямбда не можна писати return, що необхідно для блокування можливості раптово завершити функцію, що викликає inline. У сенсі написати-то можна, але по-перше IDE буде лаятися, а по друге при компіляції отримаємо ‘return’ is not allowed here Втім, байткод у crossinline не відрізняється від дефолтного инлайна — ключове слово використовується тільки компілятором.
infix
Kotlin
infix fun Int.plus(value: Int): Int {
return this+value
}
class Test {
fun test() {
val result = 5 plus 3
}
}
Decompiled Java
public final class Test {
public final void test() {
int result = TestKt.plus(5, 3);
}
}
public final class TestKt {
public static final int plus(int $receiver, int value) {
return $receiver + value;
}
}
Інфіксние функції компілюються як і экстеншны в звичайну статику
tailrec
Kotlin
tailrec fun factorial(step:Int, value: Int = 1):Int {
val newValue = step*value
return if (step == 1) newValue else factorial(step - 1,newValue)
}
Decompiled Java
public final class TestKt {
public static final int factorial(int step, int value) {
while(true) {
int newValue = step * value;
if (step == 1) {
return newValue;
}
int var10000 = step - 1;
value = newValue;
step = var10000;
}
}
// $FF: synthetic method
public static int factorial$default(int var0, int var1, int var2, Object var3) {
if ((var2 & 2) != 0) {
var1 = 1;
}
return factorial(var0, var1);
}
}
tailrec є досить цікавою штукою. Як видно з коду рекурсія просто переганяється в куди менш читається цикл, зате розробник може спати спокійно, так як нічого не вилетить з Stackoverflow в самий неприємний момент. Інша справа в реальному житті знайти застосування tailrec вийде рідко.
reified
Kotlin
inline fun <reified T>something(value: Class<T>) {
println(value.simpleName)
}
Decompiled Java
public final class TestKt {
private static final void something(Class value) {
String var2 = value.getSimpleName();
System.out.println(var2);
}
}
Взагалі про саму концепцію reified і для чого це треба можна написати цілу статтю. Якщо вкрадце, то доступ до самого типу в Java в compile time неможливий, т. к. до компіляції Java знати не знає що там буде взагалі. Котлін — інша справа. Ключове слово reified може бути використано тільки в inline функціях, які як вже зазначалося просто копіюються, вставляються в потрібні місця, таким чином вже під час виклику функції компілятор вже в курсі що саме там за тип і може модифікувати байткод.
Слід звернути увагу на те, що в байткоде компілюється статична функція з приватним рівнем доступу, а значить з Java таке смикнути не вийде. До речі з-за reified в рекламі Kotlin «100% interoperable with Java and Android» виходить як мінімум неточність.
Може все-таки 99%?
init
Kotlin
class Test {
constructor()
constructor(value: String)
init {
println("hello")
}
}
Decompiled Java
public final class Test {
public Test() {
String var1 = "hello";
System.out.println(var1);
}
public Test(@NotNull String value) {
Intrinsics.checkParameterIsNotNull(value, "value");
super();
String var2 = "hello";
System.out.println(var2);
}
}
В цілому з init все просто — це звичайна inline функція, яка відпрацьовує до виклику коду самого конструктора.
data class
Kotlin
data class Test(val argumentValue: String, val argumentValue2: String) {
var innerValue: Int = 0
}
Decompiled Java
public final class Test {
private int innerValue;
@NotNull
private final String argumentValue;
@NotNull
private final String argumentValue2;
public final int getInnerValue() {
return this.innerValue;
}
public final void setInnerValue(int var1) {
this.innerValue = var1;
}
@NotNull
public final String getArgumentValue() {
return this.argumentValue;
}
@NotNull
public final String getArgumentValue2() {
return this.argumentValue2;
}
public Test(@NotNull String argumentValue, @NotNull String argumentValue2) {
Intrinsics.checkParameterIsNotNull(argumentValue, "argumentValue");
Intrinsics.checkParameterIsNotNull(argumentValue2, "argumentValue2");
super();
this.argumentValue = argumentValue;
this.argumentValue2 = argumentValue2;
}
@NotNull
public final String component1() {
return this.argumentValue;
}
@NotNull
public final String component2() {
return this.argumentValue2;
}
@NotNull
public final Test copy(@NotNull String argumentValue, @NotNull String argumentValue2) {
Intrinsics.checkParameterIsNotNull(argumentValue, "argumentValue");
Intrinsics.checkParameterIsNotNull(argumentValue2, "argumentValue2");
return new Test(argumentValue, argumentValue2);
}
// $FF: synthetic method
@NotNull
public static Test copy$default(Test var0, String var1, String var2, int var3, Object var4) {
if ((var3 & 1) != 0) {
var1 = var0.argumentValue;
}
if ((var3 & 2) != 0) {
var2 = var0.argumentValue2;
}
return var0.copy(var1, var2);
}
@NotNull
public String toString() {
return "Test(argumentValue=" + this.argumentValue + ", argumentValue2=" + this.argumentValue2 + ")";
}
public int hashCode() {
return (this.argumentValue != null ? this.argumentValue.hashCode() : 0) * 31 + (this.argumentValue2 != null ? this.argumentValue2.hashCode() : 0);
}
public boolean equals(@Nullable Object var1) {
if (this != var1) {
if (var1 instanceof Test) {
Test var2 = (Test)var1;
if (Intrinsics.areEqual(this.argumentValue, var2.argumentValue) && Intrinsics.areEqual(this.argumentValue2, var2.argumentValue2)) {
return true;
}
}
return false;
} else {
return true;
}
}
}
Чесно кажучи взагалі не хотілося згадувати дата класи, про які вже стільки сказано, але тим не менш є пара моментів, які заслуговують уваги. По-перше варто зауважити, що в equals/hashCode/copy/toString потрапляють тільки ті змінні, які були передані в конструктор. На питання чому так — Андрій Бреслав відповів, що брати ще й поля не передані в конструкторі складно і запарно. До речі дата від класу не можна успадковуватись, правда тільки тому, що при спадкуванні нагенеренный код не був би коректним. По-друге варто зазначити метод component1() для отримання значення поля. Генерується стільки componentN() методів, скільки аргументів в конструкторі. Виглядає марно, але насправді потрібно це для destructuring declaration.
destructuring declaration
Для прикладу скористаємося дата класом з попереднього прикладу і додамо наступний код:
Kotlin
class DestructuringDeclaration {
fun test() {
val (one, two) = Test("hello", "world")
}
}
Decompiled Java
public final class DestructuringDeclaration {
public final void test() {
Test var3 = new Test("hello", "world");
String var1 = var3.component1();
String two = var3.component2();
}
}
Зазвичай ця можливість припадає пилом на полиці, але іноді може бути корисною, наприклад, при роботі з вмістом мап.
operator
Kotlin
class Something(var likes: Int = 0) {
operator fun inc() = Something(likes+1)
}
class Test() {
fun test() {
var something = Something()
something++
}
}
Decompiled Java
public final class Something {
private int likes;
@NotNull
public final Something inc() {
return new Something(this.likes + 1);
}
public final int getLikes() {
return this.likes;
}
public final void setLikes(int var1) {
this.likes = var1;
}
public Something(int likes) {
this.likes = likes;
}
// $FF: synthetic method
public Something(int var1, int var2, DefaultConstructorMarker var3) {
if ((var2 & 1) != 0) {
var1 = 0;
}
this(var1);
}
public Something() {
this(0, 1, (DefaultConstructorMarker)null);
}
}
public final class Test {
public final void test() {
Something something = new Something(0, 1, (DefaultConstructorMarker)null);
something = something.inc();
}
}
Ключове слово operator потрібно для того, щоб перевизначити який-небудь оператор мови для конкретного класу. Чесно сказати я ні разу не бачив щоб це хто-небудь використовував, але тим не менше така можливість є, а магії всередині немає. По суті компілятор просто підміняє оператор на потрібну функцію, приблизно також як typealias замінюється на конкретний тип.
І так, якщо ви прямо зараз подумали про те, що буде, якщо змінити оператор ідентичності ( === що), то поспішаю засмутити, це єдиний оператор, який змінити не можна.
inline class
Kotlin
inline class User(internal val name: String) {
fun upperCase(): String {
return name.toUpperCase()
}
}
class Test {
fun test() {
val user = User("Some1")
println(user.upperCase())
}
}
Decompiled Java
public final class Test {
public final void test() {
String user = User.constructor-impl("Some1");
String var2 = User.upperCase-impl(user);
System.out.println(var2);
}
}
public final class User {
@NotNull
private final String name;
// $FF: synthetic method
private User(@NotNull String name) {
Intrinsics.checkParameterIsNotNull(name, "name");
super();
this.name = name;
}
@NotNull
public static final String upperCase_impl/* $FF was: upperCase-impl*/(String $this) {
if ($this == null) {
throw new TypeCastException("null cannot be cast to non-null type java.lang.String");
} else {
String var10000 = $this.toUpperCase();
Intrinsics.checkExpressionValueIsNotNull(var10000, "(this as java.lang.String).toUpperCase()");
return var10000;
}
}
@NotNull
public static String constructor_impl/* $FF was: constructor-impl*/(@NotNull String name) {
Intrinsics.checkParameterIsNotNull(name, "name");
return name;
}
// $FF: synthetic method
@NotNull
public static final User box_impl/* $FF was: box-impl*/(@NotNull String v) {
Intrinsics.checkParameterIsNotNull(v, v);
return new User(v);
}
@NotNull
public static String toString_impl/* $FF was: toString-impl*/(String var0) {
return "User(name=" + var0 + ")";
}
public static int hashCode_impl/* $FF was: hashCode-impl*/(String var0) {
return var0 != null ? var0.hashCode() : 0;
}
public static boolean equals_impl/* $FF was: equals-impl*/(String var0, @Nullable Object var1) {
if (var1 instanceof User) {
String var2 = ((User)var1).unbox-impl();
if (Intrinsics.areEqual(var0, var2)) {
return true;
}
}
return false;
}
public static final boolean equals_impl0/* $FF was: equals-impl0*/(@NotNull String p1, @NotNull String p2) {
Intrinsics.checkParameterIsNotNull(p1, "p1");
Intrinsics.checkParameterIsNotNull(p2, "p2");
throw null;
}
// $FF: synthetic method
@NotNull
public final String unbox_impl/* $FF was: unbox-impl*/() {
return this.name;
}
public String toString() {
return toString-impl(this.name);
}
public int hashCode() {
return hashCode-impl(this.name);
}
public boolean equals(Object var1) {
return equals-impl(this.name, var1);
}
}
З обмежень — можна використовувати тільки один аргумент в конструкторі, втім воно й зрозуміло, враховуючи що інлайн клас це в цілому обгортка над якоюсь однією змінною. Інлайн клас може містити в собі методи, але вони являють собою звичайну статику. Також очевидно, що для підтримки интеропа з Java додані всі необхідні методи.
Підсумок
Не варто забувати, що по-перше не завжди код буде декомпилирован коректно, по-друге, не будь-код може бути декомпилирован. Однак сама по собі можливість дивитися декомпилированный код Kotlin вельми цікава і може багато чого прояснити.