Kotlin изнутри: как работают inline-функции

Часто проводя технические собеседования, я пришел к выводу, что многие разработчики не понимают, в чем польза inline-функций, зачем нужен crossinline и как работает reified. Причина распространенных заблуждений насчет inline-функций отчасти заключается в неточном их описании, ранее опубликованном на сайте kotlinlang.org. Хочу развенчать эти заблуждения и наглядно показать, как работают inline-функции (встраиваемые) и в чем преимущество их использования.

Заблуждение 1-е: inline-функции экономят стек 

Допустим, вы пытаетесь создать такую inline-функцию:

private inline fun warningInlineFun(a: Int, b: Int): Int {
return a + b
}

В этом случае компилятор выдаст предупреждение: “Expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of functional types” (“Ожидаемое влияние встраивания на производительность незначительно. Встраивание лучше всего работает для функций с параметрами функциональных типов”). Это примерно означает, что JIT-компилятор сам прекрасно справляется с встраиванием кода и не нужно пытаться ему в этом помочь.

Inline-функции следует использовать только при передаче в функцию параметров функционального типа.

Данный пример наглядно демонстрирует ошибочность 1-го заблуждения. Inline-функции не экономят стек вызовов  —  вернее, не в этом их суть.

Inline-функции следует использовать только в случае передачи в функцию параметра функционального типа.

Заблуждение 2-е: inline-функции сокращают количество методов

Посмотрим, во что компилируется inline-функция в Java:

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-функции был встроен в место вызова функции, но, несмотря на это, функция-оригинал inlineFun осталась в исходном коде.

Функция-оригинал была сохранена специально для поддержания совместимости с Java. Ведь функции Kotlin можно вызывать из кода Java, а Java ничего не знает о встраивании.

Этот пример наглядно показывает, что встраивание не помогает сократить количество методов.

Преимущества использования 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 создается экземпляр анонимного класса body, реализующего лямбду, и ее экземпляр передается обычной функции.

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 ) }
}
}

Как видите, результаты тестовых измерений наглядно показывают, что в данном тесте inline-функция работает почти в 1,5 раза быстрее. В данном случае весь профит был получен исключительно за счет отказа от создания анонимных классов для лямбды.

Это не означает, что inline-функции всегда в 1,5 раза быстрее обычных. Данный тест лишь показывает, что создание анонимных классов влечет за собой дополнительные накладные расходы, а inline-функции позволяют их избежать.

Crossinline

Чтобы понять суть crossinline, рассмотрим следующий пример. В нем создадим локальную лямбда func внутри inline-функции и используем в ней входящий параметр 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);
}

Как видите, inline-функция была встроена в то место, где она вызывалась, но поскольку параметр помечен как 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” (Невозможно использовать ‘T’ как reified-параметр типа. Вместо этого используйте класс”.

inline fun <reified T> 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 <T> testReifiedCall(externalGeneric: T) {
genericInline(externalGeneric)
genericInline("I'm a String and I'm not generic here")
}

По сути, эта ошибка предупреждает, что в месте вызова inline-функции тип параметра externalGeneric неизвестен и нельзя использовать inline-функцию с reified-параметром.

До выхода Kotlin 1.6 такой код прекрасно компилировался, а разработчик получал ошибки во время выполнения и создавал вызов, при котором reified-параметр работал некорректно. Начиная с Kotlin 1.6, была добавлена специальная ошибка компиляции, которая выполняет проверку подобных случаев и защищает от них.

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

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

Чтобы наглядно представить это, взгляните на декомпилированный Java-код для данного случая.

public final void externalGenericCall() {
this.testReifiedCall("I'm a String, but I'm an external generic");
}

public final <T> void testReifiedCall(T externalGeneric) {
// Здесь мы получим тип Object вместо ожидаемого String,
// потому что это внешний дженерик и его тип здесь неизвестен.
String var5 = (new StringBuilder())
.append("my type is ")
.append(externalGeneric.getClass().getSimpleName())
.toString();
System.out.println(var5);

// Тут мы получим правильный тип, потому что его тип здесь известен.
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-функции не должно быть большим. В противном случае увеличится объем кода, поскольку код inline-функции копируется в каждое место, где вызывается.

Inline-функции следует использовать только при передаче параметров функционального типа.

Вся польза от inline-функций заключается в отказе от анонимных классов для передачи лямбда-выражений в параметры функции.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Max Sidorov: Kotlin under the hood: How inline functions work

Предыдущая статьяСоздание UI-компонентов React на продвинутом уровне
Следующая статьяБайт-код Java: назначение, структура и использование