1. BOM: що це і навіщо він існує
Якщо ви коли-небудь потрапляли в ситуацію «рядок виглядає однаково, але == каже „ні“», то вітаю: ви познайомилися з класом проблем, у яких винні невидимі символи. BOM (Byte Order Mark) — один із найпопулярніших «ніндзя» в цій категорії. Він може зʼявитися на початку текстового файлу й ніяк не видати себе під час звичайного перегляду. А потім — раптово зламати розбір, заголовки та порівняння.
BOM — це службова мітка: набір спеціальних байтів на самому початку файлу. Історично її використовували, щоб підказати «яке кодування» і/або «який порядок байтів» (особливо це стосується UTF‑16). Важливо: BOM не є даними користувача. Це не «частина тексту», яку людина хотіла зберегти. Але якщо ви наївно читаєте файл як рядок, а потім порівнюєте «перше слово», BOM перетворюється на пісок у шестернях.
Уявіть, що ви очікуєте на вході паспорт («HEADER»), а вам підсовують паспорт із невидимою наліпкою поверх першої літери. Візуально це той самий документ, але формально — уже ні.
Як BOM ламає початок файлу
На практиці BOM найчастіше проявляється так: перший рядок або перший токен „не збігається“, хоча в логах або в редакторі все виглядає однаково. Особливо боляче це бʼє по форматах «перший рядок — заголовок» або «файл починається з магічної сигнатури».
Уявімо наш навчальний CLI‑проєкт: список завдань, який зберігається в текстовому файлі. Ми домовилися, що перший рядок файлу має бути заголовком:
TODO_V1
А далі йдуть рядки завдань (формат зараз не важливий — це просто текст).
fun main() {
val expectedHeader = "TODO_V1"
val actualHeader = "\uFEFFTODO_V1"
println(expectedHeader == actualHeader) // false
}
З погляду людини це «одне й те саме». З погляду програми — ні, тому що \uFEFF на початку робить рядок іншим.
І ось ви пишете чесну перевірку:
fun isValidHeader(header: String): Boolean {
return header == "TODO_V1"
}
…а вона починає повертати false «без причини». Причина є. Просто вона невидима — як баги в продакшн-середовищі.
Як виявити BOM за байтами
Коли йдеться про BOM, дуже корисно памʼятати про межу: на диску — байти, а рядок зʼявляється потім. Якщо ми хочемо впевнено розпізнати BOM, логічно спершу подивитися на перші байти файлу. Для UTF‑8 BOM — класичний префікс із трьох байтів:
EF BB BF
Щоб перевіряти це в Kotlin, нам потрібен ByteArray. Це як IntArray, тільки для байтів: масив примітивів, без «обгорток». Kotlin прямо виділяє такі масиви в окремі типи (зокрема ByteArray).
Мініперевірка UTF‑8 BOM за першими трьома байтами:
fun hasUtf8Bom(bytes: ByteArray): Boolean {
return bytes.size >= 3 &&
bytes[0] == 0xEF.toByte() &&
bytes[1] == 0xBB.toByte() &&
bytes[2] == 0xBF.toByte()
}
Зверніть увагу на два практичні моменти.
По-перше, ми обовʼязково перевіряємо bytes.size >= 3. BOM може й не бути, файл може бути порожнім, файл може складатися з двох байтів — і якщо забути про перевірку, ви отримаєте виняток із серії «індекс вийшов за межі масиву». А винним буде зовсім не BOM, а ваш поспіх.
По-друге, ми порівнюємо з toByte(), тому що літерали 0xEF — це Int, а в масиві лежать Byte. Kotlin тут змушує бути чесним: хочете порівнювати — зводьте типи явно.
Як BOM виглядає в рядку: '\uFEFF'
Іноді вам не хочеться лізти в байти. Наприклад, ви вже прочитали файл через readText(Charsets.UTF_8) і отримали рядок, а десь далі несподівано руйнується розбір заголовка. У таких випадках BOM може проявитися як перший символ рядка '\uFEFF'.
Важливо розуміти: це не «якийсь пробіл». І це не обовʼязок trim() — прибирати BOM. trim() працює з пробільними символами та переносами, але BOM — окрема сутність. Тож поведінка тут легко виявиться не такою, на яку ви сподівалися.
Перевірка «чи є BOM‑символ на початку рядка»:
fun startsWithBomChar(text: String): Boolean {
return text.isNotEmpty() && text[0] == '\uFEFF'
}
І обережне видалення:
fun dropLeadingBomChar(text: String): String {
return if (text.isNotEmpty() && text[0] == '\uFEFF') {
text.substring(1)
} else {
text
}
}
Ключове слово тут — обережне. Ми не «завжди відрізаємо перший символ», а робимо це лише тоді, коли впевнено розпізнали символ BOM.
Які BOM бувають: мінімум для практики
Зараз важливо не вивчити Unicode «назубок», а сформувати практичну інтуїцію: BOM — це префікс, який може мати різну довжину. Ми сфокусуємося на найчастішому випадку (UTF‑8), але корисно знати, що UTF‑16 теж любить починати файл зі службових байтів.
| Кодування (часті випадки) | BOM у hex (на початку файлу) | Довжина |
|---|---|---|
| UTF‑8 | |
3 байти |
| UTF‑16 LE / BE | бувають варіанти (часто 2 байти) | 2 байти |
Чому я не розписую всі варіанти UTF‑16 детально? Тому що мета лекції — навчитися розпізнавати сам факт BOM і його ефект, а не влаштовувати іспит «вгадай endianness». Нам достатньо такого: «у UTF‑8 це 3 байти EF BB BF, а в інших кодуваннях теж можуть бути службові байти». Це вже заощаджує купу часу під час налагодження.
Як видаляти BOM правильно: спочатку перевіряємо
Зараз буде момент, у якому новачок часто робить «різкий і красивий» хід, а потім тиждень ловить примар: «давайте завжди відкинемо перші 3 байти!» — і «проблема BOM зникне».
Так, зникне. Разом із першими трьома байтами реальних даних у тих файлах, де BOM не було. Тобто ви полагодите один сценарій, а зламаєте десять інших — просто менш помітних.
Правильна ідея така: спочатку перевірити, потім видалити.
Видалення UTF‑8 BOM на рівні байтів:
fun dropUtf8Bom(bytes: ByteArray): ByteArray {
return if (hasUtf8Bom(bytes)) {
bytes.copyOfRange(3, bytes.size)
} else {
bytes
}
}
ByteArray — це той самий «примітивний масив» байтів, з яким зручно робити такі операції.
Вбудовуємо BOM‑перевірку в проєкт із завданнями
Тепер зберемо все в мініісторію, максимально схожу на реальне життя. Нехай у нас файл data/todo.txt має починатися із заголовка TODO_V1. Ми читаємо перший рядок і порівнюємо.
Для простоти зробимо функцію, яка бере перший рядок із тексту:
fun firstLine(text: String): String {
val idx = text.indexOf('\n')
return if (idx == -1) text else text.substring(0, idx)
}
Тепер читаємо файл у UTF‑8 (як ми домовилися в минулій лекції — явно задаємо кодування) і валідуємо заголовок:
import java.io.File
fun main() {
val text = File("data/todo.txt").readText(Charsets.UTF_8)
val header = firstLine(text)
println("header='$header'") // може бути з BOM на початку
println(header == "TODO_V1") // false, якщо header = "\uFEFFTODO_V1"
}
Проблема тут у тому, що readText(Charsets.UTF_8) чесно прочитав файл як UTF‑8. Але якщо редактор або утиліта додали BOM, він перетворюється на символ у рядку — і ви отримуєте «невидимий префікс».
Виправлення «на рівні рядка» виглядатиме так: очищаємо заголовок від символа BOM перед порівнянням.
import java.io.File
fun main() {
val text = File("data/todo.txt").readText(Charsets.UTF_8)
val header = dropLeadingBomChar(firstLine(text))
println(header == "TODO_V1") // true
}
Це вже набагато краще, ніж «відрізати завжди», тому що ми видаляємо символ лише за умови.
Діагностика: як довести, що це BOM
Коли ви підозрюєте BOM, дуже хочеться «побачити докази». І тут у нас є два хороші прийоми: подивитися перші байти або вивести код першого символу рядка. Обидва варіанти корисні, тому що іноді ви читаєте файл не тим способом — і симптом змінюється.
Спочатку надрукуємо перші 6 байтів у hex‑вигляді (мінімальний варіант, без складних форматерів):
fun firstBytesHex(bytes: ByteArray, n: Int): String {
val count = minOf(n, bytes.size)
return bytes.take(count).joinToString(" ") { b ->
"%02X".format(b.toInt() and 0xFF)
}
}
А тепер застосуємо:
import java.io.File
fun main() {
val bytes = File("data/todo.txt").readBytes()
println(firstBytesHex(bytes, 6)) // наприклад: EF BB BF 54 4F 44
}
Якщо ви побачили EF BB BF, то майже напевно це UTF‑8 BOM. Далі йдуть байти 54 4F 44 (це ASCII/UTF‑8 для T O D).
Якщо ви вже читаєте рядок і хочете перевірити символ BOM:
fun main() {
val s = "\uFEFFTODO_V1"
println(s[0].code) // 65279
}
Число 65279 — це код U+FEFF. Це і є той самий «невидимий пасажир».
BOM ламає не лише ваш код
Іноді здається: «Ну це я криво парсю». Але BOM уміє ламати й інструменти. Наприклад, у changelog Kotlin згадували проблеми з UTF‑8 BOM у Kotlin scripting (тобто не лише ви страждаєте — страждають і дорослі люди з компіляторами).
Це корисно памʼятати з двох причин. Перша — ви перестаєте відчувати себе єдиною людиною на планеті, яка «зламала рядок». Друга — ви починаєте ставитися до BOM як до реального зовнішнього фактора, який потрібно враховувати під час роботи з файлами. Особливо якщо файли можуть приходити з різних редакторів, ОС і інструментів.
2. Типові помилки під час роботи з BOM
Помилка № 1: «відріжемо перший символ (або перші 3 байти) завжди — і готово».
Це дуже спокусливо, бо виглядає як швидкий фікс. Але так ви починаєте пошкоджувати коректні файли без BOM. Найнеприємніше те, що пошкодження може бути не одразу помітним: заголовок «полагодився», а от перше завдання або перша літера першого рядка — зникла. Потрібно видаляти BOM лише після перевірки, що він справді є.
Помилка № 2: плутанина рівнів «байти vs рядок».
Часто роблять так: файл уже прочитаний як String, але потім намагаються «перевірити перші байти» — і починають городити дивні конструкції, які порівнюють Char з 0xEF. Це різні сутності. Якщо ви хочете надійно перевірити UTF‑8 BOM як EF BB BF, робіть це на ByteArray. Якщо ви хочете швидко прибрати «невидимий символ» на початку рядка — перевіряйте '\uFEFF'. Важливо просто не змішувати ці рівні в одній перевірці.
Помилка № 3: очікування, що trim() зобовʼязаний прибрати BOM.
trim() — це не «універсальна швабра для всіх невидимих проблем». Він не призначений для видалення BOM. І навіть якщо в якомусь оточенні це випадково допомогло, це не контракт, на який варто спиратися. Для BOM краще мати явну перевірку на '\uFEFF' або перевірку перших байтів.
Помилка № 4: відсутність перевірки довжини масиву байтів.
Перевірка BOM за байтами майже завжди починається з bytes.size >= 3 (для UTF‑8 BOM). Якщо забути цю умову, на порожньому файлі або на короткому файлі ви отримаєте падіння за індексом. І це буде особливо дратувати, бо падіння станеться «на діагностиці», а не в реальній логіці.
Помилка № 5: спроба лікувати BOM «підбором іншого кодування».
Іноді пробують: «а давайте прочитаємо UTF‑16… а давайте прочитаємо ASCII…». Це може перетворити текст на «кракозябри», але BOM не зникне як явище. BOM — це окремий шар проблеми: службовий префікс у файлі. Кодування важливе, але BOM треба або коректно обробити на вході, або прибрати як службовий маркер — а не намагатися «вгадувати» кодування доти, доки не стане гарно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ