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) "позитивний" else "непозитивний"

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, а в catchString, компілятор швидко дасть зрозуміти: він не телепат і не збирається вгадувати, «що це взагалі має бути». Іноді спільним типом може стати 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 "Використання: remove <index>"
    }

    return "Уявімо, що ми видалили елемент №$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) { "має бути > 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()
        "Витрата(amount=$amount, category=$normalizedCategory)"
    } catch (e: NumberFormatException) {
        "Погане введення"
    }

Тут усередині try і парсинг, і нормалізація рядка, і форматування. Якщо щось піде не так (а рядки вміють дивувати), ви зловите лише NumberFormatException. Але читач коду мимоволі сприйматиме як «небезпечне» все, що всередині.

Більш охайний стиль (вузький try + зрозуміле складання результату):

fun formatExpenseLine(amountText: String, category: String): String {
    val amount = try {
        amountText.toInt()
    } catch (e: NumberFormatException) {
        return "Некоректна сума: '$amountText'"
    }

    val normalizedCategory = category.trim().lowercase()
    return "Витрата(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: залогувати щось") // 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 як вираз), а вже потім виконувати дії — коли вхідні дані підтверджені.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ