1. Введение
Когда мы пишем функцию, мы обычно думаем: «Она получает параметры и возвращает результат». Но реальная жизнь быстро вмешивается и приносит подарки: «ввод не число», «не найдено», «операция невозможна», «файл не открылся», «состояние сломано». И вот тут начинается взрослая часть программирования: нужно договориться, как функция сообщает об ошибке.
Этот договор и называется контрактом ошибок: какие ситуации считаются ошибками, какие из них ожидаемы, и в какой форме они возвращаются вызывающему коду.
Контракт важен потому, что без него код начинает жить «по настроению». В одном месте вы возвращаете null, в другом кидаете исключение, в третьем — печатаете println("Ошибка") и идёте дальше, как будто ничего не произошло. Такое поведение особенно опасно в больших программах: ошибка либо «прячется», либо всплывает слишком поздно и в странном месте.
Полезная мысль, которую стоит зафиксировать: ошибка — это не “просто упало”. Ошибка — это ситуация, когда функция не может выполнить обещание своего контракта.
2. Основные способы сигнализировать об ошибке
Когда вы выбираете стратегию обработки ошибок, у вас есть два основных инструмента: исключения и ошибка как значение.
Исключение
Исключение прерывает обычный поток выполнения: «я не могу продолжать, пусть кто-то снаружи решает». Это мощный механизм, но он похож на пожарную сигнализацию: отлично, когда реально пожар, но странно, когда вы так сообщаете, что закончился сахар.
«Ошибка как значение»
«Ошибка как значение» означает, что функция возвращает не только «успех», но и «неуспех» обычным возвращаемым значением. Мы уже делали это через sealed class с вариантами Ok и Error: вызывающий код обязан посмотреть, что вернулось, и обработать.
Полезно представлять разницу так:
flowchart TD
A[Вызов функции] --> B{Что произошло?}
B -->|Успех| C[Вернули значение]
B -->|Ожидаемая проблема| D[Вернули Error как значение]
B -->|Авария или логическая невозможность| E[Бросили исключение]
И вот тут ключ: выбор между D и E — это не «как проще написать», а какой смысл у ситуации.
3. Fail-fast проверки: require(...) и check(...)
Kotlin даёт очень удобные инструменты для fail-fast поведения: require(...) и check(...). Они похожи внешне, но отличаются смыслом.
require(...): проблема во входных данных
Частая ситуация: функция получает аргументы, и вы заранее знаете, что некоторые значения бессмысленны. Например, «процент от общего» при total = 0, или «сумма расхода» меньше нуля. Это не штатная ветка сценария, это некорректный вызов функции.
В таких местах идеально подходит require(...): он проверяет предусловие и, если оно не выполнено, бросает исключение про неверный аргумент.
Мини-пример:
fun percent(part: Int, total: Int): Int {
require(total > 0) { "total must be > 0, got $total" }
require(part >= 0) { "part must be >= 0, got $part" }
return part * 100 / total
}
fun main() {
println(percent(2, 5)) // 40
}
Обратите внимание на стиль сообщения: мы пишем не «ой», не «ошибка», а формулируем ожидание и фактическое значение: got .... Это экономит часы отладки, особенно когда вы забудете, что писали эту функцию, и вернётесь к ней через месяц (то есть завтра).
Важно понять философию require: это не «обработка ошибки», это запрет на продолжение, потому что вход не удовлетворяет контракту. То есть мы не пытаемся «как-нибудь пережить» некорректные аргументы — мы честно говорим: «так нельзя».
check(...): проблема в состоянии
Если require(...) — про «вызывающий код дал плохие аргументы», то check(...) — про «внутри программы состояние такое, что этого не должно происходить». Это уже ближе к багу: логика где-то нарушила инвариант.
Kotlin прямо разделяет эти случаи: check(...) кидает исключение про некорректное состояние.
Представим кусочек нашего практического проекта (условно назовём его ExpenseTracker), где у нас есть «текущая открытая сессия» (например, активный бюджетный месяц). Если код пытается добавить расход, а активный период не выбран — это не «пользователь виноват аргументами функции addExpense(...)», это состояние приложения не готово.
class Session(var activeMonth: String?)
fun addExpense(session: Session, title: String) {
check(session.activeMonth != null) { "Active month is not selected" }
println("Added expense '$title' for month ${session.activeMonth}") // пример
}
fun main() {
val s = Session(activeMonth = "2026-01")
addExpense(s, "Coffee") // Added expense 'Coffee' for month 2026-01
}
Если activeMonth не выбран, продолжать «как будто всё нормально» опасно. Лучше упасть сразу и громко, чтобы разработчик (то есть вы) увидел проблему в тесте/отладке, а не получил «тихий неверный отчёт» в продакшене.
4. Ожидаемые ошибки и результат Ok/Error
Иногда ситуация неприятная, но ожидаемая: пользователь ввёл несуществующую команду, попросил удалить расход по id, которого нет, или запросил отчёт по категории, которой пока не существует.
Это не похоже на «аварию». Это похоже на «нормальная ветка жизни приложения»: пользователь может ошибиться, данные могут отсутствовать, и это не повод устраивать фейерверк исключений.
В таких случаях удобнее возвращать результат в виде явного значения, например:
sealed class CmdResult<out T> {
data class Ok<T>(val value: T) : CmdResult<T>()
data class Error(val message: String) : CmdResult<Nothing>()
}
Почему это удобно?
Потому что вызывающий код видит контракт прямо в типе: функция может вернуть ошибку, и вы обязаны её обработать. Это почти как дорожный знак «Осторожно, поворот»: можно проигнорировать, но потом не удивляйтесь.
Мини-пример: «найти расход по id». Если не нашли — это не авария, это ожидаемо.
data class Expense(val id: Int, val title: String)
fun findExpense(expenses: List<Expense>, id: Int): CmdResult<Expense> {
val found = expenses.find { it.id == id }
?: return CmdResult.Error("Expense not found: id=$id")
return CmdResult.Ok(found)
}
fun main() {
val items = listOf(Expense(1, "Coffee"))
println(findExpense(items, 2)) // Error(message=Expense not found: id=2)
}
Здесь важная идея: «не нашли» — это не исключение, потому что отсутствие элемента часто является частью сценария. В консольном приложении вы обычно превратите Error в понятный текст пользователю и продолжите цикл команд.
Почему null — не универсальная «ошибка»
Очень хочется сделать так: «если что-то пошло не так — верну null». Это соблазнительно, потому что быстро и не надо объявлять новые типы. Но у этого подхода есть цена: null почти ничего не говорит о причине.
null хорош для ситуации «значения нет» или «не найдено», когда причина не важна или очевидна. Например, Map[key] возвращает V?, и это нормально: «по ключу может не быть значения».
Но как только вам важно различать причины, null начинает вредить. Представьте два случая:
- пользователь ввёл пустую строку
- пользователь ввёл "12a"
В обоих случаях toIntOrNull() вернёт null. А пользователю (и вам при отладке) полезно понимать разницу.
Контракт «мягкий, без причины» (иногда норм):
fun parseAmountOrNull(text: String): Int? = text.toIntOrNull()
fun main() {
println(parseAmountOrNull("12a")) // null
}
Контракт «явная ошибка с сообщением» (часто лучше для CLI):
fun parseAmount(text: String): CmdResult<Int> {
val trimmed = text.trim()
if (trimmed.isEmpty()) return CmdResult.Error("Amount is empty")
val n = trimmed.toIntOrNull() ?: return CmdResult.Error("Amount is not a number: '$text'")
if (n <= 0) return CmdResult.Error("Amount must be > 0, got $n")
return CmdResult.Ok(n)
}
Здесь мы не используем исключения, потому что ошибки ожидаемы: пользователь вводит данные руками, а руки иногда живут своей жизнью.
5. Граница ответственности: слои приложения
Сейчас будет важный архитектурный момент, без которого тема ошибок превращается в спор вкусов. В большом проекте граница между исключениями и «ошибкой как значением» часто совпадает с границей между слоями.
В нашем курсе мы уже мысленно делили программу на части: CLI (ввод/вывод), domain (логика команд), storage (хранение). И выбор формы ошибок логично привязать к месту.
Смотрите на такую таблицу как на «договор команды разработчиков» (даже если команда — это вы и ваш кот):
| Слой | Что делает | Типичные проблемы | Как чаще выражать |
|---|---|---|---|
| CLI | читает строки, печатает ответы | «пользователь ввёл ерунду» | ошибка как значение (Ok/Error), иногда null |
| Domain | правила предметной области (команды, проверки) | «нет такого расхода», «нельзя удалить последний элемент» | ошибка как значение (Ok/Error), require/check для внутренних гарантий |
| Storage | файлы, БД, сеть | «не открылся файл», «нет прав», «сломанный формат» | часто исключения, потому что это I/O и аварии среды |
Почему storage часто оставляют на исключениях? Потому что «файл не открылся» — это не всегда ожидаемая ветка логики предметной области. Это сбой окружения. Вы всё равно должны решить, что делать (сообщить пользователю, завершить программу, предложить путь), но внутри низкоуровневого кода проще и честнее бросить исключение и обработать его на границе сценария.
А вот в domain-слое часто удобнее возвращать Ok/Error, потому что это «правила игры» приложения: пользователь может попросить удалить несуществующий id — это ожидаемо.
6. Практика: команда remove и её контракт
Давайте соберём маленький фрагмент, который показывает контракт вживую. Представим, что CLI-слой уже распарсил id (как число), и мы в domain-слое выполняем удаление расхода.
Сценарий: если id не найден — это не исключение, а Error. Если список пустой, и мы пытаемся удалять — тоже Error. Но если нам передали отрицательный id, это уже похоже на баг в коде выше по стеку (CLI обязан был отвалидировать), и здесь можно использовать require.
data class Expense(val id: Int, val title: String)
fun removeExpense(expenses: MutableList<Expense>, id: Int): CmdResult<Expense> {
require(id > 0) { "id must be > 0, got $id" }
val index = expenses.indexOfFirst { it.id == id }
if (index == -1) return CmdResult.Error("No expense with id=$id")
val removed = expenses.removeAt(index)
return CmdResult.Ok(removed)
}
fun main() {
val expenses = mutableListOf(Expense(1, "Coffee"))
println(removeExpense(expenses, 2)) // Error(message=No expense with id=2)
}
Что здесь важно.
Некорректный аргумент (id <= 0) считается нарушением контракта вызова и оформлен как fail-fast через require(...). А ожидаемая ситуация «id не найден» возвращается как Error, потому что это штатная ветка сценария.
7. Как выбирать подход: практическая логика
На практике решение удобно принимать не по принципу «я люблю исключения» или «я люблю sealed-классы», а по нескольким приземлённым вопросам.
- Если ситуация ожидаема и встречается регулярно (например, пользователь ввёл неверную команду), то исключение превращается в шум. Вам придётся в каждом месте использования писать try/catch, а код начнёт выглядеть как сериал «Поймай меня, если сможешь». Для ожидаемых веток почти всегда лучше подходит «ошибка как значение».
- Если после проблемы вы можете продолжить работу (например, показать сообщение и попросить повторить ввод), то «ошибка как значение» естественнее: вы остались в обычном потоке управления, просто пошли по другой ветке.
- Если же ситуация аварийная или говорит о нарушении логики программы, то продолжать опасно. Тут исключение (или require/check/error) оправдано: оно заставляет нас не делать вид, что всё хорошо.
- И наконец, если вы находитесь на границе со «средой» (файлы, сеть, права доступа), то исключение часто оказывается честнее: это сбой, который трудно встроить как обычную ветку предметной логики. В таких местах обычно ловят исключение на верхнем уровне сценария и превращают его в понятное сообщение пользователю.
8. Две крайности и почему они вредны
Есть две крайности, и обе регулярно встречаются у новичков.
Первая крайность — «исключения для всего». Тогда любой чих пользователя превращается в try/catch-джунгли. В какой-то момент вы ловите Exception (потому что иначе уже невозможно жить), и это превращается в универсальный мешок, куда падают и реальные аварии, и ожидаемые ошибки ввода. Отладка такого кода обычно выглядит как археологические раскопки: вы нашли stack trace, но не уверены, это баг или пользователь нажал не ту клавишу.
Вторая крайность — «никаких исключений вообще, только Error(message)». Это тоже опасно: вы можете случайно замести под ковёр ситуацию, где программа уже не должна продолжать (например, нарушен инвариант данных). Тогда вместо честного падения вы получите «тихое неправильное состояние», а дальше отчёты будут неверными, данные — кривыми, а пользователь начнёт подозревать, что у вас в приложении живёт полтергейст.
Зрелый подход — смешанный, но согласованный: на одном уровне вы выбираете одну политику и придерживаетесь её.
Что будет дальше
Сегодняшняя лекция — про смысл и договорённости. Мы пока сознательно работаем с простыми инструментами: require/check и собственный Ok/Error.
Уже в следующих лекциях дня мы научимся осознанно писать try/catch как выражение (то есть «вернуть значение из try или из catch») и увидим более стандартные способы упаковывать ошибки в значение. Kotlin действительно поддерживает стиль, где try возвращает значение, и это удобно, когда вы выбираете политику «при ошибке вернуть дефолт или сообщение».
Фундамент остаётся тем же: сначала вы решаете, что считать ошибкой и как она должна выглядеть для вызывающего кода, и только потом выбираете синтаксис.
9. Типичные ошибки
Ошибка №1: превращать ожидаемые ситуации в исключения.
Если пользователь ввёл неизвестную команду, это не «программа сломалась», это «пользователь попросил то, чего нет». Когда вы оформляете такие ситуации исключениями, код быстро зарастает try/catch, а настоящие аварии теряются среди «ошибок-как-веток». Лекарство простое: всё, что ожидаемо в сценарии, возвращайте как Ok/Error и обрабатывайте обычным when.
Ошибка №2: использовать null как универсальную ошибку.
null отлично говорит «значения нет», но почти не говорит «почему». В результате вы вынуждены либо додумывать причину, либо печатать общее «не удалось». Если причина важна пользователю (а в CLI она почти всегда важна), лучше вернуть Error("понятное сообщение"), чем null.
Ошибка №3: применять require/check не по смыслу.
Иногда новички используют require(false) просто «чтобы упасть». Формально работает, но смысл теряется. require должен выражать предусловие аргументов, а check — инвариант состояния. Это делает код самодокументируемым: по одной строке видно, кто «ответственный» за корректность (вызывающий код или внутренняя логика).
Ошибка №4: “глушить” ошибку и делать вид, что всё нормально.
Самая коварная ошибка — когда программа продолжает работу после проблемы, хотя уже не должна. Например, удаление несуществующего id вы «молча» игнорируете, и пользователь думает, что удаление прошло. Правильнее возвращать Error с сообщением или завершать сценарий, если продолжение бессмысленно.
Ошибка №5: смешивать разные контракты в одной функции.
Когда одна и та же функция иногда кидает исключение, иногда возвращает Error, иногда возвращает null, вызывающий код превращается в детектив: «а что мне проверять на этот раз?». Старайтесь, чтобы у функции был один ясный контракт: либо она сигнализирует проблемы значениями (Ok/Error), либо она падает исключением (и это должно быть оправдано смыслом), либо возвращает T? в строго ограниченных случаях «не найдено» или «нет значения».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ