BackendBit

К статьям о Computer Science

Перед вами строка с эмодзи семьи – 👨‍👩‍👧. Сколько символов в этой строке? Человек скорее всего скажет, что в строке 1 символ. JavaScript насчитает 8, Python – 5, а в файле на диске эта строка занимает 18 байт.

Все три ответа правильные – просто каждый считает в своих единицах. Чтобы понимать, какая единица когда нужна, разберемся, как компьютер хранит текст: от чисел, которые Юникод присваивает символам, до байтов, в которые эти числа превращают кодировки. В конце статьи вернемся к эмодзи 👨‍👩‍👧 и посчитаем все три ответа вручную.

До Юникода: ASCII

Компьютер умеет хранить только числа. Поэтому текст в компьютере – это тоже числа, а стандарт кодирования определяет, какое число какому символу соответствует.

Первым массовым стандартом стал ASCII. Он описывает 128 символов: латинские буквы, цифры, знаки препинания и управляющие символы. Каждому символу соответствует число от 0 до 127.

СимволASCII кодHexBinary
A654101000001
a976101100001
B664201000010
b986201100010
d1006401100100
e1016501100101
H724801001000
l1086C01101100
o1116F01101111
r1147201110010
w1197701110111
1493100110001
8563800111000
!332100100001
+432B00101011
space322000100000

Вот так выглядит ASCII текст Hello world! в бинарном представлении:

01001000 01100101 01101100 01101100 01101111 00100000 01110111 01101111 01110010 01101100 01100100 00100001

Текст – это просто числа. Вопрос только в том, по какой таблице их читать.

Проблема 128 символов

Для 128 символов достаточно 7 бит, а в байте их 8. Свободные значения от 128 до 255 каждый использовал по-своему: так появились кодовые страницы – национальные расширения ASCII. Только для кириллицы их было несколько: CP1251 в Windows, KOI8-R в Unix-системах, CP866 в DOS.

Нижняя половина таблицы у всех совпадала с ASCII, а верхняя – различалась. Один и тот же байт означал разные символы в зависимости от того, по какой таблице его читать:

Один байт 0xE0 – три разных символа в трех кодовых страницахБайт 0xE0 (1110 0000) читается как кириллическая «а» в CP1251, как «Ю» в KOI8-R и как греческая «α» в CP437.Байт 0xE01110 0000 аCP1251 ЮKOI8-R αCP437

Если текст сохранили в одной кодовой странице, а открыли в другой, получались «кракозябры»: «Привет» превращался в «оПХБЕР». А для языков с тысячами иероглифов одного байта не хватало в принципе.

Нужна была одна таблица для всех письменностей мира. Ей стал Юникод.

Что такое Юникод

Юникод – стандарт, который присваивает номер каждому символу всех письменностей: латинице, кириллице, иероглифам, эмодзи. Этот номер называется код-поинт (code point).

Код-поинт записывается так:

U+(шестнадцатеричное представление номера)

Приведем примеры код-поинтов:

СимволКод-поинт
AU+0041
aU+0061
HU+0048
8U+0038
!U+0021
spaceU+0020
БU+0411
U+611B
😎U+1F60E

Первые 128 код-поинтов совпадают с ASCII: A – это 65 и в ASCII, и в Юникоде.

Диапазон код-поинтов – от U+0000 до U+10FFFF, это больше миллиона позиций. Занято из них около 160 тысяч, так что запас на новые письменности и эмодзи еще огромный.

Важно понимать, что делает Юникод, а что – нет:

  • Юникод присваивает символам номера.
  • Кодировки (UTF-8, UTF-16, UTF-32) превращают номера в байты.

Это разделение – ключ ко всей статье. Сначала разберемся, что символ – это не такое простое понятие, а затем перейдем к кодировкам.

Графемы и глифы

Графема

Графема (grapheme) – минимальная единица письменности. В алфавитных системах письма это буква, в неалфавитных – слоговой знак, иероглиф, идеограмма.

С точки зрения Юникода графема – это последовательность из одного или нескольких код-поинтов, которые отображаются как единое целое и воспринимаются пользователем как один символ.

Например, ä – одна графема, но записать ее можно двумя способами:

  • одним код-поинтом U+00E4 – готовой буквой ä;
  • двумя код-поинтами – буквой a (U+0061) и комбинируемым диэрезисом U+0308, который «пририсовывает» точки к предыдущему символу.

На экране оба варианта выглядят одинаково. Запомним этот пример – он еще сыграет в разделах про нормализацию и длину строки.

Глиф

Глиф (glyph) – конкретное графическое представление графемы. Если графема – единица текста, то глиф – единица графики.

Глифы хранятся внутри шрифта. Одна и та же графема – строчная буква a – в разных шрифтах выглядит по-разному:

Одна графема – разные глифыСтрочная буква «a» показана пятью разными глифами: с засечками, гротеском, моноширинным, рукописным и жирным начертанием. Графема одна, изображения разные.aс засечкамиaгротескaмоноширинныйaрукописныйaжирныйОдна графема – разные глифы

Для хранения и передачи текста глифы не важны – это забота шрифтов и рендеринга. А вот превращение код-поинтов в байты касается каждого, кто работает с текстом. Переходим к кодировкам.

Кодировки

Юникод присвоил каждому символу номер, но не сказал, как хранить этот номер в памяти. Это задача кодировок.

Кодировка оперирует блоками фиксированного размера – код-юнитами. Код-юнит (code unit) – минимальная единица хранения закодированного код-поинта. В UTF-8 код-юнит занимает 8 бит, в UTF-16 – 16 бит, в UTF-32 – 32 бита. Один код-поинт кодируется одним или несколькими код-юнитами.

UTF-8

В UTF-8 каждый код-поинт превращается в последовательность из 1–4 байт. Все 128 символов ASCII занимают 1 байт, и их биты полностью совпадают с кодировкой ASCII. Поэтому UTF-8 обратно совместим с ASCII: любой ASCII файл – это уже корректный UTF-8 файл.

СимволКод-поинтBinary
AU+004101000001
aU+006101100001
HU+004801001000
8U+003800111000
!U+002100100001
spaceU+002000100000
БU+041111010000 10010001
U+611B11100110 10000100 10011011
😎U+1F60E11110000 10011111 10011000 10001110

У всех ASCII символов первый бит равен 0.

Если символ мультибайтовый, количество байт в последовательности записано в первом байте: оно равно общему количеству ведущих единиц. Начинается байт с 110 – в последовательности 2 байта, с 1110 – 3 байта, с 11110 – 4 байта. Все остальные байты последовательности начинаются с 10.

0xxxxxxx – 1 байт (ASCII)
110xxxxx 10xxxxxx – 2 байта в последовательности
1110xxxx 10xxxxxx 10xxxxxx – 3 байта в последовательности
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx – 4 байта в последовательности
Четыре шаблона байтов UTF-8: от 1 до 4 байт в последовательностиОднобайтовый шаблон начинается с нуля, двухбайтовый – с битов 110, трехбайтовый – с 1110, четырехбайтовый – с 11110. Байты-продолжения начинаются с 10. Служебные биты выделены цветом, биты x хранят код-поинт.1 байт: U+0000–U+007F (ASCII) 0 x x x x x x x2 байта: U+0080–U+07FF 1 1 0 x x x x x 1 0 x x x x x x3 байта: U+0800–U+FFFF 1 1 1 0 x x x x 1 0 x x x x x x 1 0 x x x x x x4 байта: U+10000–U+10FFFF 1 1 1 1 0 x x x 1 0 x x x x x x 1 0 x x x x x x 1 0 x x x x x xслужебные битыx – биты код-поинта

Биты, обозначенные x, – полезная нагрузка: в них записываются биты код-поинта, дополненные слева нулями до нужной длины. Закодируем 😎 (U+1F60E):

Кодирование эмодзи U+1F60E в четыре байта UTF-821 бит код-поинта U+1F60E разбит на четыре группы: 3, 6, 6 и 6 бит. Каждая группа занимает биты полезной нагрузки своего байта. Результат – байты F0, 9F, 98 и 8E.Код-поинт U+1F60E (😎) – 21 бит 0 0 0 0 1 1 1 1 1 0 1 1 0 0 0 0 0 1 1 1 0 1 1 1 1 0 0 0 0 1 0 0 1 1 1 1 1 1 0 0 1 1 0 0 0 1 0 0 0 1 1 1 00xF00x9F0x980x8Eслужебные биты

Почему UTF-8 победил

Сегодня UTF-8 используют около 99% сайтов. На то есть три причины.

Обратная совместимость с ASCII. Старые инструменты, написанные в эпоху ASCII, продолжают работать с UTF-8 текстом, пока он состоит из ASCII символов. Переход на UTF-8 не потребовал переписать весь мир.

Самосинхронизация. По любому байту сразу видно, что это: начало последовательности (0…, 110…, 1110…, 11110…) или продолжение (10…). Попав в середину текста, можно найти границу ближайшего символа, не читая текст с начала. Поврежденный байт портит один символ, а не весь остаток текста.

Нет нулевых байтов. В многобайтовых последовательностях UTF-8 не встречается байт 00000000 – он кодирует только сам символ U+0000. Программы, которые считают нулевой байт концом строки (например, весь мир C), спокойно работают с UTF-8.

UTF-16

В UTF-16 код-юнит занимает 16 бит, поэтому каждый символ кодируется либо 2, либо 4 байтами.

Код-поинты до U+FFFF записываются одним код-юнитом «как есть»: Б (U+0411) – это просто 0x0411. Этот диапазон называется базовой многоязыковой плоскостью (Basic Multilingual Plane, BMP) – в нем живут почти все современные письменности.

Для код-поинтов выше U+FFFF – например, эмодзи – используются суррогатные пары (surrogate pairs): два код-юнита, каждый из зарезервированных диапазонов. Механизм такой:

  1. Из код-поинта вычитается 0x10000. Остается число, которое помещается в 20 бит.
  2. Старшие 10 бит прибавляются к 0xD800 – получается первый код-юнит пары (диапазон D800–DBFF).
  3. Младшие 10 бит прибавляются к 0xDC00 – получается второй (диапазон DC00–DFFF).
Суррогатная пара UTF-16 для код-поинта U+1F60EИз код-поинта U+1F60E вычитается 0x10000, остается 20 бит. Старшие 10 бит прибавляются к 0xD800 и дают старший суррогат 0xD83D, младшие 10 бит прибавляются к 0xDC00 и дают младший суррогат 0xDE0E.U+1F60E − 0x10000 = 0xF60E – 20 битстаршие 10 бит 0 0 0 0 1 1 1 1 0 1 1 1 0 1 1 0 0 0 0 0 1 1 1 1 0 10xD83D – старший суррогатмладшие 10 бит 1 0 0 0 0 0 1 1 1 0 1 1 0 1 1 1 1 0 0 0 0 0 1 1 1 00xDE0E – младший суррогатфиксированные биты суррогатов

Диапазон U+D800–U+DFFF зарезервирован в Юникоде именно под суррогаты – настоящих символов там нет. Поэтому, встретив код-юнит из этого диапазона, декодер однозначно понимает, что перед ним половина пары.

UTF-16 – внутренний формат строк в Windows API, Java, JavaScript и C#. Это наследие времен, когда Юникод помещался в 16 бит и казалось, что «2 байта на символ» – навсегда. Мы еще вспомним об этом, когда будем считать длину строки.

Что по эффективности? Для текста из ASCII символов UTF-16 тратит вдвое больше памяти, чем UTF-8: 2 байта против 1. Но для текста из иероглифов – например, китайского или японского – UTF-16 экономнее: 2 байта на символ против 3 в UTF-8 (почти все иероглифы лежат в BMP). На практике выигрыш часто съедается тем, что вокруг иероглифов живут пробелы, разметка и разделители из ASCII диапазона.

BOM и порядок байтов

Код-юнит UTF-16 – это 16 бит, то есть 2 байта. А в каком порядке записывать эти 2 байта в память? Вариантов два: старший байт первым (big-endian) или младший первым (little-endian). Б (U+0411) – это либо 04 11, либо 11 04.

Чтобы читающая сторона могла определить порядок, в начало текста ставят BOM (byte order mark) – код-поинт U+FEFF. Прочитали FE FF – порядок big-endian, прочитали FF FE – little-endian.

В UTF-8 такой проблемы нет: код-юнит равен одному байту, и порядок байтов в последовательности жестко задан самой кодировкой. Это еще один негласный пункт в списке «почему UTF-8 победил».

UTF-32

Для полноты картины – UTF-32: код-юнит занимает 32 бита, каждый код-поинт записывается ровно одним код-юнитом. Никаких последовательностей и суррогатных пар – но каждый символ, включая ASCII, стоит 4 байта. Из-за такой расточительности UTF-32 почти не используется для хранения и передачи текста. Да и обещание «один код-юнит – один символ» обманчиво: как мы уже видели на примере ä, один видимый символ может состоять из нескольких код-поинтов.

Нормализация

Вернемся к примеру с ä. Одну и ту же графему можно записать двумя способами:

  • композитная форма: один код-поинт U+00E4;
  • декомпозитная форма: два код-поинта, U+0061 и U+0308.

На экране строки выглядят одинаково, но состоят из разных код-поинтов – а значит, и из разных байтов. В большинстве языков программирования строки сравниваются по код-юнитам, без нормализации, поэтому сравнение "ä" == "ä" может вернуть false, если формы записи различаются. Тот же эффект ломает поиск по тексту и уникальность ключей в базах данных.

Решение – нормализация: приведение строки к канонической форме. Основных форм две:

  • NFC (Normalization Form C) – все, что можно, склеивается в композитные код-поинты: a + диэрезис → ä.
  • NFD (Normalization Form D) – наоборот, все раскладывается на базовые символы и комбинируемые знаки: äa + диэрезис.

Какую форму выбрать – не так важно, как применять ее последовательно. Практическое правило: нормализуйте текст на границах системы – при вводе от пользователя, перед сравнением, перед записью в ключи и индексы.

Сколько символов в строке?

Теперь у нас есть все, чтобы ответить на вопрос из начала статьи. «Длина строки» – некорректный вопрос, пока не названа единица измерения. Считать можно в байтах, код-поинтах или графемах – и для одной и той же строки это три разных числа.

Разберем 👨‍👩‍👧. Это одна графема, склеенная из трех эмодзи с помощью ZWJ (zero-width joiner, U+200D) – невидимого код-поинта-соединителя:

Код-поинтСимволБайт в UTF-8
U+1F468👨4
U+200DZWJ3
U+1F469👩4
U+200DZWJ3
U+1F467👧4
Эмодзи семьи: одна графема, пять код-поинтов, 18 байт UTF-8Эмодзи 👨‍👩‍👧 – одна графема. Она состоит из пяти код-поинтов: эмодзи мужчины, невидимого соединителя ZWJ, эмодзи женщины, еще одного ZWJ и эмодзи девочки. В UTF-8 они занимают 4, 3, 4, 3 и 4 байта – всего 18.графема (1)код-поинты (5)байты UTF-8 (18) 👨‍👩‍👧 👨U+1F468 F0 9F 91 A84 байта ZWJU+200D E2 80 8D3 байта 👩U+1F469 F0 9F 91 A94 байта ZWJU+200D E2 80 8D3 байта 👧U+1F467 F0 9F 91 A74 байта

Считаем:

  • Байты: 4 + 3 + 4 + 3 + 4 = 18 байт в UTF-8. В другой кодировке будет другое число.
  • Код-поинты: 5. Именно это вернет len() в Python.
  • Код-юниты UTF-16: каждый эмодзи – суррогатная пара (2 код-юнита), ZWJ – один код-юнит. 2 + 1 + 2 + 1 + 2 = 8. Именно это вернет .length в JavaScript – ведь строки там хранятся в UTF-16.
  • Графемы: 1. То, что видит пользователь. Стандартные средства большинства языков графемы считать не умеют – нужна библиотека с поддержкой сегментации Юникода.

Теперь проверьте на любой строке сами. Инспектор ниже раскладывает текст на графемы, код-поинты и байты UTF-8 и считает длину во всех единицах сразу. Попробуйте флаг 🇷🇺 – одна графема из двух код-поинтов без всяких ZWJ. Или введите ä и переключите нормализацию: NFD разложит букву на a и комбинируемый диэрезис, а NFC соберет обратно – прямо как в разделе про нормализацию.

Поэтому, когда в следующий раз понадобится «длина строки», сначала спросите себя, что вы считаете: память под строку – считайте байты, видимые символы для, скажем, ограничения ввода – графемы.

Итоги

Соберем лестницу понятий целиком:

  • Графема – то, что пользователь видит как один символ. Состоит из одного или нескольких код-поинтов.
  • Код-поинт – номер символа в таблице Юникода, от U+0000 до U+10FFFF.
  • Код-юнит – блок, которым кодировка записывает код-поинт: 8 бит в UTF-8, 16 в UTF-16, 32 в UTF-32.
  • Байты – то, что в итоге лежит в памяти и на диске.

Юникод присваивает символам номера, кодировки превращают номера в байты – это главное разделение, вокруг которого построено все остальное.

Практические выводы:

  • Для хранения и передачи текста выбирайте UTF-8 – это стандарт де-факто: совместим с ASCII, самосинхронизируется, не содержит нулевых байтов.
  • Нормализуйте текст на границах системы, иначе одинаковые на вид строки окажутся разными.
  • «Длина строки» зависит от единицы измерения: байты, код-поинты и графемы дают разные ответы на этот вопрос.