Files
obsidian/Kotlin/Функциональный Kotlin/Inline-функции.md
T

19 KiB
Raw Blame History

title, description, tags
title description tags
Inline-функции Подстановка тела на этапе компиляции, noinline и crossinline, нелокальный return и цена inline
kotlin
functional
performance

inline говорит компилятору: не вызывай эту функцию, а подставь её тело в место вызова. Для обычных функций это почти бесполезно (JIT справляется сам), а для функций с лямбдами - убирает объект лямбды.

inline fun measure(block: () -> Unit): Long {
    val start = System.nanoTime()
    block()
    return System.nanoTime() - start
}

fun main() {
    val time = measure { (1..1000).sum() }
    println(time > 0)        // true
}

После компиляции в main не остаётся ни вызова measure, ни объекта Function0 - там просто val start = ...; (1..1000).sum(); ....

Что именно экономится

Без inline на каждый вызов создаётся объект, реализующий Function0:

fun notInlined(block: () -> Int): Int = block()
inline fun inlined(block: () -> Int): Int = block()

fun main() {
    var captured = 0

    repeat(3) { notInlined { captured + 1 } }    // лямбда захватывает captured -> новый объект каждый раз
    repeat(3) { inlined { captured + 1 } }        // объектов нет вообще

    println(notInlined { 42 })      // 42
    println(inlined { 42 })          // 42
}

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

Поэтому все операции над коллекциями в stdlib объявлены inline:

fun main() {
    val sum = listOf(1, 2, 3).filter { it > 1 }.sumOf { it * 2 }
    println(sum)        // 10
}

В байт-коде это два обычных цикла for с телами лямбд внутри. Ни одного объекта лямбды и ни одного виртуального вызова - именно поэтому короткие цепочки в Kotlin обычно быстрее Java-стримов.

Нелокальный return

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

inline fun <T> List<T>.forEachInline(action: (T) -> Unit) { for (item in this) action(item) }

fun findFirstNegative(numbers: List<Int>): Int? {
    numbers.forEachInline { if (it < 0) return it }      // выход из findFirstNegative
    return null
}

fun main() {
    println(findFirstNegative(listOf(1, 2, -3, 4)))      // -3
    println(findFirstNegative(listOf(1, 2)))              // null
}

Это работает, потому что return оказывается физически внутри findFirstNegative. В не-inline функции такой return запрещён:

fun <T> List<T>.forEachSlow(action: (T) -> Unit) { for (item in this) action(item) }

fun firstNegative2(numbers: List<Int>): Int? {
    numbers.forEachSlow { if (it < 0) return@forEachSlow }    // только локальный выход
    return null
}

fun main() { println(firstNegative2(listOf(-1))) }        // null

Именно поэтому forEach из stdlib ведёт себя как continue, а не как break:

fun main() {
    listOf(1, 2, 3).forEach {
        if (it == 2) return@forEach        // пропустить элемент
        print("$it ")
    }
    println()        // 1 3
}

noinline

Если лямбду нужно сохранить или передать дальше, подставить её нельзя - у подставленного тела нет объекта:

inline fun register(
    name: String,
    noinline handler: (String) -> Unit,
    validator: (String) -> Boolean,
): (String) -> Unit {
    println("регистрирую $name, валиден: ${validator(name)}")
    return handler              // возвращаем объект — нужен noinline
}

fun main() {
    val h = register("событие", handler = { println("обработано: $it") }, validator = { it.isNotEmpty() })
    h("данные")
    // регистрирую событие, валиден: true
    // обработано: данные
}

Без noinline компилятор откажется: «Illegal usage of inline-parameter». Правило: лямбду, которая только вызывается внутри функции, можно инлайнить; лямбду, которую сохраняют в поле, возвращают или передают в не-inline функцию - нельзя.

crossinline

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

inline fun runLater(crossinline block: () -> Unit): Runnable = Runnable { block() }

fun main() {
    val task = runLater { println("выполнено позже") }
    task.run()        // выполнено позже
}

crossinline означает «инлайнить можно, но нелокальный return запрещён». Логика такая: тело лямбды подставляется внутрь Runnable, а оттуда выйти из вызывающей функции физически невозможно - она уже завершилась.

inline fun repeatTwice(crossinline block: () -> Unit) {
    val wrapped = { block(); block() }
    wrapped()
}

fun demo() {
    repeatTwice { println("раз") }
    // repeatTwice { return }      // ошибка: нелокальный return запрещён
}

fun main() { demo() }       // раз \n раз
Модификатор Лямбда подставляется Нелокальный return Можно сохранить/передать
(по умолчанию) да да нет
crossinline да нет только внутрь другой лямбды
noinline нет нет да

reified

Третье следствие подстановки: в месте вызова известен фактический тип, поэтому параметр типа можно «материализовать».

inline fun <reified T> filterByType(items: List<Any>): List<T> = items.filterIsInstance<T>()

fun main() {
    val mixed = listOf(1, "a", 2.0, 3)
    println(filterByType<Int>(mixed))         // [1, 3]
    println(filterByType<String>(mixed))       // [a]
}

Это основная причина объявлять inline функцию без лямбд вообще. Подробнее: Reified и inline

Inline-свойства

inline применим и к аксессорам:

class Config2(val map: Map<String, String>) {
    inline val isEmpty: Boolean get() = map.isEmpty()

    val host: String
        inline get() = map["host"] ?: "localhost"
}

fun main() {
    val c = Config2(mapOf("host" to "example.com"))
    println("${c.isEmpty} ${c.host}")       // false example.com
}

Обратите внимание: map объявлен val без private. Для private val сниппет не скомпилируется - на inline-аксессоры действует то же правило, что на inline-функции: подставленное тело окажется в чужом классе, где приватный член недоступен.

Выгода минимальна - JIT инлайнит такие геттеры и сам. Реально inline-свойства нужны редко: когда геттер должен использовать reified или когда свойство - часть публичного API, которое не хочется оставлять виртуальным.

Когда inline оправдан

Случай Нужен ли inline
функция принимает лямбду, которая вызывается сразу да
нужен reified да, обязательно
нужен нелокальный return из лямбды да, обязательно
функция без лямбд и без reified нет, JIT справится
тело большое (десятки строк) нет, байт-код раздуется
функция рекурсивная нельзя
публичное API библиотеки осторожно - см. подводные камни

Что происходит с байт-кодом

inline fun twice(block: () -> Unit) { block(); block() }

fun main() {
    var count = 0
    twice { count++ }
    twice { count += 10 }
    println(count)        // 22
}

Тело twice скопировано в main дважды, и каждая копия содержит своё тело лямбды - итого четыре инкремента в коде. Для короткой функции это дешевле вызова; для функции на сто строк, вызываемой из пятидесяти мест, это пять тысяч строк байт-кода.

Компилятор предупреждает, когда inline бесполезен:

inline fun uselessInline(x: Int): Int = x * 2      // warning: expected performance impact is insignificant

fun main() { println(uselessInline(2)) }            // 4

Такое предупреждение - сигнал убрать inline, если не нужен reified.

[!info] Отличия от Java

Java Kotlin
нет inline на уровне языка inline fun
JIT инлайнит в рантайме компилятор инлайнит в байт-код
лямбда всегда объект при inline объекта нет
return из лямбды невозможен нелокальный return при inline
Class<T> параметром reified T
final подсказывает JIT inline - директива компилятору

Java инлайнит в рантайме, Kotlin - при компиляции. HotSpot инлайнит горячие методы агрессивнее, чем любой компилятор: он знает фактические типы, статистику ветвлений и может деоптимизировать решение. Поэтому inline в Kotlin - не про скорость исполнения тела, а про три вещи, недоступные JIT: отсутствие объекта лямбды, нелокальный return и reified.

Escape analysis частично закрывает ту же проблему в Java. Если JIT доказывает, что объект лямбды не покидает метод, он может не выделять его в куче (scalar replacement). Но это оптимизация «на удачу»: она применяется не всегда и ломается при полиморфном вызове. inline в Kotlin гарантирован и виден в байт-коде.

Нелокального return в Java нет вообще. Из тела forEach нельзя выйти из внешнего метода - приходится либо возвращаться к обычному for, либо бросать исключение, либо использовать findFirst на стриме. Kotlin позволяет писать абстракции обхода, которые ведут себя как встроенный цикл:

inline fun <T> Iterable<T>.firstMatching(p: (T) -> Boolean): T? {
    for (item in this) if (p(item)) return item
    return null
}

fun main() { println(listOf(1, 2, 3).firstMatching { it > 1 }) }     // 2

Цена - совместимость библиотек. В Java изменение тела метода не требует перекомпиляции клиентов. В Kotlin тело inline-функции физически скопировано в байт-код клиента, поэтому исправление в библиотеке не подействует до пересборки вызывающего кода. Для публичного API это серьёзный аргумент, и stdlib помечает такие функции @PublishedApi, когда они обращаются к internal-коду.

Inline-функцию нельзя вызвать из Java с сохранением смысла: Java-компилятор не умеет подставлять тело, он вызовет сгенерированный метод как обычный. reified при этом не работает.

Сравнить: JIT в Java, Байт-код

[!warning] Подводные камни Inline раздувает байт-код. Тело копируется в каждое место вызова. Для больших функций это рост размера класса, давление на кеш инструкций процессора и более медленная загрузка классов. inline оправдан для коротких функций - в stdlib почти все inline-функции занимают несколько строк.

Изменение тела inline-функции не действует без перекомпиляции клиентов:

inline fun version() = "v1"        // если выпустить v2, клиенты со старой сборкой увидят v1

fun main() { println(version()) }   // v1

Это ломает обычные ожидания от семантического версионирования библиотек.

Inline-функция не видит private членов своего класса:

class Service3 {
    private val secret = "скрыто"
    // inline fun leak() = secret        // ошибка: public-inline не может обращаться к private
    fun ok() = secret
    internal inline fun fine() = ok()     // а так можно
}

fun main() { println(Service3().fine()) }     // скрыто

Причина: тело окажется в чужом классе, где private недоступен. Обход - @PublishedApi internal на члене.

Рекурсивную функцию нельзя сделать inline:

// inline fun fact(n: Int): Int = if (n <= 1) 1 else n * fact(n - 1)   // ошибка
fun fact(n: Int): Int = if (n <= 1) 1 else n * fact(n - 1)

fun main() { println(fact(5)) }      // 120

Подстановка была бы бесконечной.

Лямбду из inline-параметра нельзя сохранить:

inline fun broken(block: () -> Unit) {
    // val saved = block        // ошибка: Illegal usage of inline-parameter
    block()
}

inline fun fixed(noinline block: () -> Unit): () -> Unit = block

fun main() {
    broken { println("ок") }
    fixed { println("сохранено") }()
}

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

Нелокальный return из inline-лямбды пропускает код после вызова:

inline fun withLogging(block: () -> Unit) {
    println("до")
    block()
    println("после")        // не выполнится при return из лямбды
}

fun run1() {
    withLogging { return }
}

fun main() { run1(); println("конец") }     // до \n конец

Строка «после» потерялась. Если в функции есть освобождение ресурсов, его нужно ставить в finally - иначе нелокальный return его обойдёт:

inline fun safeLogging(block: () -> Unit) {
    println("до")
    try { block() } finally { println("после") }
}

fun run2() { safeLogging { return } }

fun main() { run2() }       // до \n после

Это важнее, чем кажется: use, withLock и транзакционные обёртки обязаны использовать finally именно по этой причине.

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

Внутри inline-лямбды нельзя использовать suspend-вызовы, если не помечено специально. Для функций вроде measure это выглядит рабочим, но обёртка над suspend-кодом требует либо suspend inline, либо crossinline и явного учёта контекста. Подробнее: Suspend-функции

Инлайн не убирает упаковку примитивов в функциональных типах автоматически - но при полной подстановке упаковки не остаётся, потому что лямбды как объекта нет. А вот с noinline параметром (Int) -> Int упаковка сохраняется.

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

Далее: Функции-расширения