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, а в 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 "Використання: 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 як вираз), а вже потім виконувати дії — коли вхідні дані підтверджені.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ