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: змішувати рівні — читати файл, парсити та валідовувати в одному місці.
Коли все зроблено одним великим шматком коду, ви не розумієте, де саме все пішло не так: файл не прочитався, байти не ті, декодування не те чи парсер свариться. Краще мати окремий крок «отримати байти», окремий крок «декодувати в рядок», і лише потім — бізнес-логіку.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ