JavaRush /Курсы /Kotlin SELF /Чем заменять null: предусловия, sealed‑результаты, Result...

Чем заменять null: предусловия, sealed‑результаты, Result

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

1. Зачем заменять null, если есть null-safety

Если вы пишете программу достаточно долго, вы замечаете одну коварную вещь: null — это не только «значения нет». На практике null часто начинает означать «что-то пошло не так, но мне лень объяснять». И вот тут начинается магия: вызывающий код получает T? и теперь обязан угадывать, что это было — «не найдено», «не введено», «ошибка парсинга», «отказано в доступе» или «разработчик забыл реализовать». Kotlin защищает нас от NPE, но он не может защитить от туманных контрактов.

Представьте, что у вас функция findExpenseById(id): Expense?. Это хороший и честный контракт: расхода с таким id может не быть. Но если у вас функция saveExpense(expense): Expense?, и она возвращает null при любой проблеме — это уже «чёрный ящик»: почему null? что делать дальше? повторить? показать сообщение? падать? И что особенно неприятно — по сигнатуре вы этого не узнаете.

В этой лекции мы разбираем три практичные замены null, и каждая из них отвечает на конкретный вопрос:

  • предусловия (require, check, requireNotNull, checkNotNull) отвечают на вопрос «это вообще можно вызывать вот так?»;
  • sealed‑результаты отвечают на вопрос «какие осмысленные исходы бывают у операции?»;
  • Result<T> отвечает на вопрос «как вернуть успех/ошибку как данные, не превращая всё в исключения и не теряя причину?».

2. Предусловия: require, check и *NotNull

Предусловия — это не «валидация пользовательского ввода», а защита контракта вашей функции. Это способ сказать: «Если сюда пришли плохие данные — это не “жизненная ситуация”, это ошибка использования API (или баг в коде выше). Я лучше упаду сразу, но с понятным сообщением, чем буду тащить мусор дальше и ломаться через 20 строк в странном месте».

Именно поэтому предусловия чаще всего живут внутри доменной логики, где уже «не должно быть плохих данных», и на границе между слоями, где вы хотите резко отделить «сырой мир» от «строгого мира».

require(...): проверяем аргументы

Небольшая «шпаргалка смысла»: require — это «ты неправильно вызвал функцию». Он бросает IllegalArgumentException.

Представим наш учебный CLI‑проект (практический проект курса) — трекер расходов. У нас есть модель:

data class Expense(
    val id: Int,
    val title: String,
    val amountCents: Int
)

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

fun createExpense(id: Int, title: String, amountCents: Int): Expense {
    require(id > 0) { "id must be positive" }
    require(title.isNotBlank()) { "title must not be blank" }
    require(amountCents > 0) { "amountCents must be positive" }

    return Expense(id = id, title = title.trim(), amountCents = amountCents)
}

Здесь важно, что require — это не «мягко обработать». Это «остановить программу, потому что кто-то явно ошибся». Если вы сейчас думаете: «Ну так нельзя же падать из‑за ввода пользователя!» — вы правы. Но это и не про ввод. Это про слой, где ввод уже должен был быть проверен.

check(...): проверяем состояние и инварианты

check — это «моя программа оказалась в невозможном состоянии». Он бросает IllegalStateException. В реальности check часто применяют, когда объект уже создан и должен быть корректным, но вы хотите защититься от логической ошибки.

Например, у нас есть репозиторий расходов, который хранит список. Мы ожидаем, что id уникальны. Если мы нашли два расхода с одним id, это не «ошибка пользователя», это ошибка нашей логики:

fun findUniqueById(items: List<Expense>, id: Int): Expense? {
    val matches = items.filter { it.id == id }
    check(matches.size <= 1) { "BUG: duplicate id=$id found (${matches.size} items)" }
    return matches.firstOrNull()
}

Да, тут всё ещё возвращается Expense?, потому что «не найдено» — это нормальная ситуация. Но мы одновременно защищаем инвариант «дубликатов быть не должно».

requireNotNull и checkNotNull: перевод T?T без !!

Очень частая боль: вы получили String? (например, из парсинга или из Map.get()), но в этой точке оно обязано быть не‑null. Вместо !! лучше использовать requireNotNull/checkNotNull — потому что они не только превращают T? в T, но и позволяют написать человеческое сообщение.

fun normalizeCommand(raw: String?): String {
    val text = requireNotNull(raw) { "Command line must not be null" }
    return text.trim()
}

В отличие от raw!!, здесь при падении вы увидите осмысленную причину, а не «NullPointerException где-то там».

Elvis и Nothing: «или значение, или мы падаем»

Ещё один полезный паттерн — «Elvis + функция, которая всегда падает». Это связывает тему предусловий с типом Nothing: выражение throw ... имеет тип Nothing, и поэтому отлично работает в правой части Elvis. В документации Kotlin это показывают на примере fail(message): Nothing, который всегда бросает исключение.

3. sealed‑результаты вместо null

Иногда проблема не в том, что «значения нет». Проблема в том, что исходов у операции несколько, и каждый имеет смысл. Если вы кодируете всё через null, вы теряете смысл и вынуждаете вызывающего угадывать.

sealed class позволяет сделать контракт таким: «результат всегда есть, но он бывает разным». И Kotlin заставляет вас обработать все варианты (исчерпывающий when), если вы не оставляете лазейку else.

Удаление по id: несколько нормальных исходов

Удаление расхода — отличный пример: возможны разные исходы. Например:

  • удалили успешно;
  • такого id нет;
  • id некорректный (например, <= 0) — и это скорее ошибка использования API.

Некорректный id мы защитим require, а «нет такого расхода» — выразим как вариант результата.

sealed class RemoveExpenseResult {
    data object Removed : RemoveExpenseResult()
    data object NotFound : RemoveExpenseResult()
}

fun removeExpenseById(items: MutableList<Expense>, id: Int): RemoveExpenseResult {
    require(id > 0) { "id must be positive" }

    val index = items.indexOfFirst { it.id == id }
    if (index == -1) return RemoveExpenseResult.NotFound

    items.removeAt(index)
    return RemoveExpenseResult.Removed
}

Обратите внимание: тут нет null вообще. Возвращается конкретный смысл.

Теперь CLI‑слой может красиво сформировать сообщение пользователю:

fun describeRemove(result: RemoveExpenseResult, id: Int): String = when (result) {
    RemoveExpenseResult.Removed -> "Removed expense id=$id"
    RemoveExpenseResult.NotFound -> "No expense with id=$id"
}

when исчерпывающий, потому что sealed закрывает набор вариантов. Если вы добавите новый вариант — компилятор сам напомнит, где вы забыли обработать. Это такой «будильник качества», который невозможно игнорировать (в отличие от комментария TODO обработать ошибки потом).

Паттерн Ok/Error, но без T? внутри Ok

Многие любят шаблон Ok/Error. Он удобен, но тут есть типичная ловушка: сделать Ok(value: T?). Тогда вы снова тащите nullable внутрь «успеха», и вся идея теряется.

Сделаем результат добавления расхода в репозиторий. Допустим, у нас запрещены дубликаты id.

sealed class AddExpenseResult {
    data class Ok(val added: Expense) : AddExpenseResult()
    data class Error(val message: String) : AddExpenseResult()
}

fun addExpense(items: MutableList<Expense>, expense: Expense): AddExpenseResult {
    val exists = items.any { it.id == expense.id }
    if (exists) return AddExpenseResult.Error("Expense with id=${expense.id} already exists")

    items.add(expense)
    return AddExpenseResult.Ok(added = expense)
}

Здесь Ok гарантированно содержит не‑null Expense. А Error несёт текст ошибки как данные (не как println внутри функции — об этом мы ещё поговорим в «типичных ошибках»).

Почему sealed лучше null для «ошибка/не найдено»

Самая частая причина выбрать sealed вместо T? — когда у вас два разных смысла отсутствия:

  • «не найдено» (нормально);
  • «ошибка» (нужно сообщить, возможно — с причиной).

Если вы сделаете Expense?, вы не сможете отличить «не нашли» от «не смогли из-за ошибки валидации/логики». Да, можно «печатать в консоль» — но тогда функция становится не тестируемой и шумной. Лучше вернуть смысл в типах.

4. Result<T>: ошибка как значение

Result<T> — это стандартный контейнер Kotlin: либо успех с T, либо неуспех с Throwable. Удобен он там, где ошибка естественно выражается исключением (например, парсинг числа через toInt()), но вам хочется вернуть её как данные, не делая try/catch в каждом углу.

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

Парсинг: Result<Int> вместо Int?

В ранних днях курса мы часто делали toIntOrNull() и получали Int?. Это нормально, когда вам достаточно факта «получилось/не получилось». Но иногда вам важна причина: пустая строка? не число? слишком большое? отрицательное? И вы хотите это различать.

Result позволяет сохранить исключение и добавить свои проверки через mapCatching.

fun parsePositiveInt(raw: String): Result<Int> {
    return runCatching { raw.trim().toInt() }
        .mapCatching { value ->
            require(value > 0) { "Expected a positive integer" }
            value
        }
}

Здесь есть важный момент: мы используем require внутри mapCatching, и если условие не выполнено — это тоже попадёт в Failure. Это удобно, потому что вызывающий код получает единый формат обработки.

Result в CLI‑слое трекера расходов

Представим, что команда выглядит так: add <id> <title> <amountCents>. Мы читаем строку, разбиваем, пытаемся распарсить.

fun parseAddArgs(parts: List<String>): Result<Triple<Int, String, Int>> {
    return runCatching {
        val id = parts[1].toInt()
        val title = parts[2]
        val amount = parts[3].toInt()
        Triple(id, title, amount)
    }
}

Да, тут может вылететь IndexOutOfBoundsException или NumberFormatException, и они аккуратно окажутся внутри Result.

Дальше мы переводим этот результат в понятное сообщение:

fun describeParse(result: Result<Triple<Int, String, Int>>): String {
    return result.fold(
        onSuccess = { "Parsed: id=${it.first}, title=${it.second}, amount=${it.third}" },
        onFailure = { "Bad command arguments: ${it.message ?: "unknown error"}" }
    )
}

Это пример, где Result полезен именно на границе ввода: мы хотим не падать, но и не молчать.

Почему Result<T?> почти всегда плохая идея

Комбинация Result<T?> означает: «операция могла завершиться ошибкой, а если не ошибкой, то ещё и значения может не быть». Это редко бывает хорошим контрактом, потому что вы создаёте две независимые оси неопределённости: сначала разберись, был ли exception, потом разберись, почему null.

Обычно лучше выбрать что-то одно:

  • если «может не быть» — используйте T? или sealed Found/Missing;
  • если «может сломаться» — используйте Result<T> или sealed Ok/Error.

И только если вам реально нужно различать «успех, но пусто» от «ошибка» (например, запрос в кэш: может не быть записи, но это не ошибка) — тогда Result<T?> иногда оправдан. Но это уже решение, которое требует очень чёткой документации контракта.

5. Как выбрать подход

Когда вы впервые начинаете заменять null, появляется новая опасность: заменить его везде сразу и сделать код «слишком умным». Поэтому полезно держать в голове простую схему выбора: что именно означает «нет значения» в вашем месте, и кто виноват — пользователь или программист.

Ниже — практичная таблица (не «академическая истина», а рабочий ориентир):

Ситуация Лучший инструмент Почему
Внутри кода это значение обязано быть (нарушение — баг/неправильный вызов)
require* / check*
Fail‑fast: падение сразу и в понятном месте
Операция имеет несколько смысловых исходов (нашли/не нашли, ок/ошибка, доступ запрещён, конфликт)
sealed class
Типы заставляют обработать все варианты, не теряем смысл
Ошибка естественно выражается исключением, но вы хотите «ошибка как данные»
Result<T>
Удобно на границах, сохраняем
Throwable

Теперь важная мысль про архитектуру: эти подходы можно комбинировать, но делать это стоит по слоям.

В нашем трекере расходов это выглядит логично так: парсинг пользовательского ввода возвращает Result, потому что ввод — грязный и непредсказуемый. Доменная операция возвращает sealed‑результат, потому что там важны смысловые исходы («дубликат id»). А предусловия защищают инварианты и контракты функций там, где «так быть не должно».

Собираем всё в один сценарий

Сейчас соберём кусочки в один маленький поток. Пусть у нас есть команда добавления расхода. Ввод мы парсим через Result, модель создаём через require‑защиту, в репозитории возвращаем sealed‑результат.

fun handleAddCommand(items: MutableList<Expense>, parts: List<String>): String {
    val parsed = parseAddArgs(parts)
    return parsed.fold(
        onSuccess = { (id, title, amount) ->
            val expense = createExpense(id, title, amount)
            when (val addRes = addExpense(items, expense)) {
                is AddExpenseResult.Ok -> "Added: ${addRes.added}"
                is AddExpenseResult.Error -> "ERROR: ${addRes.message}"
            }
        },
        onFailure = { "ERROR: bad arguments (${it::class.simpleName})" }
    )
}

Здесь много интересного, хотя код короткий: мы не используем null как «универсальный ответ». Каждый слой говорит своим языком:

  • парсер говорит «успех/исключение» (Result);
  • доменная фабрика говорит «либо создаём корректно, либо это баг/нарушение контракта» (require);
  • репозиторий говорит «успех или ошибка бизнес‑правила» (sealed Ok/Error).

И, что важно, вызывающий код не гадает. Он просто обрабатывает варианты.

6. Типичные ошибки

Ошибка №1: использовать requireNotNull как “валидацию ввода пользователя”.
Когда пользователь вводит что-то не так, это нормальная жизненная ситуация, а не повод падать с IllegalArgumentException. Предусловия уместны там, где плохие данные означают баг или неправильное использование функции, а не «пользователь ошибся в цифре». Для ввода лучше Result или sealed Error.

Ошибка №2: делать sealed‑результат, но прятать смысл обратно в null.
Классика жанра: sealed class Result { data class Ok(val value: T?) ... }. На бумаге вы «ушли от null», а на деле просто перепаковали null в коробку с надписью “Ok”. Если у вас Ok, там должно быть полноценное значение. Если значения может не быть — это отдельный вариант (Missing, NotFound).

Ошибка №3: возвращать null, а причину печатать через println внутри функции.
Такой код сложно тестировать, сложно переиспользовать и невозможно нормально обрабатывать на уровне UI/CLI. Функция должна либо возвращать понятный результат с причиной (текст/тип ошибки), либо падать (если это предусловие), но не «шептать в консоль и исчезать в туман».

Ошибка №4: превращать Result в “глушитель ошибок”.
runCatching легко использовать как ковёр, под который заметётся всё. Если вы делаете runCatching { ... }.getOrNull() и молча игнорируете Failure, вы снова приходите к null, только теперь ещё и потеряли причину. Если уж выбрали Result, уважайте его: обрабатывайте onFailure, добавляйте контекст, формируйте сообщения.

Ошибка №5: смешивать стили без договорённости и получать “три уровня неопределённости”.
Например, функция возвращает Result<User?>, где null значит «не найдено», а Failure значит «ошибка», а ещё внутри User есть name: String? “на всякий случай”. В итоге вы вынуждены проверять всё три раза. Обычно лучше сделать одну ясную модель: либо sealed Found/Missing, либо Result<User>, а внутри доменной модели — строгие non‑null поля.

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