1. Чому файлові операції ламаються
Коли ви пишете код, дуже хочеться вірити, що компʼютер — це слухняний калькулятор: сказали «прочитай файл» — він прочитав. Насправді компʼютер більше схожий на кота: інколи він робить вигляд, що вас не існує. Файл могли видалити або перемістити. Доступ могли закрити. Теку могли замінити файлом (так, таке буває). На диску могло закінчитися місце, антивірус міг заблокувати операцію, а на Windows файл може бути «зайнятий» іншим процесом.
Ключова думка: I/O (input/output) — це зона підвищеної невизначеності. В арифметиці 2 + 2 майже завжди 4. А з файлами «прочитай текст» часто означає «спробуй домовитися з операційною системою, файловою системою, правами доступу та поточним станом диска». І якщо «домовитися» не вдалося — ви отримуєте виняток.
exists() — не гарантія
Найпоширеніша логічна пастка новачків звучить так: «Я перевірив exists(), отже readText() точно спрацює». На жаль, ні. Між перевіркою та реальною операцією читання світ може змінитися. Файл могли видалити, права могли змінитися, каталог могли перейменувати, диск міг «відвалитися» (так, інколи буквально).
Це називають класичним ефектом «перевірив — і лише потім дізнався правду». Тому правило просте й трохи філософське: перевірки корисні для діагностики та ранніх повідомлень, але try/catch навколо реального I/O все одно потрібен.
Поганий (наївний) код:
import java.io.File
fun main() {
val file = File("data/input.txt")
if (file.exists()) {
val text = file.readText() // усе одно може впасти
println(text.length)
} else {
println("Файлу немає")
}
}
Більш дорослий мінімум (перевірка + захист):
import java.io.File
import java.io.IOException
fun main() {
val file = File("data/input.txt")
println("Перед читанням: exists=${file.exists()}")
try {
val text = file.readText()
println("Прочитано: ${text.length}") // наприклад: Прочитано: 120
} catch (e: IOException) {
println("Читання не вдалося: ${file.path}")
}
}
Ідея не в тому, щоб «усе обмазати перевірками». Ідея в іншому: варто прийняти, що перевірка — це підказка, а try/catch — це остання лінія оборони.
2. IOException і FileNotFoundException на JVM
Перш ніж ловити винятки, корисно розуміти, що саме ви ловите. У Kotlin (на JVM) винятки — це обʼєкти, і всі вони є нащадками Throwable. Далі йдуть Error і Exception. Уже всередині Exception живуть різні «робочі» проблеми, які ми зазвичай уміємо обробляти.
Де в ієрархії живе IOException
Дуже практичне прочитання цього факту таке: IOException — «парасолька» для всього, що повʼязане з читанням, записом і відкриттям потоків. Вам не обовʼязково памʼятати кожен конкретний підклас. Але важливо розуміти принцип: якщо ви робите реальне I/O, воно може кинути IOException.
Мініприклад «ловимо загальну парасольку»:
import java.io.File
import java.io.IOException
fun main() {
val file = File("data/input.txt")
try {
val text = file.readText()
println("OK: ${text.length}") // наприклад: OK: 42
} catch (e: IOException) {
println("I/O-помилка під час читання: ${file.path}")
println("Причина: ${e.message}")
}
}
Навіть якщо ви майже нічого не знаєте про файлові системи, ви вже можете зробити програму помітно стійкішою: не «впасти», а повідомити, що саме пішло не так.
FileNotFoundException: «файлу немає» та інші сюрпризи
Назва FileNotFoundException звучить так, ніби причина завжди одна: «файл не знайдено». Інколи так і є. Але на практиці цей тип часто означає ширшу категорію: «не вдалося відкрити шлях як файл».
Тобто можливі різні сценарії: файл справді відсутній; шлях вказує на каталог; процес не має прав на читання; файл існує, але доступ до нього заборонений або неможливий на рівні ОС. Для початківця це неприємно: ви начебто перевірили очима, що файл «там», а програма все одно каже «not found». Насправді вона має на увазі: «я не змогла відкрити цей шлях як читабельний файл».
Тут корисно почати мислити як діагност: ми не сперечаємося з винятком («та він же є!»). Натомість уточнюємо реальність: що це за обʼєкт за шляхом і які базові властивості він має саме зараз.
Ось мінімальна перевірка, яка часто одразу знімає половину запитань:
import java.io.File
fun main() {
val file = File("data/input.txt")
println("exists=${file.exists()}") // exists=true/false
println("isFile=${file.isFile}") // isFile=true/false
println("isDir=${file.isDirectory}") // isDir=true/false
}
Якщо ви побачили exists=true, але isFile=false і isDir=true, то ви намагалися читати каталог як файл. Це не баг «у Kotlin» — це баг «в очікуваннях».
Акуратний try/catch: конкретне → загальне
Багатьом новачкам хочеться ловити одразу Exception, бо «так точно впіймаємо все». Проблема в тому, що так ви самі собі «вимикаєте мозок»: у вас не виробляється звичка розрізняти причини. Kotlin (і JVM) заохочують ловити конкретніші винятки першими, а загальніші — далі. Тому порядок catch важливий: від більш специфічного до більш загального.
Хороший базовий шаблон для читання:
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, length=${text.length}")
} catch (e: FileNotFoundException) {
println("Не знайдено/не відкривається як файл: ${file.path}")
println("Діагностика: ${describePath(file)}")
} catch (e: IOException) {
println("I/O-помилка під час читання: ${file.path}")
println("Причина: ${e::class.simpleName}: ${e.message}")
}
}
Тут важливо, що ми не робимо вигляд, ніби це «однакові помилки». Якщо файл не відкривається як файл — ми говоримо про це окремо. Якщо це вже «щось загальніше про I/O» — ми так само вміємо вивести зрозумілий мінімум.
3. Діагностика I/O-помилок
Коли ви ловите IOException, ви бачите лише верхівку айсберга: «не вдалося». Користувачу (і вам у майбутньому) потрібно розуміти — чому. Хороша новина: типових причин не так багато, і їх зручно тримати в голові як мапу.
Мінімальна мапа причин
Нижче — невелика таблиця (без спроби охопити весь всесвіт), яка допомагає швидко «поставити діагноз»:
| Що ви намагалися зробити | Часта реальна причина | Як це виглядає |
|---|---|---|
| Прочитати файл | Файлу немає / шлях неправильний | |
| Прочитати файл | Шлях — це каталог | FileNotFoundException або IOException |
| Прочитати файл | Немає прав на читання | часто FileNotFoundException або AccessDenied… у повідомленні |
| Записати файл | Немає батьківського каталогу | інколи FileNotFoundException (бо відкрити на запис не вдалося) |
| Записати файл | Диск «повний» | IOException, повідомлення може натякати на space/quota |
| Прочитати/записати | Файл зайнятий/заблокований | частіше IOException, залежить від ОС |
Зараз наша мета не «навчитися лагодити все на світі». Важливіше навчитися коректно розрізняти хоча б дві великі ситуації:
1) «Не знайшли / не відкрили як файл» (часто FileNotFoundException)
2) «Відкрили, але далі зламалося читання/запис» (часто загальний IOException)
Під це ми й будуємо обробку та повідомлення.
Що друкувати, щоб не вгадувати
Є спокуса зробити так: упіймали виняток — вивели «Щось пішло не так» — пішли пити чай. Але це шлях до вічного чаю й вічного болю. Діагностика у файлових операціях майже завжди тримається на трьох речах: дія, шлях, короткі властивості обʼєкта за шляхом.
Уявіть, що вам надіслали скріншот помилки без контексту. Що вам хотілося б там побачити? Зазвичай ось це: «я намагався READ або WRITE», «ось шлях», «ось що там було», «ось тип винятку та повідомлення». Цього достатньо, щоб у більшості випадків зрозуміти причину.
Зробімо маленьку функцію, яка описує шлях. Це не «майбутня архітектура», а просто зручна мітка для друку:
import java.io.File
fun describePath(file: File): String {
return "path=${file.path}, exists=${file.exists()}, isFile=${file.isFile}, isDir=${file.isDirectory}"
}
fun main() {
val file = File("data/input.txt")
println(describePath(file))
// наприклад: path=data/input.txt, exists=false, isFile=false, isDir=false
}
Тепер, коли станеться помилка, ви зможете вивести один рядок і різко підвищити свої шанси швидко зрозуміти причину.
Приклад: читаємо витрати й не «роняємо» застосунок
Щоб усе це не залишилося теорією, давайте подумки продовжимо наш навчальний консольний застосунок (нехай це буде простий трекер витрат). Ми хочемо навчитися читати файл data/expenses.txt, щоб дані переживали перезапуск програми.
І ось тут раптово зʼясовується: якщо файлу немає — це не «катастрофа», а цілком звичайна ситуація. Наприклад, користувач запускає програму вперше. Тому нам потрібен зрозумілий контракт: або ми прочитали рядки, або повертаємо порожній список і друкуємо діагностику.
Зробимо функцію, яка не падає, а повертає список рядків. Зараз ми тренуємося саме в діагностиці помилок читання — без атомарного запису й без складної надійності.
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
fun loadExpenseLines(file: File): List<String> {
return try {
file.readLines()
} catch (e: FileNotFoundException) {
println("Немає файлу з витратами: ${file.path}")
emptyList()
} catch (e: IOException) {
println("Не вдалося прочитати витрати: ${file.path}")
println("Причина: ${e.message}")
emptyList()
}
}
І використовуємо це в main:
import java.io.File
fun main() {
val file = File("data/expenses.txt")
val lines = loadExpenseLines(file)
println("Рядків витрат: ${lines.size}") // наприклад: Рядків витрат: 0
}
Чому це хороший стиль для новачка? Тому що застосунок продовжує працювати. Він може показати меню, дати користувачу додати витрату, а вже потім — зберегти файл (про збереження та надійність поговоримо пізніше). А найголовніше: якщо щось пішло не так, ви вже бачите, з яким файлом і під час якої операції виникла проблема.
4. Типові помилки під час роботи з IOException і діагностикою
Помилка №1: ловити Exception і писати «щось пішло не так».
Такий код формально «стійкий», але практично марний: ви ховаєте від себе причину. Мінімальний стандарт якості — у повідомленні згадати дію (read/write), шлях (file.path) і e.message. Інакше ви самі перетворюєте налагодження на вгадування.
Помилка №2: сприймати FileNotFoundException як буквальне «файлу немає».
Новачки часто сперечаються з винятком: «та він же є!». Насправді це часто означає «не вдалося відкрити як файл»: шлях може вказувати на каталог, доступ може бути заборонений, або файл існує, але його не можна відкрити за поточних умов. Тому за такого винятку особливо корисно вивести діагностику exists/isFile/isDirectory.
Помилка №3: думати, що exists() гарантує успіх читання/запису.
Перевірка існування — це фотографія світу в одну конкретну мілісекунду. Далі світ може змінитися. Тому перевірки добрі як раннє попередження і як частина повідомлення, але реальна операція все одно має бути в try/catch, бо саме вона стикається з реальністю.
Помилка №4: втрачати контекст «що ми робили» і «з чим».
Якщо в застосунку кілька файлів (наприклад, data/expenses.txt, data/categories.txt, data/report.txt), то повідомлення «Не вдалося прочитати файл» без шляху перетворюється на загадку. Правило просте: у кожному повідомленні про I/O-помилку має бути шлях і операція. Тоді навіть користувач (не програміст) зможе нормально переповісти вам проблему.
Помилка №5: змішувати I/O-помилку та помилку даних в один казан.
Якщо файл прочитався, але всередині сміття (наприклад, рядок не парситься), це вже інша категорія проблем — «формат даних». Сьогодні ми свідомо тримаємо фокус на I/O: «прочитали/не прочитали», «записали/не записали». Якщо ви змішаєте це в один catch, потім буде складно зрозуміти, що лагодити: шлях чи парсер.
Помилка №6: робити вигляд, що повідомлення винятку не важливе.
Інколи розробник друкує лише тип винятку й забуває про e.message. А саме там операційна система часто лишає ключову підказку: access denied, no such file, disk full і так далі. Навіть якщо текст англійською — це все одно краще, ніж повна тиша.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ