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

А зря, т.к. недавно общаясь с одним из своих коллег, который как раз прочитал одну из статей по Котлину с обзором основных фич, доказывал мне что null-safety зло и реализовано через обработку исключения, т.е. выполняя код:

name?.length

компилятор просто оборачивает вызов в try-catch, пытаясь поймать NullPointerException.

Аналогично другой товарищ после очередного обзора считал, что раз var есть в Kotline, как и в JS, то типизация и там и там динамическая, да и вообще «все эти ваши var/val зло, ничего не понятно, хорошо, что их в Java нет». Say hello, JEP286!

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

Сама суть проблемы с autoboxing/unboxing в Java известна: есть примитивные типы, есть ссылочные классы обертки. При использовании обобщенных типов мы не можем использовать примитивы, т.к. сами generics в runtime затираются (да-да, через отражение мы все равно можем вытащить эту информацию), а вместо них живет обычный Object и привидение к типу, которое добавляет компилятор. Однако Java позволяет приводить из примитивного типа к ссылочному, т.е. из int к java.lang.Integer и наоборот, что и называется autoboxing и unboxing соответственно. Помимо всех тех очевидных проблем, вытекающих отсюда, сейчас нам интересна одна – то что при таких преобразованиях создается новый ссылочный объект, что в целом не очень хорошо влияет на производительность (да-да, на самом деле объект создается не всегда, а только если он не попадет в кеш).

Так как же ведет себя Котлин?

Сначала стоит напомнить, что у Котлина свой набор типов kotlin.Int, kotlin.Long и т.д. И на первый взгляд может показаться, что ситуация тут еще хуже, чем в Java, т.к. создание объекта происходит всегда. Однако это не так. Базовые классы в стандартной библиотеке Котлина виртуальные. Это значит, что сами классы существуют только на этапе написания кода, дальше компилятор транслирует их на целевые классы платформы, в частности для JVM kotlin.Int транслируется в int. Т.е. код на Котлине:

val tmp = 3.0
println(tmp)

После компиляции:

double tmp = 3.0D;
System.out.println(tmp);

Null-типы Котлин транслирует уже в ссылочные, т.е. kotlin.Int? -> java.lang.Integer, что вполне логично:

val tmp: Double? = 3.0
println(tmp)

После компиляции:

Double tmp = Double.valueOf(3.0D);
System.out.println(tmp);

Точно также для extension методов и свойств. Если мы укажем не null тип, то компилятор подставит примитив в качестве ресивера, если же nullable то ссылочный класс обертки.

fun Int.example() {
    println(this)
}

После компиляции:

public final void example(int receiver) {
   System.out.println(receiver);
}

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

Все это хорошо, но что на счет массивов из примитивов?

Тут ситуация похожа: для массивов из примитивов есть свои аналоги в Котлине, например, IntArray -> int[] и т.д. Для всех остальных типов используется обобщенный класс Array -> T[]. Причем массивы в Котлине поддерживают все те же «функциональные» операции, что и коллекции, т.е. map, fold, reduce и т.д. Опять же можно предположить, что под капотом лежат обобщенные функции, которые вызываются для каждой из операций, в следствии чего на уровне байт кода будет срабатывать тот самый boxing на каждой итерации:

val intArr = intArrayOf(1, 2, 3)
println(intArr.fold(0, { acc, cur -> acc + cur }))

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

int initial = 0;
int accumulator = initial;
for(int i = 0; i < receiver.length; ++i) {
   int element = receiver[i];
   accumulator += element;
}
System.out.println(accumulator);

Стоит также учесть, что многие функции (например, map) у массивов возвращают не новый массив, а список, в результате чего autoboxing таки будет срабатывать, как это было бы для любого кода с обобщениями в Java.

Очень многих скептиков сильно волнует вопрос производительности «всех этих новых языков». Из всего описанного выше можно сделать вывод (даже не прибегая к бенчмаркам, т.к. результирующий код, генерируемый Котлином и написанный вручную на Java, практически идентичен), что производительность в примерах связанных с autoboxin/unboxing будет как минимум похожа. Однако никто не отменяет того факта, что Котлином, как и любым другим инструментом или библиотекой нужно уметь пользоваться и разбираться в том, что происходит под капотом.
Поделиться с друзьями
-->

Комментарии (19)


  1. vladimir_dolzhenko
    11.09.2016 11:06

    Во что же всё-таки преобразуется name?.length? Не склонен считать авторов Kotlin такими невежами, выбрасывающими каждый раз NPE — но хотелось бы знать, что же в итоге


    1. bean
      11.09.2016 11:18

      if (name != null) {
         name.length()
      }
      


      1. lany
        11.09.2016 18:43
        +1

        Попробовал скомпилировать такую функцию:


        fun test(name: String?) {
          println(name?.length);
        }

        Кодогенератор до сих пор как-то хромает. Получается что-то в духе:


        Object tmp$ = name == null ? null: Integer.valueOf(name.length);
        nop;
        System.out.println(tmp$);

        Вроде можно было и без временной переменной обойтись. А самое главное — непонятно, зачем nop. Не следует думать, что они безобидны. Легко можно превысить какой-нибудь эвристический предел в JIT-компиляторе (например, на длину байткода метода) и лишним нопом отключить какую-нибудь оптимизацию.


        1. bean
          11.09.2016 19:35

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


      1. adelier
        11.09.2016 19:05

        Раз проверка не атомарна, значит ли это что null-safety в котлине не гарантируется при многопоточности?


        1. bean
          11.09.2016 19:28

          Естественно нет, он не будет за вас создавать синхронизацию и прочее. Для большинства случаев это просто не нужно, например если null переменная объявляется внутри метода.


        1. lany
          12.09.2016 03:39

          Никто не мешает перегрузить значение из кучи в локальную переменную или на стек однократно. А локальные переменные и стек в Java не могут измениться из других потоков — это закон. Так что, думаю, здесь всё хорошо.


  1. Trans00
    11.09.2016 11:14
    +2

    На всякий случай: вот тут есть небольшое выступление Жемерова о том как и во что компилируется Котлин.


    1. leventov
      12.09.2016 02:24
      +1

  1. etozhegdr
    11.09.2016 11:15

    С массивами не всё так однозначно, на самом деле. Есть большая проблема с vararg'ами.


    1. Sirikid
      11.09.2016 22:19

      Можно подробнее? У меня проблема была в запоминании синтаксиса.


      1. etozhegdr
        12.09.2016 00:25

        В первом случае все нормально, а во втором получается compilation error. И проблема в дизайне языка.

        interface Foo<T> {
            fun bar(vararg a: T)
        }
        class NormalDouble: Foo<java.lang.Double> {
            override fun bar(vararg a: java.lang.Double) {
                throw UnsupportedOperationException()
            }
        
        }
        
        class BadDouble: Foo<kotlin.Double> {
            override fun bar(vararg a: kotlin.Double) {
                throw UnsupportedOperationException()
            }
        }
        


        Для vararg a: T генерируется Object[] a, и для vararg a: Int это int[] a , что не является переопределением.

        Есть один вариант пофиксить это: для bar компилятор будет генерировать Integer[], вместо int[].

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

        val bi = BadInt()
        val arr = intArrayOf(1, 2)
        bi.bar(*arr) // Здесь проблема
        


        Вообще можно сделать отдельный оператор, который будет боксить каждый элемент массива, но это будет очень медленно. Единственный вариант для решения этой проблемы без изменения дизайна языка — ждать релиза проекта Valhalla.


        1. bean
          12.09.2016 01:00

          Причем тут дизайн языка, да и vararg вообще, проблема как раз в самой платформе: в Java в принципе нельзя сделать метод с примитивом в качестве generic параметра, что вы и пытаетесь осуществить. Попробуйте переписать пример выше на обычной джаве и скормить там в качестве параметра типа обычный int — на текущей версии Java так сделать нельзя, почему это происходит кидал ссылку в статье про боксинг.

          Так и Котлин не может kotlin.Double сконвертить в int для дженериков, поскольку сами дженерики и там и там практически идентичны. Да, он может это не пишет явно в сообщение об ошибке, но тут догадаться и так можно почему это происходит.

          Чтобы исправить данный пример можно отметить в самом дженерике тип как kotlin.Double? тогда все будет успешно. Или просто убрать дженерики вообще и написать:

           fun bar(vararg a: Double) {
                 throw UnsupportedOperationException("not implemented")
           }
          

          тогда он сгенерит:
          public final void bar(double... a) {
                   throw (Throwable)(new UnsupportedOperationException("not implemented"));
                }
          

          что вероятно и хотелось изначально.


          1. etozhegdr
            12.09.2016 01:54
            -1

            kotlin.Double тоже не работает.

            Но идея выкинуть дженерики из языка хорошая, мне нравится.

            А вообще таск открыт.


            1. bean
              12.09.2016 02:16

              kotlin.Double тоже не работает.

              kotlin.Double? — выделил важное жирным
              Но идея выкинуть дженерики из языка хорошая, мне нравится.

              Не знаю откуда вы это взяли вообще.
              А вообще таск открыт.

              Читайте внимательней комментарий к таску, там как раз все развернуто описано.


              1. etozhegdr
                12.09.2016 02:24
                -1

                Вижу это:

                Meanwhile, the questions is what's better:
                • (the present behavior) we refuse to compile something that intuitively should be compilable;
                • we compile it, but introduce a performance penalty that the user has little chance to be aware of at the time of writing the code.


                1. bean
                  12.09.2016 02:46

                  И?
                  В текущей ситуации, вам кажется что код выше, т.е.:

                  class BadDouble: Foo<kotlin.Double> {
                      override fun bar(vararg a: kotlin.Double) {
                          throw UnsupportedOperationException()
                      }
                  }
                  

                  Должен скомпилироваться в:
                  public final class BadDouble implements Foo<double>
                  {
                    public void bar(double... a)
                    {
                      throw ((Throwable)new UnsupportedOperationException("not implemented"));
                    }
                  }
                  

                  Но такая конструкция в текущей версии Java просто не поддерживается, как это уже писал выше.
                  Какие есть варианты решения проблемы?
                  1. (как сейчас) выдавать ошибку компиляции
                  2. не явно преобразовывать Integer[] и вставлять хаки при конвертации между int[] и bar(vararg a: kotlin.Double).


        1. Sirikid
          12.09.2016 07:56

          В принципе согласен, случай не самый частый, думаю поэтому его пропустили или даже намеренно оставили таким как есть. В разделе про vararg (https://kotlinlang.org/docs/reference/functions.html#variable-number-of-arguments-varargs) не написано что nonnull примитивы становятся массивами примитивов, в остальном не вижу ничего фатального.


  1. vektory79
    11.09.2016 17:47
    +2

    А ещё в IDEA есть специальный инструмент, показывающий во что на уровне байткода превращается код нп Kotlin. Чтобы не надо было гадать.


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