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 на практике
Когда сравнивают кодировки, легко уйти в Википедию и потерять смысл. Мы сделаем проще: сравним так, как думает разработчик, который пишет консольное приложение и просто хочет, чтобы файл читался одинаково у всех.
Сначала — краткая таблица. Не для зубрёжки, а для ориентира.
| Кодировка | Что умеет | Размер для English | Размер для кириллицы | Эмодзи | Типичный кейс |
|---|---|---|---|---|---|
| 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»: нужно декодировать исходные байты правильной кодировкой, а не латать уже сломанный текст.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ