JavaRush /Курсы /Kotlin SELF /Кодировка: почему появляются “кракозябры”

Кодировка: почему появляются “кракозябры”

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

1. Введение

Если вы когда-нибудь открывали файл блокнотом и думали “ну это же текст, там буквы”, то вы в хорошей компании: так думают почти все до первого столкновения с кодировками. На самом деле операционная система и файловая система не хранят “буквы”, “строки” и “абзацы”. Они хранят числа от 0 до 255 (байты), просто в очень длинной последовательности. А “буквы” появляются позже — когда какая-то программа берёт эти байты и решает, как их превратить в символы.

Представьте, что байты — это ноты, записанные в виде цифр, а кодировка — это правило “какая цифра какой ноте соответствует” (и иногда — сколько цифр нужно на одну ноту). Если включить музыку, интерпретируя цифры неправильной таблицей, получится как минимум странно. С текстом — то же самое: байты те же, но правила интерпретации другие — и вместо “Привет” вы видите “ÐŸÑ€Ð¸Ð²ÐµÑ‚” или “??????”.

Очень важно зафиксировать границу:

Уровень Что это Как представлено в Kotlin
Файл на диске Последовательность байтов
ByteArray
Текст в программе Последовательность символов
String
Правило перехода Как байты превращаются в символы и обратно “кодировка” (Charset)

В Kotlin (на JVM) мы можем “честно” увидеть байты файла так:

import java.io.File

fun main() {
    val bytes = File("data/note.txt").readBytes()
    println(bytes.size) // например: 128
}

Пока что никакого текста нет — есть только ByteArray.

2. Кодировка: правило “байты ↔ текст”

В этом разделе мы аккуратно введём новый термин, потому что без него дальше будет трудно разговаривать. Кодировка — это не “формат файла” и не “язык”, а правило преобразования между байтами и символами. Причём правило двустороннее: как превратить байты в текст (это декодирование) и как превратить текст в байты (это кодирование). То есть кодировка — это часть контракта: “я записал байты так-то, ты прочитай их так-то”.

Полезно мыслить кодировку как “очки”. Байты — это один и тот же объект реальности. Кодировка — это линза. Надели правильные очки — видите нормальный текст. Надели неправильные — видите те же байты, но как бессмысленный набор символов. И именно поэтому “кракозябры” часто выглядят стабильно: это не случайный шум, а вполне определённая интерпретация.

На уровне API в Kotlin/JVM кодировка представляется типом Charset. Мы будем чаще всего пользоваться готовыми значениями из Charsets, например Charsets.UTF_8. Сегодня мы не углубляемся в то, какие бывают кодировки и чем они отличаются — нам важно понять принцип: кодировка обязана быть согласована между записью и чтением.

Чтобы почувствовать идею руками, начнём с простого: строка → байты.

fun main() {
    val text = "Привет"
    val bytes = text.toByteArray(Charsets.UTF_8)
    println(bytes.size) // 12 (для UTF-8 кириллица занимает больше 1 байта)
}

Здесь мы уже сделали важный шаг: мы явно сказали “преврати строку в байты по UTF-8”.

3. Кодирование и декодирование: два направления одного пути

Сейчас легко запутаться в словах, поэтому давайте зафиксируем терминологию на бытовом уровне. Когда у вас есть ByteArray и вы хотите получить String, вы делаете декодирование. Когда у вас есть String и вы хотите записать в файл байты, вы делаете кодирование. Это зеркальные операции, и они обязаны использовать одну и ту же кодировку, если вы хотите получить исходный текст обратно.

Очень удобно держать это в голове как маленький конвейер. Схема ниже почти всегда объясняет 80% загадок про “почему текст сломался”.

flowchart TD
    A[String: 'Привет'] -->|"encode: toByteArray(UTF-8)"| B[ByteArray]
    B -->|write to file| C[(Файл на диске)]
    C -->|"readBytes()"| D[ByteArray]
    D -->|"decode: String(bytes, UTF-8)"| E[String: 'Привет']

В Kotlin обе операции выглядят довольно прямолинейно:

  • Кодирование: text.toByteArray(charset)
  • Декодирование: String(bytes, charset)

Мини-пример “туда и обратно”:

fun main() {
    val original = "Hello"
    val bytes = original.toByteArray(Charsets.UTF_8)
    val decoded = String(bytes, Charsets.UTF_8)

    println(decoded) // Hello
}

Пока кодировка совпадает — всё хорошо. “Кракозябры” появляются, когда кодировки не совпали.

4. Диагностика и “кракозябры” в реальном коде

Как “увидеть байты”, чтобы перестать гадать

В начале изучения кодировок часто происходит типичная ситуация: “я открыл файл, вижу ерунду, попробовал ещё одну кодировку — вижу другую ерунду”. Это быстро превращается в шаманство. Чтобы не гадать, полезно уметь печатать байты в понятном виде, например в hex (шестнадцатеричном виде). Это не магия и не “для хакеров”, а просто удобный способ увидеть, что реально хранится в файле.

Давайте добавим в наш учебный проект небольшую утилиту: превратить ByteArray в строку hex-значений. Здесь мы используем простое форматирование и joinToString().

fun toHex(bytes: ByteArray): String {
    return bytes.joinToString(" ") { b -> "%02X".format(b) }
}

fun main() {
    val bytes = byteArrayOf(72, 101, 108, 108, 111)
    println(toHex(bytes)) // 48 65 6C 6C 6F
}

Теперь мы можем диагностировать: “в файле реально лежат байты 48 65 6C 6C 6F”. И это уже не эмоции, а факты.

Кстати, те же байты можно сразу декодировать как UTF-8 (или ASCII — для английского текста это совпадёт):

fun main() {
    val bytes = byteArrayOf(72, 101, 108, 108, 111)
    val text = String(bytes, Charsets.UTF_8)

    println(text) // Hello
}

Как появляются “кракозябры”

Теперь — центральный фокус лекции. “Кракозябры” — это почти всегда ситуация “байты правильные, но мы читаем их не теми правилами”. То есть ошибка не в том, что “файл испортился”, а в том, что мы сделали неверное декодирование (или раньше записали неверным кодированием). Самый простой способ увидеть это — закодировать текст одним способом, а декодировать другим.

Возьмём русское слово, закодируем в UTF‑8, а потом попробуем прочитать эти байты как ASCII. ASCII очень ограничен (по сути — латиница и служебные символы), поэтому всё, что не влезает, будет превращаться в “заменители” (часто это ?).

fun main() {
    val original = "Привет"
    val bytes = original.toByteArray(Charsets.UTF_8)

    val wrong = String(bytes, Charsets.US_ASCII)
    println(wrong) // ??????  (часто так, зависит от замены)
}

Важно: байты не изменились. Изменилось правило интерпретации. Поэтому вывод “??????” — это не “сломанный файл”, а логичный результат “ASCII не умеет кириллицу”.

Можно сделать ещё один показательный трюк: для слова "Hello" в ASCII и UTF‑8 байты одинаковые. Именно поэтому иногда кажется, что “кодировка не важна”: на английском тексте многие кодировки “случайно работают”.

fun main() {
    val s = "Hello"
    val a = s.toByteArray(Charsets.US_ASCII)
    val u = s.toByteArray(Charsets.UTF_8)

    println(a.contentEquals(u)) // true
}

А вот для "Привет" такого совпадения не будет, и это нормально.

Пример: “BudgetBuddy” и граница I/O

Сейчас сделаем маленький, но очень практичный шаг в сторону “взрослого” приложения. Представим, что у нас есть консольный проект BudgetBuddy (учёт расходов), который хранит данные в текстовом файле "data/expenses.txt". Мы уже умеем читать текст через readText(), но сегодня мы хотим понимать, что происходит на границе “файл → строка”, чтобы отлаживать случаи, когда файл принесли из другой системы или редактора.

Идея простая: сделаем функцию, которая читает файл как байты и превращает в строку явным декодированием. Сегодня мы зафиксируем только сам принцип (явно декодировать) — выбор конкретной кодировки и дисциплину “не полагаться на системную по умолчанию” мы развернём в следующих лекциях дня.

import java.io.File

fun readTextUtf8(file: File): String {
    val bytes = file.readBytes()
    return String(bytes, Charsets.UTF_8)
}

fun main() {
    val text = readTextUtf8(File("data/expenses.txt"))
    println(text.length) // например: 57
}

Да, это выглядит чуть “длиннее”, чем readText(). Зато теперь у нас есть точка, где мы можем:

  • распечатать первые байты,
  • убедиться, что файл реально не пустой,
  • проверить, что мы декодируем именно тем правилом, которое ожидаем.

Например, если мы подозреваем, что файл читается “криво”, можно вывести первые несколько байтов:

import java.io.File

fun main() {
    val bytes = File("data/expenses.txt").readBytes()
    val preview = bytes.take(12).toByteArray()

    println(toHex(preview)) // например: 50 61 79 6D 65 6E 74 3A 20 ...
}

Обратите внимание: мы здесь не “лечим” ничего. Мы делаем то, что программисты делают лучше всего — перестаём гадать и начинаем измерять.

Почему “в редакторе видно нормально” — не гарантия

Этот раздел нужен, чтобы снять одну популярную иллюзию. Часто студент говорит: “Но я же открыл файл в редакторе, там всё норм. Почему программа видит кракозябры?” Это отличный вопрос, потому что он показывает ключевую мысль: редактор тоже выбирает кодировку. Иногда автоматически, иногда по настройкам проекта, иногда по “угадыванию”. То есть вы видите “нормально” не потому, что файл “магически правильный”, а потому что редактор угадал или вы настроили его правильно.

Программа же делает декодирование так, как вы ей сказали (или как получилось “по умолчанию”). Поэтому ситуация “редактор показывает нормально, а программа — нет” означает: редактор и программа выбрали разные правила декодирования. И это снова возвращает нас к главному тезису лекции: текст — это байты + правила.

Как только вы принимаете эту модель, многие странности перестают быть странностями. Вы перестаёте думать “файл проклят” и начинаете думать “какой charset использовали при записи и какой при чтении”. А это уже задача, у которой есть решение.

5. Типичные ошибки

Ошибка №1: считать, что String “лежит в файле” прямо как String.
Это одна из самых стойких ошибок мышления. В Kotlin String — это объект в памяти программы. В файле — только байты. Если держать это в голове, вы будете автоматически искать проблему на границе “байты ↔ текст”, а не пытаться чинить “не те символы” странными replace().

Ошибка №2: пытаться “чинить кракозябры” заменами символов вместо правильного декодирования.
Иногда хочется сделать что-то вроде text.replace("Ð", "П"). Это почти всегда путь к ещё большему хаосу: вы лечите симптом, а не причину. Правильная точка ремонта — выбор корректной кодировки при декодировании.

Ошибка №3: думать, что “1 символ = 1 байт”.
Это распространённая ловушка после первых занятий с ByteArray. Для ASCII это часто “как будто” так, но для Unicode-текста (например, кириллицы) размер в байтах может быть больше. Если вы начинаете резать текст “по байтам”, ожидая “по символам”, вы легко ломаете данные.

Ошибка №4: отлаживать “на глаз”, не печатая байты.
Когда у вас проблема на уровне байтов, смотреть только на println(text) — всё равно что пытаться понять баг в арифметике, не печатая числа. Простая диагностика через hex-представление первых байтов часто экономит часы.

Ошибка №5: смешивать уровни: читать файл, парсить и валидировать в одном месте.
Когда всё сделано в одном большом куске кода, вы не понимаете, где именно всё пошло не так: файл не прочитался, байты не те, декодирование не то, или парсер ругается. Лучше иметь отдельный шаг “получить байты”, отдельный шаг “декодировать в строку”, и только потом — бизнес-логику.

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