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 |
| Диагностика | «почему файл битый?» — неприятный квест | проще понять, на каком шаге сломалось |
| Реализация | проще в 1 строку | чуть больше кода, но спокойнее спится |
Это не делает прямую запись «запрещённой». Просто теперь у вас появляется инструмент на случай, когда файл важен.
Базовая версия функции
Сейчас мы соберём функцию 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 и честно сообщить, что сохранение не удалось, чем притвориться, что всё хорошо.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ