1. Введение
Когда вы только начинаете программировать, try/catch кажется чем-то вроде пожарного огнетушителя: висит на стене, пусть будет. А когда пишете реальный код (даже консольный), внезапно выясняется, что «огнетушитель» нужен постоянно: парсинг чисел, проверки предусловий через require, доступ к коллекциям, работа с внешними данными — всё это потенциальные источники исключений.
Проблема не в том, что try/catch плохой. Проблема в том, что он быстро разрастается: появляются лишние переменные, вложенные блоки, и код начинает напоминать лабиринт. Kotlin помогает тем, что try/catch является выражением (можно присвоить результат в val). Но всё равно хочется более «функциональной» формы: получить результат, а дальше работать с ним цепочкой — как мы привыкли с коллекциями (map, filter и т.д.).
Вот здесь и появляется runCatching: он превращает «код, который может бросить исключение» в значение типа Result<T>, с которым можно работать как с обычным объектом: достать значение, обработать ошибку, напечатать сообщение, вернуть дальше наверх.
2. Что делает runCatching на самом деле
Если говорить честно, runCatching — это не «новая система обработки ошибок». Это всего лишь компактная упаковка try/catch в Result. Он не решает за вас, что считать ошибкой и где её обрабатывать. Он просто меняет форму: было «исключение как управление потоком», стало «значение Result».
Важно держать в голове ещё одну деталь: try/catch в Kotlin возвращает значение последнего выражения в try или в сработавшем catch, а finally выполняется всегда, но не влияет на вычисленное значение. Эта модель очень похожа по идее: «внутри что-то произошло → на выходе получился результат».
Представьте, что runCatching концептуально делает примерно это (упрощённо):
fun <T> myRunCatching(block: () -> T): Result<T> =
try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
Синтаксис runCatching { ... } позволяет не писать всё это каждый раз.
Мини-пример на понятной задаче — парсинг числа:
fun parseIntViaTry(text: String): Int? {
return try {
text.toInt()
} catch (e: NumberFormatException) {
null
}
}
А теперь то же самое через runCatching, только мы возвращаем не Int?, а Result<Int>:
fun parseIntResult(text: String): Result<Int> {
return runCatching { text.toInt() }
}
Смысловая разница большая: Int? говорит только «получилось/не получилось», но не объясняет почему. Result<Int> хранит исключение, то есть сохраняет информацию о причине ошибки.
3. Как достать значение из Result, не превращая код в кашу
На этом этапе у новичков обычно возникает желание сделать так: «О, Result! Значит, я сейчас достану значение и пойду дальше». И это нормально — просто важно делать это осознанно, потому что в этот момент вы выбираете политику обработки ошибок.
В Kotlin try/catch как выражение уже приучил нас, что «всё заканчивается значением». Result — такая же идея, только значение может быть «успех» или «ошибка».
Давайте посмотрим на самые практичные способы извлечения:
fun main() {
val r1 = runCatching { "123".toInt() }
println(r1.getOrNull()) // 123
val r2 = runCatching { "oops".toInt() }
println(r2.getOrNull()) // null
}
getOrNull() хорош, когда вам действительно достаточно «получилось/нет». Но есть риск: вы получили null и забыли, что внутри лежала ошибка. Это почти как выключить сигнализацию, потому что она громко пищит.
getOrElse { ... } — вариант, где вы задаёте fallback-значение:
fun main() {
val amount = runCatching { "oops".toInt() }
.getOrElse { 0 }
println(amount) // 0
}
Это удобно, но опасно, если 0 меняет смысл программы. Иногда 0 — нормальный дефолт, иногда — «мы случайно записали нулевой расход и потом удивляемся отчётам».
И есть «жёсткий» вариант — getOrThrow(): он возвращает значение или снова бросает исключение. Это полезно, если на данном уровне вы не хотите принимать решение и считаете ошибку аварийной.
4. onSuccess и onFailure: ветвление без if, но с дисциплиной
Когда вы видите onSuccess { ... } и onFailure { ... }, мозг радостно думает: «Ура, я могу писать обработку красиво цепочкой!» И это правда. Но у этих методов есть важная особенность: они предназначены для побочных эффектов (напечатать, обновить состояние, записать сообщение), а не для «превращения ошибки в значение».
То есть onSuccess и onFailure не заменяют вам getOrElse и не «чинят» ошибку. Они просто дают удобное место, где можно сделать что-то по исходу.
Мини-пример:
fun main() {
runCatching { "42".toInt() }
.onSuccess { println("Успех: $it") } // Успех: 42
.onFailure { println("Ошибка: ${it.message}") }
runCatching { "x".toInt() }
.onSuccess { println("Успех: $it") }
.onFailure { println("Ошибка: ${it.message}") } // Ошибка: For input string: "x"
}
Обратите внимание на психологический эффект: даже если мы пока не решили, что делать дальше, мы хотя бы не потеряли факт ошибки — она проявилась в явном месте.
И здесь появляется ключевой принцип лекции: пустой onFailure { } — это почти всегда тревожный звоночек. Если вы написали обработчик ошибки, который ничего не делает, вы создали «чёрную дыру», куда исчезают причины проблем.
5. runCatching в консольном приложении: парсинг команды
Сейчас будет важный момент: мы не пишем runCatching «просто потому что можно». Мы используем его как инструмент внутри конкретного слоя, чтобы код стал проще, но контракт ошибок остался понятным.
Представим, что наш практический проект — консольный трекер расходов BudgetBuddy. Он уже умеет принимать команды вроде:
- add 120 food
- list
- remove 3
(Мы не будем усложнять список команд — нам здесь важна техника обработки ошибок, а не функциональность приложения.)
Схематично поток выглядит так:
flowchart TD
A["readln()"] --> B["parseCommand(line)"]
B -->|Result.Success| C["executeCommand(cmd)"]
B -->|Result.Failure| D[печать ошибки пользователю]
C --> E[печать результата]
Идея: парсинг команды — идеальное место для runCatching, потому что там много потенциальных исключений (toInt(), require(...)), а результат удобно вернуть наверх как Result.
Сделаем маленькую модель команды добавления:
data class AddExpenseCommand(
val amount: Int,
val category: String,
)
Теперь парсер, который возвращает Result<AddExpenseCommand>:
fun parseAddCommand(tokens: List<String>): Result<AddExpenseCommand> {
return runCatching {
require(tokens.size >= 3) { "Usage: add <amount> <category>" }
val amount = tokens[1].toInt()
val category = tokens[2]
AddExpenseCommand(amount = amount, category = category)
}
}
Здесь есть важная «магия без магии»: require(...) при провале бросает исключение (обычно IllegalArgumentException), а toInt() может бросить NumberFormatException. Мы не ловим их руками — мы даём им случиться, а runCatching упакует их в Result.failure.
Обработка результата в CLI через onSuccess/onFailure
Теперь давайте покажем, как это выглядит в консольном слое. Мы читаем строку, режем её на токены и пробуем распарсить.
Этот кусок важен тем, что именно здесь чаще всего рождается «глушение»: разработчик увидел ошибку, устал, сделал getOrElse { ... }, и программа перестала падать… но начала вести себя странно.
Сделаем аккуратный обработчик:
fun handleLine(line: String) {
val tokens = line.trim().split(" ").filter { it.isNotBlank() }
if (tokens.isEmpty()) return
when (tokens[0]) {
"add" -> parseAddCommand(tokens)
.onSuccess { cmd ->
println("Добавляю расход: ${cmd.amount}, категория=${cmd.category}")
}
.onFailure { e ->
println("Ошибка команды add: ${e.message}")
}
else -> println("Неизвестная команда: ${tokens[0]}")
}
}
Почему это хорошо: ошибка не исчезает. Мы явно сообщаем пользователю, что именно было не так. По сути, onFailure здесь — это наш «человеческий переводчик» ошибок.
И да, это не заменяет контракт: если на этом уровне ошибка критична, мы бы не печатали сообщение, а, возможно, завершали программу. Но для CLI-команд парсинг часто является ожидаемой ошибкой («пользователь ввёл не то») — значит, сообщение и продолжение работы выглядят адекватно.
6. Глушение ошибок: как оно выглядит и почему это больно
Слово «глушение» звучит как что-то из мира автосервисов: «Сейчас мы вам заглушим ошибку, и лампочка “Check Engine” перестанет бесить». Лампочка и правда перестанет… а двигатель нет.
В программировании «глушение» обычно случается в двух формах.
Первая форма — вы сделали getOrNull() и дальше игнорируете null, подставляя дефолт, который ломает смысл:
fun parseAmountUnsafe(text: String): Int {
return runCatching { text.toInt() }.getOrNull() ?: 0
}
Если 0 допустим — может быть нормально. Но если 0 означает «бесплатно» или «расхода нет», вы только что незаметно для себя поменяли бизнес-логику. Пользователь ввёл add abc food, а вы добавили расход 0 food. И всё «успешно». Пугающе успешно.
Вторая форма — «пустой обработчик»:
fun parseAmountSilently(text: String): Result<Int> {
return runCatching { text.toInt() }
.onFailure { /* тишина */ }
}
Снаружи кажется, что вы «обработали ошибку», но нет — вы просто сделали её невидимой.
Как защититься? Почти всегда помогают два правила.
Первое правило: если вы делаете fallback (getOrElse), он должен быть осмысленным, и желательно сопровождаться сообщением пользователю. Второе правило: если вы не готовы принимать решение здесь, не принимайте — верните Result выше или пробросьте исключение.
Пример «осмысленного fallback с объяснением»:
fun parseAmountWithMessage(text: String): Int {
return runCatching { text.toInt() }
.getOrElse { e ->
println("Некорректная сумма '$text': ${e.message}")
0
}
}
Да, это всё ещё fallback. Но теперь хотя бы понятно, почему он случился.
7. Когда runCatching не лучший выбор: лучше честный try/catch
Очень соблазнительно взять runCatching и обернуть им «вообще всё». Но тогда вы превращаете программу в набор контейнеров, где ошибки ездят по конвейеру и нигде не выходят на поверхность.
Кроме того, runCatching ловит Throwable (то есть потенциально слишком широкий спектр проблем). Иногда вам нужно поймать строго конкретную ошибку и обработать её как ожидаемую, а всё остальное — не трогать. В таких случаях try/catch с конкретным типом исключения проще читается и надёжнее по смыслу. И Kotlin прямо показывает базовую форму try/catch и напоминает, что try/catch может быть выражением, возвращающим значение.
Например, если вы хотите считать «не число» ожидаемой ошибкой, а всё остальное пусть падает (как авария), то честный try/catch иногда понятнее:
fun parseIntOrZero(text: String): Int {
return try {
text.toInt()
} catch (e: NumberFormatException) {
0
}
}
Это читается прямолинейно: «если не число — верни 0». А вот runCatching здесь добавляет лишний слой абстракции, который не всегда нужен.
8. Типичные ошибки при использовании runCatching
Ошибка №1: оборачивать в runCatching слишком большой кусок логики.
Когда в runCatching попадает «половина функции», вы теряете понимание, какая именно операция могла упасть. Потом в onFailure вы видите исключение, но контекст размывается: непонятно, это toInt(), это require, это доступ к списку или что-то ещё. Лекарство простое: оборачивайте минимальный участок, который реально может бросить исключение, особенно если дальше вы хотите дать человеку понятное сообщение.
Ошибка №2: писать onFailure { } «на всякий случай».
Пустой onFailure — это почти буквальное «я знаю, что может быть ошибка, но мне всё равно». Так появляются баги, которые невозможно воспроизвести: программа иногда делает странности, но логов нет, сообщений нет, и исключение никто не видел. Если вы уж добавили onFailure, сделайте там хотя бы минимальную реакцию: сообщение, возврат Error, прекращение сценария — любое осмысленное решение.
Ошибка №3: превращать любую ошибку в дефолт без анализа (getOrElse { 0 } повсюду).
Иногда кажется, что дефолт — это добро: программа «не падает». Но если дефолт ломает смысл, вы просто переносите ошибку дальше, где она вылезет в отчётах, итогах и пользовательских данных. Это тот случай, когда «не упало» хуже, чем «упало сразу»: потому что вы сохранили неправильное состояние.
Ошибка №4: путать смену формы и обработку.
runCatching меняет форму ошибки: из исключения делает Result.failure. Но это не обработка. Обработка — это когда вы приняли решение: продолжать/остановиться, вернуть пользователю сообщение, вернуть Error наружу, пробросить исключение. Если после runCatching вы нигде не приняли решение и не показали ошибку — она, скорее всего, просто исчезла.
Ошибка №5: смешивать Result<T> и свой sealed-результат без причины.
В проекте часто есть своя конвенция Ok/Error (например, в доменном слое). Result тоже «успех/ошибка», но с другой философией. Если вы начнёте возвращать то Result, то Ok/Error в соседних функциях без чёткого правила, код станет трудно читать. Обычно хорошо работает подход: внутри слоя можно использовать runCatching и Result, а на границе слоя превращать это в ваш стабильный контракт (Ok/Error) с понятным сообщением.
Ошибка №6: считать, что runCatching — это аналог валидации.
runCatching ловит исключения. Но многие проверки лучше выражаются не через исключения, а через явную валидацию (например, if/when и возврат Error). Если вы постоянно используете исключения для управления обычными ветками логики, вы усложняете чтение кода. Исключения хороши, когда произошло действительно «что-то не так», а не когда пользователь просто ввёл лишний пробел.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ