1. Навіщо потрібні File і Path, якщо шлях — це рядок
Коли ви вперше стикаєтеся з роботою з файлами, виникає спокуса: «Ну шлях же можна зберігати в String, отже, далі просто readText("data/input.txt") — і вперед». На практиці рядок — надто «примітивна» сутність: у ньому легко помилитися, його незручно діагностувати, і він не вміє відповідати на запитання на кшталт «а це файл чи папка?». Тому в Kotlin/JVM ми зазвичай використовуємо об’єкти File і Path, які обгортають шлях і дають корисні операції. Kotlin чудово дружить із Java-бібліотеками (це частина ідеї «interoperable with Java»), тож java.io.File — нормальний і звичний інструмент.
Одразу важливий момент: File("data/input.txt") не створює файл. Це лише об’єкт, який зберігає шлях і вміє виконувати за ним перевірки та операції.
Міні-демонстрація «об’єкта шляху»:
import java.io.File
fun main() {
val f = File("data/input.txt")
println("path = ${f.path}") // path = data/input.txt
println("absolutePath = ${f.absolutePath}") // absolutePath = .../data/input.txt
}
Зверніть увагу: ми поки нічого не читаємо й не пишемо. Ми просто робимо те, що дуже допомагає під час налагодження: дивимося, куди саме програма «цілиться».
Невелика схема того, як зазвичай виглядає шлях у голові й у реальності:
flowchart TD
A["Ви пишете: File('data/input.txt')"] --> B["ОС/Java вважає шлях відносним"]
B --> C["Відносний шлях рахується від робочого каталогу"]
C --> D["Отримуємо абсолютний шлях"]
D --> E["Уже за ним робимо перевірки exists/isFile/isDirectory"]
2. File: три запитання перед I/O
Коли ви починаєте працювати з файлами по-справжньому, головна проблема не в тому, «як прочитати текст», а в тому, що ви читаєте не те, не звідти або намагаєтеся читати папку як файл. Тому на старті варто дотримуватися простої дисципліни: перед будь-яким читанням або записом поставте три запитання об’єкту File.
Перше запитання — exists(): «за цим шляхом узагалі щось є?». Друге — isFile: «це звичайний файл?». Третє — isDirectory: «це каталог (папка)?». Ці перевірки не роблять код «довгим заради довжини». Вони роблять його передбачуваним: замість загадкового падіння «десь усередині читання» ви отримуєте зрозумілу діагностику.
Ось невеликий приклад, який можна запускати будь-коли, щоб перевірити, що насправді знаходиться за шляхом:
import java.io.File
fun main() {
val f = File("data")
println("path=${f.path}") // path=data
println("exists=${f.exists()}") // наприклад: exists=false
println("isFile=${f.isFile}") // isFile=false
println("isDirectory=${f.isDirectory}") // isDirectory=false
}
Корисно розуміти, що можливі такі ситуації:
|
|
|
Що це означає на практиці |
|---|---|---|---|
|
|
|
За шляхом нічого немає (ні файла, ні папки) |
|
|
|
Це файл (можна читати/писати як файл) |
|
|
|
Це каталог (не можна читати як файл) |
Ситуація «exists() == true, але і isFile == false, і isDirectory == false» трапляється рідко (зазвичай це щось специфічне на рівні файлової системи), але для нашого рівня достатньо пам’ятати: якщо хочете читати текст — очікуйте isFile == true.
3. Підготовка каталогів: parentFile, mkdir() і mkdirs()
Майже кожен новачок хоча б раз наступає на ці граблі: «Я хочу записати файл data/reports/report.txt — чому програма видає помилку?». Причина банальна: файл ви справді хочете створити, але папки data/reports ще не існує. Сам по собі запис файла зазвичай не зобов’язаний створювати ланцюжок каталогів — це ваша відповідальність.
Щоб робити це акуратно, варто засвоїти дві ідеї. По-перше, у файла є «батьківська папка» — parentFile. По-друге, папки можна створювати через mkdir() або mkdirs(). Різниця проста: mkdir() намагається створити рівно один каталог, а mkdirs() створює ланцюжок, якщо потрібно (і в застосунках це частіше саме те, що треба).
Міні-приклад «готуємо батьківську папку»:
import java.io.File
fun main() {
val out = File("data/reports/report.txt")
val parent = out.parentFile
val ok = parent != null && (parent.exists() || parent.mkdirs())
println("parentPath=${parent?.path}, ready=$ok") // parentPath=data/reports, ready=true/false
}
Тут важливо, що parentFile може бути null. Це трапляється, коли ви створюєте файл «без папки», наприклад File("report.txt"). Тоді «батька» як окремого об’єкта немає — і це нормально.
Трохи більш «прикладний» варіант — маленька утиліта (пізніше ми зберемо такі речі в охайний набір функцій):
import java.io.File
fun ensureParentDirExists(file: File): Boolean {
val parent = file.parentFile ?: return true
return parent.exists() || parent.mkdirs()
}
fun main() {
val out = File("data/reports/report.txt")
println(ensureParentDirExists(out)) // true/false
}
Схема того, що робить ця функція (щоб щоразу не доводилося «перекомпоновувати реальність» у голові):
flowchart TD
A["Є File для майбутнього запису"] --> B["Беремо parentFile"]
B --> C{parentFile == null?}
C -->|Так| D["ОК: писатимемо поруч, папка не потрібна"]
C -->|Ні| E{"parent.exists()?"}
E -->|Так| F["ОК: папка вже є"]
E -->|Ні| G["mkdirs()"]
G --> H["ОК/не ОК: залежить від результату mkdirs()"]
4. Збирання шляху з частин без склеювання слешів
Рядкова конкатенація шляхів виглядає як найшвидший шлях до щастя: "data" + "/" + "input.txt". Але в реальності це «найшвидший шлях до сліз»: десь забули /, десь додали два, десь у вас Windows, і ви починаєте думати про \ (а потім ще й про екранування "\\\\" — і вечір перестає бути спокійним).
У Java/Kotlin є простий і зрозумілий вихід: збирати шлях із частин через готові API. Це читабельно й зменшує ризик «кривих слешів».
Збирання через File(parent, child)
Поганий приклад (не заборонений, але підозрілий — як шаурма о 4 ранку):
fun main() {
val path = "data" + "/" + "reports" + "/" + "report.txt"
println(path) // data/reports/report.txt
}
Хороший приклад (його простіше читати, і він менше залежить від ручних слешів):
import java.io.File
fun main() {
val base = File("data")
val reportsDir = File(base, "reports")
val reportFile = File(reportsDir, "report.txt")
println(reportFile.path) // data/reports/report.txt
}
Тут особливо приємно те, що змінні починають «говорити»: reportsDir — очевидно папка, reportFile — очевидно файл. Це не магія, але це справді допомагає не плутатися.
Невелика таблиця, щоб зафіксувати «як прийнято»:
| Підхід | Плюси | Мінуси |
|---|---|---|
| Склеювання рядків | Швидко написати | Легко помилитися зі слешами, гірше читається |
|
Читабельно, менше помилок | Потрібно створювати кілька об’єктів (але це зазвичай нормально) |
Збирання через Path.of(...)
File — класичний інструмент: популярний, але дуже старий, адже прийшов ще з java.io. Більш сучасний підхід у світі JVM — java.nio.file.Path. На нашому рівні важлива проста думка: Path — це теж об’єкт шляху, який зручно збирати з частин, і він добре поєднується із сучасними API.
Чому Path інколи приємніший? Він прямо заточений під «шлях як значення»: його зручно складати, передавати, порівнювати, перетворювати. А якщо вам усе ж потрібні операції «як у File», ви завжди можете перетворити Path на File.
Міні-приклад збирання шляху через Path.of(...):
import java.nio.file.Path
fun main() {
val p = Path.of("data", "reports", "report.txt")
println(p.toString()) // data/reports/report.txt
}
А ось приклад «поєднання світів»: інколи ви хочете зібрати шлях через Path, а потім використати File-методи (або навпаки):
import java.io.File
import java.nio.file.Path
fun main() {
val p: Path = Path.of("data", "input.txt")
val f: File = p.toFile()
println(f.path) // data/input.txt
println(f.absolutePath) // .../data/input.txt
}
І зворотне перетворення теж є:
import java.io.File
fun main() {
val f = File("data/input.txt")
val p = f.toPath()
println(p.toString()) // data/input.txt
}
Зауважте, що ми не обговорюємо тут «читання/запис через NIO» та інші радощі. Сьогодні наша мета простіша: навчитися впевнено створювати шляхи, перевіряти, що за ними знаходиться, і готувати каталоги під майбутні операції.
5. Міні‑рефакторинг: централізуємо шляхи зберігання
Коли застосунок маленький, можна прямо в main() написати File("data/input.txt") і бути задоволеним. Але щойно у вас з’являються два-три файли (наприклад, «вхідні дані», «лог», «звіт»), починається хаос: шляхи розкидані по коду, ви змінюєте папку data на app-data і пропускаєте одне місце, а потім пів години шукаєте, чому «інколи працює, інколи ні».
Тому корисна звичка — централізувати побудову шляхів. Ми поки не читаємо й не пишемо, але вже зараз можемо зробити «мапу файлів» для нашого консольного застосунку (умовно назвемо його трекером витрат, який раніше жив у пам’яті, а тепер готується до збереження у файли).
Почнемо з простого data class, який зберігає наші ключові шляхи:
import java.io.File
data class StoragePaths(
val baseDir: File,
val expensesFile: File,
val reportFile: File
)
Тепер функція, яка будує ці шляхи з частин (без рядкового склеювання):
import java.io.File
fun buildStoragePaths(): StoragePaths {
val baseDir = File("data")
val expensesFile = File(baseDir, "expenses.txt")
val reportFile = File(File(baseDir, "reports"), "last-report.txt")
return StoragePaths(baseDir, expensesFile, reportFile)
}
І маленька діагностика (вона особливо корисна, коли студенти запускають проєкт «то з IDE, то з термінала»):
import java.io.File
fun main() {
val paths = buildStoragePaths()
println("workDir = ${File(".").absolutePath}") // workDir = ...
println("expenses = ${paths.expensesFile.absolutePath}") // expenses = .../data/expenses.txt
println("report = ${paths.reportFile.absolutePath}") // report = .../data/reports/last-report.txt
}
Тепер — дуже важливий крок: раз у нас з’явився файл звіту в підпапці data/reports, ми маємо заздалегідь уміти підготувати папку. Ми вже написали ensureParentDirExists, тож використаємо її:
import java.io.File
fun main() {
val paths = buildStoragePaths()
val ok = ensureParentDirExists(paths.reportFile)
println("reportParentReady=$ok") // reportParentReady=true/false
}
Поки ми не робимо writeText і не читаємо readLines — це буде в наступних лекціях. Але саме цей рефакторинг робить майбутні операції надійнішими: ви спочатку впевнені, що «місце для файла» коректне, і лише потім переходите до читання або запису.
6. Типові помилки під час роботи з File і Path
Перехід від «просто рядків» до «об’єктів шляху» зазвичай розв’язує половину проблем. Але другу половину проблем новачки радісно створюють самі — просто тому, що файлова система не зобов’язана вгадувати ваші наміри. Зараз пройдемося найчастішими помилками, щоб ви впізнавали їх одразу — як досвідчений механік упізнає стукіт у двигуні.
Помилка №1: думати, що File("x.txt") створює файл.
File створює об’єкт шляху, а не файл на диску. Це як написати адресу на конверті: конверт ще не відправлено, дім ще не збудовано, і листоноша поки взагалі не в курсі. Реальні «дії» починаються лише під час читання або запису чи явного створення.
Помилка №2: перевіряти тільки exists() і не перевіряти isFile/isDirectory.
exists() відповідає на запитання «щось є?», але не на запитання «що саме?». Якщо за шляхом знаходиться каталог, а ви спробуєте читати його як файл, отримаєте помилку вже в момент читання. Набагато спокійніше заздалегідь домовитися з реальністю: «я очікую файл» → отже, хочу exists() && isFile.
Помилка №3: використовувати mkdir() для вкладених папок і дивуватися false.
mkdir() створює один каталог і не зобов’язаний створювати батьківські. Якщо ви хочете data/reports, а папки data ще немає, mkdir() для reports не зможе «прокласти шлях». Для ланцюжка майже завжди потрібен mkdirs().
Помилка №4: не враховувати, що parentFile може бути null.
Якщо ви працюєте з File("report.txt"), то «батько» у вигляді окремого об’єкта може бути відсутнім. Це не баг. Це просто означає: файл лежить прямо в робочому каталозі. У таких випадках «готувати папку» не потрібно, і ваша утиліта має це коректно обробляти.
Помилка №5: плутати «папку проєкту» й «робочий каталог запуску».
Дуже хочеться думати, що відносний шлях рахується «від папки з вихідниками». Але процес рахує відносний шлях від робочого каталогу, який залежить від того, як ви запускаєте програму. Тому за дивностей корисно друкувати File(".").absolutePath і дивитися на ситуацію тверезо.
Помилка №6: склеювати шлях рядками й ловити дрібні помилки зі слешами.
Якщо у вас в одному місці data/, в іншому reports, а в третьому /report.txt, ви легко отримаєте data//reports//report.txt або datareportsreport.txt. Збирання через File(parent, child) або Path.of(...) прибирає цілий клас таких помилок.
Помилка №7: розмазувати шляхи по всьому коду.
Сьогодні ви використовуєте "data/expenses.txt" в одному місці, завтра додаєте "data/reports/last-report.txt" в іншому, післязавтра перейменовуєте папку — і ось у вас три різні «істини» про те, де зберігаються дані. Куди простіше завести одне місце (функцію або data class), яке будує всі шляхи, і далі використовувати тільки його. Це виглядає трохи «суворіше», зате економить багато часу й нервів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ