Kotlin, компиляция в байткод и производительность (часть 2)

в 10:10, , рубрики: java, kotlin, байткод, Блог компании ИНФОРИОН, Компиляторы, Программирование, разработка

Kotlin, компиляция в байткод и производительность (часть 2) - 1

Это продолжение публикации. Первую часть можно посмотреть тут

Содержание:

Циклы
When
Делегаты
Object и companion object
lateinit свойства
coroutines
Выводы

Циклы:


В языке Kotlin отсутствует классический for с тремя частями, как в Java. Кому-то это может показаться проблемой, но если подробнее посмотреть все случаи использования такого цикла, то можно увидеть, что по большей части он применяется как раз для перебора значений. На смену ему в Kotlin есть упрощенная конструкция.

//Kotlin
fun rangeLoop() {
    for (i in 1..10) {
        println(i)
    }
}

1..10 тут это диапазон по которому происходит итерация. Компилятор Kotlin достаточно умный, он понимает что мы собираемся в данном случае делать и поэтому убирает весь лишний оверхед. Код компилируется в обычный цикл while с переменной счетчика цикла. Никаких итераторов, никакого оверхеда, все достаточно компактно.

//Java
public static final void rangeLoop() {
      int i = 1;
      byte var1 = 10;
      if(i <= var1) {
         while(true) {
            System.out.println(i);
            if(i == var1) {
               break;
            }
 
            ++i;
         }
      }
 
 }

Похожий цикл по массиву (который в Kotlin записывается в виде Array<*>), компилируется аналогичным образом в цикл for.

//Kotlin
fun arrayLoop(x: Array<String>) {
    for (s in x) {
        println(s)
    }
}

//Java
public static final void arrayLoop(@NotNull String[] x) {
      Intrinsics.checkParameterIsNotNull(x, "x");
 
      for(int var2 = 0; var2 < x.length; ++var2) {
         String s = x[var2];
         System.out.println(s);
      }
 
 }

Немного другая ситуация возникает, когда происходит перебор элементов из списка:

//Kotlin
fun listLoop(x: List<String>) {
    for (s in x) {
        println(s)
    }
}

В этом случае приходится использовать итератор:

//Java
public static final void listLoop(@NotNull List x) {
      Intrinsics.checkParameterIsNotNull(x, "x");
      Iterator var2 = x.iterator();
 
      while(var2.hasNext()) {
         String s = (String)var2.next();
         System.out.println(s);
      }
 
 }

Таким образом, в зависимости от того по каким элементам происходит перебор, компилятор Kotlin сам выбирает самый эффективный способ преобразовать цикл в байткод.

Ниже приведено сравнение производительности для циклов с аналогичными решениями в Java:

Циклы

Kotlin, компиляция в байткод и производительность (часть 2) - 2

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

When


When — это аналог switch из Java, только с большей функциональностью. Рассмотрим ниже несколько примеров и то, во что они компилируются:

/Kotlin
fun tableWhen(x: Int): String = when(x) {
    0 -> "zero"
    1 -> "one"
    else -> "many"
}

Для такого простого случая результирующий код компилируется в обычный switch, тут никакой магии не происходит:

//Java
public static final String tableWhen(int x) {
      String var10000;
      switch(x) {
      case 0:
         var10000 = "zero";
         break;
      case 1:
         var10000 = "one";
         break;
      default:
         var10000 = "many";
      }
 
      return var10000;
}

Если же немного изменить пример выше, и добавить константы:

//Kotlin 
val ZERO = 1
val ONE = 1
 
fun constWhen(x: Int): String = when(x) {
    ZERO -> "zero"
    ONE -> "one"
    else -> "many"
}

То код в этом случае уже компилируется в следующий вид:

//Java
public static final String constWhen(int x) {
      return x == ZERO?"zero":(x == ONE?"one":"many");
}

Это происходит потому, что на данный момент компилятор Kotlin не понимает, что значения являются константами, и вместо преобразования к switch, код преобразуется к набору сравнений. Поэтому вместо константного времени происходит переход к линейному (в зависимости от количества сравнений). По словам разработчиков языка, в будущем это может быть легко исправлено, но в текущей версии это пока так.

Существует также возможность использовать модификатор const для констант, известных на момент компиляции.

//Kotlin (файл When2.kt)
const val ZERO = 1
const val ONE = 1
 
fun constWhen(x: Int): String = when(x) {
    ZERO -> "zero"
    ONE -> "one"
    else -> "many"
}

Тогда в этом случае компилятор уже правильно оптимизирует when:

public final class When2Kt {
   public static final int ZERO = 1;
   public static final int ONE = 2;

   @NotNull
   public static final String constWhen(int x) {
      String var10000;
      switch(x) {
      case 1:
         var10000 = "zero";
         break;
      case 2:
         var10000 = "one";
         break;
      default:
         var10000 = "many";
      }

      return var10000;
   }
}

Если же заменить константы на Enum:

//Kotlin (файл When3.kt)
enum class NumberValue {
    ZERO, ONE, MANY
}
 
fun enumWhen(x: NumberValue): String = when(x) {
    NumberValue.ZERO -> "zero"
    NumberValue.ONE -> "one"
    NumberValue.MANY -> "many"
}

То код, также как в первом случае, будет компилироваться в switch (практический такой же как в случае перебора enum в Java).

//Java
public final class When3Kt$WhenMappings {
   // $FF: synthetic field
   public static final int[] $EnumSwitchMapping$0 = new int[NumberValue.values().length];
 
   static {
      $EnumSwitchMapping$0[NumberValue.ZERO.ordinal()] = 1;
      $EnumSwitchMapping$0[NumberValue.ONE.ordinal()] = 2;
      $EnumSwitchMapping$0[NumberValue.MANY.ordinal()] = 3;
   }
}


public static final String enumWhen(@NotNull NumberValue x) {
      Intrinsics.checkParameterIsNotNull(x, "x");
      String var10000;
      switch(When3Kt$WhenMappings.$EnumSwitchMapping$0[x.ordinal()]) {
      case 1:
         var10000 = "zero";
         break;
      case 2:
         var10000 = "one";
         break;
      case 3:
         var10000 = "many";
         break;
      default:
         throw new NoWhenBranchMatchedException();
      }
 
      return var10000;
}

По ordinal номеру элемента определяется номер ветки в switch, по которому далее и происходит выбор нужной ветви.

Посмотрим на сравнение производительности решений на Kotlin и Java:

When

Kotlin, компиляция в байткод и производительность (часть 2) - 3

Как видно простой switch работает точно также. В случае, когда компилятор Kotlin не смог определить что переменные константы и перешел к сравнениям, Java работает чуть быстрее. И в ситуации, когда перебираем значения enum, также есть небольшая потеря на возню с определением ветви по значению ordinal. Но все эти недостатки будут исправлены в будущих версиях, и к тому же потеря в производительности не очень большая, а в критичных местах можно переписать код на другой вариант. Вполне разумная цена за удобство использования.

Делегаты


Делегирование — это хорошая альтернатива наследованию, и Kotlin поддерживает его прямо из коробки. Рассмотрим простой пример с делегированием класса:

//Kotlin
package examples
 
interface Base {
    fun print()
}
 
class BaseImpl(val x: Int) : Base {
    override fun print() { print(x) }
}
 
class Derived(b: Base) : Base by b {
    fun anotherMethod(): Unit {}
}

Класс Derived в конструкторе получает экземпляр класса, реализующий интерфейс Base, и в свою очередь делегирует реализацию всех методов интерфейса Base к передаваемому экземпляру. Декомпилированный код класса Derived будет выглядеть следующим образом:

public final class Derived implements Base {
   private final Base $$delegate_0;
 
   public Derived(@NotNull Base b) {
      Intrinsics.checkParameterIsNotNull(b, "b");
      super();
      this.$$delegate_0 = b;
   }
 
   public void print() {
      this.$$delegate_0.print();
   }

   public final void anotherMethod() {
   }
}

В конструктор класса передается экземпляр класса, который запоминается в неизменяемом внутреннем поле. Также переопределяется метод print интерфейса Base, в котором просто происходит вызов метода из делегата. Все достаточно просто.

Существует также возможность делегировать не только реализацию всего класса, но и отдельных его свойств (а с версии 1.1 еще возможно делегировать инициализацию в локальных переменных).

Код на Kotlin:

//Kotlin
class DeleteExample {
    val name: String by Delegate()
}

Компилируется в код:

public final class DeleteExample {
   @NotNull
   private final Delegate name$delegate = new Delegate();
 
  static final KProperty[] $$delegatedProperties = new KProperty[]{(KProperty)Reflection.property1(new PropertyReference1Impl(Reflection.getOrCreateKotlinClass(DeleteExample.class), "name", "getName()Ljava/lang/String;"))};
 
   @NotNull
   public final String getName() {
      return this.name$delegate.getValue(this, $$delegatedProperties[0]);
   }
}

При инициализации класса DeleteExample создается экземпляр класса Delegate, сохраняемый в поле name$delegate. И далее вызов функции getName переадресовывается к вызову функции getValue из name$delegate.

В Kotlin есть уже несколько стандартных делегатов:

— lazy, для ленивых вычислений значения поля.
— observable, который позволяет получать уведомления обо всех изменения значения поля
— map, используемый для инициализации значений поля из значений Map.

Object и companion object


В Kotlin нет модификатора static для методов и полей. Вместо них, по большей части, рекомендуется использовать функции на уровне файла. Если же нужно объявить функции, которые можно вызывать без экземпляра класса, то для этого есть object и companion object. Рассмотрим на примерах как они выглядят в байткоде:

Простое объявление object с одним методом выглядит следующим образом:

//Kotlin
object ObjectExample {
    fun objectFun(): Int {
        return 1
    }
}

В коде дальше можно обращаться к методу objectFun без создания экземпляра ObjectExample. Код компилируется в практически каноничный синглтон:

public final class ObjectExample {
   public static final ObjectExample INSTANCE;
 
   public final int objectFun() {
      return 1;
   }
 
   private ObjectExample() {
      INSTANCE = (ObjectExample)this;
   }
 
   static {
      new ObjectExample();
   }
}

И место вызова:

//Kotlin
val value = ObjectExample.objectFun()

Компилируется к вызову INSTANCE:

//Java
int value = ObjectExample.INSTANCE.objectFun();

companion object используется для создания аналогичных методов только уже в классе, для которого предполагается создание экземпляров.

//Kotlin
class ClassWithCompanion {
    val name: String = "Kurt"
    
    companion object {
        fun companionFun(): Int = 5
    }
}

//method call
ClassWithCompanion.companionFun()

Обращение к методу companionFun также не требует создания экземпляра класса, и в Kotlin будет выглядеть как простое обращение к статическому методу. Но на самом деле происходит обращение к компаньону класса. Посмотрим декомпилированный код:

//Java
public final class ClassWithCompanion {
   @NotNull
   private final String name = "Kurt";
   public static final ClassWithCompanion.Companion Companion = new ClassWithCompanion.Companion((DefaultConstructorMarker)null);
 
   @NotNull
   public final String getName() {
      return this.name;
   }
 
   public static final class Companion {
      public final int companionFun() {
         return 5;
      }
 
      private Companion() {
      }
 
      public Companion(DefaultConstructorMarker $constructor_marker) {
         this();
      }
   }
}

//вызов функции
ClassWithCompanion.Companion.companionFun();

Компилятор Kotlin упрощает вызовы, но из Java, правда, выглядит уже не так красиво. К счастью, есть возможность объявить методы по настоящему статическими. Для этого существует аннотация @JvmStatic. Ее можно добавить как к методам object, так и к методам companion object. Рассмотрим на примере object:

//Kotlin
object ObjectWithStatic {
    @JvmStatic
    fun staticFun(): Int {
        return 5
    }
}

В этом случае метод staticFun будет действительно объявлен статическим:

public final class ObjectWithStatic {
   public static final ObjectWithStatic INSTANCE;
 
   @JvmStatic
   public static final int staticFun() {
      return 5;
   }
 
   private ObjectWithStatic() {
      INSTANCE = (ObjectWithStatic)this;
   }
 
   static {
      new ObjectWithStatic();
   }
}

Для методов из companion object тоже можно добавить аннотацию @JvmStatic:

class ClassWithCompanionStatic {
    val name: String = "Kurt"

    companion object {
        @JvmStatic
        fun companionFun(): Int = 5
    }
}

Для такого кода будет также создан статичный метод companionFun. Но сам метод все равно будет вызывать метод из компаньона:

public final class ClassWithCompanionStatic {
   @NotNull
   private final String name = "Kurt";
   public static final ClassWithCompanionStatic.Companion Companion = new ClassWithCompanionStatic.Companion((DefaultConstructorMarker)null);

   @NotNull
   public final String getName() {
      return this.name;
   }

   @JvmStatic
   public static final int companionFun() {
      return Companion.companionFun();
   }

   public static final class Companion {
      @JvmStatic
      public final int companionFun() {
         return 5;
      }

      private Companion() {
      }

      // $FF: synthetic method
      public Companion(DefaultConstructorMarker $constructor_marker) {
         this();
      }
   }
}

Как показано выше, Kotlin предоставляет различные возможности для объявления как статических методов так и методов компаньонов. Вызов статических методов чуть быстрее, поэтому в местах, где важна производительность, все же лучше ставить аннотации @JvmStatic на методы (но все равно не стоит рассчитывать на большой выигрыш в быстродействии)

lateinit свойства


Иногда возникает ситуация, когда нужно объявить notnull свойство в классе, значение для которого мы не можем сразу указать. Но при инициализации notnull поля мы обязаны присвоить ему значение по умолчанию, либо сделать свойство Nullable и записать в него null. Чтобы не переходить к nullable, в Kotlin существует специальный модификатор lateinit, который говорит компилятору Kotlin о том, что мы обязуемся сами позднее инициализировать свойство.

//Kotlin
class LateinitExample {
    lateinit var lateinitValue: String
}

Если же мы попробуем обратиться к свойству без инициализации, то будет брошено исключение UninitializedPropertyAccessException. Подобная функциональность работает достаточно просто:

//Java
public final class LateinitExample {
   @NotNull
   public String lateinitValue;
 
   @NotNull
   public final String getLateinitValue() {
      String var10000 = this.lateinitValue;
      if(this.lateinitValue == null) {
         Intrinsics.throwUninitializedPropertyAccessException("lateinitValue");
      }
 
      return var10000;
   }
 
   public final void setLateinitValue(@NotNull String var1) {
      Intrinsics.checkParameterIsNotNull(var1, "<set-?>");
      this.lateinitValue = var1;
   }
}

В getter вставляется дополнительная проверка значения свойства, и если в нем хранится null, то кидается исключение. Кстати именно из-за этого в Kotlin нельзя сделать lateinit свойство с типом Int, Long и других типов, которые соответствуют примитивным типам Java.

coroutines


В версии Kotlin 1.1 появилась новая функциональность, называемая корутины (coroutines). С ее помощью можно легко писать асинхронный код в синхронном виде. Помимо основной библиотеки (kotlinx-coroutines-core) для поддержки прерываний, есть еще и большой набор библиотек с различными расширениями:

kotlinx-coroutines-jdk8 — дополнительная библиотека для JDK8
kotlinx-coroutines-nio — расширения для асинхронного IO из JDK7+.

kotlinx-coroutines-reactive — утилиты для реактивных стримов
kotlinx-coroutines-reactor — утилиты для Reactor
kotlinx-coroutines-rx1 — утилиты для RxJava 1.x
kotlinx-coroutines-rx2 — утилиты для RxJava 2.x

kotlinx-coroutines-android — UI контекст для Android.
kotlinx-coroutines-javafx — JavaFx контекст для JavaFX UI приложений.
kotlinx-coroutines-swing — Swing контекст для Swing UI приложений.

Примечание: Функциональность пока находится в экспериментальной стадии, поэтому все сказанное ниже еще может измениться.

Для того, чтобы обозначить, что функция может быть прервана и использована в контексте прерывания, используется модификатор suspend

//Kotlin
suspend fun asyncFun(x: Int): Int {
    return x * 3
}

Декомпилированный код выглядит следующим образом:

//Java
public static final Object asyncFun(int x, @NotNull Continuation $continuation) {
      Intrinsics.checkParameterIsNotNull($continuation, "$continuation");
      return Integer.valueOf(x * 3);
}

Получается практически исходная функция, за исключением того, что еще передается один дополнительный параметр, реализующий интерфейс Continuation.

interface Continuation<in T> {
   val context: CoroutineContext
   fun resume(value: T)
   fun resumeWithException(exception: Throwable)
}

В нем хранится контекст выполнения, определена функция возвращения результата и функция возвращения исключения, в случае ошибки.

Корутины компилируются в конечный автомат (state machine). Рассмотрим на примере:

val a = a()
val y = foo(a).await() // точка прерывания #1
b()
val z = bar(a, y).await() // точка прерывания #2
c(z)

Функции foo и bar возвращают CompletableFuture, на которых вызывается suspend функция await. Декомпилировать в Java такой код не получится (по большей части из-за goto), поэтому рассмотрим его в псевдокоде:

class <anonymous_for_state_machine> extends CoroutineImpl<...> implements Continuation<Object> {
    // текущее состояние машины состояний
    int label = 0
    
    // локальные переменные корутин
    A a = null
    Y y = null
    
    void resume(Object data) {
        if (label == 0) goto L0
        if (label == 1) goto L1
        if (label == 2) goto L2
        else throw IllegalStateException()
        
      L0:
        a = a()
        label = 1
        data = foo(a).await(this) // 'this' передается как continuation 
        if (data == COROUTINE_SUSPENDED) return // возвращение, если await прервал выполнение
      L1:
        // внешний код возвращает выполнение корутины, передавая результат как data
        y = (Y) data
        b()
        label = 2
        data = bar(a, y).await(this) // 'this' передается как continuation
        if (data == COROUTINE_SUSPENDED) return // возвращение, если await прервал выполнение
      L2:
        // внешний код возвращает выполнение корутины передавая результат как data 
        Z z = (Z) data
        c(z)
        label = -1 // Не допускается больше никаких шагов
        return
    }          
}    

Как видно, получаются 3 состояния: L0, L1, L2. Выполнение начинается в состоянии L0, далее из которого происходит переключение в состояние L1 и после в L2. В конце происходит переключение состояния в -1 как индикация того, что больше никаких шагов не допускается.

Сами корутины могут выполняться в различных потоках, есть удобный механизм для управления этим при помощи указания пула в контексте запуска корутины. Можно посмотреть подробный гайд с большим количеством примеров и описанием их использования.

Все исходные коды на Kotlin доступны в github. Можно открыть их у себя и поэкспериментировать с кодом, параллельно просматривая, в какой итоговый байткод компилируются исходники.

Выводы


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

Спасибо за внимание! Надеюсь вам понравилась статья. Прошу всех тех, кто заметил какие-либо ошибки или неточности написать мне об этом в личном сообщении.

Автор: nerumb

Источник


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


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