JavaRush /Курси /Kotlin SELF /Типи функцій і функції вищого порядку

Типи функцій і функції вищого порядку

Kotlin SELF
Рівень 21 , Лекція 1
Відкрита

1. Вступ

Коли ви лише починаєте програмувати, здається, що функції — це «імʼя + дужки + код усередині». Але щойно у вас зʼявляється обробка колекцій, ви швидко помічаєте неприємну річ: ви пишете один і той самий цикл знову й знову, змінюючи в ньому лише маленький шматочок логіки. Сьогодні ми навчимося передавати цей шматочок логіки параметром і перестанемо копіювати цикли так, ніби пишемо диплом у 2008 році й вставляємо текст із чужого Word-файла.

Уявіть наш навчальний консольний проєкт (практичний застосунок без ООП): простий «облік витрат», де кожна витрата — це Triple(назва, категорія, сума). Поки що ми вміємо додавати й показувати елементи за допомогою циклів. Але дуже швидко захочеться: порахувати лише «їжу», вивести тільки витрати, більші за 500, знайти першу витрату за категорією… І ось тут «поведінка як параметр» стає суперсилою.

2. Тип функції: як читати (A) -> B

Тип функції в Kotlin виглядає трохи як стрілочка зі шкільної математики: із чогось у щось. Запис (A) -> B читається так: «функція приймає A і повертає B». Це саме тип, тобто опис значення, яке можна покласти в val, передати у функцію, повернути з функції — як Int або String, тільки тут «значенням» є функція.

Щоб було простіше, тримайте в голові невеличку табличку:

Тип Читаємо Приклад змісту
(Int) -> Boolean
«бере Int, повертає Boolean» перевірка (чи парне число)
(String) -> Int
«бере рядок, повертає число» вимірювання (довжина рядка)
(Int) -> Unit
«бере Int, не повертає нічого корисного» дія (друк)
(Triple<String, String, Int>) -> Boolean
«бере витрату, повертає Boolean» фільтр витрат

А ось важливе слово дня:

предикат — це функція типу (T) -> Boolean, тобто «правило перевірки».
У Kotlin (і загалом у програмуванні) предикати трапляються постійно.

3. Предикат як значення та параметр функції

Предикат у змінній

Зробімо зовсім базову річ: покладемо правило перевірки в змінну та викличемо його. Виглядає трохи незвично, але важливо відчути головне: функція може бути значенням.

fun main() {
    val isEven: (Int) -> Boolean = { x -> x % 2 == 0 }

    println(isEven(4))  // true
    println(isEven(5))  // false
}

Зверніть увагу на два моменти.

По-перше, ліворуч ми явно написали тип: (Int) -> Boolean. Це допомагає і вашим очам, і компілятору.

По-друге, ми викликаємо змінну isEven як функцію: isEven(4). Тобто це «значення-функція».

На практиці Kotlin часто може вивести тип сам. Але поки ви навчаєтеся, інколи краще вказувати тип явно, щоб не гадати, що саме відбувається.

Функція вищого порядку: приймаємо предикат і рахуємо збіги

Тепер ключовий момент: функція вищого порядку — це функція, яка приймає іншу функцію як параметр (або повертає її). Почнімо з найпрактичнішого шаблону: «порахувати елементи, які задовольняють умову».

fun countMatching(items: List<Int>, predicate: (Int) -> Boolean): Int {
    var count = 0
    for (x in items) {
        if (predicate(x)) count++
    }
    return count
}

fun main() {
    val xs = listOf(1, 2, 3, 4, 5, 6)

    println(countMatching(xs) { it % 2 == 0 }) // 3
    println(countMatching(xs) { it > 3 })      // 3
}

Тут ми зробили дуже важливу річ: цикл один, а «умова» змінюється. В одному виклику ми передали «парне», в іншому — «більше 3». Тобто ми перестали робити окремі функції countEven, countGreaterThan3, countSomethingElse, і натомість зробили одну функцію + різні правила.

Це і є «передати поведінку».

Генерики: зробимо countMatching універсальною для будь-якого T

Зараз наша функція вміє рахувати лише Int. Але в проєкті в нас витрати, і вони взагалі не Int. Тут нам знадобляться генерики (generics), із якими ми познайомилися в блоці про колекції: зробимо функцію універсальною.

fun <T> countMatching(items: List<T>, predicate: (T) -> Boolean): Int {
    var count = 0
    for (item in items) {
        if (predicate(item)) count++
    }
    return count
}

Тепер T — це «якийсь тип». Компілятор сам підставить потрібний тип залежно від того, який список ви передали.

І це зручно не лише в навчальних задачках. Це буквально те, як влаштована половина стандартної бібліотеки Kotlin: там повно функцій, які приймають предикати та застосовують їх до елементів колекцій. Ми поки що робимо свої версії вручну, щоб мозок зрозумів механіку, перш ніж ви увімкнете «автопілот» стандартних функцій.

4. Практичний проєкт: облік витрат на MutableList

Мінімальна модель витрати

Домовмося про мінімальну модель витрати (без класів, бо ООП буде пізніше). Візьмемо Triple<String, String, Int>: назва, категорія, сума.

fun makeExpense(title: String, category: String, amount: Int): Triple<String, String, Int> {
    return Triple(title, category, amount)
}

fun main() {
    val expenses: MutableList<Triple<String, String, Int>> = mutableListOf()

    expenses.add(makeExpense("Coffee", "food", 250))
    expenses.add(makeExpense("Taxi", "transport", 900))

    println(expenses.size) // 2
}

Факт, який часто дивує новачків: колекція може бути val, але водночас залишатися змінюваною. Тобто посилання ви не переназначите, зате елементи додавати й видаляти можна. Це нормальна практика в Kotlin: val expenses = mutableListOf(...) і далі expenses.add(...).

А самі операції додавання й видалення для MutableList стандартні: add, remove, clear тощо.

Тепер використаємо нашу універсальну функцію:

fun main() {
    val expenses = mutableListOf(
        Triple("Coffee", "food", 250),
        Triple("Taxi", "transport", 900),
        Triple("Lunch", "food", 600)
    )

    val foodCount = countMatching(expenses) { it.second == "food" }
    println(foodCount) // 2
}

Ми ще не вводимо typealias (це буде значно пізніше), тому it.second виглядає трохи «нелюдяно». Зробити читабельніше можна через деконструкцію.

Деконструкція в предикаті

Деконструкція Triple знайома нам із ранніх лекцій (про Pair/Triple). Використаймо її прямо всередині лямбди — так простіше зрозуміти, що саме ми перевіряємо.

fun main() {
    val expenses = listOf(
        Triple("Coffee", "food", 250),
        Triple("Taxi", "transport", 900)
    )

    val bigFood = countMatching(expenses) { (_, category, amount) ->
        category == "food" && amount >= 500
    }

    println(bigFood) // 0
}

Так, тут є підкреслення _ — це означає «компонент є, але він мені не потрібен». І ось ми вже читаємо лямбду майже як фразу: «категорія — їжа, і сума >= 500».

5. Параметр-дія: (T) -> Unit

forEachIf: предикат + дія

Предикат відповідає на питання «чи підходить елемент?». Але часто потрібно інше: «що зробити з кожним елементом, який підходить?». Наприклад: роздрукувати, накопичити рядок звіту, додати в інший список (про повноцінні конвеєри ми поговоримо пізніше).

Для цього використовують другий популярний тип функції: дію, зазвичай (T) -> Unit.

Unit означає «нічого корисного не повертаємо». Це не «порожнеча» і не null. Радше «ок, я закінчив».

Зробімо функцію forEachIf: пройтися списком і для тих, що підходять, викликати дію.

fun <T> forEachIf(
    items: List<T>,
    predicate: (T) -> Boolean,
    action: (T) -> Unit
) {
    for (item in items) {
        if (predicate(item)) action(item)
    }
}

І використаємо:

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

    forEachIf(xs, { it % 2 == 0 }, { println("парне=$it") })
    // парне=2
    // парне=4
}

Тут ви бачите «дві лямбди підряд», і це нормально. Трохи пізніше ми обговоримо стиль запису, щоб усе це не перетворювалося на локшину.

Застосовуємо forEachIf до витрат: друк вибраних елементів

Тепер зробімо «друк витрат за умовою». Одразу зʼявиться практичне відчуття: ми пишемо один цикл і використовуємо його багато разів.

fun printExpense(e: Triple<String, String, Int>) {
    val (title, category, amount) = e
    println("$title | $category | $amount")
}

fun main() {
    val expenses = listOf(
        Triple("Coffee", "food", 250),
        Triple("Taxi", "transport", 900),
        Triple("Lunch", "food", 600)
    )

    forEachIf(expenses, { it.second == "food" }, ::printExpense)
    // Coffee | food | 250
    // Lunch | food | 600
}

Так, тут раптом ::printExpense. Це «посилання на функцію» (callable reference) — формально тема наступної лекції. Але зараз можете сприймати це як «я передаю готову функцію замість лямбди { e -> printExpense(e) }». Якщо вам поки хочеться без магії — замініть на лямбду, зміст не зміниться.

6. Пошук у колекції: чесний контракт через T?

Із підрахунком і друком усе гаразд. Але часто потрібен пошук: «знайди перший елемент, який підходить». І тут важливо бути чесними: елемент може не знайтися. Отже, результат має бути T?.

Зробімо свою версію findFirstOrNull (так, у стандартній бібліотеці є схожі функції, але ми тренуємо руки й мозок).

fun <T> findFirstOrNull(items: List<T>, predicate: (T) -> Boolean): T? {
    for (item in items) {
        if (predicate(item)) return item
    }
    return null
}

Тепер приклад із витратами:

fun main() {
    val expenses = listOf(
        Triple("Coffee", "food", 250),
        Triple("Taxi", "transport", 900)
    )

    val firstBig = findFirstOrNull(expenses) { it.third >= 500 }
    println(firstBig) // (Taxi, transport, 900)
}

Якби такого елемента не було, ми отримали б null. І це змушує нас акуратно обробити результат:

fun main() {
    val expenses = listOf(Triple("Coffee", "food", 250))

    val found = findFirstOrNull(expenses) { it.second == "transport" }
    val title = found?.first ?: "нічого не знайдено"

    println(title) // нічого не знайдено
}

Такий підхід добре дружить із null-safety: тип T? прямо говорить «може не бути».

7. Чому «передати поведінку» краще, ніж «передати прапорець»

Дуже типова помилка мислення новачків — намагатися керувати логікою функції булевими прапорцями. Наприклад: «рахуй витрати за категорією, але якщо onlyBig = true, то рахуй лише великі». Потім зʼявляються onlyFood, onlyTransport, minAmount, maxAmount, і ви раптом пишете 30 if-ів, а функція перетворюється на комбайн.

Порівняймо дві ідеї. Погану (прапорці) покажу коротко, щоб не травмувати психіку:

fun countExpenses(expenses: List<Triple<String, String, Int>>, onlyBig: Boolean): Int {
    var count = 0
    for (e in expenses) {
        val ok = if (onlyBig) e.third >= 500 else true
        if (ok) count++
    }
    return count
}

Проблема навіть не в цьому фрагменті, а в майбутньому: прапорців стане багато, і вам доведеться комбінувати варіанти.

А тепер хороший стиль: одне місце, де живе цикл, і будь-яка умова — окремим предикатом:

fun main() {
    val expenses = listOf(
        Triple("Coffee", "food", 250),
        Triple("Taxi", "transport", 900),
        Triple("Lunch", "food", 600)
    )

    val bigCount = countMatching(expenses) { it.third >= 500 }
    val foodCount = countMatching(expenses) { it.second == "food" }

    println(bigCount)  // 2
    println(foodCount) // 2
}

Такий код простіше розширювати: зʼявилася нова умова — ви додали нову лямбду. Не потрібно переписувати функцію й додавати прапорці.

8. Імена параметрів-функцій і читабельність лямбд

Поки ви тренуєтеся, є два шкідливі сценарії.

  • Перший — коли всюди it, і ви вже не розумієте, хто там «it», особливо якщо поруч два вкладені лямбда-блоки. Тоді краще чесно назвати параметр: expense, x, item.
  • Другий — коли ви називаєте параметри беззмістовно: f, g, h. Це зручно математикам і лиходіям зі світу криптографії, але в прикладному коді краще говорити прямо.

У функціях вищого порядку найчастіше трапляються імена predicate і action. Це не магічні слова, а просто загальноприйнятий стиль: читаєш сигнатуру — і відразу розумієш ролі.

Наприклад, ця сигнатура читається майже як речення:

fun <T> forEachIf(items: List<T>, predicate: (T) -> Boolean, action: (T) -> Unit)

«Для кожного елемента: якщо predicate — виконуй action». Усе.

9. Мінісхема: що відбувається під час виклику функції вищого порядку

Іноді мозок застрягає на думці «як це — передати функцію у функцію». Уявіть, що ви передаєте не функцію, а «налаштування поведінки», як перемикач режимів. Тільки перемикач — не Boolean, а повноцінний шматочок коду.

Ось проста блок-схема (логіка одна й та сама в countMatching і forEachIf):

flowchart TD
    A[Старт: є список items] --> B{Беремо наступний елемент}
    B -->|елемент є| C["Викликаємо predicate(item)"]
    C --> D{predicate повернув true?}
    D -->|так| E["Робимо дію: count++ або action(item)"]
    D -->|ні| F[Нічого не робимо]
    E --> B
    F --> B
    B -->|елементів немає| G[Фініш: повертаємо результат/завершуємо]

І ось тут важлива думка: predicate(item) — це звичайний виклик, просто predicate прийшов як параметр.

10. Типові помилки

Помилка №1: плутати Boolean і (T) -> Boolean.
Коли ви бачите predicate, памʼятайте: це не «готова відповідь true/false», а «функція, яка вміє відповідати» для кожного елемента. Тому predicate треба викликати: predicate(item). Якщо ви спробуєте написати if (predicate), компілятор справедливо здивується: «чому ви намагаєтеся використати функцію як булеве значення?».

Помилка №2: робити предикат «комбайном» (перевірка + друк + зміна зовнішнього стану).
Технічно лямбда може і перевіряти, і друкувати, і збільшувати лічильник. Але тоді ви втрачаєте передбачуваність: за типом (T) -> Boolean ніхто не очікує, що всередині ще й друкується в консоль. Краще розділяти ролі: предикат лише відповідає на питання, дія лише виконує ефект (друк, лог, додавання в список).

Помилка №3: повертати T замість T? у пошуку.
Якщо функція «шукає елемент», вона зобовʼязана враховувати варіант «не знайшли». Повертати «порожній елемент» або Triple("", "", 0) — поганий контракт: ви змішуєте реальний результат і заглушку. T? чесніше, і Kotlin якраз створений, щоб безпечно працювати з такими випадками через ?. і ?:.

Помилка №4: перебір із it і втрата читабельності.
it чудово працює в коротких лямбдах на 1–2 вирази. Але якщо умова стає складною, зʼявляються деконструкції, вкладені перевірки — краще назвати параметр явно. Ваш майбутній мозок (і мозок ревʼюера) скаже дякую, а багів стане менше.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ