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: предикат + действие

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

Для этого используется второй популярный тип функции: action, обычно (T) -> Unit.

Unit означает «ничего полезного не возвращаем». Это не «пустота» и не null. Это как «ok, я закончил».

Сделаем функцию 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("even=$it") })
    // even=2
    // even=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 выражения. Но если условие становится составным, появляются деконструкции, вложенные проверки — лучше назвать параметр явно. Ваш будущий мозг (и мозг ревьюера) скажет спасибо, а багов станет меньше.

1
Задача
Kotlin SELF, 21 уровень, 1 лекция
Недоступна
Проверка настроения
Проверка настроения
1
Задача
Kotlin SELF, 21 уровень, 1 лекция
Недоступна
Подсчёт по правилу
Подсчёт по правилу
1
Задача
Kotlin SELF, 21 уровень, 1 лекция
Недоступна
Универсальный счётчик
Универсальный счётчик
1
Задача
Kotlin SELF, 21 уровень, 1 лекция
Недоступна
Условие и действие
Условие и действие
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Vladislav Уровень 62
23 марта 2026
оч полезная тема, неплохо подан материал