JavaRush /Курсы /Kotlin SELF /try/catch как выражение

try/catch как выражение

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

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 как выражение), а уже потом выполнять действия, когда входные данные подтверждены.

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