Kotlin под капотом: inline функции
Я провожу довольно много технических интервью и вижу, что многие разработчики не до конца понимают суть inline функций. Не понимают в чем профит от использования inline функций. Зачем нужен crossinline и как работает reified. Отчасти, источник популярных заблуждений про inline функции в том, что раньше на сайте kotlinlang.org было дано не совсем верное описание. Мне захотелось это исправить и наглядно показать как работают inline функции и какой профит мы получаем от их использования.
Популярное заблуждение: inline функции экономят стек вызовов.
Если вы попробуете написать вот такую inline функцию
private inline fun warningInlineFun(a: Int, b: Int): Int {
return a + b
}
То компилятор выдаст вам warning »Expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of functional types». Что примерно означает, что JIT компилятор сам отлично умеет встраивать код и не нужно пытаться ему помогать в этом.
Inline функции следует использовать только в случае передачи в функцию параметров функционального типа.
Этот пример очень хорошо демонстрирует, что Inline функции не экономят стек вызовов, а точнее их суть совсем не в этом. Их следует использовать только в тех случаях, если в вашу функцию передается параметр функционального типа.
Популярное заблуждение: inline функции экономят количество методов.
Давайте посмотрим во что скомпилируется в Java наша inline функция
inline fun inlineFun(body: () -> String) {
println("inline func code, " + body.invoke())
}
fun testInline() {
inlineFun { "external code" }
}
Если мы посмотрим декомпилированный Java код, то мы увидим следующее
public final void inlineFun(Function0 body) {
String var2 = "inline func code, " + (String)body.invoke();
System.out.println(var2);
}
public final void testInline() {
String var1 = (new StringBuilder())
.append("inline func code, ")
.append("external code")
.toString();
System.out.println(var1);
}
Как видите, код inline функции встроился в место вызова функции, но несмотря на это, сама inline функция inlineFun осталась в исходном коде. Оригинальная inline функция оставлена в коде для того, чтобы сохранить совместимость с Java. Ведь вы можете вызывать функции kotlin из Java кода, а он ничего не знает про инлайнинг.
Этот пример очень хорошо показывает, что inline функции никак не помогают нам экономить количество методов.
В чем же профит inline функций
Для того, чтобы понять какой профит мы получаем от использования inline функций, давайте рассмотрим пример вызова inline функции и обычной функции.
private inline fun inlineFun(body: () -> String) {
println("inline func code, " + body.invoke())
}
fun testInline() {
inlineFun { "external inline code" }
}
private fun regularFun(body: () -> String) {
println("regular func code, " + body.invoke())
}
fun testRegular() {
regularFun { "external regular code" }
}
Если мы посмотрим декомпилированный Java код, то мы увидим следующее (я буду немного упрощать декомпилированный Java код, чтобы не перегружать вас лишними переменными и проверками kotlin)
public final void testInline() {
String var4 = (new StringBuilder())
.append("inline func code, ")
.append("external inline code")
.toString();
System.out.println(var4);
}
public final void testRegular() {
Function0 body = (Function0)(new Function0() {
public final String invoke() {
return "external regular code";
}
});
this.regularFun(body);
}
Основная разница между вызовами inline функции и обычной функции в том, что для вызова обычной функции в Java создается анонимный класс, реализующий нашу лямбду и его экземпляр передается в обычную функцию.
public final void testRegular() {
Function0 body = (Function0)(new Function0() {
public final String invoke() {
return "external regular code";
}
});
this.regularFun(body);
}
В случае inline функции вызывающий код и код inline функции объединяются и подставляются непосредственно в место вызова, что позволяет исключить создание анонимного класса для передаваемой лямбды.
public final void testInline() {
String var4 = (new StringBuilder())
.append("inline func code, ")
.append("external inline code")
.toString();
System.out.println(var4);
}
Создание инстанса анонимного класса в Java — это достаточно затратная операция и профит inline функций именно в этом.
Inline функции позволяют исключить создание анонимных классов для передачи лямбд в параметры функции
Измерение профита от inline функций
Чтобы продемонстрировать это наглядно в цифрах, давайте проведем небольшой тест.
Исходный код теста производительности inline функций
@State(Scope.Benchmark)
@Fork(1)
@Warmup(iterations = 0)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
class InlineTest {
private inline fun inlineFun(body: () -> Int): Int {
return body() + Random.nextInt(1000 )
}
private fun nonInlineFun(body: () -> Int): Int {
return body() + Random.nextInt(1000 )
}
@Benchmark
fun inlineBenchmark(): Int {
return inlineFun { Random.nextInt(1000 ) }
}
@Benchmark
fun nonInlineBenchmark(): Int {
return nonInlineFun { Random.nextInt(1000 ) }
}
}
Обычные функции (ops / sec) | Inline функции (ops / sec) | % |
110 383 864 | 159 463 508 | 144% |
Как видите результаты измерения теста наглядно показывают, что конкретно в этом тесте inline функция работает почти в полтора раза быстрее. В данном случае весь профит получен исключительно за счет отказа от создания анонимных классов для нашей лямбды.
Нельзя сказать, что inline функции в целом работают в полтора раза быстрее. Данный тест показывает лишь то, что создание анонимных классов — это дополнительные накладные расходы и inline функции позволяют их избегать.
Crossinline
Чтобы разобраться в чем суть crossinline, давайте рассмотрим следующий пример. Здесь мы создаем внутри inline функции локальную лямбду func, внутри которой используем входящий параметр body. И дальше мы передаем нашу локальную лямбду func за пределы inline функции, в обычную функцию regularFun.
private inline fun crossInlineFun(body: () -> String) {
val func = {
"crossInline func code, " + body.invoke()
}
regularFun(func)
}
Если вы напишите такой код, то получите ошибку компилятора. Это происходит из-за того, что компилятор не может заинлайнить вашу функцию, так как она использует входящую лямбду body внутри локальной лямбды func. В случае инлайнинга у нас просто нет анонимного класса для body и мы не можем его передать в локальную лямбду.
Но мы можем пометить наш параметр как noinline и в этом случае все скомпилируется. Когда мы помечаем параметр как noinline, то для него будет создаваться анонимный класс и его можно будет передать в локальную лямбду.
private inline fun crossInlineFun(noinline body: () -> String) {
val func = {
"crossInline func code, " + body.invoke()
}
regularFun(func)
}
fun testCrossInline() {
crossInlineFun { "external code" }
}
Давайте посмотрим декомпилированный Java код для такого случая.
public final void testCrossInline() {
Function0 body = (Function0)(new Function0() {
public final String invoke() {
return "external code";
}
});
Function0 func = (Function0)(new Function0() {
public final String invoke() {
return "crossInline func code, " + (String)body.invoke();
}
});
regularFun(func);
}
Как видите функция crossInlineFun встроилась в место вызова, но так как параметр помечен как noinline, то мы потеряли весь профит от инлайнинга. У нас создается два анонимных класса и второй анонимный класс func вызывает из себя первый анонимный класс body.
Теперь давайте пометим наш параметр как crossinline и посмотрим как изменится Java код.
private inline fun crossInlineFun(crossinline body: () -> String) {
val func = {
"crossInline func code, " + body.invoke()
}
regularFun(func)
}
fun testCrossInline() {
crossInlineFun { "external code" }
}
Давайте посмотрим декомпилированный Java код для случая crossinline.
public final void testCrossInline() {
Function0 func = (Function0)(new Function0() {
public final String invoke() {
return (new StringBuilder())
.append("crossInline func code, ")
.append("external code")
.toString();
}
});
regularFun(func);
}
Как видите, в случае crossinline мы имеем не два анонимных класса, а только один, который объединяет в себе код inline функции и код внешней лямбды.
В случае использования входящего параметра функционального типа в локальной лямбде, добавление crossinline позволяет исключить создание дополнительного анонимного класса. Вместо двух анонимных классов будет создан только один.
Reified
В документации указано, что добавление этого параметра позволяет узнать внутри inline функции тип передаваемого дженерика.
Многие думают, что здесь есть какая то магия kotlin, которая отменяет стирание типов для дженериков Java, но на самом деле reified — это просто побочный эффект от встраивания кода и никакой магии здесь нет.
Чтобы продемонстрировать это, давайте рассмотрим эту магию под микроскопом. Если вы попробуете написать такой код, то вы получите ошибку компиляции »Cannot use 'T' as reified type parameter. Use a class instead.».
inline fun genericInline(param: T) {
println("my type is " + param!!::class.java.simpleName)
}
fun externalGenericCall() {
testReifiedCall("I'm a String, but I'm an external generic")
}
fun testReifiedCall(externalGeneric: T) {
genericInline(externalGeneric)
genericInline("I'm a String and I'm not generic here")
}
По сути эта ошибка предупреждает вас, что в месте вызова inline функции тип параметра externalGeneric неизвестен и вы не можете здесь использовать inline функцию с reified параметром.
Раньше, до выхода kotlin 1.6 такой код прекрасно компилировался и люди получали ошибки в runtime и создавали issue, что параметр reified работает некорректно. Начиная с kotlin 1.6 была добавлена специальная ошибка компиляции, которая проверяет этот случай и защищает нас от него.
Чтобы понимать, что такой код просто не может работать корректно, достаточно понимать принцип работы inline функций. Код вашей inline функции объединяется с кодом вызывающим вашу функцию. Естественно в месте вызова вашей функции вам известны все локальные типы.
Но если вы пытаетесь использовать переменную, которая приходит в место вызова вашей функции как внешний дженерик, то естественно вы получите для нее тип Object, так как ее конечный тип неизвестен в месте вызова вашей inline функции.
Чтобы лучше понять это, давайте посмотрим декомпилированный Java код для этого случая.
public final void externalGenericCall() {
this.testReifiedCall("I'm a String, but I'm an external generic");
}
public final void testReifiedCall(T externalGeneric) {
// We will get the type Object here instead of the expected String
// because it is an external generic and its type is unknown here
String var5 = (new StringBuilder())
.append("my type is ")
.append(externalGeneric.getClass().getSimpleName())
.toString();
System.out.println(var5);
// Here we will get the correct type because its type is known here.
String localGeneric = "I'm a String and I'm not generic here";
var5 = (new StringBuilder())
.append("my type is ")
.append(localGeneric.getClass().getSimpleName())
.toString();
System.out.println(var5);
}
Из этого кода становится понятно, что компилятору kotlin приходится выполнять дополнительную работу и принудительно очищать типы для дженериков inline функций, если они не помечены ключевым словом reified. А возможность узнавать локальные типы дженериков была оставлена опционально, как полезный побочный эффект инлайнинга и именно для этого ввели ключевое слово reified.
Выводы
Inline функции следует использовать, если вы пишете универсальную функцию или предполагается, что ваша функция будет использоваться в циклах. Это позволит вам сделать вашу функцию немного быстрее.
Тело встроенной функции не должно быть большим. В противном случае вы увеличите объем кода, поскольку код встроенной функции копируется в каждое место, где она вызывается.
Встроенные функции следует использовать только при передаче параметров функционального типа.
Вся польза встроенных функций заключается в отказе от анонимных классов для передачи лямбда-выражений в параметры inline функции.
Если вам интересно, как kotlin работает под капотом, то вы можете почитать другие мои статьи об этом.