1. Що таке «брудний текст»
Коли ви читаєте файл і отримуєте String, дуже хочеться думати: «Ну все, це вже текст — далі звичайні split, trim, порівняння». Але зовнішній світ любить сюрпризи: файл міг приїхати з Windows, з Excel, із чужого редактора, зі старої системи або з архіву. І в ньому можуть бути «некрасивості», яких не видно оком, але які ламають логіку.
«Брудний текст» у контексті сьогоднішньої лекції — це текст, який загалом читається, але має приховані технічні ефекти. Найпоширеніші два: BOM на початку (заважає першому слову або заголовку) і різні переноси рядків ("\n" проти "\r\n"). Вони ламають розбір за рядками, якщо ви очікували один конкретний формат.
Щоб не перетворювати парсинг на ворожіння на кавовій гущі, дотримуймося дисципліни: спочатку робимо вхід передбачуваним, і лише потім розбираємо дані.
2. План очищення
Якщо в попередньому дні про файли ви вже впіймали себе на думці «чому ж я читаю текст, а потім усе одно думаю про байти?», то сьогодні цей внутрішній конфлікт завершиться мирною угодою. Будемо чесно визнавати: файл починається з байтів, і деякі проблеми (наприклад, BOM) простіше й надійніше розвʼязувати саме на рівні байтів.
Цю схему зручно тримати в голові як мініпайплайн:
flowchart LR
A["readBytes()"] --> B[remove UTF-8 BOM]
B --> C["String(bytes, UTF_8)"]
C --> D[normalizeNewlines]
D --> E[parse / split / порівняння]
Зауважте: ми чистимо двічі, але на різних рівнях. BOM зручніше вирізати як байти, а переноси рядків — як текст (бо це саме послідовності символів у рядку).
3. Робота з байтами та BOM
Читаємо файл як байти
Перш ніж «правити» файл, корисно вміти швидко перевірити: а чи є там узагалі якісь підозрілі байти? Особливо в перших 3–4 байтах, де може «сидіти» UTF‑8 BOM. Для цього нам потрібен readBytes(). Він повертає ByteArray — масив байтів фіксованого розміру, з яким ми вже вміємо працювати за індексами.
Мініприклад «подивитися перші байти»:
import java.io.File
fun main() {
val bytes = File("data/input.txt").readBytes()
val preview = bytes.take(8).joinToString(separator = " ") { it.toUByte().toString() }
println("firstBytes=$preview") // наприклад: firstBytes=239 187 191 105 100 44 110 97
}
Коментар: я використовую toUByte(), щоб вивід був «людським» (0..255), а не відʼємними байтами. Це суто для діагностики.
Видаляємо UTF‑8 BOM
Тепер переходимо до головного «невидимки». UTF‑8 BOM — це три байти на самому початку файлу: EF BB BF (у hex). У Kotlin байт знаковий (-128..127), тому ми порівнюємо через 0xEF.toByte() і так далі — інакше буде плутанина.
Функція акуратного видалення BOM має робити дві речі: перевіряти наявність BOM і лише потім відрізати перші три байти. Якщо BOM немає — повертати вихідні байти без змін. Ми не «відрізаємо про всяк випадок», бо це може зʼїсти реальний перший символ.
fun removeUtf8Bom(bytes: ByteArray): ByteArray {
val hasBom =
bytes.size >= 3 &&
bytes[0] == 0xEF.toByte() &&
bytes[1] == 0xBB.toByte() &&
bytes[2] == 0xBF.toByte()
return if (hasBom) bytes.copyOfRange(3, bytes.size) else bytes
}
Тут є два практичні нюанси.
По‑перше, copyOfRange(3, bytes.size) створює новий масив і копіює дані. Це нормально: ми «чистимо вхід» і отримуємо нову версію. Такий підхід простіший для розуміння, ніж намагатися «зсувати» масив на місці. До того ж масиви мають фіксований розмір, тож «відрізати початок без копії» не вийде без хитріших конструкцій.
По‑друге, ми перевіряємо bytes.size >= 3, інакше звернення до bytes[2] вийде за межі індексу. Це той рідкісний випадок, коли кілька зайвих символів в умові рятують вам вечір.
Декодуємо в рядок UTF‑8
Після видалення BOM (якщо він був) можна безпечно перетворювати байти на рядок. І тут дуже важливо не повертатися до «кодування за замовчуванням». Ми вже домовилися, що в нормальному застосунку кодування — частина контракту, тому його вказують явно.
Мініфункція декодування:
fun decodeUtf8(bytes: ByteArray): String {
return String(bytes, Charsets.UTF_8)
}
І маленька перевірка «до/після» (на навчальних даних ви інколи буквально побачите різницю):
import java.io.File
fun main() {
val rawBytes = File("data/input.txt").readBytes()
val cleanBytes = removeUtf8Bom(rawBytes)
println("rawSize=${rawBytes.size}") // rawSize=...
println("cleanSize=${cleanBytes.size}") // cleanSize=... (може бути менше на 3)
}
4. Нормалізація переносів рядків
Навіть якщо ви перемогли BOM, переноси рядків можуть зіпсувати настрій. На Unix‑подібних системах частіше "\n" (LF). На Windows зазвичай "\r\n" (CRLF). Іноді «з давнини» трапляється навіть одиночний "\r". Візуально в редакторі ви бачите «новий рядок», а в даних це різні послідовності символів.
Чому це важливо? Тому що ви часто робите щось на кшталт text.split("\n") або шукаєте перший рядок через indexOf('\n'). Якщо всередині сидить "\r\n", то в рядків буде хвіст "\r". Далі раптово ламається порівняння: "id" не дорівнює "id\r".
Нормалізація переносів — це перетворення всіх варіантів на один. Найчастіше обирають "\n", бо він коротший і звичніший у коді.
fun normalizeNewlines(text: String): String {
val step1 = text.replace("\r\n", "\n")
return step1.replace("\r", "\n")
}
Порядок замін тут важливий: спочатку "\r\n", потім одиночний "\r". Інакше можна випадково перетворити "\r\n" на "\n\n" (спочатку замінили "\r" на "\n", а потім друга частина теж дала "\n").
Мінідемонстрація «чому це ламає порівняння»:
fun main() {
val windowsLine = "id,name\r\n"
val parts = windowsLine.split("\n")
println(parts[0] == "id,name") // false (насправді "id,name\r")
}
А після нормалізації все стає нудно‑правильним (а «нудно‑правильним» — це комплімент для вхідних даних):
fun main() {
val windowsLine = "id,name\r\n"
val normalized = normalizeNewlines(windowsLine)
println(normalized.split("\n")[0] == "id,name") // true
}
5. Функція readCleanUtf8
Тепер зберемо пайплайн в одну зручну функцію, щоб решта коду (парсинг, команди, звіти) взагалі не думала про BOM і переноси рядків. Це важливий стиль: «брудний вхід» приводимо до норми на межі, а всередині програми працюємо з чистими даними.
import java.io.File
fun readCleanUtf8(file: File): String {
val rawBytes = file.readBytes()
val bytesNoBom = removeUtf8Bom(rawBytes)
val rawText = String(bytesNoBom, Charsets.UTF_8)
return normalizeNewlines(rawText)
}
Зверніть увагу на імена змінних. rawBytes, bytesNoBom, rawText — це не «словесна вода», а спосіб не загубитися в рівнях. Коли ви за тиждень відкриєте код, вам не доведеться гадати, на якому кроці ви перебуваєте й «чи чистили ми вже вхід».
6. Приклад: читаємо список задач
Щоб ця лекція не була суцільною теорією, продовжімо просту консольну історію: «у нас є файл зі списком задач — по одній задачі на рядок». Це дуже схоже на те, як багато CLI‑утиліт зберігають дані в текстовому вигляді.
Уявімо формат файлу data/tasks.txt:
- перший рядок — заголовок TASKS
- далі — рядки задач
- порожні рядки ігноруємо
Проблема, якій ми хочемо запобігти: якщо файл створено чимось, що додало BOM, заголовок стане "\uFEFFTASKS" (або еквівалентом через байти), і перевірка заголовка зламається.
Зробімо функцію, яка бере текст і повертає список задач. (Так, це List<String> — колекції ми вже проходили, і тут вони дуже доречні.)
fun firstLine(text: String): String {
val idx = text.indexOf('\n')
return if (idx == -1) text else text.substring(0, idx)
}
Тепер простий розбір (зауважте: у поясненні можна обійтися й без списків, але в коді без колекцій було б штучно складно):
fun parseTasks(cleanText: String): List<String> {
val lines = cleanText.split('\n')
if (lines.isEmpty() || lines[0] != "TASKS") return emptyList()
return lines.drop(1).map { it.trim() }.filter { it.isNotEmpty() }
}
І звʼязуємо все в main:
import java.io.File
fun main() {
val file = File("data/tasks.txt")
val text = readCleanUtf8(file)
val tasks = parseTasks(text)
println("tasksCount=${tasks.size}") // наприклад: tasksCount=3
}
Тут «магія» в тому, що parseTasks отримує вже чистий текст. Вона не знає і не повинна знати про BOM, байти та "\r\n". Це і є гарний поділ відповідальності: функція очищення відповідає за середовище, а функція парсингу — за зміст.
7. Чому BOM краще чистити на байтах
Іноді виникає спокуса: «А давайте після читання тексту просто зробимо if (text.startsWith("\uFEFF")) text.substring(1)». І це справді інколи працює. Але байтовий підхід зазвичай стабільніший із двох причин.
Перша причина в тому, що BOM — це насамперед байтовий маркер. Якщо ви спершу декодували байти «як завгодно» (або навіть правильно), а потім намагаєтеся вгадувати, на що BOM перетворився в рядку, ви вже робите крок назад — до «симптомів», а не до «причини».
Друга причина практичніша: байти дозволяють вам точно сказати «так, це ті самі EF BB BF» і не чіпати дані, якщо це не BOM. На рівні рядків іноді починають зʼявлятися несподівані «схожі символи», і налагодження перетворюється на виставу «чому в мене перший символ невидимий».
Водночас знати про '\uFEFF' корисно як про діагностичний слід: якщо ви десь надрукували рядок і бачите дивну поведінку першого символу — це гарний натяк. Але «лікувати» зручніше на байтах.
8. Типові помилки
Помилка № 1: спочатку робити split("\n"), а нормалізацію переносів — потім.
Такий порядок майже гарантовано дасть вам рядки на кшталт "TASKS\r" або "id,name\r", після чого почнуться фантомні помилки порівняння. Нормалізувати переноси потрібно до будь-якого розбиття за рядками, інакше ви «вбудовуєте» проблему в структуру даних.
Помилка № 2: «відріжемо перший символ — раптом це BOM».
Це класична спроба лікувати невідому проблему методом «видалимо щось». Якщо BOM не було, ви зіпсуєте дані: перша літера заголовка зникне, і ви створите помилку там, де її не було. BOM видаляють лише після перевірки сигнатури (на байтах) або конкретного "\uFEFF" на початку рядка.
Помилка № 3: змішувати рівні даних в одній змінній text.
Коли одна й та сама змінна по черзі означає «сирі байти», «рядок, як прочитався», «рядок після видалення BOM», «рядок після нормалізації» — ви дуже швидко втрачаєте контроль. Код починає виглядати коротким, але стає крихким: ви не розумієте, у якому стані дані зараз. Різні імена (rawBytes, bytesNoBom, rawText, cleanText) роблять код довшим на кілька рядків, зате економлять години.
Помилка № 4: забути про bytes.size >= 3 під час перевірки BOM.
Так, файли інколи бувають порожніми. Або містять 1–2 байти (наприклад, хтось створив «порожняк»). Якщо без перевірки полізти в bytes[2], ви отримаєте падіння за індексом і шукатимете «проблеми кодування», хоча проблема — у межах масиву. А масиви, як ми вже знаємо, межі люблять.
Помилка № 5: вважати, що якщо «в редакторі видно нормально», то кодувати й чистити не треба.
Редактор — це теж програма, яка робить вибір: яке кодування застосувати і як показати переноси. Ваша програма має бути стійкою без підказок редактора. Тому очищення входу — це не параноя, а нормальна інженерна звичка.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ