1. Вступ
Коли ми кажемо «прочитати файл», то насправді маємо на увазі таке: «операційній системі потрібно пояснити, де саме лежить цей файл». Для цього й існує шлях (path) — рядок (або обʼєкт), який описує адресу файлу чи папки у файловій системі. На практиці шлях часто виглядає як "data/input.txt" або "C:\\Projects\\App\\data\\input.txt" — і саме тут починаються пригоди.
Проблема в тому, що один і той самий рядок шляху може вказувати на різні місця на диску — залежно від того, звідки запущено програму. Тож тема шляхів — це не занудство, а спосіб заощадити собі кілька годин життя й один нервовий тик. Адже помилка «файл не знайдено» зазвичай означає не те, що файлу немає, а те, що програма дивиться не туди.
Абсолютний шлях
Абсолютний шлях — це шлях, який повністю описує розташування файлу, починаючи від «кореня» файлової системи. Він не залежить від того, де ви перебуваєте і звідки запустили програму. Якщо абсолютний шлях коректний, то файл або існує в цій точці, або ні — без «магії» та без «ну в мене на компʼютері працювало».
У Windows абсолютний шлях зазвичай починається з літери диска (наприклад, C:\...), а на macOS/Linux — з /.... Але важливіша сама ідея: абсолютний шлях — це адреса з містом, вулицею, будинком і квартирою, а не «приблизно десь поруч із домом».
У Kotlin/JVM ми часто працюємо з абсолютними шляхами через java.io.File. І один із найкорисніших діагностичних трюків — подивитися absolutePath.
import java.io.File
fun main() {
val f = File("data/input.txt")
println("relative = ${f.path}") // relative = data/input.txt
println("absolute = ${f.absolutePath}") // absolute = .../data/input.txt
}
У цьому прикладі файл може навіть не існувати — і це нормально. File("...") тут не «відкриває файл», а просто зберігає шлях і вміє показувати його в різних формах.
Відносний шлях
Відносний шлях — це шлях, який не є повною адресою, а рахується «звідкись». І саме це «звідкись» — ключ до загадки.
Наприклад, шлях "data/input.txt" означає: «знайди папку data у поточному робочому каталозі процесу, а в ній — файл input.txt».
Тобто відносний шлях — це як фраза «магазин за рогом». Вона працює доти, доки ми знаємо, за яким саме рогом. Якщо ж ви «телепортувалися» в інший район (запустили програму інакше), то «за рогом» раптово став зовсім інший ріг.
Відносні шляхи дуже зручні, бо роблять проєкт переносним: ви можете зберігати дані поруч із проєктом, комітити структуру папок і не «вшивати» C:\Users\... у код. Але з ними треба дружити правильно: завжди памʼятати, який робочий каталог зараз.
2. Робочий каталог і пошук файлу
Робочий каталог
Робочий каталог — це папка, відносно якої інтерпретуються відносні шляхи. І це одна з найчастіших причин, чому в IDE усе працює, а під час запуску з термінала — «файл не знайдено» (або навпаки).
Перевірити «де ми зараз» можна максимально просто:
import java.io.File
fun main() {
val wd = File(".").absolutePath
println("Working dir = $wd") // Working dir = ... (абсолютний шлях поточної папки)
}
File(".") означає «поточна папка». А absolutePath — це спосіб побачити, що саме JVM вважає робочим каталогом у цьому запуску.
Чому це так важливо? Бо ви можете чесно написати File("data/input.txt"), бути впевненими, що папка data існує, а програма все одно скаже «не знайдено». Так трапляється, якщо вона стартувала з іншого каталогу й шукає data не там, де ви очікували.
Чому в IDE і в терміналі може бути по-різному
Є класичний сюжет.
Ви робите проєкт, структура така:
MyApp/
src/
data/
input.txt
Ви запускаєте з IDE — і IDE може встановити робочий каталог як MyApp/. Тоді відносний шлях "data/input.txt" інтерпретується як MyApp/data/input.txt — усе добре.
Потім ви запускаєте інакше: наприклад, із термінала — не з MyApp/, а з батьківської папки або взагалі з іншого каталогу. Тоді "data/input.txt" інтерпретується як інша_папка/data/input.txt — і файлу там, звісно, немає.
Та сама логіка працює не лише під час запуску програми, а й у будь-яких інструментах, які читають файли за відносними шляхами: відносні шляхи майже завжди рахуються саме від поточного робочого каталогу процесу.
Мораль проста: відносний шлях — це не «шлях усередині проєкту», а «шлях від робочого каталогу запуску».
Діагностика «файл не знайдено»
Коли ви бачите FileNotFoundException або просто розумієте, що програма «не бачить файл», перша реакція новачка — «чому Kotlin не може знайти файл, він же ось лежить!». Реакція трохи досвідченішої людини — «де саме програма шукає файл?».
Ось золотий мінішаблон діагностики:
import java.io.File
fun main() {
val relative = "data/input.txt"
val f = File(relative)
println("Working dir = ${File(".").absolutePath}")
println("Trying file = ${f.absolutePath}")
}
Сенс у тому, що ви отримуєте дві важливі підказки:
- Working dir — точка відліку для відносних шляхів
- Trying file — абсолютний шлях, куди насправді дивиться програма
Після цього «магія» зникає: ви буквально бачите адресу, за якою програма шукає файл. І далі зʼясовується, що ви очікували .../MyApp/data/input.txt, а вийшов .../MyApp/src/data/input.txt або .../SomeOtherFolder/data/input.txt.
Мінісхема: як відносний шлях стає абсолютним
Слова словами, але корисно один раз побачити це як процес. Умовно:
flowchart TD
A["Відносний шлях: data/input.txt"] --> B["Робоча директорія процесу: WD"]
B --> C["Склеювання: WD + data/input.txt"]
C --> D["Абсолютний шлях: /.../WD/data/input.txt"]
Ця схема не означає «просте додавання рядків» на рівні ОС, але для нас сенс саме такий: відносний шлях стає абсолютним через робочий каталог.
3. File як обʼєкт шляху та складання шляхів
File(path) — це обʼєкт шляху
Слово File збиває з пантелику: здається, що File("data/input.txt") «відкриває файл». Насправді в Java/Kotlin File — це насамперед обʼєкт шляху. Він зберігає рядок шляху й уміє:
- повертати path (як ви задали),
- повертати absolutePath (як ОС побачить цей шлях),
- пізніше — перевіряти існування, читати, писати тощо.
Тобто File — це як обʼєкт «Адреса», а не як «Вміст посилки». Ви можете створити File("data/input.txt"), навіть якщо файл не існує: це просто «адреса, яку ви хочете використати».
Як не склеювати шляхи рядками
Коли новачок уперше починає працювати з файлами, виникає сильне бажання робити так:
val path = "data/" + "input.txt"
І це ще відносно нешкідливо. А потім починається:
val path = "data" + "/" + "input.txt"
А далі раптово — Windows, зворотні слеші, подвійні слеші, пробіли… і все перетворюється на археологічні розкопки.
Навіть у межах цієї лекції (ще до Path.of(...), який буде далі) можна зробити значно акуратніше: зібрати шлях через File(parent, child). Це не «магічна» функція, а просто безпечніший спосіб поєднати частини.
import java.io.File
fun main() {
val dir = File("data")
val file = File(dir, "input.txt")
println(file.path) // data/input.txt
println(file.absolutePath) // .../data/input.txt
}
Так код читається як «файл input.txt усередині папки data», а не як «рядок, зібраний шаманством».
4. Вбудовуємо шляхи в застосунок і робимо вивід корисним
«Де лежать дані проєкту»
Щоб приклади не були абстрактними, уявімо наш практичний CLI-застосунок: припустімо, це невеликий трекер витрат. Дотепер без файлів він жив у памʼяті, а тепер ми хочемо зберігати дані в папці data/.
Сьогодні ми ще не читаємо й не пишемо (це будуть наступні лекції), але можемо зробити важливий крок: централізувати місце, де програма «очікує» файл даних, і додати діагностику.
import java.io.File
private fun dataFile(): File {
return File("data/expenses.txt")
}
fun main() {
val f = dataFile()
println("Working dir = ${File(".").absolutePath}")
println("Data file (relative) = ${f.path}") // Data file (relative) = data/expenses.txt
println("Data file (absolute) = ${f.absolutePath}") // Data file (absolute) = .../data/expenses.txt
}
Тут ми зробили одразу дві хороші речі.
По‑перше, шлях до файлу тепер в одному місці. І пізніше (коли почнемо реально читати/писати) ми не ловитимемо ситуацію «в одному місці data/expenses.txt, в іншому ./data/expenses.txt, а в третьому взагалі data\expenses.txt».
По‑друге, вивід Working dir та абсолютного шляху до файлу — це вбудована діагностика. У момент, коли ви отримаєте «файл не знайдено», ви вже бачитимете, куди саме програма дивиться.
Друкуємо шлях так, щоб його можна було перевірити вручну
Коли ви друкуєте шляхи, корисно робити це так, щоб людина могла буквально скопіювати й вставити їх, відкрити в провіднику чи терміналі та перевірити. Тобто краще друкувати абсолютний шлях і поруч — коротке пояснення.
import java.io.File
fun main() {
val reportFile = File("data/reports/today.txt")
println("I will look for report at:")
println(reportFile.absolutePath)
// .../data/reports/today.txt
}
Так, це виглядає надто просто. Але це саме той випадок, коли простота — не ознака слабкості, а ознака того, що ви не хочете дебажити «невидимий» шлях.
5. Типові помилки під час роботи зі шляхами
Помилка №1: вважати, що відносний шлях рахується від папки з кодом.
Це дуже логічне припущення, бо в голові «проєкт» = папка з src. Але JVM так не думає. Відносний шлях рахується від робочого каталогу запуску, який залежить від IDE, термінала й налаштувань Run Configuration. Найкращі ліки — у разі проблем друкувати File(".").absolutePath.
Помилка №2: плутати «шлях усередині проєкту» зі «шляхом під час запуску».
Папка data/ може лежати поруч із src/, але якщо робочий каталог вказує на src/, то data/input.txt очікуватиметься як src/data/input.txt. Ззовні здається, що шлях «правильний», але він правильний лише у вашій голові. Абсолютний шлях через absolutePath швидко повертає в реальність.
Помилка №3: склеювати шляхи рядками й ловити дивні слеші.
Рядкова конкатенація швидко призводить до подвійних роздільників, пропущених /, і до сценарію «полагодили на Windows — зламали на Linux». Навіть якщо ви поки не використовуєте Path.of(...), складання шляху через File(parent, child) уже робить код помітно надійнішим і читабельнішим.
Помилка №4: намагатися діагностувати читання файлу, не діагностуючи шлях.
Часто новачки одразу лізуть у try/catch, ловлять виняток і друкують «помилка читання». Але без виводу робочого каталогу й абсолютного шляху це повідомлення майже безкорисне: ви знаєте, що «не знайдено», але не знаєте, «де шукали». Правильний порядок мислення: спочатку зʼясувати «куди дивимося», а вже потім розбиратися з читанням.
Помилка №5: тримати шлях до файлу розмазаним по всьому проєкту.
Коли рядок "data/expenses.txt" трапляється у пʼяти місцях, одного разу ви зміните його в чотирьох і отримаєте баг, який виглядає як містика: «то читає старе, то пише в нове». Набагато спокійніше, коли шлях створюється в одному місці (функція або константа), а далі передається кодом як File.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ