JavaRush /Курсы /Kotlin SELF /map и filter — преобразование и отбор как базовый стиль

map и filter — преобразование и отбор как базовый стиль

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

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 { ... }
«Во что превратить каждый элемент?» значение (например,
Int
,
String
,
Double
)
List<R>
да, может
filter { ... }
«Оставить элемент или выбросить?»
Boolean
List<T>
(подмножество)
нет

Если вы ловите себя на мысли «я хочу новый список» — это нормально для обеих операций. Но если вы хотите новый список другой формы, обычно это 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() }).

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