1. Чому writeText() інколи небезпечно
Коли ви вперше починаєте зберігати дані у файл, здається, що задачу розвʼязано раз і назавжди: «ну я ж викликав file.writeText(text) — отже, файл записано». Але файлова система — це не просто функція, а окремий маленький світ зі своїми правилами. І цей світ інколи підкидає сюрпризи: раптове завершення процесу, нестача місця, вимкнення живлення, антивірус, який «задумався», або просто хтось висмикнув флешку (так, таке буває).
Проблема не в тому, що writeText() «поганий». Проблема в іншому: прямий перезапис цільового файла може залишити файл у проміжному стані. Наприклад, ви зберігали дані застосунку в data/expenses.txt, почали записувати нову версію — і посеред запису все обірвалося. У результаті файл може стати порожнім або записаним лише частково. І найнеприємніше: під час наступного запуску ви спробуєте прочитати його, а там — «половина рядка» або «обрубок».
Уявіть, що ваш файл — це зошит із домашкою. Перезапис напряму — це коли ви стираєте стару відповідь гумкою і в процесі раптово засинаєте обличчям у зошиті. Вранці у вас немає ні старого розвʼязання, ні нового. Підхід atomic write — це коли ви пишете нову домашку на чернетці, а потім акуратно вклеюєте готовий аркуш у зошит.
Невелика демонстрація «як ми зазвичай пишемо» (і чому це ризиковано під час збоїв):
import java.io.File
fun main() {
val target = File("data/expenses.txt")
target.writeText("...нові дані...\n")
println("Зберегли!") // Зберегли!
}
З погляду коду все красиво. З погляду реального життя — між початком і кінцем запису може статися багато чого.
2. Ідея atomic write: temp → rename
Зараз ми розберемо простий протокол, який застосунки часто використовують, щоб зменшити ризик «часткового запису». Я навмисно кажу «по‑побутовому», бо справжнє «строго атомарно на всіх ОС і файлових системах за будь‑яких умов» — тема глибша й складніша. У межах курсу нам важливо опанувати практичний і помітно надійніший підхід — без магії та без занурення в низькорівневі деталі.
Ідея така: ми не чіпаємо цільовий файл, доки повністю не готова нова версія. Спочатку записуємо весь текст у тимчасовий файл (temp) поруч із цільовим. Якщо запис temp минув успішно, тоді «опубліковуємо» результат: перейменовуємо temp у цільовий. Якщо щось «падає» посеред запису temp, цільовий файл лишається старим, цілим і читабельним.
Схема процесу (як мініалгоритм):
flowchart TD
A[Зібрали новий текст] --> B[Записали в temp-файл поруч із target]
B -->|успіх| C[Опублікували: rename temp -> target]
B -->|помилка| D[Залишили target як був]
C --> E[Видалили залишки temp, якщо лишилися]
D --> E
Тут ключовий сенс простий: «робимо новий результат окремо, а потім швидко підміняємо». «Швидко» — важливо, бо що менше часу ви перебуваєте в «небезпечній зоні», то менше шансів отримати проміжний стан.
3. Чому temp потрібно створювати поруч із цільовим файлом
Коли чуєш «тимчасовий файл», рука так і тягнеться записати його кудись «у /tmp», «у поточний каталог» або «в окрему папку temp». І часом це справді нормальна ідея. Але в нашому протоколі є важлива тонкість: перейменування через renameTo() працює значно передбачуваніше, коли temp лежить у тій самій папці, що й target.
Для новачка це може виглядати як дивна прискіпливість («яка різниця, де лежить файл, якщо я його перейменовую?»), але на практиці різниця є. Якщо temp лежить на іншому диску/розділі/файловій системі, перейменування може перетворитися на «переміщення», а переміщення вже не зобовʼязане бути ані швидким, ані надійним. А інколи воно просто не спрацює — і renameTo() чесно поверне false.
Нам зараз не потрібно заглиблюватися в будову ОС. Запамʼятаймо практичне правило: temp створюємо в тій самій папці, де лежатиме цільовий файл. Тоді у вас менше шансів отримати false від renameTo() і більше шансів, що «публікація» пройде так, як задумано.
Зробімо маленьку утиліту, яка будує шлях до temp поруч із target:
import java.io.File
fun tempFor(target: File): File {
val parent = target.parentFile ?: File(".")
return File(parent, target.name + ".tmp")
}
Так, розширення .tmp доволі просте. У реальних застосунках часто додають випадковий суфікс, щоб уникнути конфліктів. Але для навчального проєкту почнемо зі зрозумілого варіанта.
4. renameTo() і перевірка результату
Перейменування звучить як «залізобетонна операція», але в JVM‑методі File.renameTo(...) є важлива особливість: він повертає Boolean. Тобто операція може не вдатися — і ви не отримаєте виняток автоматично (це залежить від ситуації та платформи). Тому наш код має ставитися до renameTo() як до кроку, результат якого потрібно перевіряти.
Тут дуже легко припуститися помилки: «ну раз винятку немає — значить, усе нормально». Ні, не значить. Треба перевіряти true/false і ухвалювати рішення.
Мініприклад правильного стилю:
import java.io.File
fun main() {
val from = File("data/a.tmp")
val to = File("data/a.txt")
val renamed = from.renameTo(to)
println("renamed=$renamed") // renamed=true (або false)
}
Якщо renamed=false, це не «дрібна неприємність», а сигнал: публікація не відбулася. І якщо ви в цей момент видалите temp‑файл «про всяк випадок», то, можливо, зітрете єдину актуальну версію даних.
5. Реалізація atomicWriteText: протокол і нюанси
Прямий запис vs temp+rename
Перед тим як писати код повністю, корисно зафіксувати різницю підходів не на рівні «відчуттів», а як очікувану поведінку під час збоїв.
| Ситуація | Прямий перезапис target | Temp → rename |
|---|---|---|
| Помилка під час запису (закінчилося місце, процес завершився) | target може стати частково записаним | target лишається старою версією |
| Помилка під час «публікації» | зазвичай уже пізно: target міг бути зачеплений | підміна може не відбутися, зате у вас лишається temp |
| Діагностика | «чому файл битий?» — неприємний квест | простіше зрозуміти, на якому кроці зламалося |
| Реалізація | простіше: один рядок | трохи більше коду — зате спиться спокійніше |
Це не робить прямий запис «забороненим». Просто тепер у вас є інструмент на випадок, коли файл справді важливий.
Базова версія функції
Зараз ми зберемо функцію atomicWriteText, яка реалізує протокол:
- переконатися, що папка під target існує (або створити її)
- записати новий текст у temp
- якщо target уже існує — вирішити, що робити (у цій лекції проста політика: видалити, щоб не заважав rename)
- виконати renameTo(temp -> target) і перевірити результат
- у будь‑якому разі спробувати прибрати temp («best effort») у finally
Тут важливо, що finally — це наш «прибиральник сцени»: він виконається і в разі успіху, і в разі помилки. Kotlin (і JVM) гарантують виконання finally під час виходу з try (окрім рідкісних екзотичних випадків), тож це чудовий інструмент для cleanup.
Перша версія функції буде максимально зрозумілою — без «оптимізацій для краси»:
import java.io.File
import java.io.IOException
fun atomicWriteText(target: File, text: String): Boolean {
val parent = target.parentFile ?: File(".")
if (!parent.exists() && !parent.mkdirs()) return false
val temp = File(parent, target.name + ".tmp")
return try {
temp.writeText(text)
if (target.exists() && !target.delete()) return false
temp.renameTo(target)
} catch (e: IOException) {
false
} finally {
if (temp.exists()) temp.delete() // best effort
}
}
Зверніть увагу на важливий «психологічний момент»: finally — це не «100 % гарантія видалення temp», бо delete() теж може не спрацювати. Але нам і не потрібна абсолютна гарантія. Нам важливо після звичайних помилок не залишати тимчасові файли — настільки, наскільки це можливо.
Поліпшуємо читабельність: невеликі helper‑функції
Коли функція починає робити багато дрібних перевірок, новачок часто впадає в одну з двох крайнощів: або пише все в одному величезному блоці й плутається, або ділить на десятки мікрофункцій і губить нитку. Ми оберемо золоту середину: винесемо най«промовистіші» частини в helpers, щоб основний протокол читався як історія.
До речі, Kotlin заохочує «вирази замість церемоній», і try теж може бути виразом, тобто повертати значення. Це нормально й часто справді читабельно.
Зробімо підготовку директорії окремою функцією:
import java.io.File
fun ensureParentDir(target: File): Boolean {
val parent = target.parentFile ?: File(".")
if (parent.exists()) return parent.isDirectory
return parent.mkdirs()
}
І генерацію temp поруч:
import java.io.File
fun tempFor(target: File): File {
val parent = target.parentFile ?: File(".")
return File(parent, target.name + ".tmp")
}
Тепер основний протокол можна переписати так, щоб його було простіше читати:
import java.io.File
import java.io.IOException
fun atomicWriteText(target: File, text: String): Boolean {
if (!ensureParentDir(target)) return false
val temp = tempFor(target)
return try {
temp.writeText(text)
if (target.exists() && !target.delete()) return false
temp.renameTo(target)
} catch (e: IOException) {
false
} finally {
if (temp.exists()) temp.delete()
}
}
Це все ще той самий алгоритм — просто тепер його легше пояснити людською мовою.
Видаляти target перед rename: компроміс
Ви могли помітити рядок:
if (target.exists() && !target.delete()) return false
Це проста політика: якщо файл уже є, ми видаляємо його, щоб renameTo міг «зайняти місце». На деяких платформах перейменування поверх наявного файла працює, а на деяких — ні. Тому для навчального проєкту ми робимо поведінку передбачуванішою.
Але тут є компроміс. Якщо ви видалили target, а потім renameTo() не спрацював, може статися ситуація «старого вже немає, а новий не опубліковано». Ми частково зменшуємо ризик тим, що видаляємо target після успішного запису temp, а не до нього. Проте все одно лишається вікно, коли старий файл уже видалено.
У цій лекції ми фіксуємо базовий протокол temp→rename і акуратну дисципліну перевірок. Якщо вам потрібно додатково захистити стару версію даних, знадобиться окремий механізм, який зберігає попередній файл перед заміною. Це окрема тема, і в межах цієї лекції ми в неї не заглиблюємося.
Чому try має бути вузьким
Ще одна типова пастка — загорнути в try взагалі все підряд: і збирання тексту, і форматування, і парсинг, і виведення повідомлень. Тоді ви ловите IOException, але насправді помилка могла бути зовсім не в I/O — і ви втрачаєте розуміння, що саме зламалося.
Ідея «вузького» try/catch така: всередину кладемо те, що справді взаємодіє з файловою системою. Усе інше — лишаємо зовні. Такий стиль узгоджується і з загальною рекомендацією тримати вирази (try, if, when) у читабельній формі: нехай вони повертають значення й не розростаються в «купу всього одразу».
Тобто ось так — нормально:
val text = lines.joinToString("\n", postfix = "\n")
val ok = atomicWriteText(out, text)
А ось так — уже сумнівно:
try {
val text = lines.joinToString("\n", postfix = "\n")
out.writeText(text)
println("ok")
} catch (e: Exception) {
println("щось пішло не так")
}
У другому варіанті ви «спіймали все», але втратили сенс.
6. Вбудовуємо atomic write в консольний застосунок
Щоб приклади не були «у вакуумі», продовжимо практичну ідею навчального застосунку: консольний трекер витрат. Припустімо, на цьому етапі курсу у вас уже є список витрат у памʼяті та прості команди (add/list). Зберігати їх лише в RAM сумно: закрили програму — і все зникло. Тому ми записуємо дані в текстовий файл.
Нехай формат буде простий: один рядок — один запис виду 2026-01-14;Coffee;4.50. Формат не ідеальний, але зараз мета не в ньому, а в надійній «публікації» файла.
Зробімо функцію збирання тексту для збереження (без складних форматів):
fun formatExpenseLine(date: String, title: String, amount: Double): String {
return "$date;$title;$amount"
}
Тепер — функція збереження списку витрат у файл. Зверніть увагу: ми спочатку будуємо весь текст у памʼяті, і лише потім пишемо його «атомарно» через temp+rename. Це важливо: якщо ви писатимете «по шматочку» прямо в target, то знову повернетеся до проблеми часткового запису.
import java.io.File
fun saveExpenses(target: File, lines: List<String>): Boolean {
val text = lines.joinToString(separator = "\n", postfix = "\n")
return atomicWriteText(target, text)
}
І приклад міні-main, який зберігає дві витрати:
import java.io.File
fun main() {
val out = File("data/expenses.txt")
val lines = listOf(
formatExpenseLine("2026-01-14", "Coffee", 4.50),
formatExpenseLine("2026-01-14", "Sandwich", 8.00)
)
val ok = saveExpenses(out, lines)
println("saved=$ok") // saved=true (якщо все минуло успішно)
}
Навіть якщо програма впаде посеред запису temp‑файла, старий expenses.txt не повинен перетворитися на «обрубок». Це і є практичний виграш від протоколу.
7. Типові помилки
Помилка №1: записувати temp‑файл не поруч із target.
Новачки часто пишуть temp у поточний каталог, а target — у data/out/.... Потім renameTo() раптово повертає false, і починається шаманство: «ну я ж перейменовую, чому не можна?». У межах нашого протоколу простіше й надійніше створювати temp у тій самій папці, що й target, — тоді «публікація» буде передбачуванішою.
Помилка №2: не перевіряти результат renameTo(...).
Дуже поширений сценарій: людина пише temp.renameTo(target) і відразу після цього друкує «Збережено!». Якщо renameTo повернув false, ви фактично збрехали собі й користувачу. А потім ще й можете видалити temp у finally, втративши свіжі дані. Правило просте: renameTo — це крок алгоритму, його результат обовʼязково потрібно перевіряти.
Помилка №3: видаляти target до того, як успішно записали temp.
Інколи код виглядає так: «спочатку видалю старий файл, потім запишу новий». Це повертає нас до найгіршого сценарію: якщо запис нового файла зірвався, старого вже немає. У нашому протоколі порядок важливий: спочатку повністю записуємо temp, і лише потім вирішуємо, що робити з target.
Помилка №4: забувати про cleanup тимчасового файла.
Якщо ви не використовуєте finally, то у разі винятку temp‑файл залишиться на диску. Один раз це не страшно, але з часом папка може перетворитися на музей .tmp-артефактів. finally — нормальний інструмент саме для таких «прибиральних» дій, і він гарантовано виконується під час виходу з try незалежно від того, чи був виняток.
Помилка №5: робити «атомарний запис», але записувати частинами прямо в temp без гарантії завершеності.
Інколи намагаються писати temp порядково й паралельно оновлювати якісь частини файла. Саме по собі це не заборонено, але тоді ви маєте бути впевнені: temp або дописано до кінця, або ви вважаєте його сміттям. Найзрозуміліший шлях для навчального проєкту — зібрати весь текст у рядок і записати його цілком. Так простіше читати й простіше налагоджувати.
Помилка №6: ловити Exception «про всяк випадок» і повертати true.
Найнебезпечніший варіант: «якщо помилка — ну гаразд, усе одно скажемо, що зберегли». Так ви гарантовано втратите дані й дізнаєтеся про це лише від користувача (який, як відомо, завжди «все робив правильно»). Краще повернути false і чесно повідомити, що збереження не вдалося, ніж удавати, що все добре.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ