[Из песочницы] 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 something(value: Class) {
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 весьма интересная и может многое прояснить.