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("Обробляємо $x") // Обробляємо 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("Категорія: ")
    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() }).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ