Кодирование текста

Предпосылки: Двоичная система и байты.

Числа с плавающей точкой | Порядок байтов

Экран отображает буквы, знаки пунктуации, иероглифы. Каждый символ занимает место в памяти — значит, записан теми же байтами, что и числа. Какое число соответствует какой букве? Кто решает? И что будет, если два компьютера не согласны?

Идея: таблица «число — символ»

Задача стара как электрическая связь. Телеграф (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), невидимые коды для управления устройствами:

КодСимволНазначение
0NULПусто (заполнитель)
9TABГоризонтальная табуляция
10LFLine Feed — перевод строки
13CRCarriage Return — возврат каретки
27ESCEscape — начало управляющей последовательности

Названия пришли из эпохи телетайпов — механических печатных машинок. Carriage Return двигал каретку к левому краю, Line Feed прокручивал бумагу на строку вверх. Их комбинация CR+LF давала «переход на новую строку». Отсюда различие, сохранившееся до сих пор: Windows использует CR+LF (два байта: 13, 10), Unix — только LF (один байт: 10).

Позиции 32–126 — печатные символы (printable characters):

ДиапазонСимволы
32Пробел (space)
48–57Цифры 09
65–90Заглавные AZ
97–122Строчные az
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+0041AЛатинская заглавная 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+007F10xxxxxxxASCII
U+0080..U+07FF2110xxxxx 10xxxxxxЛатинские расширения, кириллица, арабское, иврит
U+0800..U+FFFF31110xxxx 10xxxxxx 10xxxxxxКитайское, японское, корейское, деванагари
U+10000..U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxxEmoji, исторические письменности

Как читать формат: 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
ASCIIAU+00411
КириллицаЩU+04292
ЯпонскийU+4E163
Emoji😀U+1F6004

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Описание
PrecomposedU+00E9Один code point: «e с акутом»
DecomposedU+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Графемных кластеров
Hello555
café (NFC)544
café (NFD)654
🇯🇵821
👨‍👩‍👧‍👦2571

Только графемные кластеры совпадают с тем, что видит пользователь. Байты — это размер в памяти. Code points — единицы Unicode-каталога. Графемные кластеры — единицы восприятия. Операции «выделить символ», «удалить символ», «переместить курсор на символ» должны работать с графемными кластерами, иначе пользователь увидит разрушенные эмодзи и оторванные акценты.


Числа, текст — всё записывается как последовательность байтов. Когда значение занимает больше одного байта, возникает неочевидный вопрос: в каком порядке байты хранить в памяти? Число 0x0429 — это 04 29 или 29 04? Ответ — в следующей заметке.

Sources


Числа с плавающей точкой | Порядок байтов