1. Введение
Если честно, println() — это как разговаривать с программой через окно: удобно, пока вы рядом и смотрите. Но как только вам нужно сохранить результат «на потом» (отчёт, лог, экспорт данных), консоль становится плохим архивом: она очищается, теряется, и вообще не обязана быть источником правды. Запись в файл — это способ сделать результат работы программы долговечным: сегодня посчитали, завтра открыли, через неделю отправили коллеге.
На практике текстовая запись в файл часто нужна даже самым простым консольным программам: сохранить отчёт в "data/report.txt", вести журнал событий "data/app.log", выгрузить список строк «как есть», чтобы потом другой инструмент (или вы сами) мог это прочитать. И главное — это очень легко сделать, если понимать два метода: writeText() и appendText().
В этой лекции мы пишем под Kotlin/JVM и используем java.io.File (это нормальная и ожидаемая практика на JVM).
2. writeText и appendText: перезапись и дозапись
writeText(...): перезаписать файл целиком
Когда вы впервые видите writeText("..."), мозг часто думает: «О, записать текст в файл, отлично». А потом наступает классика: «Почему мой файл каждый раз пустеет и там только последняя строка?!». Потому что writeText перезаписывает файл целиком. Он ведёт себя так, будто говорит: «старое содержимое — спасибо, до свидания».
Это очень полезно для отчётов, финальных результатов и любых файлов, где вы хотите иметь актуальную версию целиком. Например: «сгенерировать отчёт о расходах» — вы обычно хотите, чтобы файл содержал ровно один свежий отчёт, а не все отчёты за последние 300 запусков.
Мини‑пример: записали текст, потом прочитали обратно (чтение — это прошлый материал, тут только для наглядности).
import java.io.File
fun main() {
val out = File("data/out.txt")
out.parentFile?.mkdirs()
out.writeText("Hello, file!\n")
println(out.readText()) // Hello, file!
}
Обратите внимание на "\n": перенос строки — это не «само получится». Про это мы поговорим ниже, но уже сейчас полезно запомнить: если вы пишете «строками», то перенос строки чаще всего добавляете сами.
Чуть более «жизненный» пример: перезаписываем файл дважды и смотрим, что останется.
import java.io.File
fun main() {
val f = File("data/demo.txt")
f.parentFile?.mkdirs()
f.writeText("First\n")
f.writeText("Second\n")
println(f.readText()) // Second
}
В файле останется только "Second". Это ожидаемо. Если вам нужно «добавить к уже существующему» — это другой метод.
appendText(...): дописать в конец
appendText() — это способ сказать файлу: «Не трогай то, что уже есть, просто добавь в конец вот это». Он идеально подходит для логов, накопительных журналов, простых историй действий: «программа стартовала», «пользователь ввёл команду», «выполнили экспорт».
Важно почувствовать разницу на уровне намерения:
- Отчёт обычно делают через writeText(), потому что отчёт — это «текущее состояние целиком».
- Лог обычно делают через appendText(), потому что лог — это «история событий во времени».
Мини‑пример «лог из двух строк»:
import java.io.File
fun main() {
val log = File("data/app.log")
log.parentFile?.mkdirs()
log.appendText("Started\n")
log.appendText("Finished\n")
println(log.readText())
// Started
// Finished
}
Тут вы сразу видите вторую важную мысль: appendText не добавляет перенос строки автоматически. Он добавляет ровно те символы, которые вы ему дали. Если вы забудете "\n", лог будет выглядеть так, будто его писал очень торопливый человек на одном дыхании.
Переносы строк: как не склеить записи
Переносы строк — штука простая, но коварная. Она коварная потому, что человеческий глаз ожидает, что «две записи» будут на двух строках. А компьютеру всё равно: ему сказали «дописать текст», он дописал. Где там «строка» — это уже ваша договорённость с форматом.
У вас есть два базовых подхода:
- Первый — вы всегда добавляете "\n" в конце каждой строковой записи, когда делаете appendText(). Это самый прямой, «честный» способ.
- Второй — вы собираете многострочный текст заранее (например, через joinToString("\n") или через """...""".trimIndent()), а потом делаете один writeText(...).
Небольшая таблица, чтобы не путаться:
| Сценарий | Что делаем | Почему так |
|---|---|---|
| Пишем финальный отчёт целиком | |
проще контролировать итоговый формат |
| Ведём лог событий | |
каждое событие — отдельная строка |
| Записываем список строк | |
понятно, где разделители |
Пример: у нас есть список строк, и мы хотим аккуратно сохранить его как «файл со строками».
import java.io.File
fun main() {
val lines = listOf("milk", "bread", "coffee")
val text = lines.joinToString(separator = "\n") + "\n"
val file = File("data/list.txt")
file.parentFile?.mkdirs()
file.writeText(text)
println(file.readText())
// milk
// bread
// coffee
}
Почему я добавил + "\n" в конце? Это не строго обязательно, но это частая привычка: заканчивать текстовый файл переносом строки. Тогда, если вы потом будете appendText("new item\n"), у вас не получится «слипание» последней строки с новой записью.
3. Подготовка директории перед записью
Это один из самых типичных «первых ударов» при работе с файлами: вы делаете путь вроде "data/reports/report.txt", вызываете writeText(...), и получаете ошибку. Причина часто не в файле, а в том, что директории "data/reports" не существует.
Файл нельзя создать в папке, которой нет. Это звучит как очевидность, но мозг новичка почему-то уверен, что компьютер «сам догадается» и создаст папки. Нет, не догадается: ему же не сказали.
Правильный минимальный паттерн перед записью:
- создать File на целевой файл
- создать родительскую директорию через parentFile?.mkdirs()
- сделать writeText или appendText
Пример:
import java.io.File
fun main() {
val report = File("data/reports/today.txt")
report.parentFile?.mkdirs()
report.writeText("Report: OK\n")
println(report.absolutePath) // (выведет абсолютный путь на вашей машине)
}
Тут есть аккуратный нюанс: parentFile может быть null, если вы пишете в файл без папок, например File("out.txt"). Поэтому мы и используем безопасный вызов ?..
Ещё один полезный момент: mkdirs() создаёт цепочку директорий. Если вы попробуете mkdir() на "data/reports", а "data" ещё нет — mkdir() не справится. В этом смысле mkdirs() для «вложенных путей» почти всегда удобнее.
4. Практический пример: отчёт и лог для трекера расходов
Чтобы тема не осталась набором отдельных команд, давайте вплетём запись файлов в маленький фрагмент нашего консольного приложения (условно назовём его ExpenseTracker). Представим, что к этому моменту курса вы уже умеете хранить расходы в коллекции и строить текстовый отчёт строкой. Сейчас наша задача — сохранить этот отчёт в файл и параллельно вести простой лог запусков.
Начнём с модели (минимально, чтобы пример был самодостаточным):
data class Expense(val title: String, val amount: Int)
Теперь функция, которая строит многострочный текст отчёта. Мы специально делаем это отдельной функцией: файл — это про запись, а отчёт — это про форматирование данных. Разделение «собрали текст → записали текст» делает программу заметно проще для мозга.
fun buildReport(expenses: List<Expense>): String {
val total = expenses.sumOf { it.amount }
val body = expenses.joinToString("\n") { "- ${it.title}: ${it.amount}" }
return "Expenses:\n$body\nTotal: $total"
}
Обратите внимание: здесь мы формируем переносы строк внутри строки отчёта. Это нормально, потому что отчёт — это именно текстовый документ.
Теперь — запись отчёта в файл через writeText(). И сразу в этой же логике подготовим директорию.
import java.io.File
fun saveReport(reportText: String) {
val file = File("data/reports/expenses.txt")
file.parentFile?.mkdirs()
file.writeText(reportText + "\n")
}
А теперь — лог через appendText(). Пусть каждый запуск добавляет строку в "data/app.log".
import java.io.File
fun appendLog(message: String) {
val log = File("data/app.log")
log.parentFile?.mkdirs()
log.appendText(message + "\n")
}
И маленький main, который показывает идею целиком: создали данные, собрали отчёт, сохранили, записали лог.
import java.io.File
fun main() {
val expenses = listOf(
Expense("Coffee", 250),
Expense("Lunch", 600)
)
val report = buildReport(expenses)
saveReport(report)
appendLog("Saved report with ${expenses.size} expenses")
println("Done") // Done
}
Тут важна дисциплина: main не превращается в «лапшу из файловых операций». Он вызывает понятные функции: buildReport, saveReport, appendLog. Внутри функций — ровно одна ответственность: подготовить путь и записать.
5. Как выбрать метод: мини‑схема
Когда вы пишете реальную программу, выбор между writeText и appendText можно сделать почти механическим. Если вы ловите себя на мысли «я хочу получить актуальную версию файла, чтобы там было ровно то, что сейчас посчитано», то это writeText. Если мысль другая: «я хочу сохранить историю, чтобы новые записи добавлялись», то это appendText.
Для визуальной памяти вот простая блок‑схема:
flowchart TD
A["Нужно записать текст в файл"] --> B{"Это финальный результат (файл должен отражать текущее состояние)?"}
B -->|Да| C["writeText(...)"]
B -->|Нет, это история событий| D["appendText(...)"]
C --> E["Не забыть подготовить папку parentFile?.mkdirs()"]
D --> E
E --> F["Не забыть перенос строки, если пишем 'строками'"]
6. Типичные ошибки при записи текста в файл
Ошибка №1: использовать writeText там, где нужен appendText, и терять историю.
Самый болезненный вариант — это логирование. Вы хотите увидеть, что происходило в течение десяти запусков, а видите только последнее событие. Причина почти всегда одна: вы по привычке сделали writeText и каждый раз перезаписывали файл. Для логов, журналов, накопительных списков почти всегда нужен appendText.
Ошибка №2: забыть "\n" при дозаписи и получить «склеенный» файл.
appendText("Started") и потом appendText("Finished") создадут в файле "StartedFinished". Компьютер не обязан угадывать, что вы имели в виду строки. Если вы пишете «по строкам», добавляйте перенос строки явно: appendText(message + "\n"). Это кажется мелочью, но именно из таких мелочей потом появляются «почему лог нечитаемый».
Ошибка №3: пытаться записать файл в несуществующую директорию.
Путь вроде "data/reports/report.txt" выглядит красиво, но если папка "data/reports" не создана, запись упадёт. Хорошая привычка: перед writeText/appendText всегда делать file.parentFile?.mkdirs(). Это особенно важно, когда путь строится динамически и вы не контролируете, какие подпапки понадобятся.
Ошибка №4: смешивать «сбор текста» и «запись текста» в один огромный кусок кода.
Когда вы одновременно внутри одного блока делаете вычисления, форматирование, работу со строками, проверку папок и запись, код становится тяжело читать и легко ломать. Гораздо устойчивее стиль, где вы сначала получаете val reportText = ..., а затем одним понятным шагом делаете writeText(reportText). Если позже формат отчёта изменится — вы не рискуете случайно сломать файловую часть.
Ошибка №5: не понимать, что appendText добавляет «как есть», включая лишние пробелы и случайные пустые строки.
Иногда в лог попадает строка с лишними пробелами или пустым сообщением, и потом вы «не можете понять», откуда в файле мусор. Причина простая: вы передали в appendText строку, которую не нормализовали. Даже для логов полезно заранее договориться: либо вы чистите строку (trim()), либо сознательно оставляете как есть, но тогда не удивляетесь последствиям.
Ошибка №6: писать в один и тот же файл из разных мест программы без единого правила.
Если в одном месте вы делаете writeText, а в другом — appendText в тот же файл, получится хаотичное поведение: то файл «обнуляется», то внезапно растёт. Здесь помогает дисциплина: один файл — одна роль. Если это отчёт, то он перезаписывается. Если это лог, то он дозаписывается. Смешение почти всегда приводит к сюрпризам.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ