1. Навіщо потрібні попередні перевірки
Коли ви пишете file.readText() або file.writeText(...), ви ніби кажете операційній системі: «Дай мені дані» або «Запиши ось це». І вона може відповісти: «Гаразд»… або «Ні, тому що…».
Попередні перевірки — це не спроба «скасувати існування помилок». Це спосіб раніше помітити очевидні проблеми і пояснити людині (або собі) нормальну причину, а не відбутися фразою «щось пішло не так». Це особливо корисно в CLI-застосунках: користувачам потрібна конкретика, а не філософія.
Важливо одразу прийняти неприємну, але корисну правду: попередні перевірки не гарантують успіху операції. Навіть якщо ви перевірили exists() і canRead(), файл може зникнути за мілісекунду. Тож попередні перевірки — це перша лінія оборони, а реальна операція все одно має бути готовою до I/O-збою (обробку винятків розглянемо окремо, а сьогодні тримаємо увагу саме на перевірках).
Намалюймо коротку ментальну модель:
flowchart TD
A[Хочемо прочитати/записати файл] --> B[Робимо попередні перевірки]
B -->|перевірки не пройшли| C[Не починаємо I/O, пояснюємо проблему]
B -->|перевірки пройшли| D[Пробуємо реальний I/O]
D -->|успіх| E[Працюємо з даними]
D -->|збій| F[Це вже область обробки винятків]
2. File як шлях і запитання до файлової системи
Перш ніж перевіряти читання й запис, варто звикнути до думки: File("data/input.txt") — це не «файл у руках», а об’єкт шляху. За його допомогою ви ставите файловій системі запитання: чи існує щось за цим шляхом, це каталог чи файл, чи можна читати/писати тощо.
У Kotlin/JVM ви зазвичай використовуєте java.io.File. У нього є базові методи, які ми будемо поєднувати:
- exists() — чи існує щось за шляхом
- isFile — це файл?
- isDirectory — це каталог?
- canRead(), canWrite() — чи є права на читання/запис (важливе «здається», а не «гарантую»)
- parentFile — батьківський каталог (може бути null)
- mkdirs() — створити каталог і всі батьківські каталоги
Почнімо з маленької «діагностичної звички»: перш ніж сперечатися з комп’ютером, корисно запитати в нього, що саме він «бачить» за цим шляхом.
import java.io.File
fun main() {
val file = File("data/input.txt")
println("path=${file.path}") // path=data/input.txt
println("exists=${file.exists()}") // наприклад: exists=false
println("isFile=${file.isFile}") // наприклад: isFile=false
println("isDirectory=${file.isDirectory}") // наприклад: isDirectory=false
}
Так, це виглядає як «зайві рядки». Але коли одного разу виникне ситуація «чому не читається?!», ці чотири рядки заощадять пів години життя й один мікроспалах ненависті до людства.
Що перевіряти перед читанням
Коли ми читаємо файл, потрібно переконатися в трьох речах: за шляхом щось є, це саме файл (а не каталог), і ми маємо право читати.
Найпрактичніший мінімум для читання зазвичай виглядає так:
import java.io.File
fun canReadFile(file: File): Boolean {
return file.exists() && file.isFile && file.canRead()
}
fun main() {
val ok = canReadFile(File("data/input.txt"))
println("canReadFile=$ok") // наприклад: canReadFile=false
}
Зверніть увагу на порядок: exists() → isFile → canRead(). Він не лише логічний, а й психологічно комфортний: ми рухаємося від «взагалі чи є щось» до «це правильного типу» — і лише потім до прав доступу.
Чому одного exists() недостатньо
Дуже типова помилка новачків — зробити ось так:
if (file.exists()) {
val text = file.readText()
}
Проблема в тому, що exists() каже: «так, щось є». Але це «щось» може виявитися каталогом. А читання каталогу як тексту — це вже гра «вгадай, яку помилку сьогодні поверне ОС».
Тому isFile — це не прискіпливість, а захист від логічної помилки: «я думав, що це файл».
3. canRead() і canWrite(): корисні, але не гарантія
Права доступу — тема, у якій легко повірити в ілюзію контролю. Методи canRead() і canWrite() справді дають корисний сигнал: «найімовірніше, читати/писати можна». Але вони не є договором із реальністю.
Чому так? Тому що між «перевірив» і «зробив» усе може змінитися: файл могли заблокувати, права могли змінитися, файл міг зникнути. До того ж ви можете працювати в середовищі з мережевою файловою системою, де перевірки та реальний доступ поводяться хитріше.
З погляду розробки це означає просте правило: canRead()/canWrite() допомагають не починати завідомо приречену операцію, але не замінюють обробки реального збою введення/виведення. Помилки такого типу зазвичай «живуть» під парасолькою IOException.
Перевірмо ці «прапорці» на практиці:
import java.io.File
fun main() {
val file = File("data/input.txt")
println("exists=${file.exists()}") // наприклад: true
println("canRead=${file.canRead()}") // наприклад: true
println("canWrite=${file.canWrite()}") // наприклад: false
}
Якщо canWrite() раптом false, це не означає, що «Kotlin зламався». Найімовірніше, це підказка: ви намагаєтеся писати туди, де у вас немає прав (наприклад, у системний каталог), або файл позначено як read-only.
4. Попередні перевірки перед записом: батьківський каталог
Запис відрізняється від читання тим, що файл може ще не існувати — і це нормально. Тому набір перевірок тут інший.
Ключова думка: щоб записати файл "data/out/report.txt", вам насамперед потрібен каталог "data/out". І саме його стан найчастіше «ламає» запис.
Тобто перед writeText() нас цікавить:
- чи існує батьківський каталог
- чи є він каталогом (а не файлом)
- чи можемо ми в нього писати
- якщо каталогу немає — чи можемо ми його створити
parentFile і чому він може бути null
Якщо шлях відносний і без каталогів, наприклад File("report.txt"), то parentFile буде null. Це не помилка — просто «батьківський каталог не вказано».
У такому разі зручно вважати, що батьківський каталог — поточний каталог ".".
import java.io.File
fun parentOrCurrent(target: File): File {
return target.parentFile ?: File(".")
}
fun main() {
val a = File("data/out/report.txt")
val b = File("report.txt")
println(parentOrCurrent(a).path) // data/out
println(parentOrCurrent(b).path) // .
}
mkdirs(): готуємо каталог для запису
Тепер найпрактичніше: функція, яка гарантує (наскільки це можливо), що каталог існує й справді є каталогом. Якщо його немає — вона спробує його створити.
Важливо: mkdirs() повертає Boolean. Це не «для галочки». Це єдиний прямий сигнал «вийшло / не вийшло».
import java.io.File
fun ensureDir(dir: File): Boolean {
if (dir.exists()) return dir.isDirectory
return dir.mkdirs()
}
fun main() {
val dir = File("data/out/reports")
val ok = ensureDir(dir)
println("dirReady=$ok") // наприклад: dirReady=true
println("exists=${dir.exists()}") // exists=true
println("isDir=${dir.isDirectory}") // isDir=true
}
Тут ми свідомо не використовуємо «список перевірок: усе й одразу», а читаємо код як історію: «якщо існує — переконайся, що це каталог; якщо не існує — створи».
mkdir() vs mkdirs() простими словами
mkdir() створює один каталог. Якщо вам потрібно створити "data/out/reports", а "data/out" не існує, mkdir() зазвичай не впорається.
mkdirs() намагається створити увесь ланцюжок каталогів.
Для новачків правило просте: майже завжди для вкладених шляхів потрібен mkdirs().
Готовність до запису через parentFile
Зберімо це в ще одну маленьку утиліту: підготувати батьківський каталог для файлу.
import java.io.File
fun ensureParentDir(target: File): Boolean {
val parent = target.parentFile ?: File(".")
return ensureDir(parent)
}
fun main() {
val out = File("data/out/report.txt")
println("parentReady=${ensureParentDir(out)}") // наприклад: parentReady=true
}
Ця функція здається нудною (а нудне часто буває надійним). Проте саме вона найчастіше перетворює сценарій «не записується, бо немає каталогу» на сценарій «каталог створився, усе гаразд».
Шпаргалка: що перевіряємо перед читанням і записом
Іноді корисно зафіксувати логіку у вигляді таблиці, щоб не тримати все в голові. Вважайте це «шпаргалкою» на крайній випадок.
| Дія | Що хочемо гарантувати (наскільки можемо) | Типові перевірки |
|---|---|---|
| Читання | За шляхом є файл, і ми можемо читати | |
| Запис | Є каталог, куди можна записати файл | |
Зверніть увагу: під час запису ми частіше перевіряємо каталог, а не «сам файл». Файл може з’явитися лише в момент запису — і це нормальний сценарій.
5. Приклад: CLI-застосунок із попередніми перевірками
Уявімо, що в нас є простий консольний застосунок, який зберігає дані в текстовому файлі "data/storage.txt" і іноді пише звіт у "data/out/report.txt". Неважливо, що саме всередині — витрати, нотатки чи список «образ на компілятор». Нам важливе інше: як акуратно перевіряти шляхи перед операцією.
Завантаження даних
import java.io.File
fun loadStorageText(storageFile: File): String? {
if (!canReadFile(storageFile)) return null
return storageFile.readText()
}
fun main() {
val file = File("data/storage.txt")
val text = loadStorageText(file)
println(text?.length ?: "НЕМАЄ ДАНИХ") // наприклад: НЕМАЄ ДАНИХ
}
Тут немає обробки винятків — і це навмисно. Сьогодні ми зосереджуємося на попередніх перевірках як на фільтрі. Обробку реальних I/O-збоїв розглянемо в наступній лекції. Але навіть у такому вигляді ви вже отримуєте «розумнішу» поведінку: якщо файлу немає — ми не ліземо читати й не провокуємо падіння.
Збереження звіту
import java.io.File
fun saveReportText(reportFile: File, report: String): Boolean {
if (!ensureParentDir(reportFile)) return false
reportFile.writeText(report)
return true
}
fun main() {
val ok = saveReportText(
File("data/out/report.txt"),
"Звіт: усе добре\n"
)
println("saved=$ok") // наприклад: saved=true
}
Якщо каталог "data/out" не існував — він створиться. Якщо створити його не можна — повернеться false, і ви зможете коректно завершити сценарій (або попросити користувача вибрати інший шлях).
6. Швидка діагностика шляху
Коли починаються I/O-проблеми, зазвичай хочеться швидко зрозуміти: що саме в мене за цим шляхом? Файл? Каталог? Чи існує? Чи читається?
Зробімо маленький «діагностичний текст», який можна виводити в консоль (і за який ви потім подякуєте собі, що написали).
import java.io.File
fun describe(file: File): String {
return "path=${file.path}, exists=${file.exists()}, isFile=${file.isFile}, isDir=${file.isDirectory}, canRead=${file.canRead()}, canWrite=${file.canWrite()}"
}
fun main() {
val file = File("data/input.txt")
println(describe(file))
// наприклад: path=data/input.txt, exists=false, isFile=false, isDir=false, canRead=false, canWrite=false
}
Це ще не «логування» (до нього далеко), а просто зрозуміла діагностика в стилі «що бачить файлова система».
7. Типові помилки під час попередніх перевірок
Помилка №1: перевіряти лише exists() і вважати, що цього достатньо.
exists() не каже, що це файл, і не каже, що його можна читати. Дуже легко отримати ситуацію «exists=true», але «isDirectory=true», і тоді читання ламається. Звикайте до формули exists() + isFile + canRead() для читання.
Помилка №2: під час запису перевіряти canWrite() у файлу, який ще не існує.
Це звучить логічно («я ж буду писати у файл»), але на практиці часто позбавлене сенсу: якщо файлу немає, то й «право писати у файл» оцінювати нема що. Насправді важливо, чи можна писати в каталог, у якому файл має з’явитися. Тому перевірка починається з parentFile.
Помилка №3: викликати mkdirs() й ігнорувати результат.
mkdirs() може повернути false — і це не рідкість: немає прав, конфлікт імен (за шляхом уже лежить файл), дивний шлях, проблеми з диском. Якщо ви проігнорували результат, то пізніше «падіння» виглядатиме загадковіше й буде далі від справжньої причини.
Помилка №4: вважати canRead()/canWrite() гарантією успіху.
Ці методи корисні як ранній фільтр, але середовище може змінитися між перевіркою та операцією. Крім того, реальні помилки бувають складнішими (наприклад, тимчасове блокування або проблеми з файловою системою). Тримайте в голові: попередні перевірки зменшують кількість помилок і покращують повідомлення, але не скасовують реальний ризик I/O-збоїв, які належать до сімейства IOException.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ