JavaRush /Курси /Kotlin SELF /Попередні перевірки перед читанням і записом: існування, ...

Попередні перевірки перед читанням і записом: існування, тип, права, каталоги

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

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()isFilecanRead(). Він не лише логічний, а й психологічно комфортний: ми рухаємося від «взагалі чи є щось» до «це правильного типу» — і лише потім до прав доступу.

Чому одного 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
}

Ця функція здається нудною (а нудне часто буває надійним). Проте саме вона найчастіше перетворює сценарій «не записується, бо немає каталогу» на сценарій «каталог створився, усе гаразд».

Шпаргалка: що перевіряємо перед читанням і записом

Іноді корисно зафіксувати логіку у вигляді таблиці, щоб не тримати все в голові. Вважайте це «шпаргалкою» на крайній випадок.

Дія Що хочемо гарантувати (наскільки можемо) Типові перевірки
Читання За шляхом є файл, і ми можемо читати
exists() && isFile && canRead()
Запис Є каталог, куди можна записати файл
ensureDir(parentFile) + parent.canWrite()

Зверніть увагу: під час запису ми частіше перевіряємо каталог, а не «сам файл». Файл може з’явитися лише в момент запису — і це нормальний сценарій.

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.

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