JavaRush /Курсы /Kotlin SELF /Убираем null из коллекций и пайплайнов преобразований

Убираем null из коллекций и пайплайнов преобразований

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

1. null в коллекциях — это как песок в подшипниках

Коллекции и пайплайны (цепочки map/filter/...) обычно пишутся ради читаемости: «берём список → чистим → преобразуем → получаем результат». Но если внутри коллекции сидит null, то почти на каждом шаге появляется ?., ?:, или вообще волшебное !!, которое превращает код в квест «угадай, где упадёт». Kotlin заставляет нас быть честными: nullable-тип — это сигнал, что дальше нужно принять решение.

Представьте, что мы продолжаем наш учебный консольный проект (условно назовём его ExpenseTracker): он хранит расходы и строит отчёты. И вот мы решили обработать «сырой импорт» строк (например, строки пришли из внешнего источника или из истории команд).

Пусть формат строки такой: amount;category;note, например 120;food;pizza.

data class Expense(val amount: Int, val category: String, val note: String)

fun main() {
    val rawLines = listOf(
        "120;food;pizza",
        " ;transport;metro",
        "100; ;coffee",
        "oops;fun;bug"
    )

    println(rawLines.size) // 4
}

Проблема: в реальности часть строк «кривые». Мы можем пытаться тянуть их дальше как Expense?, но тогда почти каждая следующая операция будет вынуждена помнить про ?.

2. Два «плохих запаха»: List<T?> и nullable-пайплайн

Когда в коде появляются List<T?> или Map<K, V?>, это не всегда ошибка. Иногда это нормальная промежуточная форма. Но чаще это знак, что вы смешали две вещи: обработку входной грязи и осмысленную работу с данными. То есть вы хотели считать суммы по категориям, а в итоге обсуждаете философию: «а что означает null у третьего элемента списка?».

В Kotlin особенно важно заметить, где именно null появился.

List<T?> обычно появляется, когда вы делаете map { ... }, а внутри иногда возвращаете null. Kotlin стандартно поддерживает идею, что если преобразование может вернуть null, то дальше вы либо должны с ним жить, либо должны явно его убрать. Для этого есть отдельные инструменты — мы к ним сейчас придём.

Мини-иллюстрация «как всё начинается»:

fun parseAmountOrNull(text: String): Int? =
    text.trim().toIntOrNull()

fun main() {
    val rawAmounts = listOf("10", "  ", "oops", "25")

    val parsed: List<Int?> = rawAmounts.map { parseAmountOrNull(it) }
    println(parsed) // [10, null, null, 25]
}

Список уже стал List<Int?>. И если вы сейчас захотите сделать sum(), то не получится — и это правильно: «сумма чего?» (чего-то и null?) — математика начинает нервно курить в стороне.

3. filterNotNull(): убираем null и возвращаем нормальный тип

Когда вы точно знаете, что null в коллекции означает «не подходит / не получилось / пропускаем», самая простая стратегия — убрать null как можно раньше. Для этого существует filterNotNull(): после него тип коллекции меняется с List<T?> на List<T>, и вы снова можете пользоваться обычными операциями без ?. на каждом шаге.

Это хорошее место для дисциплины: «одна функция чистит, дальше работаем с чистыми данными». Тогда null не расползается по проекту, как вода по клавиатуре после кружки чая (которая точно стояла «в безопасном месте»).

fun main() {
    val parsed: List<Int?> = listOf(10, null, null, 25)

    val clean: List<Int> = parsed.filterNotNull()
    println(clean)         // [10, 25]
    println(clean.sum())   // 35
}

Здесь важно почувствовать разницу: filterNotNull() — это не просто «фильтр», это ещё и смена типа. Это одна из причин, почему он удобнее, чем filter { it != null } в «ручном стиле»: Kotlin понимает намерение и возвращает не-null коллекцию (в типах).

4. mapNotNull(): преобразовать и выбросить плохое за один шаг

Иногда filterNotNull() — это второй шаг, а хочется одним махом: «попробовать распарсить → если не получилось, выбросить». Для этого существует mapNotNull { ... }. Он буквально создан под сценарий: «обычный map, но если преобразование вернуло null, элемент не попадает в результат».

Вернёмся к нашему импорту расходов. Мы хотим из List<String> получить List<Expense>, пропуская плохие строки.

Сделаем маленькие функции-парсеры. Они возвращают T?, и это нормально: мы на границе «сырого ввода».

fun parseNonEmptyOrNull(raw: String): String? =
    raw.trim().takeIf { it.isNotEmpty() }

fun parseIntOrNull(raw: String): Int? =
    raw.trim().toIntOrNull()

Теперь парсер строки целиком:

fun parseExpenseLineOrNull(line: String): Expense? {
    val parts = line.split(";")
    if (parts.size != 3) return null

    val amount = parseIntOrNull(parts[0]) ?: return null
    val category = parseNonEmptyOrNull(parts[1]) ?: return null
    val note = parts[2].trim() // note пусть будет даже пустым, но не null

    return Expense(amount = amount, category = category, note = note)
}

И главное: mapNotNull.

fun main() {
    val rawLines = listOf(
        "120;food;pizza",
        " ;transport;metro",
        "100; ;coffee",
        "oops;fun;bug"
    )

    val expenses: List<Expense> = rawLines.mapNotNull { parseExpenseLineOrNull(it) }
    println(expenses.size) // 1
    println(expenses[0])   // Expense(amount=120, category=food, note=pizza)
}

Получилось ровно то, что мы хотели: null был «внутри» парсинга, но наружу в доменную коллекцию не попал. Пайплайн снова стал читабельным: rawLines.mapNotNull(::parseExpenseLineOrNull) — почти как декларация намерения.

5. Паттерн «шлюз»: чистим вход, дальше работаем с List<T>

У новичков часто бывает соблазн: «ну пусть будет List<Expense?>, а потом разберусь». Это работает ровно до первого отчёта, где вы внезапно пишете it?.amount ?: 0, потом ещё раз, потом ещё, и вот вы уже случайно посчитали сумму, где ошибки ввода превратились в нули. А ноль — это не «ошибка», это вполне себе число, которое тихо ломает статистику.

Правило, которое спасает психику: сделайте один явный этап нормализации/парсинга, а дальше работайте только с чистыми типами.

Схематично это можно представить так:

flowchart TD
    A["Сырые строки (List⟨String⟩)"] --> B["Нормализация (trim, split)"]
    B --> C["Парсинг (T?)"]
    C --> D["mapNotNull / filterNotNull"]
    D --> E["Чистые данные (List⟨T⟩)"]
    E --> F["Отчёты / сортировки / группировки"]

Теперь добавим маленькую практичную «ступеньку» в наш проект: построим отчёт по суммам категорий. У нас уже есть List<Expense>.

fun totalByCategory(expenses: List<Expense>): Map<String, Int> {
    return expenses
        .groupBy { it.category }
        .mapValues { (_, items) -> items.sumOf { it.amount } }
}

fun main() {
    val expenses = listOf(
        Expense(120, "food", "pizza"),
        Expense(80, "transport", "metro"),
        Expense(30, "food", "coffee")
    )

    println(totalByCategory(expenses)) // {food=150, transport=80}
}

Ключевое: здесь нет ни одного ?. И это не магия — это дисциплина «шлюза»: мы однажды превратили сырьё в Expense, и дальше живём как взрослые (иногда это больно, но эффективно).

6. Поиск в коллекциях: T? и как не размазать ?.

Когда вы ищете элемент в коллекции (find, firstOrNull, getOrNull), Kotlin честно говорит: «может не найтись» — держи T?. Это нормально: отсутствие результата — реальный сценарий.

И вот здесь типичная ошибка: вы нашли Expense?, а потом начинаете передавать его дальше как nullable, и через три функции у вас внезапно Expense? в самом центре программы.

Вместо этого обычно лучше сделать одно из двух: либо обработать отсутствие рядом с поиском, либо превратить отсутствие в понятное значение (через ?:, Result, sealed, или ранний return — выбор зависит от контракта).

Пример: ищем первый расход по категории.

fun findFirstByCategory(expenses: List<Expense>, category: String): Expense? =
    expenses.find { it.category == category }

fun main() {
    val expenses = listOf(Expense(120, "food", "pizza"))

    val found = findFirstByCategory(expenses, "transport")
    println(found) // null
}

А теперь «не размазываем null»: обработаем рядом.

fun printFirstExpenseNote(expenses: List<Expense>, category: String) {
    val expense = expenses.find { it.category == category } ?: run {
        println("Nothing found for category='$category'") // Nothing found for category='transport'
        return
    }

    println("Note: ${expense.note}") // (не выполнится, если не нашли)
}

Заметьте, мы сделали «шлюз» ещё раз: expense после Elvis (?:) становится non-null, и дальше код проще.

Кстати, если вы работаете с индексами, getOrNull() и похожие безопасные операции тоже возвращают nullable, и это ожидаемо: «элемент может отсутствовать».

7. Map<K, V?> в отчётах: как получить Map<K, V> без !!

Словари (Map) — это место, где nullable появляется «естественным образом»: map[key] возвращает V?, потому что ключа может не быть. И тут легко случайно получить Map<String, Int?> в вычислениях, если вы начали кодировать «нет данных» как null.

Рассмотрим пример: хотим посчитать средний расход по категории, но если в категории пусто — получится null (среднее не определено). Это как раз тот случай, когда null может быть смысловым промежуточным значением.

fun avgByCategoryOrNull(expenses: List<Expense>): Map<String, Int?> {
    return expenses
        .groupBy { it.category }
        .mapValues { (_, items) ->
            if (items.isEmpty()) null else items.sumOf { it.amount } / items.size
        }
}

Да, items.isEmpty() после groupBy обычно не случится (группа не будет пустой), но допустим, логика сложнее — идея нам важнее.

Теперь вопрос: как получить отчёт, где показываем только категории с определённым средним, и хотим Map<String, Int>?

Вариант «плохой, но популярный»:

// Не делайте так без железной уверенности:
val clean = avgMap.filterValues { it != null }.mapValues { it.value!! }

Здесь !! как раз и опасен: вы вручную обещаете компилятору, что null не будет. Лучше сделать «честно» через mapNotNull по entries:

fun onlyDefinedAvgs(avgMap: Map<String, Int?>): Map<String, Int> {
    return avgMap
        .entries
        .mapNotNull { (category, avgOrNull) ->
            avgOrNull?.let { avg -> category to avg }
        }
        .toMap()
}

Идея простая: мы превращаем Map в список пар, где пары создаются только если значение не null, а потом собираем обратно в Map. Да, чуть больше букв — зато ноль риска «упасть внезапно» и ноль скрытых обещаний.

Отдельно приятно, что mapNotNull здесь снова выражает намерение: «преобразую, но если null — выбрасываю».

8. Полезные нюансы для пайплайнов

takeIf / takeUnless: контролируем null в одном месте

Иногда null нужен как промежуточный сигнал: например, «строка после нормализации пустая — значит, значения нет». Делать для этого отдельный if можно, но в пайплайнах часто хочется компактнее. Тут и помогают takeIf и takeUnless: они возвращают значение, если условие истинно (или ложно), иначе возвращают null.

Это звучит как «мы сами создаём null», и да — но в этом и смысл: мы создаём его в одном понятном месте, где он означает ровно одно.

Пример: нормализуем заметку (note). Если после trim() пусто, считаем, что заметки нет, и хотим её отбросить из списка заметок.

fun normalizeNoteOrNull(raw: String): String? =
    raw.trim().takeIf { it.isNotEmpty() }

fun main() {
    val notes = listOf("  pizza ", "   ", "coffee", "")
    val cleanNotes = notes.mapNotNull { normalizeNoteOrNull(it) }

    println(cleanNotes) // [pizza, coffee]
}

Здесь takeIf — это маленький, аккуратный «генератор null по правилам», а mapNotNull — «пылесос», который сразу убрал этот null из результата. В итоге наружу ушёл List<String>, без nullable.

Мини-отчёт top categories без nullable в середине пайплайна

Теперь сделаем кусочек, который выглядит как реальный код в нашем ExpenseTracker: строим топ категорий по сумме, но хотим игнорировать строки импорта, которые не распарсились. Важно: мы не хотим List<Expense?> и не хотим Map<String, Int?> в финале.

Соберём всё в одном месте: «сырьё → mapNotNull(parse) → отчёт».

fun topCategories(rawLines: List<String>, topN: Int): List<Pair<String, Int>> {
    val expenses = rawLines.mapNotNull { parseExpenseLineOrNull(it) }

    return expenses
        .groupBy { it.category }
        .mapValues { (_, items) -> items.sumOf { it.amount } }
        .entries
        .sortedByDescending { it.value }
        .take(topN)
        .map { it.key to it.value }
}

fun main() {
    val rawLines = listOf(
        "120;food;pizza",
        "80;transport;metro",
        "oops;food;bug",
        "30;food;coffee"
    )

    val top = topCategories(rawLines, topN = 2)
    println(top) // [(food, 150), (transport, 80)]
}

Обратите внимание на приятную вещь: null был только внутри parseExpenseLineOrNull, а в «бизнес-части» отчёта его нет вообще. Это и есть практическая цель лекции: null должен жить там, где он объясним, и умирать там, где он мешает.

9. Типичные ошибки при удалении null из коллекций и пайплайнов

Ошибка №1: оставлять List<T?> «на потом», а потом писать it?.x ?: 0 в каждом отчёте.
Такой код выглядит как забота о безопасности, но на деле вы начинаете тихо «исправлять» данные, превращая ошибки в нули, пустые строки или другие значения по умолчанию. Это ломает смысл отчётов: вы уже не отличаете «нет данных» от «данные равны нулю». Лучше сделать один шлюз очистки (mapNotNull/filterNotNull) и дальше работать с List<T>.

Ошибка №2: использовать !! после фильтра «ну я же проверил».
Сценарий обычно такой: вы сделали фильтр, потом где-то чуть поменяли условие, добавили ещё одну ветку — и !! внезапно стал точкой падения. Даже если сейчас «всё очевидно», через неделю это будет неочевидно вам же. Если уж вы фильтруете null, делайте это так, чтобы тип стал non-null, или используйте ?.let { ... }, а не обещание через !!.

Ошибка №3: строить пайплайн, который на каждом шаге создаёт новые nullable-значения.
Например, map { ...? }map { it?.something }filter { it != null }map { it!! }. Это превращается в «лестницу из костылей». Обычно либо делайте mapNotNull сразу, либо разделите на два читаемых шага: val clean = ...filterNotNull() и затем val transformed = clean.map { ... }.

Ошибка №4: смешивать «не найдено» и «ошибка» в одном null.
В пайплайнах это особенно коварно: один null означает «строка пустая», другой — «не распарсилось число», третий — «не нашлась категория», и всё это в итоге просто null. Потом вы делаете filterNotNull() и теряете информацию, почему элемент исчез. Для отчётов это нормально, но для диагностики и UX — часто нет. Если причина важна, лучше возвращать Result/sealed и уже потом решать, что отбрасывать.

Ошибка №5: чистить null слишком поздно — уже после группировок и расчётов.
Если null попал внутрь groupBy, mapValues, сложных сортировок и top-N, то вы вынуждены делать проверки в середине пайплайна, где они хуже читаются. Чаще всего правильнее чистить раньше: «как можно раньше получить List<T>», а уже потом группировать и агрегировать.

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