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



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

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

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


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

image

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

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 весьма интересная и может многое прояснить.
Поделиться публикацией
Комментарии 15
    +2
    По декомпилированному коду видно, что полученный синглтон похож на eager реализацию синглтона, он создается в тот момент, когда класслоудер загружает класс.

    Ну да, но это не проблема, класс-то все равно загрузится в момент первого обращения, так что можно считать это lazy-реализацией.

      0
      По факту да, так и есть. Впрочем, никто и не говорил, что это проблема =)
        0

        А, пардон, значит, неправильно Ваш посыл понял. Мне показалось, что Вы сказали это в том ключе, мол, реализация eager, значит это не настоящий синглтон.

          0

          Eager вполне себе нормальный синглтон) Вообще как по мне "ненастоящим" синглноном можно назвать только инстанс образованный какой-нибудь DI библиотекой в скоупе Singleton)

            0

            Теоретически – да :) На практике же большинство ожидает от них ленивость, потокобезопасность, а то и защиту от рефлексии (хотят тут уже перебор, как по мне).

      –1
      дел
        0

        Несколько моментов:


        • Часто можно смотреть не только Kotlin->ByteCode->Java, а еще и скомпиленный JS. Но, конечно, учитывать, что бэкенды разные.
        • Интересно посмотреть, как сделаны замыкания.
        • Интересно посмотреть, как сделаны всякие около-reflection штуки. И тут Kotlin->ByteCode->Java не поможет. На банальном println(::main) отсыпется
        • Интересно посмотреть во что всякие конструкторы inner превращаются

        Ну то есть всё в целом ожидаемо, но интересно.

          0
          На банальном println(::main) отсыпется

          Уже не должен, по идее. Накидайте пожалуйста примеров, которые вам интересно было бы посмотреть, я их потестирую. Только лучше полными/компилирующимися примерами — так как Котлина не знаю, к сожалению, могу только скормить пример как есть.
            0
            Уже не должен, по идее.

            Почему "уже"? Почему "не должен"?
            Полный код:


            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.

              +3
              Если его прогнать 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);
              }
              
                0
                так лучше :)
          0
          во-первых не всегда код будет декомпилирован корректно, во-вторых не любой код может быть декомпилирован

          В теории практически любой (правда не всегда потом рекомпилируется, но это немного другой вопрос). В 99% случаев если что-то не (или неверно) декомпилируется, то это баг или недоработка. Если есть примеры подобного, дайте пожалуйста.
            0
            Почему декомпилировалось со странным (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);
               }
            }
            
              +1
              Спасибо, учту! Как включить vac?
                0
                Если в коде, то вот здесь поставить 1.
                Если в командной строке, то задать -vac=1

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое