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 надо либо корректно обработать на входе, либо убрать как служебный маркер, а не пытаться «угадать» кодировку до тех пор, пока не станет красиво.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ