Графемы и нормализация
Предпосылки: Кодирование текста, Двоичная система и байты.
← Кодирование текста | Порядок байтов →
Спросите длину строки 👨👩👧👦 — и в зависимости от языка программирования получите 7, 11 или 25. Один «символ» на экране — три разных числа. После UTF-8 строка казалась понятной: последовательность code points, каждый кодируется байтами, длина считается перебором. Но за «одним символом», который видит человек, скрываются сразу три уровня, и совпадают они не всегда. Пользователь копирует café и не находит его поиском, хотя на экране буквы идентичны; нажимает Backspace на флаге 🇯🇵 — исчезает весь флаг, а не половина. Везде, где эти уровни расходятся, ломаются сравнение, поиск, курсор и подсчёт длины.
Три уровня одного символа: байт, code point, кластер
Человек смотрит на экран и говорит «одна буква». Программа видит три возможных уровня:
- Байт — единица памяти.
éв UTF-8 может занимать 2 байта или 3 — в зависимости от способа записи. - Code point — единица каталога Unicode.
é— это U+00E9 или последовательность U+0065 + U+0301. - Графемный кластер — то, что человек воспринимает как один символ.
é— это всегда один кластер, независимо от того, сколько под ним code points.
Пока операции работают на уровне, который совпадает с пользовательским восприятием, всё хорошо. Как только они опускаются ниже — начинаются сюрпризы.
Канонические формы: NFC и NFD
Буква é в 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.
Есть ещё две формы — NFKC и NFKD (Compatibility Composition / Decomposition). Они дополнительно сводят визуально «похожие» символы: лигатура fi (U+FB01) при NFKC превратится в две буквы f + i. Полезно для поиска, где хочется, чтобы find нашлось по запросу find. Опасно для хранения: преобразование необратимо.
Правило: перед сравнением, поиском, индексированием — нормализовать обе стороны к одной форме. NFC — частый выбор по умолчанию (компактнее и совпадает с precomposed-вариантом для большинства европейских языков).
Нормализация на практике
Поведение файловых систем расходится:
- HFS+ (macOS до 2017) — хранил имена файлов в полностью разложенной канонической форме (fully decomposed, близко к NFD, но с версионными оговорками: таблицы разложения были зафиксированы на Unicode 2.1 в Mac OS 8.1–10.2, на Unicode 3.2 с Mac OS X 10.3). Файл, созданный в Windows как
café.txtв NFC, при копировании на HFS+ пересохранялся в разложенной форме. - APFS (macOS с 2017) — хранит имена как последовательность байт без преобразования: что записали, то и прочитали. Сравнение имён у современного APFS нормализационно-нечувствительное —
café(NFC) иcafé(NFD) считаются одним именем. В ранних переходных версиях (iOS 10.3, первые релизы на macOS Sierra/High Sierra) поведение отличалось в деталях. - Windows (NTFS) — автоматической нормализации не делает; сравнение имён от регистра зависит по настройке (по умолчанию не зависит). Нормализация строк — ответственность приложения (
NormalizeString,IsNormalizedString). Два файла с визуально одинаковыми именами в разных нормализациях для NTFS — два разных файла. - Linux (ext4, btrfs) — имя файла это последовательность байт, оканчивающаяся нулём. Ядро не знает ни про Unicode, ни про нормализацию.
Следствие: перенос файлов é-в-имени между системами может привести к тому, что файл виден в списке, но не открывается по имени, скопированному из соседнего документа. Это не баг приложения — приложение сравнивает одну форму записи с другой.
То же касается строк в базах данных, URL-ах, ключах хеш-таблиц: две строки, одинаковые на взгляд, не равны друг другу — пока одна из сторон не нормализует вход.
Графемные кластеры
Человек смотрит на экран и видит «один символ». Но под капотом этот символ может состоять из нескольких кодовых позиций.
Флаг 🇯🇵 — это не один 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) — минимальная единица, которую человек воспринимает как отдельный символ. Правила сегментации текста на кластеры закреплены в UAX #29 (Unicode Annex #29, Text Segmentation): как именно группируются базовый символ с комбинирующими, regional indicators с regional indicators, ZWJ-последовательности. Стандарт различает legacy и extended grapheme clusters — в современных приложениях используется extended-вариант, который правильно обрабатывает emoji и региональные флаги.
Проверьте себя: равны ли
caféв NFC и в NFD побайтово?
Три разных способа измерить «длину строки»:
| Строка | Байтов (UTF-8) | Code points | Графемных кластеров |
|---|---|---|---|
Hello | 5 | 5 | 5 |
café (NFC) | 5 | 4 | 4 |
café (NFD) | 6 | 5 | 4 |
🇯🇵 | 8 | 2 | 1 |
👨👩👧👦 | 25 | 7 | 1 |
Только графемные кластеры совпадают с тем, что видит пользователь.
Emoji и ZWJ-последовательности
Emoji оказались главным практическим потребителем механизма графемных кластеров. Три категории, о которых полезно помнить:
- Модификаторы тона кожи.
👋+ U+1F3FD (Medium Skin Tone) →👋🏽. Два code point, один кластер. - Regional Indicators. Пара символов A–Z из блока U+1F1E6..U+1F1FF даёт флаг страны. Два code point, один кластер. Добавить третий regional indicator между флагами — получить новую пару.
- ZWJ-последовательности. Части emoji склеиваются через U+200D. Семья, профессии (
👨⚕️—👨+ ZWJ +⚕), новые комбинации emoji (🏳️🌈— флаг + ZWJ + радуга) — всё строится этим механизмом. Стандарт Unicode ведёт список зарегистрированных ZWJ-последовательностей; приложение, не знающее какой-то последовательности, покажет составляющие части по отдельности.
Где ломаются операции над строками
Следствия из разницы байт / code point / кластер:
- Длина строки.
"café".lengthв JavaScript — это число UTF-16 code units (не code points и не кластеров); для emoji даст неправильный ответ. В RubyString#length— это code points;String#grapheme_clusters.length— графемные кластеры. Для отображения пользователю нужны кластеры, для протоколов с ограничением в байтах — байты. - Сравнение. Никогда не сравнивать строки пользовательского ввода побайтово без нормализации. Привести обе стороны к NFC (или NFD, если согласовано с окружением).
- Поиск. Индекс для поиска по тексту хранит нормализованную форму; запрос нормализуется той же функцией. Без этого
caféв документе не находится по запросуcafé. - Курсор и выделение. Движение курсора на «одну букву», выделение «пяти букв», обрезка до длины N — операции над графемными кластерами, не над байтами или code point. Иначе пользователь видит разрушенные emoji и оторванные акценты.
- Обрезка по длине в байтах. Если хранилище или протокол ограничивает поле именно в байтах (имя файла, поле фиксированной длины в бинарном формате), обрезать всё равно нужно по границе кластера — иначе в конце строки окажется половинка символа. Для Ruby-строк этим занимается
String#grapheme_clusters, в других языках — библиотека ICU или аналоги; механика ruby-строки разобрана в заметке про устройство строки в Ruby.
Нормализация и кластеры отвечают на вопрос «что такое один символ». Но символы вроде é в UTF-16 — это многобайтовые единицы, а в каком порядке их байты ложатся в память — отдельное соглашение, не связанное с тем, что человек считает символом. Тот же вопрос порядка решает, прочтётся ли 16-битное число как 1 или как 256. Этим занимается порядок байтов — в том числе меткой BOM в начале текстового файла.
Sources
- The Unicode Consortium, 2024, Unicode Standard Annex #15: Unicode Normalization Forms. https://www.unicode.org/reports/tr15/
- The Unicode Consortium, 2024, Unicode Standard Annex #29: Unicode Text Segmentation. https://www.unicode.org/reports/tr29/
- The Unicode Consortium, 2024, Unicode Emoji (UTS #51). https://www.unicode.org/reports/tr51/
- Apple, 2017, Apple File System Reference. https://developer.apple.com/support/apple-file-system/