1. Зачем писать цепочки, а не циклы
Если вы когда-нибудь писали код вида «создать пустой список → пройти циклом → в if добавить → потом ещё раз пройти циклом», то вы уже знакомы с тем, как постепенно появляется ощущение, что программа — это кастрюля: мы постоянно что-то помешиваем и боимся расплескать. Цепочки вызовов в Kotlin позволяют описывать обработку данных как последовательность шагов, где каждый шаг возвращает результат, и вы видите «трубопровод» (pipeline) прямо в коде.
Ключевая идея этой лекции: мы стараемся не мутировать исходную коллекцию, а строить новые результаты поверх неё. То есть вместо «взяли список расходов и удалили из него всё лишнее» мы делаем «взяли список расходов и получили новый список — только то, что нужно». Это делает код предсказуемее: вы меньше боитесь, что где-то «там» список уже поменяли, и теперь отладка превращается в поиск призраков.
Небольшая мини-таблица для фокуса:
| Операция | Что возвращает | Мутирует исходную коллекцию? | Типичная роль |
|---|---|---|---|
|
|
нет | «оставить подходящее» |
|
|
нет | «превратить в другое» |
|
|
нет | «найти первое подходящее» |
|
|
нет | «быстрая проверка» |
|
|
нет | «сколько элементов подходит» |
|
|
нет | «сделать действие в конце» |
2. Скелет читаемой цепочки операций
Когда вы впервые начинаете писать цепочки, есть соблазн сделать их «как можно короче», а потом с гордостью смотреть на 12 вызовов подряд и думать: «Я функциональный гений». Обычно в этот момент будущий вы из будущего достаёт табличку «за что» и молча показывает её вам. Поэтому нам нужен простой, повторяемый скелет, по которому цепочка читается как рассказ: сначала подготовили данные, затем отобрали, затем преобразовали, затем получили итог и (если надо) вывели.
Давайте зафиксируем это как схему. Не как догму, а как удобный ритм чтения кода:
flowchart TD
A[Источник данных: List⟨T⟩] --> B[Нормализация: map / trim / lowercase]
B --> C[Отбор: filter]
C --> D[Преобразование: map / mapNotNull]
D --> E[Итог: find / any / all / count]
E --> F[Действие: forEach / println]
Здесь важно, что forEach чаще всего стоит в самом конце, когда вы уже получили то, что нужно посчитать/подготовить. forEach — это «вывести на экран», «залогировать», «вызвать функцию с эффектом», но не «посчитать и вернуть» (для этого есть count, any, find и друзья).
Мини‑пример «в одну строчку смысла»: почистить строки, оставить непустые, превратить в длины, посчитать сколько длина >= 3:
fun main() {
val raw = listOf(" a ", " ", "kotlin", " go ")
val count = raw
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { it.length }
.count { it >= 3 }
println(count) // 2
}
3. Учебный пример: расходы и команды без мутации
Чтобы цепочки не висели в вакууме, продолжим практический консольный проект из дней про коллекции и команды. Мы всё ещё «без ООП» (классы будут позже), поэтому представим расход как Triple:
- first — сумма,
- second — категория ("food", "taxi", "books"),
- third — комментарий.
Да, Triple — не вершина читаемости, но это честный «мостик» до классов. И, кстати, это отличный повод писать маленькие функции‑помощники, чтобы код не был похож на волшебный ритуал expense.first по всей программе.
Данные расходов и функции‑помощники
Сделаем пару функций, которые улучшают читабельность (и пригодятся для callable references ::):
fun normalizeCategory(raw: String): String =
raw.trim().lowercase()
fun formatExpense(e: Triple<Int, String, String>): String =
"${e.first} | ${e.second} | ${e.third}"
Обратите внимание на стиль: каждая функция делает одну вещь. Как только функция начинает «и нормализовать, и валидировать, и печатать» — это обычно сигнал, что вы пытаетесь упаковать три разных шага в один.
Длинная цепочка vs промежуточные val
Почти любая цепочка имеет «точку перелома»: до неё читается легко, после неё мозг начинает делать вид, что у него срочный обед. В Kotlin нормально и даже полезно разрывать длинные цепочки промежуточными val с говорящими именами. Это не «лишний код», а подсказки читателю: «вот здесь мы уже получили очищенные расходы», «вот здесь оставили только категорию food».
Сравним подходы на примере: вывести все расходы в категории, которую ввёл пользователь, причём категорию надо нормализовать.
Вариант «всё в одной цепочке» (коротко, но может быть тяжело, если шагов станет больше):
fun printByCategory(
expenses: List<Triple<Int, String, String>>,
rawCategory: String
) {
expenses
.filter { it.second == rawCategory.trim().lowercase() }
.map(::formatExpense)
.forEach(::println)
}
Вариант с промежуточными val (обычно читается спокойнее, особенно новичкам):
fun printByCategory(
expenses: List<Triple<Int, String, String>>,
rawCategory: String
) {
val category = normalizeCategory(rawCategory)
val filtered = expenses.filter { it.second == category }
val lines = filtered.map(::formatExpense)
lines.forEach(::println)
}
Здесь вы прямо видите: «ввели категорию → нормализовали → отфильтровали → отформатировали → вывели». И это та самая структура «шагов», о которой мы говорили.
Команды: ввод → нормализация → результат
В консольных приложениях почти всегда есть слой «сырого ввода»: пользователь вводит команду строкой, в строке бывают пробелы, странные регистры и вообще полная свобода личности. Цепочки помогают превратить «сырой ввод» в нормальные данные без того, чтобы код выглядел как лапша из if и временных переменных.
Сделаем маленькую функцию: пользователь вводит строку с числами через пробел, а мы хотим получить список Int, игнорируя мусор. Это классический случай mapNotNull, потому что toIntOrNull() возвращает Int?, и мы хотим отбросить null‑результаты.
fun parseInts(raw: String): List<Int> =
raw.split(" ")
.map { it.trim() }
.filter { it.isNotEmpty() }
.mapNotNull { it.toIntOrNull() }
Здесь особенно важно не перепутать: filterNotNull() убирает именно null, а не пустые строки. Пустые строки убираются отдельным filter { it.isNotEmpty() }.
Теперь применим идею к нашему проекту: допустим, у нас есть команда "list food", которая выводит расходы по категории. Мы хотим:
- не менять expenses,
- получить новый список строк для печати,
- вывести через forEach в конце.
fun listCommand(
expenses: List<Triple<Int, String, String>>,
rawCategory: String
) {
val category = normalizeCategory(rawCategory)
expenses
.filter { it.second == category }
.map(::formatExpense)
.forEach(::println)
}
Обратите внимание: ни одного add, ни одного remove, ни одной мутации. Команда list должна быть «чистой» в хорошем смысле: она только показывает.
4. Итоговые операции: find, any, all, count
Очень частая ошибка новичков: они делают filter, получают список, а дальше снова идут циклом и сами считают или ищут. Это нормально на старте (вы честно закрепляете циклы), но теперь у нас есть операции, которые выражают намерение прямо: «найди», «проверь», «посчитай». Когда код говорит человеческим языком, он редко ломается случайно.
find: найти первый подходящий расход
В реальном приложении часто хочется: «Покажи первую покупку больше 5000» или «Найди первую запись категории "taxi"». find возвращает T?, и это не каприз, а честность: элемента может не быть.
fun printFirstOver(
expenses: List<Triple<Int, String, String>>,
threshold: Int
) {
val found = expenses.find { it.first > threshold }
val message = found?.let(::formatExpense) ?: "NOT_FOUND"
println(message)
}
Тут важен стиль: мы не делаем found!!. Мы явно описываем поведение «если не нашли» через Elvis ?:.
any и all: проверки
Проверки — это место, где цепочки особенно красивы: вместо ручного флага var ok = false мы пишем то, что имеем в виду.
Например, проверим: есть ли хоть один расход с подозрительно большим значением (допустим, больше 100_000):
fun hasHugeExpense(expenses: List<Triple<Int, String, String>>): Boolean =
expenses.any { it.first > 100_000 }
А теперь пример all: все ли расходы неотрицательные. Да, это звучит как «проверка на здравый смысл», но в пользовательском вводе здравый смысл — гость нечастый.
fun allNonNegative(expenses: List<Triple<Int, String, String>>): Boolean =
expenses.all { it.first >= 0 }
Отдельно напомню важное поведение: all { ... } на пустой коллекции возвращает true (потому что нет ни одного контрпримера). Это нормально, просто нужно помнить при интерпретации.
count: сколько расходов подходит
Если вам нужно число, то count обычно выразительнее, чем filter {...}.size, потому что он прямо говорит: «посчитай по условию».
fun countInCategory(
expenses: List<Triple<Int, String, String>>,
rawCategory: String
): Int {
val category = normalizeCategory(rawCategory)
return expenses.count { it.second == category }
}
5. Вычисление отдельно, печать отдельно
Когда код маленький, очень хочется сделать println прямо внутри map или filter: «ну а что такого, работает же». Работает — до тех пор, пока вы не захотите переиспользовать вычисление без печати, протестировать его, или просто понять, почему вывод дублируется. Поэтому полезная привычка: сначала вы строите результат как значение (List<String>, Int, Boolean), а потом уже делаете действие (println, forEach).
Сравним на простом примере.
Плохо (смешали преобразование и печать):
fun printBad(expenses: List<Triple<Int, String, String>>) {
expenses.map {
println(it) // побочный эффект в map — читатель плачет
it.first
}
}
Лучше (сначала получили строки, потом вывели):
fun printGood(expenses: List<Triple<Int, String, String>>) {
val lines = expenses.map(::formatExpense)
lines.forEach(::println)
}
forEach в конце цепочки — это как поставить точку в предложении. Вы уже всё посчитали, и теперь просто «используете» результат.
6. Мини‑сценарий в main
Чтобы увидеть стиль целиком, соберём маленький фрагмент main. Он не претендует на полный командный парсер (он был раньше), но показывает, как цепочки используются в реальном потоке: получили данные → сделали проверку → вывели список → посчитали.
fun main() {
val expenses = listOf(
Triple(1200, "food", "coffee"),
Triple(8000, "taxi", "airport"),
Triple(300, "food", "bread")
)
println(hasHugeExpense(expenses)) // false
println(allNonNegative(expenses)) // true
listCommand(expenses, " FOOD ")
// 1200 | food | coffee
// 300 | food | bread
println(countInCategory(expenses, "food")) // 2
}
Здесь хорошо видно главное: список expenses не меняется, мы лишь «снимаем с него срезы» и строим результаты.
7. Типичные ошибки при построении цепочек
Ошибка №1: превращать forEach в «универсальную лопату» для всего.
Новички часто делают так: внутри forEach и фильтруют, и форматируют, и считают, и обновляют внешние переменные. Это работает, но код становится трудно проверять и сложно менять: любая правка добавляет ещё один побочный эффект. Хорошее правило: всё, что возвращает значение (map/filter/find/any/all/count), делаем до forEach, а forEach оставляем как финальный шаг «использовать результат».
Ошибка №2: печатать внутри map или filter и получать «волшебный» вывод.
map и filter читаются как преобразования данных. Когда внутри них внезапно появляется println, у читателя ломается ожидание: он думает, что строит новый список, а на самом деле ещё и запускает побочные эффекты. Это особенно опасно, когда цепочка будет переиспользована в другом месте: там «вдруг» начнётся печать, хотя вы этого не планировали.
Ошибка №3: писать цепочку на 12 вызовов и гордиться, что она «в одну строку».
Компактность не равна читаемости. Если цепочка длинная, разбейте её промежуточными val с нормальными именами. Вы не проиграете в качестве кода — наоборот, вы сделаете его дружелюбным к будущему себе, который откроет проект через неделю и не будет вспоминать, что именно делал it на шестом шаге.
Ошибка №4: забывать, где появляется null, и пытаться обращаться к результату как к non-null.
find возвращает T?, toIntOrNull() возвращает Int?, и это не «помеха», а сигнал: возможен сценарий «не нашли / не распарсилось». Если вы игнорируете это и тянетесь к !!, то вы не «упрощаете код», а откладываете падение программы на момент, когда пользователь введёт что-то неожиданное. Чаще всего лучше применить ?: или заранее очистить данные mapNotNull.
Ошибка №5: путать filterNotNull() с фильтрацией пустых строк.
filterNotNull() выбрасывает только null‑элементы и при этом улучшает тип результата до non-null, что очень удобно. Но пустая строка "" — не null, и она никуда не денется. Поэтому для строк обычно нужен дуэт: сначала map { it.trim() }, потом filter { it.isNotEmpty() }, а filterNotNull() применим только если у вас реально List<String?>.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ