Просмотр декомпилированного в 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 реализацию синглтона, он создается в тот момент, когда класслоудер загружает класс. C одной стороны 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 и кидает исключение если вы подсунули
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);
}
}
В нижней части опять видна статика, а в верхней видно, что лямбда в аргументе функции также инлайнится, а не создает дополнительный анонимный класс, как в случае с обычной лямбдой в Kotlin.
Примерно на этом познания 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 весьма интересная и может многое прояснить.
Комментарии (15)
speshuric
03.10.2018 05:00Несколько моментов:
- Часто можно смотреть не только Kotlin->ByteCode->Java, а еще и скомпиленный JS. Но, конечно, учитывать, что бэкенды разные.
- Интересно посмотреть, как сделаны замыкания.
- Интересно посмотреть, как сделаны всякие около-reflection штуки. И тут Kotlin->ByteCode->Java не поможет. На банальном
println(::main)
отсыпется - Интересно посмотреть во что всякие конструкторы
inner
превращаются
Ну то есть всё в целом ожидаемо, но интересно.
Stiver
03.10.2018 18:12На банальном println(::main) отсыпется
Уже не должен, по идее. Накидайте пожалуйста примеров, которые вам интересно было бы посмотреть, я их потестирую. Только лучше полными/компилирующимися примерами — так как Котлина не знаю, к сожалению, могу только скормить пример как есть.speshuric
03.10.2018 21:31Уже не должен, по идее.
Почему "уже"? Почему "не должен"?
Полный код:
fun main(args: Array<String>) { println(::main) }
Код компилируется, работает. Вывод:
fun main(kotlin.Array<kotlin.String>): kotlin.Unit
Байткод показывается нормально.
Если его прогнать Show bytecode и Decompile, то там некомпилируемая шляпа:
public static final void main(@NotNull String[] args) { Intrinsics.checkParameterIsNotNull(args, "args"); <undefinedtype> var1 = null.INSTANCE; System.out.println(var1); }
Но это, пожалуй, простительно. Function references завязаны на reflection, а с ним в Котлине с всё непросто: приходится поддерживать и интероп с java, и js/native/ir, и котлиновые фичи (локальные функции, например). Это прослеживается и в трекере и в документации, особенно если посмотреть на JS.
Stiver
03.10.2018 21:42+2Если его прогнать Show bytecode и Decompile, то там некомпилируемая шляпа
Угу, потому что параметер 'vac' (VERIFY_ANONYMOUS_CLASSES) по умолчанию неактивен. В результате декомпилятор считает класс анонимным, который им на самом деле не является. Если активировать, то результат выглядит так:
public static final void main(@NotNull String[] args) { Intrinsics.checkParameterIsNotNull(args, "args"); final class NamelessClass_1 extends FunctionReference implements Function1 { public static final NamelessClass_1 INSTANCE = new NamelessClass_1(); public final void invoke(@NotNull String[] p1) { Intrinsics.checkParameterIsNotNull(p1, "p1"); TestPrintlnKt.main(p1); } public final KDeclarationContainer getOwner() { return Reflection.getOrCreateKotlinPackage(TestPrintlnKt.class, "main"); } public final String getName() { return "main"; } public final String getSignature() { return "main([Ljava/lang/String;)V"; } NamelessClass_1() { super(1); } } NamelessClass_1 var1 = NamelessClass_1.INSTANCE; System.out.println(var1); }
Stiver
03.10.2018 18:11во-первых не всегда код будет декомпилирован корректно, во-вторых не любой код может быть декомпилирован
В теории практически любой (правда не всегда потом рекомпилируется, но это немного другой вопрос). В 99% случаев если что-то не (или неверно) декомпилируется, то это баг или недоработка. Если есть примеры подобного, дайте пожалуйста.
Stiver
03.10.2018 22:16Почему декомпилировалось со странным (Function0)null.INSTANCE; — я без понятия, вероятнее всего это баг декомпилятора.
Это не баг, просто часть функционала сейчас по умолчанию отключена (см. пример выше, то же самое). Надо включить параметр 'vac', тогда получится:
public final class Test { public final void test() { final class NamelessClass_1 extends Lambda implements Function0 { public static final NamelessClass_1 INSTANCE = new NamelessClass_1(); public final void invoke() { String var1 = "hello"; System.out.println(var1); } NamelessClass_1() { super(0); } } Function0 action$iv = (Function0)NamelessClass_1.INSTANCE; action$iv.invoke(); String var2 = "world"; System.out.println(var2); } }
ookami_kb
Ну да, но это не проблема, класс-то все равно загрузится в момент первого обращения, так что можно считать это lazy-реализацией.
DEADMC Автор
По факту да, так и есть. Впрочем, никто и не говорил, что это проблема =)
ookami_kb
А, пардон, значит, неправильно Ваш посыл понял. Мне показалось, что Вы сказали это в том ключе, мол, реализация eager, значит это не настоящий синглтон.
DEADMC Автор
Eager вполне себе нормальный синглтон) Вообще как по мне "ненастоящим" синглноном можно назвать только инстанс образованный какой-нибудь DI библиотекой в скоупе Singleton)
ookami_kb
Теоретически – да :) На практике же большинство ожидает от них ленивость, потокобезопасность, а то и защиту от рефлексии (хотят тут уже перебор, как по мне).