1. Вступ
Коли ви пишете програму, то майже завжди запускаєте її на своїй машині — зі своїм редактором і власними налаштуваннями. У цей момент легко повірити в добру казку: «Раз у мене файл відкрився нормально, значить, так буде в усіх». На жаль, світ ширший за ваш ноутбук: там бувають інші ОС, інші налаштування локалі й інші інструменти, які створюють файли з несподіваними параметрами. У результаті ви отримуєте класичну ситуацію: у мене працює, а в колеги замість „Привіт“ — незрозумілий набір символів.
Тут важливо розрізняти дві речі. По‑перше, деякі API (особливо з Java‑світу) під час читання або запису тексту можуть використовувати «кодування за замовчуванням» системи — і воно справді може відрізнятися. По‑друге, навіть якщо конкретний Kotlin‑метод має розумне значення за замовчуванням, воно все одно лишається «прихованою домовленістю»: читаючи код, ви не бачите, у якому форматі лежить файл. А файл — це зовнішній контракт. Краще, щоб цей контракт був прописаний прямо в коді, а не «в голові автора».
Формула проста: явний Charset робить поведінку відтворюваною. Це як val замість «ну я постараюся не змінювати змінну». Не тому, що ви погана людина, а тому що люди забувають, а код — ні.
2. Charset і Charsets.*: як вибрати кодування в коді
Якщо в попередніх лекціях ми обговорювали кодування як ідею («правило перетворення байтів ↔ текст»), то тепер час перейти до конкретних об’єктів і викликів. У Kotlin/JVM кодування найчастіше представлене типом java.nio.charset.Charset, а стандартні варіанти зручно брати з Charsets.
Найпростіша практична звичка: винести кодування застосунку в одну константу й використовувати її всюди на межі I/O. Так ви не отримаєте хаосу на кшталт «тут UTF‑8, тут забули, тут хтось поставив UTF‑16, бо “так красивіше”».
import java.nio.charset.Charset
private val APP_CHARSET: Charset = Charsets.UTF_8
Чому це зручно:
- ви один раз обрали формат файлів і далі його дотримуєтеся;
- якщо колись знадобиться змінити формат — ви зміните один рядок (і свідомо приймете наслідки);
- код стає самодокументованим: видно, що файл очікується в UTF‑8.
Невелика ремарка про ByteArray: коли ми читаємо файл як байти, то зазвичай кладемо їх саме в ByteArray — це «примітивний масив байтів» у Kotlin, без пакування в об’єкти. Це базовий контейнер для сирого вмісту файлу.
3. Читання і запис тексту з явним Charset
Коли ви лише починаєте, хочеться мати один універсальний рецепт: «Скажіть, яку одну функцію вивчити, і я піду жити». Добра новина: на старті вам справді вистачить двох методів — «прочитати весь файл у рядок» і «записати рядок повністю». Погана новина: життя різноманітніше, і часом потрібно читати посторінково або писати акуратно через writer. Але почнемо з простого.
Уявімо, що ми розвиваємо невеликий консольний застосунок NotesApp, який зберігає нотатки у файлі data/notes.txt. На цьому курсі ми намагаємося тримати приклади маленькими, але пов’язаними, тож сьогодні просто стабілізуємо шар читання й запису. Так завтра й післязавтра будь‑яка логіка парсингу не залежатиме від «магії оточення».
Читаємо весь файл як String (задаємо кодування)
import java.io.File
private val APP_CHARSET = Charsets.UTF_8
fun main() {
val file = File("data/notes.txt")
val text = file.readText(APP_CHARSET)
println("Символів у файлі: ${text.length}") // наприклад: Символів у файлі: 42
}
Зверніть увагу на важливу дисципліну: readText(APP_CHARSET) — це не «зайві літери». Це частина контракту. Так ви кажете: «мій файл — UTF‑8». І якщо він не UTF‑8, це теж корисна інформація: отже, проблема не у вашому парсингу нотаток, а у форматі вхідних даних.
Записуємо String у файл (задаємо кодування)
import java.io.File
private val APP_CHARSET = Charsets.UTF_8
fun main() {
val out = File("data/notes.txt")
out.parentFile?.mkdirs()
out.writeText("Перша нотатка\nДруга нотатка\n", APP_CHARSET)
println("Записали файл notes.txt") // Записали файл notes.txt
}
Знову той самий принцип: ви не просто записали текст — ви записали його у конкретному кодуванні. Це означає, що інший код (або ви за тиждень) зможе прочитати файл за тим самим правилом.
4. Чому кодування за замовчуванням — ризик
У цьому розділі важливо не впасти в релігію. Немає мети «заборонити будь‑які значення за замовчуванням» і назавжди вигнати стислість із коду. Мета інша: прибрати приховані зовнішні залежності там, де вони справді шкодять.
Кодування за замовчуванням небезпечне тим, що:
воно залежить від оточення запуску, а оточення може змінюватися між вашою машиною, CI, комп’ютером колеги та сервером. Воно також може залежати від того, як саме створювали файл (редактор, експорт з Excel, завантаження з вебу). Якщо ви «вгадали» кодування випадково, ви побудували програму на піску. Вона не зламалася лише тому, що вам поки щастило.
Перевірити, яке кодування вважається «за замовчуванням» у поточній JVM, можна так:
import java.nio.charset.Charset
fun main() {
val def = Charset.defaultCharset()
println("Default charset = $def") // наприклад: Default charset = UTF-8
}
Цей вивід корисний як діагностика, але як стратегія розроблення — слабуватий. Надійніше не покладатися на значення за замовчуванням там, де ви читаєте або пишете зовнішні файли, а передавати Charsets.UTF_8 (або інший заздалегідь обраний формат).
Коли зручніше читати байти (readBytes) і декодувати вручну
Якщо файл «звичайний і чистий», readText(charset) — ідеальний варіант. Але інколи варто зробити крок назад і взяти під контроль найперший етап: байти. Зазвичай це потрібно, коли ви хочете:
акуратно продіагностувати вміст, подивитися перші байти, зрозуміти «що взагалі прийшло», або очистити чи нормалізувати дані перед тим, як перетворювати їх на рядок.
Тут корисна схема (вона ж чекліст у голові):
flowchart LR
A[Файл на диску] --> B[ByteArray]
B --> C["Декодування: String(bytes, charset)"]
C --> D[Обробка рядка: replace/trim/split]
D --> E[Логіка програми]
І код, який прямо відображає ці кроки:
import java.io.File
private val APP_CHARSET = Charsets.UTF_8
fun main() {
val file = File("data/notes.txt")
val bytes: ByteArray = file.readBytes()
val text: String = String(bytes, APP_CHARSET)
println(text)
}
Зверніть увагу: ми свідомо використовуємо ByteArray як контейнер сирих даних. Це стандартний «байтовий» тип у Kotlin, який напряму відповідає масиву байтів.
5. Потокове читання і запис із Charset
Читати файл цілком зручно, доки він маленький. Але щойно файл стає більшим, хочеться читати його поступово: посторінково або блоками. Ми поки не йдемо в «потокове декодування гігабайтів», але базовий патерн корисно знати вже зараз: bufferedReader(charset) / bufferedWriter(charset) + use {}.
Сенс простий: ви відкриваєте reader/writer, гарантуєте закриття через use, а всередині працюєте звичними методами.
Читаємо перший непорожній рядок нотаток
import java.io.File
private val APP_CHARSET = Charsets.UTF_8
fun main() {
val file = File("data/notes.txt")
val firstNonEmpty = file.bufferedReader(APP_CHARSET).use { reader ->
reader.lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotEmpty() }
}
println("Перший непорожній рядок: $firstNonEmpty")
}
Так, тут є Sequence, але сприймайте це як «лінивий список рядків»: ми не вантажимо весь файл у пам’ять, а беремо рядки в міру потреби.
Дописуємо нотатку в кінець файлу
import java.io.File
private val APP_CHARSET = Charsets.UTF_8
fun main() {
val file = File("data/notes.txt")
file.parentFile?.mkdirs()
file.appendText("Нова нотатка\n", APP_CHARSET)
println("Додали рядок у notes.txt") // Додали рядок у notes.txt
}
Ключовий момент: кодування має збігатися в усіх операціях з одним і тим самим файлом. Якщо ви один раз записали файл у UTF‑8, а потім дописали в іншому кодуванні, ви створите «листковий пиріг», з яким буде дуже «весело» під час парсингу.
6. Практичні патерни: перекодування і міні‑API
Перекодування файлу: два кроки без магії
Інколи трапляється задача: «У мене файл в одному кодуванні, а потрібно зберегти в іншому». Важливо розуміти: перекодування завжди проходить через рядок (тобто через Unicode‑подання всередині програми). Це не копіювання байтів один‑в‑один. Це переклад тексту за правилами.
Правильна схема:
flowchart LR
A[Bytes in old charset] -->|decode| B[String]
B -->|encode| C[Bytes in new charset]
І мінімальний приклад (без ускладнень):
import java.io.File
fun main() {
val input = File("data/in.txt")
val output = File("data/out.txt")
val text = input.readText(Charsets.UTF_8) // decode
output.writeText(text, Charsets.UTF_16) // encode
println("Готово") // Готово
}
У реальності ви маєте знати, яке кодування у вхідного файлу, інакше ви не перекодуєте, а «вгадуватимете». Вгадування ми сьогодні не розглядаємо: це окрема складна тема, і вона часто закінчується тим, що програма впевнено робить неправильно.
Міні‑API: одна точка правди про кодування
Коли проєкт зростає, найчастіша проблема новачка — «ось тут прочитав так, ось тут по‑іншому, а ось тут узагалі забув». Найкращі ліки — зробити невеликий шар утиліт: функції readAppText(...), writeAppText(...), які завжди використовують одне кодування й ховають його від решти коду.
І тут стане в пригоді добра звичка з практики: файли й функції варто групувати логічно, щоб код не перетворювався на звалище. Kotlin‑рекомендації прямо заохочують тримати пов’язані речі поруч і давати файлам змістовні назви — замість «Util.kt» на всі випадки життя.
Зробімо файл TextFiles.kt (назва по суті), а всередині — дві функції.
import java.io.File
import java.nio.charset.Charset
private val APP_CHARSET: Charset = Charsets.UTF_8
fun readAppText(file: File): String {
return file.readText(APP_CHARSET)
}
fun writeAppText(file: File, text: String) {
file.parentFile?.mkdirs()
file.writeText(text, APP_CHARSET)
}
Тепер main нашого NotesApp стає спокійнішим і чистішим:
import java.io.File
fun main() {
val file = File("data/notes.txt")
val current = if (file.exists()) readAppText(file) else ""
val updated = current + "Нотатка, додана програмою\n"
writeAppText(file, updated)
println("Гаразд, оновили notes.txt") // Гаразд, оновили notes.txt
}
Що ми виграли:
Ми відокремили «брудний зовнішній світ файлів» від решти логіки, а також зробили кодування частиною інфраструктури. У результаті будь‑яка функція, яка «просто працює з рядком», узагалі не зобов’язана знати, що файл — це байти й що там є UTF‑8. Вона працює з рядком — і живе спокійно.
7. Типові помилки під час явного задання Charset
Помилка № 1: указати кодування лише в одному місці.
Перехід на явний Charset часто починається з ентузіазму («зараз усе виправлю!»), а закінчується несподіваними багами («чому стало гірше?»). Найчастіший промах — прописати кодування під час читання, але залишити запис «як було», або навпаки. Тоді половина файлу обробляється за одним правилом, а половина — за іншим, і ви отримуєте проблеми, які виглядають випадковими. На практиці кодування має бути узгоджене на всіх межах «байти ↔ рядок».
Помилка № 2: намагатися «перекодувати рядок» без байтів.
Перекодування — це завжди пара дій: decode → encode. Якщо ви вже отримали String, то всередині програми це нормалізований текст у пам’яті, і в нього немає «вбудованого кодування». Рядок не зберігає інформації про те, у якому кодуванні його було прочитано. Кодування існує лише в момент перетворення між байтами й текстом. Тому «змінювати кодування рядка» без роботи з байтами — логічна помилка.
Помилка № 3: плутати кількість символів і кількість байтів.
String.length повертає кількість символів (точніше, Char), а розмір файлу — це кількість байтів. Для UTF‑8 і UTF‑16 різниця може бути суттєвою: візуально ви бачите «6 символів», але у файлі вони займають більше місця. Спроби оцінити розмір файлу через text.length майже завжди приводять до хибних висновків. Якщо потрібен реальний розмір, використовуйте file.length() або рахуйте bytes.size.
Помилка № 4: використовувати String(bytes) без явного зазначення кодування.
Конструктор String(bytes) виглядає зручно, тож його легко написати «на автоматі». Але саме такі місця найчастіше стають джерелом несподіваних проблем, бо неочевидно, за яким правилом байти перетворюються на текст. Поведінка може залежати від налаштувань середовища або платформи. Набагато безпечніше писати явно: String(bytes, Charsets.UTF_8). Трохи довше — зате однозначно й передбачувано.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ