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»: потрібно декодувати вихідні байти правильним кодуванням, а не латати вже зламаний текст.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ