21 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Функции высшего порядка | Функции, принимающие и возвращающие функции, композиция, каррирование и мемоизация |
|
Функция высшего порядка - это функция, которая принимает функцию в параметре или возвращает функцию. В 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) -> RPredicate<T>(T) -> BooleanRunnable() -> UnitIntFunction,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-функции