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

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


  1. qwert2603
    27.11.2023 08:23
    +3

    Спасибо за статью и подробное объяснение!

    Добавлю небольшое уточнение по поводу crossinline.

        private inline fun crossInlineFun(crossinline body: () -> String) {
            val func = {
                "crossInline func code, " + body.invoke()
            }
            regularFun(func)
        }
    

    В этом примере нужен crossinline, так как в передаваемой лямбде-параметре body на стороне вызывающей функции могут быть non-local returns, и поэтому body нельзя передавать в другой контекст исполнения (в лямбду func). Чтобы исправить это, можно либо отменить инлайнинг body (добавив noinline), либо запретив использовать non-local returns внутри body (добавив crossinline). В этом примере более предпочтителен вариант с crossinline, так как он позволяет всё-таки заинлайнить body.

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

        private inline fun crossInlineFun(noinline body: () -> String) {
            val someFunc = body
            regularFun(func)
        }
    

    Здесь отменяется илнайнинг body, поэтому для него создаётся инстанс анонимного класса, и с ним можно работать как с обычной переменной.


    1. MaxSidorov Автор
      27.11.2023 08:23

      Спасибо за уточнение, я поэкспериментирую с этим и возможно поправлю статью. Это может быть важно и возможно я сам что то упустил.


      1. MaxSidorov Автор
        27.11.2023 08:23

        Да, если бы не локальный return, то crossinline можно было бы применять автоматом


  1. flange
    27.11.2023 08:23

    Этот пример очень хорошо показывает, что inline функции никак не помогают нам экономить количество методов.

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


  1. koperagen
    27.11.2023 08:23

    Лямбда аргументы inline функции прозрачны для контекстов suspend и @Composable

    suspend fun test() = Unit
    
    fun repeat1(i: Int, action: (Int) -> Unit): Unit = TODO()
    
    suspend fun context() {
        repeat1(5) {
            test()
        }
    }

    Такой код с repeat без inline не скомпилируется, нужно чтобы было action: suspend (Int) -> Unit. А потом появляется Compose и нужна +1 перегрузка. С inline одна декларация работает сразу с любыми вызовами


    1. MaxSidorov Автор
      27.11.2023 08:23

      Да, интересное решение. Кажется что отменяем строгую типизацию, но она просто переносится в место вызова функции.