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. Якщо ж ви хочете скоротити список за умовою, зазвичай це 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() }).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ