JavaRush /Курси /Kotlin SELF /Atomic write: temp‑файл + r...

Atomic write: temp‑файл + renameTo як захист від часткового запису

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

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, яка реалізує протокол:

  1. переконатися, що папка під target існує (або створити її)
  2. записати новий текст у temp
  3. якщо target уже існує — вирішити, що робити (у цій лекції проста політика: видалити, щоб не заважав rename)
  4. виконати renameTo(temp -> target) і перевірити результат
  5. у будь‑якому разі спробувати прибрати 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 і чесно повідомити, що збереження не вдалося, ніж удавати, що все добре.

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