JavaRush /Курси /Kotlin SELF /Запис тексту: writeText і appendText

Запис тексту: writeText і appendText

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

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(...).

Невелика таблиця, щоб не плутатися:

Сценарій Що робимо Чому так
Пишемо фінальний звіт повністю
writeText(report + "\n")
простішe контролювати підсумковий формат
Ведемо лог подій
appendText(line + "\n")
кожна подія — окремий рядок
Записуємо список рядків
writeText(lines.joinToString("\n") + "\n")
зрозуміло, де розділювачі

Приклад: маємо список рядків і хочемо акуратно зберегти його як «файл із рядками».

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" не існує.

Файл не можна створити в каталозі, якого немає. Звучить очевидно, але мозок новачка чомусь упевнений, що компʼютер «сам здогадається» й створить каталоги. Ні, не здогадається: ви ж йому цього не сказали.

Правильний мінімальний патерн перед записом:

  1. створити File для цільового файлу
  2. створити батьківський каталог через parentFile?.mkdirs()
  3. зробити 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 у той самий файл, поведінка стане хаотичною: то файл «обнуляється», то раптом зростає. Тут допомагає дисципліна: один файл — одна роль. Якщо це звіт, то він перезаписується. Якщо це лог, то він дописується. Змішування майже завжди призводить до сюрпризів.

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