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(...).
Невелика таблиця, щоб не плутатися:
| Сценарій | Що робимо | Чому так |
|---|---|---|
| Пишемо фінальний звіт повністю | |
простішe контролювати підсумковий формат |
| Ведемо лог подій | |
кожна подія — окремий рядок |
| Записуємо список рядків | |
зрозуміло, де розділювачі |
Приклад: маємо список рядків і хочемо акуратно зберегти його як «файл із рядками».
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 у той самий файл, поведінка стане хаотичною: то файл «обнуляється», то раптом зростає. Тут допомагає дисципліна: один файл — одна роль. Якщо це звіт, то він перезаписується. Якщо це лог, то він дописується. Змішування майже завжди призводить до сюрпризів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ