1. Введение
Когда вы только начинаете программировать, кажется, что функции — это «имя + скобки + код внутри». Но как только у вас появляется обработка коллекций, вы быстро замечаете неприятную штуку: вы пишете один и тот же цикл снова и снова, меняя в нём только маленький кусочек логики. Сегодня мы научимся передавать этот кусочек логики параметром и перестанем копировать циклы, как будто мы пишем диплом в 2008 году и вставляем текст из чужого Word-файла.
Представьте наш учебный консольный проект (практическое приложение без ООП): простой «учёт расходов», где каждый расход — это Triple(название, категория, сумма). Пока мы умеем добавлять и показывать элементы через циклы. Но уже скоро захочется: посчитать только «еда», вывести только расходы больше 500, найти первый расход по категории… И вот тут «поведение как параметр» становится суперсилой.
2. Тип функции: как читать (A) -> B
Тип функции в Kotlin выглядит немного как стрелочка из школьной математики: из чего-то во что-то. Запись (A) -> B читается так: «функция принимает A и возвращает B». Это именно тип, то есть описание значения, которое можно положить в val, передать в функцию, вернуть из функции — как Int или String, только это «функция».
Чтобы было проще, держите в голове маленькую табличку:
| Тип | Читаем | Пример смысла |
|---|---|---|
|
«берёт Int, возвращает Boolean» | проверка (чётное ли число) |
|
«берёт строку, возвращает число» | измерение (длина строки) |
|
«берёт Int, ничего полезного не возвращает» | действие (печать) |
|
«берёт расход, возвращает 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 выражения. Но если условие становится составным, появляются деконструкции, вложенные проверки — лучше назвать параметр явно. Ваш будущий мозг (и мозг ревьюера) скажет спасибо, а багов станет меньше.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ