1. Зачем превращать try/catch в выражение
Когда вы только знакомитесь с исключениями, try/catch выглядит как «скобки безопасности»: обернул опасный код — и вроде не падает (если повезёт). Но как только программа становится чуть сложнее, начинается типичная боль: в одном месте вы парсите число, в другом — делите, в третьем — проверяете предусловия, и везде хочется получить значение (или понятный запасной вариант), а не «кучу var и флагов».
Kotlin поддерживает аккуратный стиль: try — это выражение, то есть он может возвращать значение из try или из catch. Это считается идиоматичным подходом.
Представьте, что вы делаете CLI-приложение (наш практический проект v1) и читаете команды пользователя. Пользователь может ввести сумму расхода как 120, а может как сто двадцать (романтично, но компьютеру не близко). Варианта два: либо вы пишете лапшу из переменных и if, либо вы превращаете «попробовать распарсить» в выражение и получаете аккуратный результат.
2. try/catch как выражение: синтаксис и типы
Выражения в Kotlin — это не высшая математика
Перед тем как трогать try, важно ещё раз поймать мысль: в Kotlin очень многие конструкции могут возвращать значение. Вы уже видели это на if и when, где можно делать так:
val label = if (x > 0) "positive" else "non-positive"
try/catch работает похоже: он тоже может вернуть значение. Причём возвращаемое значение определяется последним выполненным выражением внутри try или внутри подходящего catch.
Это даёт нам стиль: «посчитал → вернул» прямо внутри конструкции, без промежуточных переменных. И код начинает выглядеть как рассказ, а не как протокол заседания.
Базовый синтаксис: val x = try { ... } catch { ... }
Ключевой фрагмент, который вы будете использовать ещё много раз: try в Kotlin можно присваивать в val. Это нормально и ожидаемо: «использовать try-catch как выражение».
Простейший пример — «распарси число, иначе верни 0»:
fun parseOrZero(text: String): Int =
try {
text.toInt()
} catch (e: NumberFormatException) {
0
}
fun main() {
println(parseOrZero("42")) // 42
println(parseOrZero("сорок")) // 0
}
Обратите внимание на маленькую, но важную деталь: и try, и catch заканчиваются значением одного и того же типа (Int). Благодаря этому весь try/catch — тоже Int.
Если вы вдруг сделаете так, что в try возвращается Int, а в catch возвращается String, компилятор быстро даст понять, что он не телепат и не хочет угадывать «что это вообще должно быть». Иногда общий тип может стать Any, но обычно это сигнал «вы делаете что-то странное», и читателю кода потом будет грустно.
Мини-таблица: откуда берётся значение try-выражения
| Что произошло в try | Что вернёт выражение try/catch |
|---|---|
| Ошибки не было | последнее выражение блока try |
| Ошибка была и поймана | последнее выражение подходящего блока catch |
Когда try — весь смысл функции: return try { ... } catch { ... }
Иногда функция настолько простая, что её реально можно описать одним try/catch. Тогда можно писать вообще без промежуточных переменных и даже без val.
Выглядит так:
fun safeDivide(a: Int, b: Int): Int =
try {
a / b
} catch (e: ArithmeticException) {
0
}
fun main() {
println(safeDivide(10, 2)) // 5
println(safeDivide(10, 0)) // 0
}
Идея простая: «если деление невозможно — возвращаем запасной вариант». Конечно, выбор «0» — это часть контракта функции. В некоторых задачах это нормально, в некоторых — опасно (потому что 0 может выглядеть как корректный результат). Важно, что синтаксис позволяет выразить решение коротко и читаемо.
3. Практика: парсим команды в CLI
Аккуратный парсинг аргументов команды
Теперь давайте привяжем это к нашему консольному приложению (практический проект v1). Допустим, у нас есть команда:
add <amount> <category>
Пользователь вводит строку, мы её разбиваем на части, и нам нужно аккуратно получить число. Да, вы знаете toIntOrNull(), но сегодня мы тренируем именно try/catch как выражение, потому что иногда падает не только toInt(), а ещё и доступ по индексу, и какие-то другие вещи.
Сделаем маленькую утилиту: «попробовать достать токен и распарсить его в Int»:
fun parseAmountToken(tokens: List<String>, index: Int): Int? =
try {
tokens[index].toInt()
} catch (e: Exception) {
null
}
fun main() {
val tokens = listOf("add", "120", "food")
println(parseAmountToken(tokens, 1)) // 120
println(parseAmountToken(tokens, 9)) // null
println(parseAmountToken(listOf("add", "сто"), 1)) // null
}
Здесь мы вернули Int?: если всё хорошо — число, если нет — null. Это «мягкий» контракт: вызывающий код сам решит, что делать дальше (показать подсказку пользователю, попросить повторить ввод и так далее). В прошлых днях мы уже обсуждали, что null уместен для «не получилось получить значение», особенно когда причина не принципиальна.
Но вот важный момент: ловить Exception — часто плохая идея. Сейчас мы сделали это ради демонстрации «в одном месте могут упасть разные штуки». Чуть дальше обсудим, как делать это точнее и безопаснее.
«Прочитал → попытался обработать → сформировал ответ»
В нашем проекте мы уже старались отделять ввод/CLI от «домена» и хранения. Сейчас нас интересует одна практичная вещь: команда пользователя почти всегда приходит строкой, а строка почти всегда умеет быть «креативной».
Сделаем маленькую функцию, которая пытается обработать команду remove <index> и возвращает текст для пользователя. Мы специально не полезем в файлы/БД (это будет позже по курсу), просто покажем стиль.
fun handleRemoveCommand(line: String): String {
val tokens = line.trim().split(" ").filter { it.isNotBlank() }
val index = try {
tokens[1].toInt()
} catch (e: Exception) {
return "Usage: remove <index>"
}
return "Pretend we removed item #$index"
}
fun main() {
println(handleRemoveCommand("remove 3")) // Pretend we removed item #3
println(handleRemoveCommand("remove x")) // Usage: remove <index>
println(handleRemoveCommand("remove")) // Usage: remove <index>
}
Технически здесь мы снова ловим слишком широко (Exception). Но с точки зрения архитектуры чтения кода пример полезен: try — это просто способ выразить «попробовать получить значение, иначе — быстро выйти с понятным сообщением».
Чуть лучше (и честнее) было бы разделить два случая: «нет токена» и «не число». Но сегодня наш фокус — увидеть стиль try как выражения и как части логики с ранним выходом.
4. Нюансы и стиль: как не испортить читаемость
Несколько catch: порядок имеет значение
Когда начинаешь писать «взрослые» catch, быстро выясняется: ошибок бывает несколько, и обрабатывать их хочется по-разному. Kotlin позволяет писать несколько блоков catch, и первое подходящее совпадение «победит». При этом порядок важен: более конкретные исключения должны идти раньше, более общие — позже.
Пример на уровне идеи (без привязки к бизнес-логике):
fun parseAndValidatePositive(text: String): Int? =
try {
val n = text.toInt()
require(n > 0) { "must be > 0" }
n
} catch (e: NumberFormatException) {
null
} catch (e: IllegalArgumentException) {
null
}
fun main() {
println(parseAndValidatePositive("10")) // 10
println(parseAndValidatePositive("x")) // null
println(parseAndValidatePositive("-5")) // null
}
Здесь вы видите два разных источника проблем: toInt() может бросить NumberFormatException, а require(...) — IllegalArgumentException. Мы явно проговорили это в коде. Такой код читается лучше, чем «один большой catch (e: Exception) и там магия».
Держим try маленьким: «опасный участок» должен быть коротким
Одна из самых частых причин «нечитабельного кода с исключениями» — огромный try, в который завернули половину функции. Тогда непонятно: что именно могло упасть? что именно мы хотим обработать? и не ловим ли мы случайно что-то важное, что надо было бы не ловить?
Хороший стиль — держать try максимально узким: только вокруг участка, который реально может бросить исключение, и который вы реально хотите обработать.
Сравним два стиля.
Плохой стиль (слишком широкий try):
fun formatExpenseLine(amountText: String, category: String): String =
try {
val amount = amountText.toInt()
val normalizedCategory = category.trim().lowercase()
"Expense(amount=$amount, category=$normalizedCategory)"
} catch (e: NumberFormatException) {
"Bad input"
}
Здесь внутри try и парсинг, и нормализация строки, и форматирование. Если что-то пойдёт не так (а строки умеют удивлять), вы поймаете только NumberFormatException, но читатель кода будет думать, что «всё это опасно».
Более аккуратный стиль (узкий try + понятная сборка результата):
fun formatExpenseLine(amountText: String, category: String): String {
val amount = try {
amountText.toInt()
} catch (e: NumberFormatException) {
return "Bad amount: '$amountText'"
}
val normalizedCategory = category.trim().lowercase()
return "Expense(amount=$amount, category=$normalizedCategory)"
}
Да, кода стало на пару строк больше. Зато теперь чётко видно, где риск, какая именно ошибка ожидается, и почему ранний return тут уместен.
finally: это не «ещё один способ вернуть значение»
Про finally у новичков часто возникает «волшебная» идея: мол, можно в finally поправить результат или «точно вернуть правильное значение». Увы (или к счастью), так нельзя и не нужно.
finally — это блок, который выполняется в любом случае: была ошибка или нет. Он существует для действий «уборки» (cleanup). При этом он не меняет результат try-catch как выражения.
Покажем на маленьком примере:
fun divideWithLog(a: Int): Int {
try {
return 44 / a
} catch (e: ArithmeticException) {
return -1
} finally {
println("cleanup: log something") // cleanup: log something
}
}
fun main() {
println(divideWithLog(0)) // cleanup: log something
// -1
}
Даже если вы сделали return в try или в catch, finally всё равно выполнится. Это полезно, когда нужно гарантированно закрыть ресурс, вернуть флаг в исходное состояние, записать лог.
Важно помнить и формальное правило: try нельзя писать «сам по себе», к нему должен прилагаться хотя бы catch или finally. То есть конструкция try { ... } без всего не скомпилируется.
Нюанс Kotlin 2.x: smart cast в catch/finally стал честнее
Это небольшой, но приятный момент именно для Kotlin 2.x (а у нас курс на Kotlin 2.3). Раньше компилятор мог иногда вести себя слишком оптимистично со smart cast вокруг try/catch, а в Kotlin 2.0+ обработку улучшили, чтобы информация о типах корректнее «переживала» catch и finally.
Практический смысл для новичка простой: если у вас есть переменная nullable-типа, и внутри try вы её меняете, то в catch компилятор будет осторожнее и чаще потребует ?. (и это хорошо, потому что он защищает вас от ложной уверенности).
В этой лекции мы не будем специально усложнять примеры nullable-логикой, но если вы увидите, что компилятор «вдруг» просит safe-call в catch, — это не вредность, это дисциплина.
5. Типичные ошибки при использовании try/catch как выражения
Ошибка №1: делать try огромным, «на всякий случай».
Когда в try попадает половина функции, код перестаёт отвечать на главный вопрос: «Что именно мы пытаемся защитить?» В итоге вы ловите не только ожидаемую ошибку (например, формат числа), но и случайные проблемы, которые лучше бы не скрывать. Лечится просто: try должен оборачивать минимальный рискованный участок.
Ошибка №2: ловить Exception, когда можно поймать конкретный тип.
catch (e: Exception) звучит как «я поймал вообще всё, я молодец». На практике это часто означает «я случайно спрятал баг». Если вы ожидаете конкретную проблему (например, NumberFormatException при toInt()), ловите её. При нескольких catch важно располагать их от более конкретных к более общим.
Ошибка №3: пытаться использовать finally для выбора результата.
finally не предназначен для того, чтобы «подменять» возвращаемое значение. Он про действия, которые должны выполниться в любом случае, и при этом он не меняет результат try/catch как выражения. Если вам нужно выбрать значение — выбирайте его в try/catch, а finally оставьте для уборки.
Ошибка №4: возвращать «дефолт» просто чтобы не падало, не думая о смысле.
catch { 0 } выглядит красиво, но иногда это тихая катастрофа: программа продолжает работать с неверными данными. Дефолт должен быть частью контракта и реально иметь смысл. Если смысла нет — лучше вернуть null, понятное сообщение пользователю или завершить сценарий.
Ошибка №5: смешивать в одном try и обработку ошибки, и бизнес-логику.
Если вы в try и парсите ввод, и меняете состояние приложения, и печатаете отчёт, то при исключении вы получаете «полу-сделанное действие». В консольных приложениях это особенно неприятно: пользователь видит, что «что-то произошло», но не понимает, насколько. Лучше сначала получить значения (через try как выражение), а уже потом выполнять действия, когда входные данные подтверждены.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ