JavaRush /Курсы /Kotlin SELF /Читаемые цепочки операций — результат без мутации исходно...

Читаемые цепочки операций — результат без мутации исходной коллекции

Kotlin SELF
22 уровень , 4 лекция
Открыта

1. Зачем писать цепочки, а не циклы

Если вы когда-нибудь писали код вида «создать пустой список → пройти циклом → в if добавить → потом ещё раз пройти циклом», то вы уже знакомы с тем, как постепенно появляется ощущение, что программа — это кастрюля: мы постоянно что-то помешиваем и боимся расплескать. Цепочки вызовов в Kotlin позволяют описывать обработку данных как последовательность шагов, где каждый шаг возвращает результат, и вы видите «трубопровод» (pipeline) прямо в коде.

Ключевая идея этой лекции: мы стараемся не мутировать исходную коллекцию, а строить новые результаты поверх неё. То есть вместо «взяли список расходов и удалили из него всё лишнее» мы делаем «взяли список расходов и получили новый список — только то, что нужно». Это делает код предсказуемее: вы меньше боитесь, что где-то «там» список уже поменяли, и теперь отладка превращается в поиск призраков.

Небольшая мини-таблица для фокуса:

Операция Что возвращает Мутирует исходную коллекцию? Типичная роль
filter { ... }
List<T>
нет «оставить подходящее»
map { ... }
List<R>
нет «превратить в другое»
find { ... }
T?
нет «найти первое подходящее»
any/all
Boolean
нет «быстрая проверка»
count { ... }
Int
нет «сколько элементов подходит»
forEach { ... }
Unit
нет «сделать действие в конце»

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?>.

1
Задача
Kotlin SELF, 22 уровень, 4 лекция
Недоступна
Чистые слова
Чистые слова
1
Задача
Kotlin SELF, 22 уровень, 4 лекция
Недоступна
Чётные из мусора
Чётные из мусора
1
Задача
Kotlin SELF, 22 уровень, 4 лекция
Недоступна
Покупка по бюджету
Покупка по бюджету
1
Задача
Kotlin SELF, 22 уровень, 4 лекция
Недоступна
Проверка email
Проверка email
1
Опрос
Операции коллекций, 22 уровень, 4 лекция
Недоступен
Операции коллекций
Операции коллекций
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ