СоХабр закрыт.

С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.

| сохранено

H Автоматические объекты компилятора в черновиках

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


Статья основана на версии Kotlin «1.1-М04».


В Java существует только один способ создания элемента кода – это лямбда-функция, которая может реализовывать только функциональный интерфейс (подробнее см. в документации на Java). В Kotlin существует несколько различных способов описать объект, содержащий пользовательский код.


Всем хорошо известно и широко используется особенность Kotlin очень легко реализовывать код, который позволяет писать такие конструкции:


fun Action( cb:()->Unit ) {}

fun Test() {
  Action{
    // Этот блок кода будет передан в функцию в качестве параметра
  }
}

Как это реализовано и чем чревато использование такого кода в программе?



Автоматические объекты компилятора


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


Анонимная функция


Блок кода с произвольными параметрами и возвращаемым значением, который может быть вызван непосредственно или передан в качестве параметра в функцию.


fun Action( cb:()->Unit ) {}

fun statMethod() {}

fun Test() {
  val cb = {}

  cb()
  Action( cb )
  Action{}
  Action( ::statMethod )
}

Анонимная функция объекта


Блок кода, который выполнится в контексте экземпляра объекта, т.е. будет иметь поле «this», ссылающееся на объект для которого вызван этот код.


fun cAction( cb:String.()->Unit ) {
  val str = "text"
  str.cb()
}

fun Test() {
  cAction{
    println( "Hello from anonymous receiver: $this" )
  }
}

Лямбда-функция


Блок, реализуемый в автоматически создаваемом классе, наследнике функционального интерфейса, для единственного метода этого интерфейса.


class AA {
  fun add(i : ActionListener) {
  }
}

fun Test() {
  val a = AA()
  a.add(ActionListener { })
}

Анонимный класс


Автоматически создаваемый безымянный класс, с возможностью наследования от указанного класса.


class AA {
  fun add(i : ActionListener) {
  }
}

fun Test() {
  val a = AA()
  a.add(object : ActionListener { })
}

Внешние автоматические классы


Давайте соберем воедино все описанные выше способы объявления автоматических объектов в один исходный файл и скомпилируем его.


Текст программы
import java.awt.event.ActionEvent
import java.awt.event.ActionListener

interface Intf : ActionListener

class AA {
  fun add(i : ActionListener) {}
}

fun cAction( cb:String.()->Int ) {
  val str = "text"
  str.cb()
}

fun Action( cb:()->Unit ) {}

fun statMethod() {}

fun Test() {
  val a = AA()

  //jm/test/ktest/KMainKt$Test$1
  a.add(object : Intf {
    override fun actionPerformed(e : ActionEvent?) {}
  })

  //jm/test/ktest/KMainKt$Test$2
  a.add(ActionListener { })

  //jm/test/ktest/KMainKt$Test$cb$1
  val cb = {}

  cb()
  Action( cb )

  //jm/test/ktest/KMainKt$Test$3
  Action{}

  //jm/test/ktest/KMainKt$Test$4
  Action( ::statMethod )

  //jm/test/ktest/KMainKt$Test$5
  cAction( String::length )
}

Если посмотреть в каталог, куда компилятор сохранил созданные классы, то можно увидеть следующие файлы:


Имя файла Размер, байт
AA.class 888
Intf.class 401
KMainKt.class 2402
KMainKt$Test$1.class 951
KMainKt$Test$2.class 911
KMainKt$Test$3.class 989
KMainKt$Test$4.class 1413
KMainKt$Test$5.class 1222
KMainKt$Test$cb$1.class 995

Понятно откуда взялись первые три файла – это классы, которые описаны в нашей программе:


  • Файл «AA.class» — это объявленный в тексте класс.
  • Файл «Intf.class» — это объявленный в тексте программы интерфейс.
  • Файл «KMainKt.class» — это основной класс программы Kotlin, в который включены все данные и методы, не включенные в другие классы. Имя этого файла состоит из имени файла исходной программы (в нашем случае это «kMain.kt») и суффикса «Kt». В нашем примере в этот класс помещены методы «Action», «cAction», «statMethod» и «Test».

Откуда взялись остальные файлы?


Эти файлы содержат в себе код классов, которые компилятор сгенерировал автоматически. В тексте программы приведенной выше, в комментариях, указаны имена сгенерированных классов, в соответствии месту их объявления в программе.


ВНИМАНИЕ: Компилятор создает каждый раз новый КЛАСС, а не новый экземпляр какого-то класса. Т.е. при каждом использовании кода в программе создается новый класс, который используется в единственном месте программы, создавая единственный его экземпляр!


Если посмотреть исходный текст, то можно обратить внимание на то, что текст, казалось-бы, не имеющий отношения к созданию временных объектов, так же приводит к генерации классов.


  //jm/test/ktest/KMainKt$Test$4
  Action( ::statMethod )

  //jm/test/ktest/KMainKt$Test$5
  cAction( String::length )

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


Action{ statMethod() }
cAction{ length }

Если посмотреть на таблицу, приведенную выше, то можно заметить, что генерируемые файлы классов очень большие. Нужно учесть, что в рассматриваемом примере объем собственно полезного кода вообще нулевой — все тела блоков не содержат никаких операций, т.е. сгенерированный код состоит из единственной команды «return».


Если предположить, что в большинстве случаев код, который будет помещен в такой автоматический класс, будет довольно большим, то на объем этих файлов можно особого внимания не обращать, однако, такое предположение не соответствует действительности.
Как правило, объем кода размещаемого в автоматических блоках как раз очень невелик, и назвать создание файлов по 1-1.5Кб для каждого анонимного блока описанного в программе, эффективным или даже приемлемым довольно сложно.


Внутренние автоматические классы


Проведем эксперимент с нашим примером, изменив описание двух методов.


inline fun cAction( cb:String.()->Int ) {
  val str = "text"
  str.cb()
}

inline fun Action( cb:()->Unit ) {}

В этом случае мы добавили ключевое слово inline к писанию функций.


После компиляции программы можно увидеть, что список файлов созданных компилятором для автоматических классов сократился до трех: «KMainKt$Test$1.class», «KMainKt$Test$2.class» и «KMainKt$Test$cb$1.class». Т.е. остались только те классы, которые не передаются в качестве параметра функциям «Action» и «cAction». При этом размер файла «KMainKt.class» увеличился, но всего на 180 байт!


Куда делись остальные классы?


Имея опыт использования классических языков программирования можно предположить, что ключевое слово inline могло бы обозначать что-то типа: «подстановки кода в место использования». Т.е. то, что от создания лишних объектов компилятор избавился совсем, но это не так.


Классы для всех блоков кода, по прежнему, создаются в полном объеме, имеют точно те же имена и содержимое что и раньше, но они теперь помещены в файл того класса, где происходило их объявление, т.е., в нашем случае, в файл «KMainKt.class».


Размер файла с классами, используемыми для inline функций, увеличился всего на 180 байт, тогда как при их объявлении в виде отдельных файлов они, в сумме, занимали около 4Кб!


Дублирование. Оптимизация?


Проведем еще один эксперимент: проверим способности Kotlin к оптимизации создания автоматических классов.


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


Для проверки возможностей оптимизации просто размножим две строчки кода. Одну, использующую пользовательский код, и вторую, использующую полностью автоматический код.


Action{}
Action{}
Action{}
Action{}

Action( ::statMethod )
Action( ::statMethod )
Action( ::statMethod )
Action( ::statMethod )

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


Оставлю модификацию примера, его компиляцию и просмотр файлов классов в качестве самостоятельного упражнения и перейду сразу к результату.


Для каждого места описания кода будет создан новый уникальный класс!


К сожалению, в текущей версии Kotlin (используется версия «1.1-М04») никакой оптимизации, связанной с генерацией автоматических классов не существует и при любом использовании кода будет создан новый автоматический класс.


Текущее поведение компилятора приводит к тому, что активное использование анонимного кода приводит к стремительному росту объема скомпилированной программы. Рост объема будет тем стремительнее, чем больше кода генерируется для использования вне функций с модификатором inline.


Выводы


В итоге описанных выше экспериментов на основе простейшей программы можно сделать следующие выводы:


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


  • Реализации автоматических классов будут размещены в отдельных файлах для всех применений, где экземпляр такого класса не передается сразу же в качестве параметра «inline» функции. Классы, передаваемые в «inline» функции будут созданы в полном объеме, но будут размещены в файле того класса, где происходило их описание. (Второй вариант приводит к генерации значительно меньшего объема данных.)


  • Детали реализации автоматических классов, используемой в Kotlin, не играют никакой роли и, для разных ситуаций они различны, но во всех случаях они довольно громоздки по объему и размещение их в раздельных файлах может привести к тому, что итоговый объем собранной программы, использующей такой код, будет расти очень быстро.


  • Kotlin создает отдельные и уникальные классы для каждого места использования кода, поэтому как-то сравнить их между собой невозможно.


  • Некоторые реализации автоматических классов используют заранее сконструированный уникальный объект для передачи его в качестве параметра, что оставляет надежду на будущую автоматизацию использования таких классов, но в настоящее время ее нет.


  • В текущей реализации нет абсолютно никакой разницы между использованием функций с модификатором «inline» и без него и, учитывая разницу в объеме итоговой программы, описание функций без этого модификатора не имеет никакого смысла. Возможно, что что-то изменится в будущих версиях компилятора, но пока везде где это возможно нужно использовать этот модификатор.


  • «Оптимизация» размера генерируемого кода возможна только с использованием модификатора «inline» и только для классов, которые сразу же передаются как параметр, поэтому, во-первых, стоит избегать описания автоматических классов отдельно от их передачи в функцию и, во-вторых, стараться использовать синтаксис с передачей кода как параметра функции. «Оптимизировать» размещение анонимных объектов, не передаваемых анонимных функций или лямбда-функций в Kotlin невозможно и их использование будет приводить к необоснованно быстрому росту объема программы.

комментарии (13)

0
UbuRus ,  
но пока везде где это возможно нужно использовать этот модификатор (inline)

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

0
JouriM ,  

Обоснование моего вывода описано выше.
Обоснование того что это вредно привести можно?


Так же было бы очень интересно узнать о том, где inline нельзя использовать или хотя бы те, где его наличие и отсутствие хоть на что-то влияло бы.

0
UbuRus ,  
Обоснование моего вывода описано выше.

Я так и не понял практического смысла. Да у вас jar будет на пару кб больше. И?


Обоснование того что это вредно привести можно?

Очень просто, если у вас размер метода такими инлайнами вырастет за определенный размер(емнип около 35 инструкций), к нему не будут применяться многие оптимизации JIT.


Но меня это мало интересует, меня больше интересует чистота кода, и inline который используется от балды есть плохо пахнущий код, имо.

0
JouriM ,   * (был изменён)
Я так и не понял практического смысла. Да у вас jar будет на пару кб больше. И?

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


fun Action( cb:()->Unit ) {}

fun statMethod() {}

fun Test() {
  Action( ::statMethod )  // эта конструкция
}

Будет сгенерирован очередной уникальный класс, который от прошлого использования такой конструкции будет иметь всего одно отличие: номер в имени.
Если Вас абсолютно не волнует какой код будет сгенерирован компилятором и как он будет работать, лишь бы программа выполнялась, то эту статью можно с чистой совестью забыть.
Меня лично такие вещи интересуют и "плохо пахнущим" кодом я как раз считаю такой, где разработчику абсолютно дофонаря какую именно конструкцию использовать.
Но это мои тараканы, я не навязываю :)


Да у вас jar будет на пару кб больше. И?

Насчет пары кб — это откуда взялось?
В тексте выше же есть цифры.
КАЖДОЕ использование такой конструкции добавит около полутора Кб.
Если их в коде используется сто, то будет + 1.5Мб...


Вообще, эта статья родилась как результат исследования, которым я занялся, когда с удивлением обнаружил что тест для моего DSL, не имея практически никакого кода с текстом на пру экранов, занимает в 4 раза больше, чем лежащая рядом софтина в размером исходников в пару сотен Кб.
Заинтересовался, выяснил отчего так происходит.
Решил поделиться.


Очень просто, если у вас размер метода такими инлайнами вырастет

Либо я чего-то в принципе не понимаю, либо Вы.
О размере какого метода идет речь?
Код, который генерируется в месте объявления автоматического класса, абсолютно никак не зависит от того в каком именно файле этот класс физически расположен.
Наличие inline влияет только на место расположения сгенерированного класса и не более того.

0
UbuRus ,  

По первой части: если это действительно проблема, то нужно написать разработчикам в слаке, или еще лучше завести issue youtrack:
https://youtrack.jetbrains.com/issues/KT
http://kotlinslackin.herokuapp.com/


В противном случае вы только учитите плохому.


По второй части — 3 часа ночи, завтра на свежую голову перечитаю.

0
JouriM ,   * (был изменён)
если это действительно проблема

С точки зрения меня (если поставить себя на место разработчиков) довольно сложно называть это проблемой, и тем более проблемой именно Kotlin.


Разработчики сохраняют классы в отдельные файлы а, при использовании inline, пользуются возможностью появившейся в Java, которая позволяет разместить в одном файле несколько классов.
То, что в первом случае файлы обладают гигантским оверхедом к коду, в сравнении с размещением их в уже существующем файле, ставить в вину нужно скорее тем, кто придумал формат class-файлов, а не разработчикам Kotlin.


На мой взгляд описанное в статье — это скорее "фича", которую имеет смысл знать и использовать, а не какая-то проблема.


Проблемой можно назвать отсутствие какой-нибудь оптимизации, но, я уверен, это известно и без меня и, если язык будет развиваться, она появится.


ПС: Повторно прошу обосновать утверждение о вреде, которому я "учу".

0
WFrag ,  
Разработчики сохраняют классы в отдельные файлы а, при использовании inline, пользуются возможностью появившейся в Java, которая позволяет разместить в одном файле несколько классов.


А это что за возможность такая?
0
JouriM ,  

Я имел в виду то, что в Java с какой-то версии (1.2 или 1.4 — не разбирался точно) появилась возможность в одном файле класса описывать сколько угодно классов верхнего уровня.
Но в первоначальной статье у меня была ошибка, поэтому в текущем варианте это уже не важно.

0
WFrag ,  
Я с деталями реализации Kotlin не знаком, а почему они не пошли по пути лямбд Java, где класс-реализация генерируется автоматически в райнтайме через LambdaMetafactory#metafactory?
0
JouriM ,  

Я, в свою очередь, не знаком с деталями Java, но то что я вижу сейчас в сгенерированном из Java коде производит абсолютно такие же действия, как и компилятор Kоtlin: создается новый класс для каждого места использования лямбды и ему в конструкторе передаются захваченные переменные.
Насколько я понял "LambdaMetafactory" — это возможность для пользователя. Компилятор Java ее не использует.


Сейчас реализация Kotlin описана более подробно в текущем варианте статьи выше.

0
WFrag ,   * (был изменён)
Нет, Java (компилятор) не создает новый класс для каждого места использования лямбды (если речь о 1.8 -> 1.8), а использует LambdaMetafactory/invokedynamic. Это легко проверяется компиляцией вот такого кода:
import java.util.function.Supplier;

public class Test {

    public static void main(String... args) {
        String hello = "hello";
        print(() -> hello + ", world!");
    }

    public static <T> void print(Supplier<T> s) {
        System.out.println(s.get());
    }
}


И последующей декомпиляцией через «javap -verbose -p Test»

P.S. В рантайме да, создается класс, насколько я помню.
0
JouriM ,  

В любом случае создается класс.
Какая разница как это делается, компилятором при сборке программы или генерацией его кода на лету через миллион библиотечных прослоек?
Лично мне, после беглого разглядывания java\lang\invoke*.java, гораздо более симпатичен подход, который используется именно в Kotlin.


ПС: Я не думаю что в Kotlin изменят способ обработки кода т.к. одной из задач этого компилятора собираться под мобильные платформы а, насколько я знаю, даже на Android, существенные проблемы с генерацией кода на лету.
Но я не специалист, поэтому в обсуждение ввязываться не буду :)

0
UbuRus ,  

Потому что до версии 1.1 Котлин таргетится на байткод 1.6. После релиза 1.1 (который должен быть довольно скоро) будет возможность указывать таргет 1.8, там будет именно так.