Files
obsidian/Kotlin/Функциональный Kotlin/Функции высшего порядка.md
T

21 KiB
Raw Blame History

title, description, tags
title description tags
Функции высшего порядка Функции, принимающие и возвращающие функции, композиция, каррирование и мемоизация
kotlin
functional

Функция высшего порядка - это функция, которая принимает функцию в параметре или возвращает функцию. В Kotlin функциональный тип ((Int) -> String) полноправный, поэтому ничего особенного для этого не нужно.

fun applyTwice(x: Int, f: (Int) -> Int): Int = f(f(x))

fun main() {
    println(applyTwice(3) { it * 2 })        // 12
    println(applyTwice(3) { it + 10 })       // 23
}

Возврат функции:

fun multiplier(factor: Int): (Int) -> Int = { it * factor }

fun main() {
    val triple = multiplier(3)
    println(triple(5))                        // 15
    println(multiplier(10)(7))                // 70
}

multiplier(3) возвращает лямбду, которая захватила factor. Это замыкание, и оно работает так же, как в любом функциональном языке. Подробнее о синтаксисе: Лямбды и функциональные типы

Зачем это нужно

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

fun <T> retry(times: Int, block: () -> T): T {
    var lastError: Throwable? = null
    repeat(times) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            lastError = e
            println("попытка ${attempt + 1} не удалась")
        }
    }
    throw IllegalStateException("все $times попыток провалились", lastError)
}

fun main() {
    var counter = 0
    val result = retry(3) {
        counter++
        if (counter < 3) error("ещё не готово")
        "успех на попытке $counter"
    }
    println(result)        // успех на попытке 3
}

Логика повторов написана один раз; что именно повторять - решает вызывающий. Без функциональных типов пришлось бы заводить интерфейс с одним методом и анонимный класс.

Та же идея для измерения, транзакций и логирования:

inline fun <T> measured(name: String, block: () -> T): T {
    val start = System.nanoTime()
    try {
        return block()
    } finally {
        println("$name: ${(System.nanoTime() - start) / 1_000} мкс")
    }
}

fun main() {
    val sum = measured("сумма") { (1..100_000).sumOf { it.toLong() } }
    println(sum)        // 5000050000
}

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

Функциональные типы как параметры

fun processNumbers(
    numbers: List<Int>,
    filter: (Int) -> Boolean = { true },
    transform: (Int) -> String = { it.toString() },
): List<String> = numbers.filter(filter).map(transform)

fun main() {
    println(processNumbers(listOf(1, 2, 3)))                        // [1, 2, 3]
    println(processNumbers(listOf(1, 2, 3), filter = { it > 1 }))   // [2, 3]
    println(processNumbers(listOf(1, 2), transform = { "№$it" }))   // [№1, №2]
}

Функциональный параметр может иметь значение по умолчанию - это заменяет перегрузки.

Nullable функциональный тип требует скобок:

fun handle(value: Int, onSuccess: ((Int) -> Unit)? = null): Int {
    onSuccess?.invoke(value)
    return value
}

fun main() {
    println(handle(1))                                  // 1
    println(handle(2) { println("получено $it") })       // получено 2 \n 2
}

(Int) -> Unit? - это функция, возвращающая Unit?. Скобки меняют смысл принципиально.

Композиция

infix fun <A, B, C> ((A) -> B).then(next: (B) -> C): (A) -> C = { next(this(it)) }

fun main() {
    val parse: (String) -> Int = { it.trim().toInt() }
    val double: (Int) -> Int = { it * 2 }
    val format: (Int) -> String = { "результат: $it" }

    val pipeline = parse then double then format
    println(pipeline("  21 "))        // результат: 42
}

В stdlib готовой композиции нет - её пишут сами в три строки. Направление then (слева направо) читается лучше математического compose, но встречаются оба:

infix fun <A, B, C> ((B) -> C).compose(before: (A) -> B): (A) -> C = { this(before(it)) }

fun main() {
    val inc: (Int) -> Int = { it + 1 }
    val double: (Int) -> Int = { it * 2 }

    println((inc compose double)(5))      // 11 — сначала double, потом inc
    println((double compose inc)(5))      // 12 — наоборот
}

Каррирование и частичное применение

fun <A, B, C> curry(f: (A, B) -> C): (A) -> (B) -> C = { a -> { b -> f(a, b) } }

fun main() {
    val sum: (Int, Int) -> Int = { a, b -> a + b }
    val curried = curry(sum)

    println(curried(2)(3))            // 5
    val add10 = curried(10)
    println(add10(5))                  // 15
    println(listOf(1, 2, 3).map(add10))   // [11, 12, 13]
}

На практике частичное применение чаще делают прямо лямбдой - это короче и понятнее:

fun log(level: String, module: String, message: String) = "[$level][$module] $message"

fun main() {
    val errorInAuth: (String) -> String = { log("ERROR", "auth", it) }
    println(errorInAuth("неверный пароль"))      // [ERROR][auth] неверный пароль
}

Ещё проще - значения по умолчанию и именованные аргументы, которые в Kotlin закрывают большинство задач, решаемых каррированием в других языках. Подробнее: Функции

Мемоизация

fun <A, R> memoize(f: (A) -> R): (A) -> R {
    val cache = mutableMapOf<A, R>()
    return { arg -> cache.getOrPut(arg) { f(arg) } }
}

fun main() {
    var calls = 0
    val slowSquare = memoize<Int, Int> { calls++; it * it }

    println(slowSquare(4))      // 16
    println(slowSquare(4))      // 16
    println(slowSquare(5))      // 25
    println("вызовов: $calls")  // вызовов: 2
}

getOrPut делает это в одну строку. Ограничения такого кеша: он не ограничен по размеру и не потокобезопасен - для продакшена нужен ConcurrentHashMap или готовый кеш.

Рекурсия с мемоизацией требует ссылки на саму функцию:

fun main() {
    val cache = mutableMapOf(0 to 0L, 1 to 1L)
    fun fib(n: Int): Long = cache.getOrPut(n) { fib(n - 1) + fib(n - 2) }

    println(fib(50))        // 12586269025
    println(cache.size)     // 51
}

Локальная функция может вызывать себя, а лямбда - нет: внутри val f = { ... f(...) } имя f ещё не инициализировано.

Функции, возвращающие функции

Типичный случай - настраиваемый валидатор:

fun lengthBetween(min: Int, max: Int): (String) -> String? = { value ->
    when {
        value.length < min -> "короче $min символов"
        value.length > max -> "длиннее $max символов"
        else -> null
    }
}

fun validateAll(value: String, vararg rules: (String) -> String?): List<String> =
    rules.mapNotNull { it(value) }

fun main() {
    val notBlank: (String) -> String? = { if (it.isBlank()) "пустое значение" else null }

    println(validateAll("ab", notBlank, lengthBetween(3, 10)))   // [короче 3 символов]
    println(validateAll("привет", notBlank, lengthBetween(3, 10)))  // []
    println(validateAll("  ", notBlank))                          // [пустое значение]
}

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

Ссылки на функции вместо лямбд

fun isEven(x: Int) = x % 2 == 0

fun main() {
    val numbers = listOf(1, 2, 3, 4)

    println(numbers.filter { isEven(it) })      // [2, 4] — лямбда-обёртка
    println(numbers.filter(::isEven))            // [2, 4] — ссылка напрямую
    println(numbers.map(Int::toString))          // [1, 2, 3, 4] — ссылка на метод
    println(listOf("1", "2").map(String::toInt)) // [1, 2]

    val strings = listOf("bb", "a", "ccc")
    println(strings.sortedBy(String::length))    // [a, bb, ccc]
}

Ссылка ::isEven короче и не создаёт лишнего уровня вложенности. Подробнее: Лямбды и функциональные типы

Лямбда с получателем

Особый вид функционального типа - T.() -> R. Внутри такой лямбды this - это T:

fun buildMessage(build: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.build()
    return sb.toString()
}

fun main() {
    val text = buildMessage {
        append("Привет")          // this == StringBuilder
        append(", ")
        append("мир")
    }
    println(text)        // Привет, мир
}

На этом построены все билдеры Kotlin, включая buildList и apply. Подробнее: Receivers и context parameters, DSL

Карта раздела

Заметка О чём
Inline-функции как убрать накладные расходы лямбд
Функции-расширения добавление методов к готовым типам
Scope-функции let, run, apply, also, with
Инфиксные и операторные функции свой синтаксис для своих типов
Receivers и context parameters неявный this и контекстные зависимости
DSL сборка всего перечисленного в язык под задачу
Паттерны проектирования что от паттернов осталось в Kotlin

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

Java Kotlin
Function<T, R>, BiFunction, Supplier, Consumer один синтаксис (T) -> R
Predicate<T> (T) -> Boolean
Runnable () -> Unit
IntFunction, ToIntFunction, ... (40+ интерфейсов) нет, но есть упаковка
f.apply(x) f(x)
f.andThen(g) своя then
лямбда - объект лямбда инлайнится при inline
нет аналога лямбда с получателем T.() -> R

Функциональный тип вместо интерфейса. В Java функция передаётся как реализация интерфейса, и интерфейс нужно выбрать: Function<T,R> для одного аргумента, BiFunction для двух, Supplier без аргументов, Consumer без результата, Predicate для boolean, плюс примитивные специализации - в java.util.function их больше сорока. В Kotlin тип записывается напрямую:

fun demo(
    supplier: () -> Int,
    consumer: (Int) -> Unit,
    predicate: (Int) -> Boolean,
    biFunction: (Int, Int) -> Int,
) {
    val v = supplier()
    consumer(v)
    println("${predicate(v)} ${biFunction(v, v)}")
}

fun main() { demo({ 2 }, { }, { it > 1 }, { a, b -> a + b }) }   // true 4

Под капотом это всё равно интерфейсы Function0, Function1, ... Function22, но в коде их не видно. Три с лишним аргумента в Java требуют собственного интерфейса - в Kotlin предел 22.

Вызов через invoke, а не apply. В Java функциональный объект вызывают методом интерфейса: f.apply(x), p.test(x), c.accept(x), s.get() - имя зависит от интерфейса. В Kotlin оператор () работает для любого функционального типа, потому что все они объявляют operator fun invoke.

Композиции в stdlib нет. Java даёт Function.andThen, Function.compose, Predicate.and/or/negate. Kotlin их не предоставляет сознательно: обычно короче написать одну лямбду, чем собирать её из частей. Когда композиция действительно нужна, она пишется в одну строку - как then выше.

Лямбда с получателем аналога в Java не имеет. Это основа билдеров и DSL в Kotlin; в Java эквивалента нет даже близко, поэтому билдеры там пишут методами, возвращающими this.

Сравнить: Функции в Java, Интерфейсы в Java

[!warning] Подводные камни Лямбда без inline создаёт объект на каждый вызов:

fun notInlined(block: () -> Unit) { block() }
inline fun inlined(block: () -> Unit) { block() }

fun main() {
    repeat(3) { notInlined { } }      // объект Function0 на каждый вызов
    repeat(3) { inlined { } }          // объектов нет
    println("готово")
}

Лямбда без захвата переменных кешируется компилятором в синглтон, а лямбда с захватом - нет. В горячем цикле это заметно. Подробнее: Inline-функции

return внутри лямбды возвращает из внешней функции - но только в inline:

inline fun forEachInline(items: List<Int>, action: (Int) -> Unit) { for (i in items) action(i) }

fun findFirstEven(items: List<Int>): Int? {
    forEachInline(items) { if (it % 2 == 0) return it }      // выход из findFirstEven
    return null
}

fun main() { println(findFirstEven(listOf(1, 3, 4, 6))) }     // 4

В не-inline функции такой return не скомпилируется - нужен return@label. Это разное поведение для внешне одинакового кода.

Nullable функциональный тип требует скобок:

fun main() {
    val a: ((Int) -> Unit)? = null        // функция может быть null
    val b: (Int) -> Unit? = { }            // функция возвращает Unit?
    println("${a == null} ${b(1)}")        // true kotlin.Unit
}

Без скобок получается совсем другой тип, и ошибка проявится далеко от объявления.

Вызов nullable-функции только через invoke:

fun main() {
    val f: ((Int) -> Int)? = { it * 2 }
    // println(f?(1))        // синтаксическая ошибка
    println(f?.invoke(1))     // 2
}

Захваченная var меняется после создания лямбды:

fun main() {
    var counter = 0
    val show = { println("counter = $counter") }
    counter = 10
    show()        // counter = 10, а не 0
}

В отличие от Java, Kotlin разрешает захват изменяемых переменных - они оборачиваются в объект-ссылку. Удобно, но лямбда перестаёт быть чистой функцией и становится зависимой от момента вызова.

Лямбды в цикле захватывают переменную, а не значение:

fun main() {
    val actions = mutableListOf<() -> Int>()
    var i = 0
    while (i < 3) { actions.add { i }; i++ }
    println(actions.map { it() })      // [3, 3, 3] — все видят финальное i

    val correct = (0 until 3).map { v -> { v } }
    println(correct.map { it() })       // [0, 1, 2] — параметр цикла свой у каждой

    val fixed = mutableListOf<() -> Int>()
    for (j in 0 until 3) fixed.add { j }
    println(fixed.map { it() })          // [0, 1, 2] — переменная for неизменяема
}

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

Мемоизация не потокобезопасна. getOrPut на обычном MutableMap при одновременном доступе может повредить структуру. Минимальная защита - ConcurrentHashMap, но и у него getOrPut не атомарен: используйте computeIfAbsent.

Лямбда не может ссылаться на себя:

fun main() {
    // val fact: (Int) -> Int = { if (it <= 1) 1 else it * fact(it - 1) }    // ошибка
    fun fact(n: Int): Int = if (n <= 1) 1 else n * fact(n - 1)               // локальная функция может
    println(fact(5))        // 120
}

Больше двух-трёх функциональных параметров делают вызов нечитаемым. process(list, { }, { }, { }) ничего не говорит о смысле аргументов. Варианты: именованные аргументы, объект-конфигурация или интерфейс с несколькими методами.

Функциональные типы упаковывают примитивы. (Int) -> Int в байт-коде - это Function1<Integer, Integer>. Специализаций вроде IntUnaryOperator в Kotlin нет, поэтому в численно-интенсивном коде inline важен не только из-за объекта лямбды, но и из-за упаковки.

Далее: Inline-функции