1. Чому try/catch потрібен навіть після попередніх перевірок
Коли ви вперше додаєте попередні перевірки, легко подумати: «Ну все, я ж перевірив exists() і canRead() — отже, можна читати!» І тут Kotlin (та операційна система) мʼяко нагадують: «ха-ха, ні». Попередні перевірки кажуть лише те, що на момент перевірки усе виглядало пристойно. А от реальна операція readText() — це вже справжня спроба прочитати дані, і вона все одно може завершитися винятком.
Уявімо просту ситуацію: ви перевірили exists() == true, а через мілісекунду файл видалив (або перейменував) інший процес чи користувач. Це не фантастика — це звичайне життя на компʼютері.
import java.io.File
fun main() {
val file = File("data/input.txt")
if (file.exists()) {
// Між exists() і readText() файл може зникнути.
val text = file.readText()
println("OK: ${text.length}") // якщо пощастило
}
}
Якщо не пощастило — отримаєте виняток уже під час читання. І це нормально: try/catch — не заміна попереднім перевіркам, а другий барʼєр, який обовʼязково має стояти навколо реального I/O.
2. Вузький try: усередині — лише реальний I/O
Найпоширеніша помилка новачків виглядає так: «обгорну все в один великий try, щоб точно не впало». Це як одягнути бронежилет на холодильник: важко, незручно, а холодильнику — байдуже. Великий try робить код мутним: незрозуміло, що саме могло зламатися, і в catch ви часто намагаєтеся «якось продовжити», не розуміючи, у якому стані дані.
Антиприклад: занадто широкий try.
import java.io.File
import java.io.IOException
fun main() {
val file = File("data/amount.txt")
try {
val text = file.readText()
val amount = text.trim().toInt() // це вже НЕ I/O, це формат даних
println("amount=$amount")
} catch (e: IOException) {
println("Не вдалося прочитати файл")
}
}
Проблема тут тонка: toInt() може кинути NumberFormatException, а це не IOException. Тобто програма все одно впаде, але вже «якось дивно»: ви ж «усе обгорнули». А обгорнули не все — лише I/O-частину.
Правильніше розділити відповідальність: читання файла — це I/O й обробляється як IOException, а розбір тексту — це вже «помилка даних/формату», і вона має жити окремо (навіть якщо сьогодні ми фокусуємося саме на I/O).
import java.io.File
import java.io.IOException
fun readTextOrNull(file: File): String? =
try {
file.readText()
} catch (e: IOException) {
null
}
fun main() {
val file = File("data/amount.txt")
val text = readTextOrNull(file) ?: run {
println("Файл не прочитано: ${file.path}")
return
}
val amount = text.trim().toIntOrNull()
println("amount=$amount") // amount=123 або amount=null
}
Зверніть увагу на відчуття коду: читання відокремлене, try — вузький, а далі ви вже спокійно вирішуєте, що робити з умістом.
Невелика «мапа» відповідальності корисна навіть у консольних проєктах:
flowchart TD
A[CLI / main] --> B["loadRawText()"]
B -->|I/O: readText| C[(File)]
B -->|String? / помилка| A
A --> D[parse / validate]
D -->|помилка формату| A
3. try як вираз: повертаємо значення, а не «розвалюємося»
Kotlin уміє те, що робить обробку помилок помітно приємнішою: try/catch — це вираз, тобто він може повертати значення. Це дозволяє писати функції зі зрозумілим контрактом: «якщо вийшло — поверну результат, якщо ні — поверну запасний варіант». У Kotlin такий стиль вважається ідіоматичним: try повертає одне значення, catch — інше.
Найпростіший варіант для читання файла: «текст або null».
import java.io.File
import java.io.IOException
fun readTextOrNull(file: File): String? = try {
file.readText()
} catch (e: IOException) {
null
}
fun main() {
val file = File("data/input.txt")
val text = readTextOrNull(file)
println(text?.take(20) ?: "NO DATA") // наприклад: NO DATA
}
Так само для запису можна обрати контракт «успіх/неуспіх».
import java.io.File
import java.io.IOException
fun writeTextSafely(file: File, text: String): Boolean = try {
file.writeText(text)
true
} catch (e: IOException) {
false
}
fun main() {
val ok = writeTextSafely(File("data/out/report.txt"), "Hello!\n")
println("written=$ok") // written=true або written=false
}
Ця ідея здається простою, але вона дисциплінує програму: замість того щоб сподіватися «ну воно не впаде», ви заздалегідь проєктуєте: що повернемо в разі проблем.
4. Контракт функції при I/O: T?, Boolean або прокидання винятку
Коли ви ставите try/catch, ви щоразу ухвалюєте рішення: що побачить код, який викликає? Для навчальних консольних застосунків зазвичай вистачає трьох контрактів: повернути T?, повернути Boolean або не ловити виняток тут і дати йому піти вище (інколи це теж нормальна стратегія).
Зручно тримати це порівняння в голові у вигляді таблиці:
| Контракт функції | Як виглядає | Коли зручно | Ціна помилки |
|---|---|---|---|
|
|
Коли відсутність результату — нормальна гілка логіки | Потрібно не забути обробити |
|
|
Коли дані не потрібні, важливо лише «чи вдалося» | Діагностика слабша: чому ? |
| Прокинути виняток | без try/catch (або throw далі) | Коли без файла програма не має сенсу | Програма може впасти, якщо вище не обробили |
Важливо: ми не обираємо «правильно раз і назавжди». Ми обираємо те, що зручніше у вашому застосунку — у цій конкретній точці коду.
Наприклад, у нашому навчальному консольному застосунку нехай буде проста «скарбничка витрат»: ми зберігаємо витрати в текстовому файлі й показуємо короткий звіт. Якщо файл не прочитався, можна показати зрозуміле повідомлення та стартувати з порожнього списку — це чудовий кандидат на контракт List<String>?.
import java.io.File
import java.io.IOException
fun readLinesOrNull(file: File): List<String>? = try {
file.readLines()
} catch (e: IOException) {
null
}
fun main() {
val file = File("data/expenses.txt")
val lines = readLinesOrNull(file) ?: emptyList()
println("loaded=${lines.size}") // loaded=0, якщо файла немає/не читається
}
А от запис звіту можна зробити через Boolean: не записали — просто повідомимо.
5. Повідомлення про помилки: користувачу і вам
Повідомлення про помилку — це не художній твір і не «щось пішло не так». Хороше повідомлення відповідає щонайменше на три питання: що хотіли зробити, з чим саме (шлях) і який тип проблеми виник. Навіть без системи логування це вже дуже допомагає.
Тут корисно завести маленький форматер повідомлень для I/O. Він не має бути «розумним» — натомість має бути стабільним і однаковим у всьому застосунку.
import java.io.File
import java.io.IOException
fun ioErrorMessage(action: String, file: File, e: IOException): String {
val type = e::class.simpleName ?: "IOException"
val detail = e.message ?: "no details"
return "Не вдалося $action: ${file.path}. Причина: $type: $detail"
}
І використовувати його в catch:
import java.io.File
import java.io.IOException
fun readTextOrNullVerbose(file: File): String? = try {
file.readText()
} catch (e: IOException) {
println(ioErrorMessage("прочитати файл", file, e))
null
}
fun main() {
val text = readTextOrNullVerbose(File("data/input.txt"))
println(text?.length ?: "no text") // no text
}
Так, ми друкуємо прямо в консоль — і це нормально для навчального CLI. У більш серйозних проєктах ви б розділяли «повідомлення користувачу» та «діагностику для логів», але сьогодні ми свідомо не заглиблюємося в логування.
Кілька catch: порядок і сенс розділення
Інколи хочеться ловити все одним catch (IOException). Це нормально, але часто корисно відрізняти хоча б один окремий випадок: «файл не знайдено / не відкривається», тобто FileNotFoundException. Тоді ви можете дати більш людське повідомлення: «перевірте шлях» замість «ой, I/O».
Правило в Kotlin (і загалом на JVM): спочатку ловимо більш конкретне, потім більш загальне, інакше конкретне ніколи не спрацює.
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
fun main() {
val file = File("data/input.txt")
try {
val text = file.readText()
println("OK: ${text.length}") // OK: ...
} catch (e: FileNotFoundException) {
println("Файл не знайдено або не відкривається: ${file.path}")
} catch (e: IOException) {
println("Помилка введення/виведення під час читання: ${file.path}")
println("Деталі: ${e.message}")
}
}
Тут важливо не ускладнювати: ми винесли окремо лише найчастіший випадок, а все інше залишили під IOException.
6. Де ставити try/catch: межа відповідальності
Дуже хочеться ловити винятки в main, бо «там усе видно». Але зі зростанням застосунку це перетворюється на величезний комбайн: main і читає введення, і розбирає команди, і працює з даними, і ще ловить усі можливі проблеми. Хороший стиль — ловити I/O там, де воно відбувається, і повертати назовні зрозумілий результат.
Для нашого трекера витрат це означає: функції «сховища» самі обгортають readText()/writeText() у try/catch, а main отримує або дані, або сигнал «не вийшло».
Схема виходить проста:
flowchart TD
M[main: команди користувача] --> S[storage: read/write file]
S -->|List⟨String⟩? / Boolean| M
S -->|IOException усередині| S
Приклад мінісховища (без «магії» — коротко й явно):
import java.io.File
import java.io.IOException
fun loadExpensesOrNull(file: File): List<String>? = try {
file.readLines()
} catch (e: IOException) {
null
}
fun saveExpenses(file: File, lines: List<String>): Boolean = try {
file.writeText(lines.joinToString(separator = "\n"))
true
} catch (e: IOException) {
false
}
І використання в main без відчуття, що ви керуєте атомною станцією:
import java.io.File
fun main() {
val file = File("data/expenses.txt")
val lines = loadExpensesOrNull(file)
if (lines == null) {
println("Не вдалося завантажити витрати, стартуємо з порожнього списку.")
} else {
println("Завантажено записів: ${lines.size}") // наприклад: Завантажено записів: 12
}
}
Психологічно це важлива річ: main не зобовʼязаний знати, як саме ОС відмовила в читанні. main зобовʼязаний ухвалити рішення: продовжувати з порожніми даними, попросити користувача виправити шлях, завершитися — і так далі.
7. finally і use {}: коли вони потрібні
Слово finally часто звучить як «секретна техніка джедаїв». Насправді воно потрібне рівно для одного: гарантувати, що певний шматок коду виконається наприкінці, навіть якщо всередині try сталася помилка. У файлових задачах це особливо важливо для закриття ресурсів, якщо ви відкриваєте потоки або рідери вручну.
Але Kotlin дає ще зручніший стиль: use {}, який автоматично закриває ресурс (приблизно як finally, але акуратніше). Це вважається ідіоматичним підходом для AutoCloseable.
Ми не будемо сьогодні заглиблюватися в потоки, але маленький приклад корисний, щоб побачити межу: readText() не потребує use, а bufferedReader() — уже так.
import java.io.File
import java.io.IOException
fun readFirstLineOrNull(file: File): String? = try {
file.bufferedReader().use { reader ->
reader.readLine()
}
} catch (e: IOException) {
null
}
fun main() {
val first = readFirstLineOrNull(File("data/input.txt"))
println(first ?: "<empty>") // наприклад: <empty>
}
Запамʼятайте просте правило: якщо ви бачите ...Reader(), ...Writer(), InputStream, OutputStream — майже завжди поруч має бути use {} (або принаймні finally). Якщо ви використовуєте «готові» функції на кшталт readText()/writeText(), вони самі всередині відкривають і закривають ресурси, тож вам не потрібно вручну писати finally.
Невеликий нюанс Kotlin 2.x: компілятор став краще відстежувати інформацію smart-cast через catch/finally, тому в деяких сценаріях він акуратніше підказує, де змінна знову стає nullable. Це стосується поліпшень обробки винятків у K2.
8. Типові помилки при try/catch навколо файлових операцій
Помилка №1: величезний try, усередині якого і читання файла, і розбір даних, і половина бізнес-логіки.
Такий код поганий тим, що ви втрачаєте розуміння причини збою: це I/O, це формат чи це ваша логіка? До того ж у catch зʼявляється спокуса «доробити щось», хоча дані могли так і не завантажитися. Лікується просто: виносьте I/O у невеликі функції й тримайте try вузьким — навколо readText()/writeText() та близьких операцій.
Помилка №2: «глушити» виняток мовчки.
Повернути null або false — це нормально, але якщо ви взагалі нічого не повідомляєте, то за реальної проблеми ви налагоджуватимете «чому порожньо» замість «чому не прочиталося». Навіть у навчальному CLI корисно хоча б один раз вивести: дію, шлях, e.message. Інакше діагностика перетворюється на ворожіння.
Помилка №3: ловити Exception «про всяк випадок».
Це виглядає як турбота про стабільність, але частіше це стиль «замести під килим»: ви починаєте змішувати I/O-помилки, помилки формату й власні баги. У результаті програма «ніби працює», але поводиться дивно. Для файлів тримайте фокус на IOException (а інколи — на FileNotFoundException як окремому випадку), а інші помилки не ховайте без причини.
Помилка №4: повертати Boolean, але не давати жодної інформації «чому false».
Boolean зручний, коли вам важливий лише факт успіху. Але якщо вам усе-таки інколи потрібно розуміти причину, можна (у межах сьогоднішнього дня) хоча б друкувати повідомлення всередині catch, або повертати null/String? із текстом помилки. Головне — не створювати API, де false означає десять різних причин, і ви ніде не можете дізнатися, яку саме.
Помилка №5: неправильний порядок catch.
Якщо спочатку написати catch (e: IOException), а потім catch (e: FileNotFoundException), то другий блок стане недосяжним, бо FileNotFoundException — це окремий випадок IOException. Порядок має бути «спочатку конкретне, потім загальне» — як і в класичних правилах обробки винятків.
Помилка №6: продовжувати використовувати дані після неуспішного читання.
Це тонкий баг: ви спіймали виняток, вивели «не вдалося», але змінна лишилася, наприклад, порожнім рядком, і ви далі розбираєте його так, ніби це вміст файла. У підсумку отримуєте вторинну помилку (наприклад, NumberFormatException) і плутаєтеся, що зламалося насправді. Якщо читання не вдалося — повертайте null/false і явно припиняйте цей шлях виконання.
Помилка №7: вважати canRead()/canWrite() гарантією.
Ці перевірки корисні, але вони не обіцяють успіху: права можуть змінитися, файл може бути зайнятий, файлова система може «відвалитися» тощо. Тому правильна звʼязка виглядає так: попередні перевірки покращують повідомлення й дозволяють раніше відмовитися, а try/catch страхує реальну операцію.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ