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