JavaRush /Курси /Kotlin SELF /try/catch навколо файлових операцій

try/catch навколо файлових операцій

Kotlin SELF
Рівень 45 , Лекція 2
Відкрита

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 або не ловити виняток тут і дати йому піти вище (інколи це теж нормальна стратегія).

Зручно тримати це порівняння в голові у вигляді таблиці:

Контракт функції Як виглядає Коли зручно Ціна помилки
T?
String?, List<String>?
Коли відсутність результату — нормальна гілка логіки Потрібно не забути обробити
null
Boolean
true/false
Коли дані не потрібні, важливо лише «чи вдалося» Діагностика слабша: чому
false
?
Прокинути виняток без 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 страхує реальну операцію.

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