JavaRush /Курси /Kotlin SELF /Кодування: UTF‑8 / UTF‑16 / ASCII

Кодування: UTF‑8 / UTF‑16 / ASCII

Kotlin SELF
Рівень 44 , Лекція 1
Відкрита

1. ASCII — як усе починалося

ASCII — це як дідусь у світі кодувань: йому вже багато років, він не знає ні емодзі, ні кирилиці, але й досі приходить на сімейні свята. Важливо сприймати ASCII не як «стару штуку, яку можна забути», а як базову точку відліку: від нього виросли і сумісність, і чимало практичних правил.

ASCII (у класичному вигляді) — це таблиця зі 128 символів (7 біт): латиниця, цифри, знаки пунктуації та керувальні символи на кшталт переведення рядка. Тобто Hello, 123, A-Z, {}, + — окей. А от Привіт або 你好 — уже ні: «не завезли».

Що буде, якщо намагатися «впхнути» кирилицю в ASCII

На практиці, коли ви кодуєте рядок у ASCII, а в цьому рядку трапляється символ, якого ASCII не знає, він замінюється на символ-заглушку. Дуже часто це знак питання ?. Це не «помилка компілятора» і не «поганий Kotlin» — це чесна реакція кодування: «я не вмію, тримай сурогат».

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

    val decoded = String(bytes, Charsets.US_ASCII)
    println(decoded) // ??????
}

Підступність тут у тому, що програма не падає. Вона просто «тихо псує дані». Це один із найнеприємніших класів багів: усе працює, але результат неправильний.

Чому ASCII важливий і досі

ASCII важливий із двох причин. По-перше, багато протоколів і форматів (особливо старих) історично розраховані на нього. По-друге, UTF‑8 (про нього — трохи далі) зроблено так, що будь-який «чистий» ASCII‑текст у UTF‑8 виглядає байт‑у‑байт так само, як у ASCII. Через це ASCII часто «ховається» всередині UTF‑8 як окремий випадок.

2. UTF‑8 — стандарт де‑факто

Якщо ASCII — дідусь, то UTF‑8 — та сама «універсальна мова», якою сьогодні «розмовляє» більша частина інтернету. Він уміє кодувати весь Unicode (тобто майже все, що люди вигадали написати) і водночас зберігає надважливу властивість сумісності з ASCII для простого тексту.

Головна практична ідея UTF‑8кодування змінної довжини. Один символ може займати 1, 2, 3 або 4 байти. Це означає, що правило «1 символ = 1 байт» працює лише в дуже вузькому світі латиниці та кількох знаків.

ASCII‑сумісність UTF‑8: чому Hello однаковий

Перевірмо вручну: у ASCII і в UTF‑8 для англійського тексту байти будуть однаковими. Це одна з причин, чому UTF‑8 так зручно використовувати всюди: старі ASCII‑дані не ламаються.

fun main() {
    val s = "Hello"

    val ascii = s.toByteArray(Charsets.US_ASCII)
    val utf8 = s.toByteArray(Charsets.UTF_8)

    println(ascii.size)                // 5
    println(utf8.size)                 // 5
    println(ascii.contentEquals(utf8))  // true
}

Тобто, якщо ваш файл містить лише ASCII‑символи, ви часто навіть не помітите різниці між «ASCII» і «UTF‑8» (що іноді розслабляє — і саме тому потім боляче, коли зʼявляється перша літера Ж).

Чому Привіт у UTF‑8 більший, ніж здається

Кирилиця в UTF‑8 зазвичай займає 2 байти на літеру. У слові «Привіт» 6 літер — отже, приблизно 12 байтів. Подивімося:

fun main() {
    val ru = "Привіт"

    val utf8 = ru.toByteArray(Charsets.UTF_8)
    println("utf8 bytes = ${utf8.size}") // utf8 bytes = 12
}

Це нормально. Це не «зайві байти», а ціна за універсальність: UTF‑8 економний на англійському тексті, але чесно розширюється для інших мов.

Емодзі в UTF‑8: чому 4 байти — нормально

Емодзі — класичний приклад символів, які в UTF‑8 займають 4 байти.

fun main() {
    val emoji = "🙂"
    val bytes = emoji.toByteArray(Charsets.UTF_8)

    println(bytes.size) // 4
}

Якщо вам колись трапиться завдання «обмежити повідомлення до N байтів» (наприклад, мережевий протокол, ліміт поля, розмір файла), ви вже відчуваєте, чому наївне text.length взагалі не гарантує потрібного результату.

3. UTF‑16 і JVM‑реальність

UTF‑16 часто сприймають як просту ідею: «ну це ж там, де 2 байти на символ». У побутовому сенсі це іноді правда… до першого емодзі. А ще — до моменту, коли ви стикаєтеся з порядком байтів і службовими штуками на початку файла (але про це ми сьогодні лише натякнемо, без глибокого занурення).

Практична картина UTF‑16 така: він кодує Unicode через 16‑бітні кодові одиниці. На JVM тип Char — це якраз 16‑бітне значення. Kotlin на JVM тут успадковує модель Java: Char зберігає 16‑бітне значення (точніше, 16‑бітну кодову одиницю UTF‑16).

Чому String.length може «брехати» на емодзі

Дуже поширений когнітивний шок: рядок з одного емодзі може мати довжину 2.

fun main() {
    val s = "🙂"
    println(s.length) // 2
}

Так стається тому, що в UTF‑16 деякі символи (поза базовою площиною) кодуються парою 16‑бітних значень. Це не тема «на завчання таблиць», а радше важливе практичне попередження: length на JVM — це кількість Char, а не «кількість видимих символів».

Якщо зараз це звучить як «мені б просто файл прочитати, а не філософію Unicode», то ви все зрозуміли правильно. Наша мета — не заглиблюватися, а перестати робити небезпечні припущення.

Розмір UTF‑16 у байтах: чому іноді зʼявляються «зайві» 2 байти

UTF‑16 для кирилиці зазвичай дає 2 байти на літеру (тобто «Привіт» ≈ 12 байтів), але на практиці під час кодування в Charsets.UTF_16 можуть зʼявлятися додаткові службові байти на початку. Тому порівнювати розміри краще обережно — розуміючи, що UTF_16 в API може містити маркер порядку байтів.

Ось безпечний спосіб побачити, що розмір відрізняється:

fun main() {
    val ru = "Привіт"

    val utf8 = ru.toByteArray(Charsets.UTF_8)
    val utf16 = ru.toByteArray(Charsets.UTF_16)

    println("utf8=${utf8.size}")   // utf8=12
    println("utf16=${utf16.size}") // utf16=14
}

Чому 14, а не 12? Тому що до 12 байтів даних додалися 2 службові байти на початку. Так, це той самий «сюрприз», який потім ламає читання першої колонки CSV, бо перший заголовок раптом починається не з i, а з «невидимої штуки + i». Деталі цього «невидимого гостя» краще розбирати окремо, щоб не змішувати все в одну кашу.

Як побачити байти UTF‑16: друкуємо hex

Друкувати Byte напряму не дуже приємно: він signed (від -128 до 127), і в консолі легко заплутатися. Для діагностики зручно виводити байти у hex.

fun main() {
    val bytes = "A".toByteArray(Charsets.UTF_16)

    val hex = bytes.joinToString(" ") { b ->
        b.toUByte().toString(16).padStart(2, '0')
    }
    println(hex) // fe ff 00 41
}

Тут ми бачимо: спочатку fe ff (службові байти), потім 00 41 (це літера A). Навіть якщо ви поки не знаєте, «що таке fe ff», головну ідею вже видно: у UTF‑16 початок байтової послідовності може містити не «дані тексту», а службову частину.

4. Міні‑практика: трекер витрат і текстовий формат

Щоб наші знання не існували окремо від життя, привʼяжімо їх до застосунку, який ми розвиваємо в курсі. Припустімо, у нас є консольний трекер витрат, що зберігає дані рядок за рядком, наприклад так:

100;food;Lunch
250;transport;Taxi

Логіка парсингу зазвичай виглядає так: «прочитав рядок → split по ; → розпарсив числа». І ось тут кодування починають впливати несподівано: не на split, а на те, що взагалі вважається символами рядка.

Сьогодні ми не чіпатимемо читання/запис файла із зазначенням кодування (це окрема тема). Але можемо змоделювати проблему «в памʼяті»: взяти рядок, закодувати його в UTF‑16, а декодувати як UTF‑8. Це і є «класичні кракозябри».

fun main() {
    val header = "amount;category;comment"

    val bytesUtf16 = header.toByteArray(Charsets.UTF_16)
    val wrongText = String(bytesUtf16, Charsets.UTF_8)

    println(wrongText == header) // false
}

Ключовий момент: ми не зобовʼязані виводити wrongText (у консолі це може виглядати по-різному), але точно знаємо, що контракт порушено. Ми закодували за одними правилами, а прочитали за іншими — і отримали інший текст.

Ось чому в реальних програмах ситуація «файл читається нормально в мене, але ламається в колеги» дуже часто зводиться до того, що одна сторона мовчки використовує UTF‑8, а інша — мовчки використовує UTF‑16 або системне кодування.

5. Практичні правила роботи з кодуваннями

У темі кодувань легко почати «чаклувати»: міняти UTF_8 на UTF_16, потім на Windows-1251, потім назад — доки не стане «гарно». Це працює приблизно як лікувати головний біль зміною шпалер: іноді допомагає, але причини ви так і не зрозуміли.

Нормальна інженерна дисципліна виглядає так.

Порівняння UTF‑8 / UTF‑16 / ASCII на практиці

Коли порівнюють кодування, легко піти у Вікіпедію й загубити сенс. Зробімо простіше: порівняймо так, як думає розробник, що пише консольний застосунок і просто хоче, аби файл читався однаково всюди.

Спочатку — коротка таблиця. Не для зазубрювання, а як орієнтир.

Кодування Що вміє Розмір для англійського Розмір для кирилиці Емодзі Типовий кейс
ASCII ~128 символів 1 байт/символ не вміє (заміна на ?) не вміє дуже старі протоколи/дані, жорсткі обмеження
UTF‑8 весь Unicode 1 байт/символ зазвичай 2 байти/символ 4 байти файли, мережа, JSON, «за замовчуванням» у сучасному світі
UTF‑16 весь Unicode зазвичай 2 байти/символ (+ службові байти) зазвичай 2 байти/символ (+ службові байти) часто 4 байти (як пара 16‑бітних одиниць) внутрішнє подання в деяких системах, інколи Windows‑світ

Тепер закріпімо ключовий практичний висновок, який береже нерви: байти залежать від кодування, а отже — і розмір файла, і «видимість» символів, і коректність парсингу.

Правила, які допомагають не перетворитися на шамана

Передусім тримайте в голові, що ASCII — не універсальне кодування. Якщо ви бачите «??????» замість кирилиці, це майже завжди означає, що дані «запакували» в надто бідне кодування (або хтось вирішив, що світ складається лише з латиниці).

Далі — запамʼятайте: UTF‑8 майже завжди найкращий вибір для зовнішнього обміну текстом. Він компактний для англійського тексту, підтримує весь Unicode і сумісний із ASCII там, де це важливо (наприклад, заголовки, ключі, структурні символи). Тому, якщо у вас немає спеціальних вимог, «домовитися про UTF‑8» зазвичай розумніше, ніж «залишити як вийде».

І нарешті, ставтеся обережно до UTF‑16: він не поганий і не хороший — він просто інший. У ньому часто спливають службові байти на початку, а на JVM багато речей на рівні Char і length поводяться як «кількість 16‑бітних шматочків», а не «кількість видимих символів». Це нормально — просто не варто писати код, який припускає протилежне.

Якщо зібрати це в одну коротку «мантру без магії», вийде:

Текст ≠ байти.
Кодування — це договір про правила перекладу.
Порушив договір — отримав кракозябри.

6. Типові помилки під час порівняння кодувань

Помилка №1: думати, що «1 символ = 1 байт».
Це працює лише у світі ASCII — і лише для обмеженого набору символів. Щойно ви зустрічаєте кирилицю, діакритику, китайські ієрогліфи або емодзі — розмір у байтах починає відрізнятися. Якщо ви будуєте логіку «обріжу рядок до N байтів» через substring(0, n), ви майже гарантовано отримаєте некоректний результат.

Помилка №2: вважати, що ASCII і UTF‑8 — одне й те саме.
Вони справді збігаються на багатьох текстах (наприклад, на Hello), і це вводить в оману. На практиці вони однакові лише на перетині ASCII‑символів. Щойно зʼявляється ЖASCII перетворює її на ?, а UTF‑8 спокійно кодує.

Помилка №3: очікувати від String.length відповіді на питання «скільки символів у тексті».
На JVM length рахує кількість Char, тобто 16‑бітних кодових одиниць. Тому один візуальний символ може займати два Char (класичний приклад — деякі емодзі). Це не робить length марним, але робить небезпечним припущення, що length == кількість видимих символів.

Помилка №4: порівнювати розміри текстів «на око» й робити висновки про кодування.
Іноді розробник бачить, що файл «більший, ніж очікував», і вирішує: «значить, там сміття». Насправді «більший» може означати лише те, що файл в іншому кодуванні (наприклад, UTF‑16) або що в ньому багато не ASCII символів. Розмір — це підказка, але не доказ.

Помилка №5: намагатися «полагодити кракозябри» замінами символів замість виправлення кодування.
Заміни на кшталт text.replace("�", "") виглядають як швидкий фікс, але зазвичай це спроба витерти наслідки замість причини. Правильне виправлення майже завжди знаходиться на межі «bytes string»: потрібно декодувати вихідні байти правильним кодуванням, а не латати вже зламаний текст.

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