JavaRush /Курсы /Kotlin SELF /Очистка “грязного текста”

Очистка “грязного текста”

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

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

Читаем файл как байты

Перед тем как “править” файл, полезно уметь быстро проверить: а есть ли там вообще какие-то подозрительные байты? Особенно в первых 34 байтах, где может сидеть 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.
Да, файлы иногда бывают пустыми. Или содержат 12 байта (например, кто-то создал пустышку). Если без проверки полезть в bytes[2], вы получите падение по индексу и будете искать “проблемы кодировки”, хотя проблема — в границах массива. А массивы, как мы уже знаем, границы любят.

Ошибка №5: считать, что раз “в редакторе видно нормально”, значит кодировать и чистить не надо.
Редактор — это тоже программа, которая делает выбор: какую кодировку применить и как показать переносы. Ваша программа должна быть устойчивой без подсказок редактора. Поэтому чистка входа — это не паранойя, а нормальная инженерная привычка.

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