Kotlin под капотом — смотрим декомпилированный байткод

в 7:49, , рубрики: kotlin, Программирование, разработка

Kotlin под капотом — смотрим декомпилированный байткод - 1

Просмотр декомпилированного в Java байткода Kotlin едва ли не лучший способ понять как он все-таки работает и как некоторые конструкции языка влияют на перфоманс. Многие само собой уже давно это сделали, так что особенно актуальной данная статья будет для новичков и тех, кто уже давно осилил Java и решил использовать Kotlin недавно.

Я специально упущу довольно избитые и известные моменты так как, наверное, нет смысла в сотый раз писать о генерации геттеров/сеттеров для var и подобных вещах. Итак начнем.

Как посмотреть декомпилированный байткод в Intellij Idea?

Довольно просто — достаточно открыть нужный файл и выбрать в меню Tools -> Kotlin -> Show Kotlin Bytecode

image

Далее в появившемся окне просто нажимаем Decompile

Kotlin под капотом — смотрим декомпилированный байткод - 3

Для просмотра будет использоваться версия 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» получается как минимум неточность.

image

Может все-таки 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 весьма интересная и может многое прояснить.

Автор: DEADMC

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js