Кодирование текста
Предпосылки: Двоичная система и байты.
← Числа с плавающей точкой | Порядок байтов →
Экран отображает буквы, знаки пунктуации, иероглифы. Каждый символ занимает место в памяти — значит, записан теми же байтами, что и числа. Какое число соответствует какой букве? Кто решает? И что будет, если два компьютера не согласны?
Идея: таблица «число — символ»
Задача стара как электрическая связь. Телеграф (1830-е) передавал только электрические импульсы: длинный, короткий, пауза. Чтобы передать букву, нужна договорённость — какая последовательность импульсов означает какой символ. Такая договорённость называется код (code, от латинского codex — свод правил). Код Морзе присваивал каждой букве комбинацию точек и тире: A = .-, B = -... и так далее.
С появлением цифровых машин задача не изменилась — только вместо точек и тире стали комбинации битов. Каждому символу присваивается число, число записывается в биты. Осталось договориться: какое число — какой символ.
Код Бодо: 5 бит и сдвиг режима
Один из первых цифровых кодов — код Бодо (Baudot code, 1870-е), разработанный для телетайпов. 5 бит дают 2⁵ = 32 комбинации. Но только латинского алфавита — 26 букв, плюс цифры 0–9, плюс знаки препинания. 32 слота не хватает.
Решение: два режима — LTRS (letters, буквы) и FIGS (figures, цифры/знаки). Специальный код переключает режим: после LTRS комбинация 00011 означает букву, после FIGS — цифру. Одна и та же последовательность битов даёт разные символы в зависимости от текущего режима.
Проблема: если один символ переключения потерялся при передаче — всё, что идёт дальше, интерпретируется в неверном режиме. Буквы превращаются в случайные цифры и знаки, пока приёмник случайно не встретит новый символ переключения. Код Бодо работал, но хрупко: потеря одного служебного символа ломала весь поток.
ASCII: 7 бит и 128 символов
В 1963 году американский институт стандартов (ANSI) опубликовал ASCII (American Standard Code for Information Interchange — «американский стандартный код для обмена информацией»). 7 бит = 2⁷ = 128 позиций. Никаких режимов: каждому символу — ровно одно число.
Первые 32 позиции (0–31) — управляющие символы (control characters), невидимые коды для управления устройствами:
| Код | Символ | Назначение |
|---|---|---|
| 0 | NUL | Пусто (заполнитель) |
| 9 | TAB | Горизонтальная табуляция |
| 10 | LF | Line Feed — перевод строки |
| 13 | CR | Carriage Return — возврат каретки |
| 27 | ESC | Escape — начало управляющей последовательности |
Названия пришли из эпохи телетайпов — механических печатных машинок. Carriage Return двигал каретку к левому краю, Line Feed прокручивал бумагу на строку вверх. Их комбинация CR+LF давала «переход на новую строку». Отсюда различие, сохранившееся до сих пор: Windows использует CR+LF (два байта: 13, 10), Unix — только LF (один байт: 10).
Позиции 32–126 — печатные символы (printable characters):
| Диапазон | Символы |
|---|---|
| 32 | Пробел (space) |
| 48–57 | Цифры 0–9 |
| 65–90 | Заглавные A–Z |
| 97–122 | Строчные a–z |
| 33–47, 58–64, 91–96, 123–126 | Знаки препинания и спецсимволы |
Расположение не случайно. Цифры начинаются с 48 (двоичное 0110000) — младшие 4 бита совпадают со значением цифры: 0 = 0000, 5 = 0101. Заглавные и строчные буквы отличаются ровно на 32 (один бит): A = 65 = 1000001, a = 97 = 1100001. Переключение регистра — смена одного бита. Это упрощало аппаратную обработку текста на машинах 1960-х.
В итоге ASCII решил проблему для английского языка. Один символ — один байт (7 бит данных, 8-й бит изначально не использовался или служил для проверки чётности). Простая, компактная, однозначная схема.
128 символов хватает не всем
Французский требует é, ç, à. Немецкий — ü, ö, ß. Японский — тысячи иероглифов. Арабский — буквы, меняющие форму в зависимости от позиции в слове. Кириллица — 33 буквы только для русского, плюс специфические символы для украинского, сербского, болгарского.
128 позиций ASCII заняты полностью. Ни одного свободного слота.
Восьмой бит — тот самый неиспользуемый — даёт ещё 128 позиций (128–255). Общее пространство — 256 значений на байт. Но 128 дополнительных слотов не вмещают даже кириллицу и западноевропейские буквы одновременно, не говоря о китайском или арабском. Кто-то должен решить, какие символы попадут в позиции 128–255. Единого арбитра не было — и каждый регион решил по-своему.
Кодовые страницы: одни байты — разный текст
Кодовая страница (code page) — таблица, заполняющая верхнюю половину байта (128–255) символами конкретного региона. Первые 128 позиций везде одинаковы (ASCII), но позиции 128–255 — нет:
| Кодовая страница | Регион | Позиция 0xC0 | Позиция 0xD6 |
|---|---|---|---|
| ISO 8859-1 | Западная Европа | À | Ö |
| ISO 8859-5 | Кириллица | Р | Ц |
| Windows-1251 | Кириллица (Windows) | А | Ц |
| Shift_JIS | Японский | (часть двухбайтового иероглифа) | (часть двухбайтового иероглифа) |
Тот же самый байт 0xC0 означает À во Франции, Р на компьютере с ISO 8859-5 и А на компьютере с Windows-1251. Три разных символа. Одни и те же биты.
Результат: пользователь в Токио отправляет текст в Shift_JIS, получатель в Лондоне открывает его как ISO 8859-1 — и видит бессмысленный набор символов. Это явление получило японское название mojibake (文字化け — «превращение символов», дословно «символы, ставшие призраками»). Текст технически цел — биты не повреждены, — но интерпретация неверна, потому что отправитель и получатель используют разные таблицы.
Ситуация с кириллицей была особенно хаотичной: KOI8-R, ISO 8859-5, Windows-1251, MacCyrillic — четыре разных способа закодировать один и тот же алфавит. Файл, сохранённый в KOI8-R и открытый как Windows-1251, превращался в набор неправильных букв.
Кодовые страницы создали проблему, которую невозможно решить внутри самой схемы: один байт — один символ — 256 вариантов — этого принципиально не хватает, чтобы вместить все письменности в одну таблицу.
Unicode: один каталог для всех символов
В 1991 году консорциум из инженеров Apple, Xerox, Sun и других компаний опубликовал первую версию Unicode (Universal Coded Character Set — «универсальный набор кодированных символов»). Цель: присвоить уникальное число каждому символу каждой письменности — прошлой, настоящей и будущей.
Три ключевых понятия.
Code point (кодовая позиция) — уникальный номер символа в каталоге. Записывается как U+ и шестнадцатеричное число:
| Code point | Символ | Описание |
|---|---|---|
| U+0041 | A | Латинская заглавная A |
| U+0429 | Щ | Кириллическая заглавная Щ |
| U+4E16 | 世 | Китайский иероглиф «мир» |
| U+1F600 | 😀 | Grinning face |
Plane (плоскость) — группа из 65 536 (2¹⁶) кодовых позиций. Unicode разделён на 17 плоскостей:
- BMP (Basic Multilingual Plane, базовая многоязычная плоскость) — плоскость 0, кодовые позиции U+0000..U+FFFF. Покрывает большинство живых письменностей: латиницу, кириллицу, арабское, китайское, японское, корейское письмо, деванагари.
- Плоскости 1–16 (supplementary planes, дополнительные плоскости) — U+10000..U+10FFFF. Здесь живут исторические письменности (египетские иероглифы, клинопись), математические символы, музыкальная нотация и emoji.
Всего доступно ~1.1 миллиона кодовых позиций. Назначено ~150 000 — менее 14%. Пространства хватит надолго.
Главное: Unicode — это каталог, а не способ записи. Он говорит, что символ Щ имеет номер 1065 (U+0429). Но не говорит, как записать число 1065 в байты. Для этого нужна кодировка.
От каталога к байтам: задача кодирования
Кодовая позиция U+0429 — десятичное 1065. Чтобы хранить это число в файле или передать по сети, нужно превратить его в последовательность байтов. Можно придумать разные схемы — и каждая имеет свою цену.
UTF-32: просто, но расточительно
Самый прямолинейный подход: записать каждую кодовую позицию как 32-битное (4-байтное) число. UTF-32 (Unicode Transformation Format, 32 bits) делает именно это.
Символ A (U+0041) → 00 00 00 41. Символ Щ (U+0429) → 00 00 04 29. Символ 😀 (U+1F600) → 00 01 F6 00.
Достоинство: фиксированная длина. Десятый символ строки всегда начинается на позиции 10 × 4 = 40 байт. Переход к произвольному символу — одна операция.
Цена: каждый символ занимает 4 байта, даже если это A, которая помещается в один. Английский текст из 1000 символов занимает 4000 байт вместо 1000. Три четверти — нули. Для файлов, сетевых протоколов, баз данных — неприемлемый расход.
UTF-16: компромисс для азиатских языков
UTF-16 (16 bits) использует 2 байта для символов из BMP (U+0000..U+FFFF) и 4 байта для остальных.
Символы за пределами BMP кодируются через суррогатные пары (surrogate pairs): одна кодовая позиция записывается как два 16-битных значения. Unicode зарезервировал для этого диапазон U+D800..U+DFFF — 2048 позиций, которые не являются символами, а служат «половинками» для кодирования дополнительных плоскостей. Первая половинка (high surrogate, U+D800..U+DBFF) и вторая (low surrogate, U+DC00..U+DFFF) вместе адресуют до 2²⁰ = 1 048 576 дополнительных позиций — ровно столько, сколько нужно для плоскостей 1–16.
UTF-16 экономнее UTF-32 для текстов, где большинство символов попадает в BMP. Китайский, японский, корейский текст — почти полностью BMP, 2 байта на символ. Но английский текст всё равно тратит 2 байта на каждый ASCII-символ вместо одного. И ещё одна проблема: 2 байта — это 16 бит, а 16-битное число можно записать двумя способами (какой байт первый — подробнее в порядке байтов). Если порядок не задан внешним соглашением, файл может начинаться с BOM (Byte Order Mark) — специального маркера U+FEFF, по которому декодер определяет порядок. Существуют также явные варианты UTF-16BE (big-endian) и UTF-16LE (little-endian), которые фиксируют порядок без BOM.
Несмотря на эти сложности, UTF-16 оставил глубокий след. Java и JavaScript используют его как внутреннее представление строк. Это историческое решение 1990-х, когда Unicode ещё помещался в 2 байта (первые версии стандарта планировали обойтись 65 536 позициями). Потом Unicode вырос, появились суррогатные пары, и работа со строками усложнилась.
UTF-8: переменная длина и совместимость с ASCII
UTF-8 (8 bits) кодирует каждый символ от 1 до 4 байтов. Автор — Кен Томпсон (Ken Thompson, один из создателей Unix), совместно с Робом Пайком (Rob Pike), 1992 год.
Схема кодирования:
| Диапазон code points | Байтов | Формат байтов | Описание |
|---|---|---|---|
| U+0000..U+007F | 1 | 0xxxxxxx | ASCII |
| U+0080..U+07FF | 2 | 110xxxxx 10xxxxxx | Латинские расширения, кириллица, арабское, иврит |
| U+0800..U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx | Китайское, японское, корейское, деванагари |
| U+10000..U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | Emoji, исторические письменности |
Как читать формат: x — биты кодовой позиции, остальное — служебные маркеры. Первый байт начинается с 0 (1 байт), 110 (2 байта), 1110 (3 байта) или 11110 (4 байта). Продолжающие байты всегда начинаются с 10.
Три свойства, которые сделали UTF-8 стандартом.
Совместимость с ASCII. Любой файл, корректный в ASCII, корректен и в UTF-8 — однобайтовые символы 0–127 кодируются идентично. Переход на UTF-8 не ломает существующие ASCII-данные. Ни одна другая кодировка Unicode не даёт этого: UTF-16 записывает A как 00 41, что ломает программы, ожидающие ASCII.
Самосинхронизация. По любому байту можно определить: это начало символа (начинается с 0, 110, 1110 или 11110) или продолжение (начинается с 10). Если поток данных повреждён и несколько байтов потеряны, декодер пропускает повреждённый символ, но корректно читает все следующие. В кодовых страницах и в коде Бодо потеря синхронизации ломала весь хвост потока — UTF-8 решает ту самую проблему, с которой начиналась история.
Нет проблемы порядка байтов. UTF-16 и UTF-32 используют многобайтовые единицы (2 и 4 байта), для которых нужно знать порядок: старший байт первый или младший? UTF-8 — это поток одиночных байтов. Порядок однозначен.
Результат: по данным W3Techs, UTF-8 используется более чем на 98% веб-страниц (2024). RFC 8259 (формат JSON) требует UTF-8. Многие современные протоколы и форматы принимают только UTF-8.
Обратная сторона: кириллица занимает 2 байта на символ вместо одного (в Windows-1251 было по одному). Китайский и японский — 3 байта на символ вместо 2 (в UTF-16 — по два). Для текстов на этих языках UTF-8 менее компактен. Но унификация и совместимость с ASCII перевесили.
Пошагово: кодирование символа Щ в UTF-8
Символ Щ имеет кодовую позицию U+0429. Переведём в двоичную систему:
0x0429 = 0000 0100 0010 1001
11 значащих бит. Диапазон U+0080..U+07FF требует 2 байтов. Формат двухбайтовой записи:
110xxxxx 10xxxxxx
11 позиций для бит кодовой позиции (5 в первом байте, 6 во втором). Распределяем биты 10000 101001 по слотам:
110 10000 10 101001
Переводим в шестнадцатеричную форму:
11010000 = 0xD0
10101001 = 0xA9
Символ Щ в UTF-8 занимает два байта: D0 A9.
Проверка: декодер видит байт 0xD0 = 11010000. Начинается с 110 — значит, это первый байт двухбайтовой последовательности. Извлекает 5 бит: 10000. Следующий байт 0xA9 = 10101001. Начинается с 10 — продолжение. Извлекает 6 бит: 101001. Склеивает: 10000 + 101001 = 10000101001 = 0x0429 = U+0429 = Щ.
Сколько байтов занимают разные типы символов:
| Тип символа | Пример | Code point | Байтов в UTF-8 |
|---|---|---|---|
| ASCII | A | U+0041 | 1 |
| Кириллица | Щ | U+0429 | 2 |
| Японский | 世 | U+4E16 | 3 |
| Emoji | 😀 | U+1F600 | 4 |
BOM: маркер порядка байтов
BOM (Byte Order Mark — «маркер порядка байтов») — символ U+FEFF, помещённый в начало файла или потока. Его задача — сообщить декодеру, в каком порядке записаны байты многобайтовой кодировки.
Для UTF-16 без явной метки (UTF-16BE / UTF-16LE) BOM — основной способ определить порядок байтов. 16-битное число можно записать двумя способами: FE FF (big-endian) или FF FE (little-endian). Декодер читает первые два байта: если FE FF — интерпретирует как big-endian, если FF FE — как little-endian. Без BOM и без внешней метки декодер вынужден угадывать. Подробнее о порядке байтов — Порядок байтов.
Для UTF-32 — аналогично, только маркер занимает 4 байта: 00 00 FE FF (big-endian) или FF FE 00 00 (little-endian).
Для UTF-8 BOM выглядит как три байта EF BB BF (UTF-8 представление U+FEFF). Его функция не в указании порядка — у UTF-8 нет проблемы порядка байтов. BOM здесь служит подсказкой: «этот файл в UTF-8, а не в какой-то кодовой странице». На практике BOM в UTF-8 чаще мешает, чем помогает: программы, не ожидающие его, принимают три байта за содержимое файла. Современная рекомендация (Unicode FAQ) — не использовать BOM в UTF-8.
Нормализация: один символ, несколько способов записи
Буква é в Unicode может быть записана двумя способами:
| Форма | Code points | Описание |
|---|---|---|
| Precomposed | U+00E9 | Один code point: «e с акутом» |
| Decomposed | U+0065 + U+0301 | Два code points: «e» + «combining acute accent» |
Визуально — одинаковы. На уровне байтов — разные последовательности. Если программа сравнивает строки побайтово, "café" (precomposed) ≠ "café" (decomposed), хотя человек видит один и тот же текст.
Unicode определяет нормализацию (normalization) — приведение к канонической форме перед сравнением:
- NFC (Canonical Decomposition, followed by Canonical Composition) — результат: составные символы.
é= U+00E9. - NFD (Canonical Decomposition) — результат: базовый символ + комбинирующие символы.
é= U+0065 + U+0301.
Без нормализации поиск по имени файла может не найти файл, который виден в списке. macOS нормализует имена файлов в NFD, Windows — нет. Один и тот же файл, скопированный между системами, может перестать находиться по имени.
Графемные кластеры: что видит человек
Человек смотрит на экран и видит «один символ». Но под капотом этот символ может состоять из нескольких кодовых позиций.
Флаг 🇯🇵 — это не один code point, а два: U+1F1EF (Regional Indicator Symbol Letter J) + U+1F1F5 (Regional Indicator Symbol Letter P). Рендеринг объединяет их в один флаг. Если поставить курсор рядом с 🇯🇵 и нажать Delete — исчезает весь флаг, не половина.
Семейный emoji 👨👩👧👦 — семь кодовых позиций: четыре фигуры, соединённые тремя символами ZWJ (Zero-Width Joiner, U+200D — «невидимый соединитель»): 👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦. В UTF-8 это 25 байтов. Но пользователь видит один символ.
Графемный кластер (grapheme cluster) — минимальная единица, которую человек воспринимает как отдельный символ. Три разных способа измерить «длину строки»:
| Строка | Байтов (UTF-8) | Code points | Графемных кластеров |
|---|---|---|---|
Hello | 5 | 5 | 5 |
café (NFC) | 5 | 4 | 4 |
café (NFD) | 6 | 5 | 4 |
🇯🇵 | 8 | 2 | 1 |
👨👩👧👦 | 25 | 7 | 1 |
Только графемные кластеры совпадают с тем, что видит пользователь. Байты — это размер в памяти. Code points — единицы Unicode-каталога. Графемные кластеры — единицы восприятия. Операции «выделить символ», «удалить символ», «переместить курсор на символ» должны работать с графемными кластерами, иначе пользователь увидит разрушенные эмодзи и оторванные акценты.
Числа, текст — всё записывается как последовательность байтов. Когда значение занимает больше одного байта, возникает неочевидный вопрос: в каком порядке байты хранить в памяти? Число 0x0429 — это 04 29 или 29 04? Ответ — в следующей заметке.
Sources
- Spolsky, J., 2003, The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!). https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/
- The Unicode Consortium, 2024, The Unicode Standard. https://www.unicode.org/standard/standard.html
- Jennings, T., 2024, An annotated history of some character codes. https://www.sr-ix.com/Archive/CharCode/
- Mackenzie, C., 1980, Coded Character Sets, History and Development. Addison-Wesley.
- Pike, R., Thompson, K., 2003, Hello World, or Καλημέρα κόσμε, or こんにちは 世界. Plan 9 documentation.