JavaRush /Курси /Kotlin SELF /mapNotNull і filterNotNull: очищення даних і зміна типів

mapNotNull і filterNotNull: очищення даних і зміна типів

Kotlin SELF
Рівень 22 , Лекція 3
Відкрита

1. Чому нам узагалі заважає null у колекціях

Коли ви тільки починаєте програмувати, null здається корисною штукою: «ну, немає значення — значить null». Проблема в тому, що null — це не просто «порожньо», а окреме значення, яке потрібно постійно враховувати. У Kotlin це підсилено системою типів: String і String? — це різні типи, і компілятор не дає вам удавати, ніби null «сам якось розсмокчеться».

У колекціях null стає особливо неприємним гостем із двох причин. По‑перше, він псує тип результату й змушує нас писати перевірки там, де за змістом «усе вже має бути гаразд». По‑друге, null часто зʼявляється як результат безпечного парсингу (toIntOrNull()), пошуку (find) або безпечного доступу (getOrNull). Тобто він зʼявляється не тому, що ми «любимо null», а тому, що пишемо стійкий код.

Давайте подивимося на мініприклад, дуже схожий на реальність: ми отримали список рядків, де хтось написав числа, а хтось — «ой». Ми хочемо отримати список чисел.

fun main() {
    val raw = listOf("10", "x", "7")
    val parsed = raw.map { it.toIntOrNull() }

    println(parsed) // [10, null, 7]
}

Результат формально чесний: List<Int?>. Але далі ви майже напевно захочете порахувати суму або порівняти елементи — і раптом виявиться, що це не Int, а Int?, тож просто так «додати» вже не вийде. Саме тут і зʼявляється «прибирання null» як окремий крок обробки даних.

2. mapNotNull: перетворюємо й одночасно відкидаємо null

Якщо map — це «перетвори кожен елемент», то mapNotNull — це «перетвори кожен елемент, але якщо вийшло null, просто відкинь цей результат». Звучить як невелике поліпшення, але на практиці воно економить чимало нервів: ви отримуєте список без null і можете працювати далі без постійних перевірок.

У документації стандартної бібліотеки цю ідею сформульовано прямо: якщо перетворення може давати null, замість map() використовуйте mapNotNull(). Це рівно наш випадок із toIntOrNull().

Почнемо з дуже маленького прикладу: будемо множити числа, але 2 вважаємо підозрілою й перетворюємо на null (просто щоб побачити механіку).

fun main() {
    val numbers = setOf(1, 2, 3)

    val result = numbers.mapNotNull { x ->
        if (x == 2) null else x * 3
    }

    println(result) // [3, 9]
}

Зверніть увагу на дві речі. По‑перше, mapNotNull повертає список, у якому немає null. По‑друге, тип результату стає не List<Int?>, а List<Int> — і це головний виграш.

Міні‑схема в голові

mapNotNull можна сприймати як «map + фільтрація за not-null», тільки записану коротше й зрозуміліше за змістом:

елемент T --(transform)--> R?  --(якщо null, відкинути)--> R

І це не філософія, а цілком практичний контракт функції: «перетворити й відкинути результати зі значенням null».

3. Парсинг і очищення «брудного» введення

Зараз буде ситуація, яка в консольних програмах трапляється частіше, ніж хотілося б: користувач увів щось не те. А наше завдання — не впасти, не влаштувати драму, а спокійно витягти звідти хоча б корисні шматочки.

toIntOrNull() — базовий інструмент безпечного парсингу: якщо рядок не є числом, він повертає null, а не кидає виняток. Цей патерн («прочитав → спробував розпарсити → отримав T?») ви вже бачили, і він вважається ідіоматичним для Kotlin.

Приклад: нормалізація + парсинг

Припустімо, у нас є «сирі» рядки: пробіли, порожні рядки, сміття. Ми хочемо отримати список чисел.

fun main() {
    val raw = listOf("10", " x ", " 7 ", "")
    val numbers = raw
        .map { it.trim() }
        .mapNotNull { it.toIntOrNull() }

    println(numbers) // [10, 7]
}

Тут важливо, що trim() — це просто «косметика», а ключовий крок — саме mapNotNull { it.toIntOrNull() }. Він перетворює список рядків на список чисел і відкидає все, що не розпарсилося.

Якщо ви раптом подумали: «а де повідомлення користувачеві, що він увів сміття?» — це справді слушне питання. Але в цій лекції ми тримаємо фокус на типах і чищенні даних. Повідомлення можна зробити окремим кроком (і найчастіше це справді окрема логіка, інакше ви отримаєте друк усередині map, а цього ми намагаємося уникати).

Чому не map { ... }.filterNotNull()?

Можна й так. Але mapNotNull кращий із двох причин: він коротший і точніше описує намір («я розумію, що з перетворення може вийти null, і я хочу його відкинути»). У документації це й подається як прямий інструмент для такого випадку.

Покажу обидва варіанти поруч на одному й тому самому прикладі (не як «правильно/неправильно», а як «пряміший спосіб висловити думку»).

fun main() {
    val raw = listOf("1", "oops", "3")

    val a = raw.map { it.toIntOrNull() }.filterNotNull()
    val b = raw.mapNotNull { it.toIntOrNull() }

    println(a) // [1, 3]
    println(b) // [1, 3]
}

Обидва дадуть один результат. Але mapNotNull читається як єдина операція: «парсимо числа, сміття ігноруємо».

Невелика блок-схема ланцюжка

Іноді корисно буквально намалювати, що відбувається:

flowchart TD
    A[Сирі рядки] --> B["trim()"]
    B --> C["toIntOrNull()"]
    C -->|Int| D[у список результатів]
    C -->|null| E[викинути]
    D --> F[List<Int>]

Ця схема — «внутрішній світ» mapNotNull у конкретному кейсі.

filterNotNull(): прибираємо null із уже nullable-колекції

Якщо mapNotNull рятує нас, коли null зʼявляється під час перетворення, то filterNotNull() корисний, коли null уже є в колекції й нам потрібно просто його вичистити. Це інший сценарій: ми нічого не перетворюємо, ми лише кажемо: «залиш тільки не‑null».

У документації з фільтрації колекцій окремо підкреслюється, що filterNotNull() повертає всі не‑null елементи і, що особливо приємно, звужує тип: з List<T?> виходить List<T>.

Приклад: список імен, де інколи трапляється null.

fun main() {
    val maybeNames: List<String?> = listOf("Ann", null, "Bob")
    val names: List<String> = maybeNames.filterNotNull()

    println(names) // [Ann, Bob]
}

Тут головний ефект навіть не в тому, що null зник, а в тому, що зник ? із типу. Тепер ви можете спокійно робити names.map { it.length }, і компілятор не буде щоразу нагадувати вам про тлінність буття.

Важлива межа: filterNotNull() не видаляє «порожнє», він видаляє саме null.

Це часта плутанина. Порожній рядок "" — це не null. Якщо у вас список listOf("", "Bob"), то filterNotNull() нічого не змінить. Він не про «погані дані», а саме про значення null.

4. Очищення даних у міні‑бюджет‑трекері

Зараз ми зробимо кілька невеликих кроків, які виглядають як «дрібниця», але на практиці перетворюють консольний застосунок із «зламався на першому ж введенні» на «живе в реальному світі».

Уявімо, що в нас уже є найпростіший трекер витрат, де витрата — це Triple(category, note, amount). Так, це не ідеально, але ми поки свідомо без ООП-моделей: нам важливіше навчитися обробляти колекції.

Парсимо «імпорт сум» одним рядком із mapNotNull

Припустімо, ми хочемо команду на кшталт: користувач вводить рядок із числами через пробіл, а ми додаємо їх як витрати в категорію "import". Помилкові шматки ігноруємо.

fun parseInts(line: String): List<Int> =
    line.split(" ").mapNotNull { it.trim().toIntOrNull() }

fun main() {
    val nums = parseInts("10  x  7   -3")
    println(nums) // [10, 7, -3]
}

Зверніть увагу: ми не робимо «розумних» правил (наприклад, не забороняємо відʼємні). Сьогодні ми тренуємо механіку mapNotNull. Правила валідації можна додати прямо сюди, повертаючи null, коли число «не підходить».

Ось як це виглядає, якщо ми хочемо пропускати все, що менше або дорівнює нулю:

fun parsePositiveInts(line: String): List<Int> =
    line.split(" ").mapNotNull { token ->
        val n = token.trim().toIntOrNull()
        if (n != null && n > 0) n else null
    }

fun main() {
    val nums = parsePositiveInts("10  x  7   -3  0")
    println(nums) // [10, 7]
}

Ключова думка: оскільки mapNotNull відкидає null, ви можете використовувати null як «сигнал відбракування». Це набагато зручніше, ніж накопичувати список вручну й писати купу if.

Витягуємо суми витрат, де частина записів може бути «битою», через filterNotNull

Тепер уявімо інший сценарій: у нас уже є список витрат, але десь по дорозі ми отримали «пошкоджені» елементи. Наприклад, у нас є функція, яка намагається розпарсити рядок команди у витрату й повертає Triple<String, String, Int>?: якщо не змогла — null.

Це дуже реалістично: парсер майже завжди повертає nullable-тип.

fun parseExpenseOrNull(line: String): Triple<String, String, Int>? {
    val parts = line.trim().split(" ")
    val amount = parts.getOrNull(2)?.toIntOrNull()
    if (parts.size < 3 || amount == null) return null
    return Triple(parts[0], parts[1], amount)
}

fun main() {
    val rawLines = listOf(
        "food pizza 500",
        "oops",
        "taxi ride 250"
    )

    val parsed: List<Triple<String, String, Int>?> = rawLines.map(::parseExpenseOrNull)
    val expenses: List<Triple<String, String, Int>> = parsed.filterNotNull()

    println(expenses.size) // 2
}

Тут filterNotNull() перетворює «список можливих витрат» на «список витрат напевно». І це той момент, коли код починає читатися як послідовність кроків: розпарсили → відкинули те, що не розпарсилося → далі працюємо з нормальними даними.

Сам контракт filterNotNull() якраз у цьому й полягає: залишити лише не‑null елементи й звузити тип до non-null.

Комбінація «очищення → перевірка» через mapNotNull + any

Тепер зберемо мініланцюжок, який справді корисний: «користувач увів щось, ми витягли числа й хочемо зрозуміти, чи є серед них великі суми».

fun main() {
    val line = "10  200  x  50"
    val hasBig = line.split(" ")
        .mapNotNull { it.trim().toIntOrNull() }
        .any { it >= 100 }

    println(hasBig) // true
}

Зверніть увагу, як добре поєднуються теми дня: спочатку mapNotNull повертає List<Int>, і лише потім any працює зі звичайними числами, без Int?.

5. Типові помилки під час роботи з mapNotNull і filterNotNull

Помилка № 1: використовувати map { toIntOrNull() } і далі поводитися так, ніби це List<Int>.
Це відбувається майже автоматично: ви очима бачите «числа», мозок радіє, а компілятор нагадує, що це Int?. На практиці це лікується одним рухом: якщо ви не хочете працювати з nullable-елементами, замінюйте map на mapNotNull (особливо під час парсингу). І так, це рівно той випадок, для якого mapNotNull і задуманий: «перетворення, яке може дати null».

Помилка № 2: очікувати, що filterNotNull() «почистить дані взагалі», включно з порожніми рядками, нулями та пробілами.
filterNotNull() — не «санітар», а радше «контролер на вході»: він прибирає тільки null. Порожній рядок — це рядок, і він залишиться. Якщо вам потрібно прибрати порожні рядки, це робиться окремим фільтром на кшталт filter { it.isNotEmpty() }. Змішувати ці поняття небезпечно: ви будете впевнені, що дані чисті, а потім здивуєтеся, чому "" раптом десь «проліз».

Помилка № 3: писати map { ... }.filterNotNull() там, де логічніше mapNotNull { ... }, і тим самим погіршувати читабельність.
Технічно це працює, але втрачається зміст. mapNotNull читається як цілісна операція: «перетвори й відкинь результати зі значенням null». Коли ви пишете map().filterNotNull(), читачеві треба спершу зрозуміти, що ви отримали nullable-результати, і лише потім побачити, що ви їх очищаєте. Це не катастрофа, але в навчальних і командних проєктах «читабельність за змістом» — половина перемоги.

Помилка № 4: намагатися повідомляти про помилки парсингу через println прямо всередині mapNotNull.
Іноді хочеться так: «якщо не розпарсилося — надрукую попередження». Так можна, але це вже побічний ефект усередині операції перетворення. Код стає менш передбачуваним (особливо якщо потім ви захочете повторно використати цей ланцюжок не в CLI, а, наприклад, у тестах). Зазвичай краще розділяти: або повертати null як сигнал «пропусти», або збирати помилки окремим способом. Якщо дуже хочеться — винесіть це в окрему функцію й уже там вирішуйте, друкувати чи ні.

Помилка № 5: використовувати toInt() замість toIntOrNull() у «брудних» даних і сподіватися, що все буде добре.
toInt() у разі сміття кидає виняток NumberFormatException, а toIntOrNull() повертає null, що ідеально поєднується з mapNotNull. Це не просто «стиль», це фундаментальна різниця в поведінці. Kotlin прямо показує безпечний шлях як альтернативу виняткам: toIntOrNull() замість падіння.

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