1. Введение
Когда вы только начинаете программировать, цикл for кажется универсальной отвёрткой: им можно пройтись по списку, что-то посчитать, что-то распечатать, что-то «переделать». Но в реальных задачах циклы начинают разрастаться: внутри появляется if, внутри него ещё if, где-то копится результат, где-то печатается, и всё это превращается в «лапшу», которую тяжело читать, тестировать и менять.
Идея операций над коллекциями в Kotlin — сделать ваш код похожим на конвейер обработки данных: сначала мы отбираем нужное, потом преобразуем, потом используем результат. В Kotlin стандартная библиотека прямо поддерживает такой стиль: map() для преобразования и filter() для отбора — это базовые «кирпичики» для конвейера преобразований коллекций.
С этого момента полезная привычка звучит так: если вы хотите получить новый список — сначала подумайте не о цикле, а о map/filter.
2. map: превращаем каждый элемент во что-то другое
map — это операция, которая берёт исходную коллекцию и для каждого элемента вычисляет новое значение, складывая результаты в новый список. В Kotlin это называется mapping transformation: «преобразование отображением». Важно, что map() применяет лямбду к каждому элементу по порядку и возвращает List результатов в том же порядке.
Представьте себе штамп на заводе: по конвейеру едут детали (элементы списка), а map ставит на каждую деталь свой «штамп‑преобразование», после чего детали складываются в новую коробку (новый список).
Минимальный пример: числа → квадраты
Начнём с очень простой иллюстрации, чтобы мозг не перегрелся:
fun main() {
val xs = listOf(1, 2, 3)
val squares = xs.map { x -> x * x }
println(xs) // [1, 2, 3]
println(squares) // [1, 4, 9]
}
Здесь важно заметить две вещи. Во-первых, xs не изменился: map не «переделывает список», а строит новый. Во-вторых, тип результата может отличаться от типа входа: это главный кайф map.
Тип результата может меняться: строки → длины
map очень часто используют, чтобы достать «какое-то поле» или «производное значение» от каждого элемента:
fun main() {
val names = listOf("Ann", "Bob", "Chris")
val lengths = names.map { name -> name.length }
println(lengths) // [3, 3, 5]
}
Вход был List<String>, выход стал List<Int>. Это нормально и ожидаемо: map не обязан возвращать тот же тип, он «перекрашивает» элементы.
it или именованный параметр
В коротких лямбдах удобно использовать it, чтобы не писать имя параметра. Но как только внутри лямбды появляется больше одной мысли, it начинает выглядеть как «это… ну… это…». Лучше дать имя.
Сравните:
fun main() {
val raw = listOf(" Ann ", " Bob ")
val cleaned1 = raw.map { it.trim() }
val cleaned2 = raw.map { s -> s.trim() }
println(cleaned1) // [Ann, Bob]
println(cleaned2) // [Ann, Bob]
}
Оба варианта корректны. Просто во втором случае читателю проще, если дальше вы захотите добавить ещё шаги.
Важный момент: map — не для печати
Иногда новички пишут так: «а давайте я в map сразу и преобразую, и распечатаю». Так можно, но это плохая привычка: вы смешиваете вычисление и действие. В Kotlin стиль обычно такой: map/filter считают данные, а печать — это уже forEach в конце цепочки.
Технически это будет работать:
fun main() {
val xs = listOf(1, 2, 3)
val ys = xs.map { x ->
println("Processing $x") // Processing 1 / 2 / 3
x * 10
}
println(ys) // [10, 20, 30]
}
Но теперь map делает два дела сразу: и преобразует, и печатает (побочный эффект). А значит — сложнее отлаживать и сложнее переиспользовать.
3. filter: оставляем только то, что подходит
Если map — это «станок, который переделывает каждую деталь», то filter — это «сито»: пропускаем только то, что удовлетворяет условию, а остальное выкидываем. В Kotlin фильтрация определяется предикатом — функцией (T) -> Boolean, которая отвечает на вопрос «оставить элемент или убрать».
filter() возвращает новую коллекцию, в которой только подходящие элементы, и исходная коллекция при этом не меняется. Это очень важная мысль, потому что она делает код предсказуемым.
Минимальный пример: оставить только длинные слова
fun main() {
val words = listOf("a", "home", "to", "kotlin")
val longWords = words.filter { it.length >= 4 }
println(longWords) // [home, kotlin]
}
Внутри filter мы не «добавляем элементы», мы просто говорим: «вот этот подходит (true), а этот — нет (false)».
Пример ближе к жизни: оставить только положительные числа
fun main() {
val xs = listOf(-2, 0, 5, -1, 7)
val positives = xs.filter { x -> x > 0 }
println(positives) // [5, 7]
}
Заметьте, что filter не меняет тип элементов: был Int, и остался Int. Это ключевое отличие от map.
filter работает и на Map
Хотя сегодня мы фокусируемся на базовом стиле для списков, полезно знать: filter есть и у Map, и там результатом остаётся Map. В лямбду при фильтрации карты можно принимать (key, value) через деконструкцию.
Мини‑пример «на посмотреть»:
fun main() {
val scores = mapOf("Ann" to 10, "Bob" to 3, "Chris" to 8)
val good = scores.filter { (_, score) -> score >= 8 }
println(good) // {Ann=10, Chris=8}
}
Мы не будем сейчас строить вокруг этого отдельные сценарии, просто запомните: тот же принцип «сито», только элементы — это пары ключ‑значение.
4. map и filter: сравнение, цепочки и «конвейер»
После первых примеров у многих появляется путаница: «погодите, а filter же тоже что-то делает со списком… а map тоже как будто что-то отбирает…». Давайте зафиксируем разницу аккуратно.
map vs filter: таблица для быстрой проверки
| Операция | Главный вопрос | Лямбда возвращает | Что возвращает операция | Меняется ли тип элементов? |
|---|---|---|---|---|
|
«Во что превратить каждый элемент?» | значение (например, , , ) |
|
да, может |
|
«Оставить элемент или выбросить?» | |
(подмножество) |
нет |
Если вы ловите себя на мысли «я хочу новый список» — это нормально для обеих операций. Но если вы хотите новый список другой формы, обычно это map. Если вы хотите сократить список по условию, обычно это filter.
Цепочки: порядок шагов — часть логики
Когда в коде появляется цепочка .filter { ... }.map { ... }, её полезно читать как список шагов обработки данных сверху вниз: «взяли исходный список → убрали лишнее → преобразовали оставшееся». Kotlin прямо показывает такой стиль в базовом синтаксисе: цепочка фильтрации, преобразования и финального действия выглядит очень естественно.
Вот классический пример «почистить строки»:
fun main() {
val raw = listOf(" Ann ", " ", "Bob", " Chris")
val cleaned = raw
.map { it.trim() }
.filter { it.isNotEmpty() }
println(cleaned) // [Ann, Bob, Chris]
}
Здесь важна логика: сначала trim, потому что строка " " после trim() превращается в "", и только после этого мы можем корректно отфильтровать пустые.
Иногда условие фильтрации удобнее проверять уже на преобразованном значении. Например, если вы переводите строки в нижний регистр, чтобы сравнение было без учёта регистра:
fun main() {
val words = listOf("Kotlin", "java", "KOTLIN", "Python")
val onlyKotlin = words
.map { it.lowercase() }
.filter { it == "kotlin" }
println(onlyKotlin) // [kotlin, kotlin]
}
Мы получили два элемента "kotlin", потому что исходно их было два в разном регистре. Здесь map делает нормализацию, а filter уже работает по нормализованным данным.
Ментальная модель: «конвейер» из шагов
Когда вы видите .filter { ... }.map { ... }.forEach { ... }, полезно думать о ней как о конвейере на заводе. Это не философия, это очень практичная штука: мозгу проще, когда он видит «шаги». Kotlin поощряет именно такой стиль, потому что операции преобразований коллекций построены вокруг идеи «создать новую коллекцию по правилам».
flowchart LR
A["Исходный список (List⟨T⟩)"] --> B["filter { ... }
отбор"]
B --> C["map { ... }
преобразование"]
C --> D["Результат (List⟨R⟩)"]
D --> E["forEach { ... }
использование, печать"]
Пока что воспринимайте это буквально: filter и map — про данные и результат, forEach — про действие.
5. map и filter в консольном проекте
С этого дня у нас будет очень практичное правило: любые команды «показать список» в консольном приложении почти всегда состоят из шагов «отобрать → преобразовать → вывести». Наш практический проект (который мы начали на коллекциях) можно представлять как простейший CLI‑трекер расходов: мы храним операции в MutableList, добавляем запись, выводим список, удаляем по индексу. Сейчас мы не вводим классы (это будет позже), поэтому будем хранить операции как Triple.
Пусть одна операция выглядит так: (title, category, amount).
Данные и маленький помощник для красивого отображения
Сначала сделаем небольшую функцию форматирования записи. Да, это тоже «преобразование» — и это отличная цель для map.
fun formatExpense(e: Triple<String, String, Int>): String {
val (title, category, amount) = e
return "[$category] $title: $amount"
}
fun main() {
val expenses = mutableListOf(
Triple("Coffee", "food", 250),
Triple("Book", "education", 1200),
Triple("Taxi", "transport", 600)
)
val lines = expenses.map(::formatExpense)
lines.forEach(::println)
// [food] Coffee: 250
// [education] Book: 1200
// [transport] Taxi: 600
}
Обратите внимание на идею: map превращает список «сложных троек» в список «готовых строк для печати», а печать мы делаем уже forEach. Так обязанности разделены аккуратно.
Команда list food: фильтруем по категории
Теперь добавим фильтрацию. Представим, что пользователь ввёл категорию, и мы хотим вывести только элементы этой категории.
fun main() {
val expenses = listOf(
Triple("Coffee", "food", 250),
Triple("Burger", "food", 500),
Triple("Book", "education", 1200)
)
val category = "food"
expenses
.filter { (_, cat, _) -> cat == category }
.map(::formatExpense)
.forEach(::println)
// [food] Coffee: 250
// [food] Burger: 500
}
Здесь деконструкция (_, cat, _) помогает читать условие как нормальный русский текст: «оставь только те, где cat == category».
Категория из ввода и «грязные» данные
Допустим, пользователь вводит " Food " с пробелами и разным регистром. Мы пока не делаем сложной валидации, просто нормализуем ввод и сравниваем в нижнем регистре. Получится «цепочка на цепочке», но всё ещё читаемо, если шаги короткие.
fun main() {
val expenses = listOf(
Triple("Coffee", "food", 250),
Triple("Book", "education", 1200)
)
print("Category: ")
val raw = readln()
val category = raw.trim().lowercase()
expenses
.filter { (_, cat, _) -> cat.lowercase() == category }
.map(::formatExpense)
.forEach(::println)
}
Тут важно не переусложнять: мы сделали нормализацию ввода отдельно, а потом читаемый конвейер. Если попытаться «впихнуть всё внутрь filter», получится тяжело читать.
6. Типичные ошибки при работе с map и filter
Ошибка №1: попытка использовать map как “цикл для печати”.
Очень распространённая история: «я же прохожу по всем элементам, значит map подходит». Формально — да, но смысл map в другом: он должен строить полезный список результата. Если вы печатаете внутри map, вы добавляете побочный эффект и смешиваете обязанности. Обычно печать лучше оставить на финальный forEach, а map держать “чистым”.
Ошибка №2: путаница “что возвращает лямбда”.
У filter лямбда должна вернуть Boolean. У map лямбда должна вернуть значение нового типа (или того же типа). Если вы в filter случайно вернули строку, компилятор вас остановит, но иногда ошибка выглядит пугающе. Полезная самопроверка: «я сейчас отвечаю “да/нет”?» — значит filter. «я сейчас вычисляю новое значение?» — значит map.
Ошибка №3: ожидание, что filter или map меняют исходную коллекцию.
После императивного стиля (где вы привыкли делать list[i] = ...) легко подсознательно ожидать, что map «переделает список». Но по контракту эти операции создают новый результат, а исходная коллекция остаётся прежней. Это полезно: меньше сюрпризов, меньше случайно сломанного состояния.
Ошибка №4: слишком длинные лямбды внутри map/filter.
Пока код маленький, хочется писать всё внутри одной лямбды. Но как только у вас внутри map появляется 8 строк с if/else, временными переменными и форматированием, цепочка перестаёт быть цепочкой и превращается в мини‑роман. В таких случаях проще вынести преобразование в отдельную функцию (как мы сделали с formatExpense) и использовать ::formatExpense — и код снова становится читаемым.
Ошибка №5: неправильный порядок шагов в цепочке.
Иногда вы фильтруете по условию, которое имеет смысл только после преобразования (или наоборот). Типичный пример — строки с пробелами: если сначала фильтровать isNotEmpty(), строка " " пройдёт фильтр, хотя вам она не нужна. Поэтому порядок шагов — не “косметика”, а часть логики. Если данные требуют нормализации, сначала нормализуйте (map { it.trim() }), а потом отбирайте (filter { it.isNotEmpty() }).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ