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, появляется новая опасность: заменить его везде сразу и сделать код «слишком умным». Поэтому полезно держать в голове простую схему выбора: что именно означает «нет значения» в вашем месте, и кто виноват — пользователь или программист.
Ниже — практичная таблица (не «академическая истина», а рабочий ориентир):
| Ситуация | Лучший инструмент | Почему |
|---|---|---|
| Внутри кода это значение обязано быть (нарушение — баг/неправильный вызов) | |
Fail‑fast: падение сразу и в понятном месте |
| Операция имеет несколько смысловых исходов (нашли/не нашли, ок/ошибка, доступ запрещён, конфликт) | |
Типы заставляют обработать все варианты, не теряем смысл |
| Ошибка естественно выражается исключением, но вы хотите «ошибка как данные» | |
Удобно на границах, сохраняем |
Теперь важная мысль про архитектуру: эти подходы можно комбинировать, но делать это стоит по слоям.
В нашем трекере расходов это выглядит логично так: парсинг пользовательского ввода возвращает 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 поля.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ