Юникод
Перед вами строка с эмодзи семьи – 👨👩👧. Сколько символов в этой строке? Человек скорее всего скажет, что в строке 1 символ. JavaScript насчитает 8, Python – 5, а в файле на диске эта строка занимает 18 байт.
Все три ответа правильные – просто каждый считает в своих единицах. Чтобы понимать, какая единица когда нужна, разберемся, как компьютер хранит текст: от чисел, которые Юникод присваивает символам, до байтов, в которые эти числа превращают кодировки. В конце статьи вернемся к эмодзи 👨👩👧 и посчитаем все три ответа вручную.
До Юникода: ASCII
Компьютер умеет хранить только числа. Поэтому текст в компьютере – это тоже числа, а стандарт кодирования определяет, какое число какому символу соответствует.
Первым массовым стандартом стал ASCII. Он описывает 128 символов: латинские буквы, цифры, знаки препинания и управляющие символы. Каждому символу соответствует число от 0 до 127.
| Символ | ASCII код | Hex | Binary |
|---|---|---|---|
| A | 65 | 41 | 01000001 |
| a | 97 | 61 | 01100001 |
| B | 66 | 42 | 01000010 |
| b | 98 | 62 | 01100010 |
| d | 100 | 64 | 01100100 |
| e | 101 | 65 | 01100101 |
| H | 72 | 48 | 01001000 |
| l | 108 | 6C | 01101100 |
| o | 111 | 6F | 01101111 |
| r | 114 | 72 | 01110010 |
| w | 119 | 77 | 01110111 |
| 1 | 49 | 31 | 00110001 |
| 8 | 56 | 38 | 00111000 |
| ! | 33 | 21 | 00100001 |
| + | 43 | 2B | 00101011 |
| space | 32 | 20 | 00100000 |
Вот так выглядит 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, а верхняя – различалась. Один и тот же байт означал разные символы в зависимости от того, по какой таблице его читать:
Если текст сохранили в одной кодовой странице, а открыли в другой, получались «кракозябры»: «Привет» превращался в «оПХБЕР». А для языков с тысячами иероглифов одного байта не хватало в принципе.
Нужна была одна таблица для всех письменностей мира. Ей стал Юникод.
Что такое Юникод
Юникод – стандарт, который присваивает номер каждому символу всех письменностей: латинице, кириллице, иероглифам, эмодзи. Этот номер называется код-поинт (code point).
Код-поинт записывается так:
U+(шестнадцатеричное представление номера)Приведем примеры код-поинтов:
| Символ | Код-поинт |
|---|---|
| A | U+0041 |
| a | U+0061 |
| H | U+0048 |
| 8 | U+0038 |
| ! | U+0021 |
| space | U+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 – в разных шрифтах выглядит по-разному:
Для хранения и передачи текста глифы не важны – это забота шрифтов и рендеринга. А вот превращение код-поинтов в байты касается каждого, кто работает с текстом. Переходим к кодировкам.
Кодировки
Юникод присвоил каждому символу номер, но не сказал, как хранить этот номер в памяти. Это задача кодировок.
Кодировка оперирует блоками фиксированного размера – код-юнитами. Код-юнит (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 |
|---|---|---|
| A | U+0041 | 01000001 |
| a | U+0061 | 01100001 |
| H | U+0048 | 01001000 |
| 8 | U+0038 | 00111000 |
| ! | U+0021 | 00100001 |
| space | U+0020 | 00100000 |
| Б | U+0411 | 11010000 10010001 |
| 愛 | U+611B | 11100110 10000100 10011011 |
| 😎 | U+1F60E | 11110000 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 байта в последовательностиБиты, обозначенные x, – полезная нагрузка: в них записываются биты код-поинта, дополненные слева нулями до нужной длины. Закодируем 😎 (U+1F60E):
Почему 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): два код-юнита, каждый из зарезервированных диапазонов. Механизм такой:
- Из код-поинта вычитается 0x10000. Остается число, которое помещается в 20 бит.
- Старшие 10 бит прибавляются к 0xD800 – получается первый код-юнит пары (диапазон D800–DBFF).
- Младшие 10 бит прибавляются к 0xDC00 – получается второй (диапазон DC00–DFFF).
Диапазон 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+200D | ZWJ | 3 |
| U+1F469 | 👩 | 4 |
| U+200D | ZWJ | 3 |
| U+1F467 | 👧 | 4 |
Считаем:
- Байты: 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, самосинхронизируется, не содержит нулевых байтов.
- Нормализуйте текст на границах системы, иначе одинаковые на вид строки окажутся разными.
- «Длина строки» зависит от единицы измерения: байты, код-поинты и графемы дают разные ответы на этот вопрос.