JavaRush /Курсы /Kotlin SELF /BOM — как распознать и почему он ломает файл

BOM — как распознать и почему он ломает файл

Kotlin SELF
44 уровень , 3 лекция
Открыта

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
EF BB BF
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 надо либо корректно обработать на входе, либо убрать как служебный маркер, а не пытаться «угадать» кодировку до тех пор, пока не станет красиво.

1
Задача
Kotlin SELF, 44 уровень, 3 лекция
Недоступна
Чистый заголовок
Чистый заголовок
1
Задача
Kotlin SELF, 44 уровень, 3 лекция
Недоступна
Призрачный символ
Призрачный символ
1
Задача
Kotlin SELF, 44 уровень, 3 лекция
Недоступна
Сигнал по байтам
Сигнал по байтам
1
Задача
Kotlin SELF, 44 уровень, 3 лекция
Недоступна
Хекс контроль
Хекс контроль
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ