Что такое символ и почему Unicode — самая глубокая кроличья нора в программировании

Что такое символ и почему Unicode — самая глубокая кроличья нора в программировании

Спросите любого программиста, сколько символов в строке "Привет", и он ответит «шесть». Спросите, сколько символов в "👨‍👩‍👧‍👦", и начнётся заминка. Один? Четыре? Семь? Двадцать пять? Все ответы одновременно верны — зависит от того, что считать символом. За этим вопросом скрывается одна из самых запутанных тем в программировании — Unicode. Стандарт, который должен был навести порядок, а вместо этого показал, насколько сложна человеческая письменность.

От телеграфа до вавилонской башни кодировок

История кодирования текста начинается задолго до компьютеров. Код Морзе (1837), код Бодо (1870), телетайп — каждая система изобретала собственный способ превратить буквы в электрические сигналы. Когда в 1963 году появился ASCII (American Standard Code for Information Interchange), он решил задачу для одного языка: 128 позиций хватило на латиницу, цифры, знаки препинания и управляющие символы. Семь бит на символ. Компактно, понятно, элегантно.

Проблема обнаружилась, когда компьютеры перестали быть исключительно американскими. Каждая страна начала изобретать свою кодировку для восьмого бита. Для кириллицы появились KOI-8R, Windows-1251, ISO 8859-5, MacCyrillic — четыре несовместимых способа записать одни и те же буквы. Для китайского, японского и корейского ситуация была ещё хуже: там одного байта не хватало в принципе, и возникли двухбайтовые кодировки (Big5, Shift_JIS, EUC-KR), каждая со своими правилами.

Результат — настоящая вавилонская башня. Один и тот же байт 0xC0 означал «А» в Windows-1251, «└» в CP437, «À» в ISO 8859-1 и что-то совершенно иное в Shift_JIS. Открыть русский текст, сохранённый в KOI-8R, на компьютере с Windows-1251 — и вместо слов на экране появлялась каша из «кракозябр». Те, кто застал интернет 1990-х, помнят это хорошо.

В 1987 году Джо Бекер из Xerox и Ли Коллинз из Apple начали проект с амбициозной целью: одна кодировка для всех языков мира. В 1991 году вышла первая версия Unicode — 7161 символ, покрывающий 24 письменности. Создатели были настроены оптимистично: они считали, что 65 536 позиций (16 бит) хватит на все живые языки.

Не хватило.

159 801 символ и продолжает расти

В актуальной версии Unicode 17.0, вышедшей в сентябре 2025 года, — 159 801 символ из 172 письменностей. Стандарт давно вышел за рамки 16-битного адресного пространства. Сейчас Unicode определяет 1 114 112 возможных позиций (code points) — от U+0000 до U+10FFFF, разбитых на 17 «плоскостей» (planes) по 65 536 позиций каждая. Занято около 14 % пространства.

Каждый год добавляются новые символы: Unicode 16.0 (2024) принёс 5185 символов и 7 новых письменностей, версия 17.0 — ещё 4803 символа и 4 письменности, включая Beria Erfe (письменность народа загава в Центральной Африке) и Tolong Siki (письменность курукх в Индии). В стандарте есть клинопись шумеров, египетские иероглифы, музыкальная нотация, алхимические знаки и астрономические символы астероидов.

И, разумеется, эмодзи. Emoji 17.0 добавил искажённое лицо, косатку, тромбон и сундук с сокровищами.

Что такое символ: три уровня абстракции

Вот центральная проблема. Слово «символ» (character) в контексте Unicode означает как минимум три разных вещи, и смешение этих уровней — причина большинства ошибок.

Байт — единица хранения. Строка "Привет" в UTF-8 занимает 12 байт (по 2 на каждую кириллическую букву). Это то, что видит файловая система, сетевой протокол и функция len() в большинстве языков.

Code point (кодовая позиция) — единица Unicode. Каждый code point — это число от U+0000 до U+10FFFF, за которым стоит конкретное определение: «латинская строчная буква a», «комбинируемый бреве», «мужчина». Буква «П» — это code point U+041F. Одна кириллическая буква = один code point = 2 байта в UTF-8.

Grapheme cluster (графемный кластер) — единица восприятия. Это то, что человек считает «одним символом». Графемный кластер может состоять из нескольких code points.

Разница становится наглядной на примерах:

СтрокаБайт (UTF-8)Code pointsGrapheme clusters
"A"111
"П"211
"й" (предсоставленная)211
"й" (разложенная: и + ̆ )421
"🌍"411
"👨‍👩‍👧‍👦"2571

Эмодзи «семья» — отдельный случай. Один видимый «символ» состоит из четырёх эмодзи (мужчина, женщина, девочка, мальчик), склеенных тремя невидимыми code points U+200D (Zero Width Joiner). Семь code points, 25 байт — и всё это один графемный кластер.

UTF-8: кодировка, которая победила

Unicode — это набор символов, а не кодировка. Кодировка определяет, как code points превращаются в байты. Существуют UTF-8, UTF-16 и UTF-32, но победителем стала UTF-8, и это заслуженно.

Её придумали Кен Томпсон и Роб Пайк в 1992 году для операционной системы Plan 9. По легенде, схему кодирования они набросали на салфетке в закусочной. UTF-8 — переменной длины: один code point занимает от 1 до 4 байт. Латиница и ASCII-символы — 1 байт. Кириллица — 2 байта. Китайские иероглифы — 3 байта. Эмодзи и редкие письменности — 4 байта.

Ключевые свойства, обеспечившие победу UTF-8:

  • обратная совместимость с ASCII — любой валидный ASCII-файл одновременно является валидным UTF-8;
  • самосинхронизация — по любому байту в потоке можно определить, начало это code point или продолжение, что позволяет восстановиться после ошибки;
  • отсутствие нулевых байтов внутри многобайтовых последовательностей — строки совместимы с C-функциями, работающими с нулевым терминатором;
  • сортировка — побайтовое сравнение UTF-8 строк даёт тот же порядок, что и сравнение по code points.

По данным W3Techs, доля UTF-8 среди веб-страниц превышает 98 %. Windows, долгое время использовавшая UTF-16 (из-за наследия эпохи, когда казалось, что 16 бит хватит), с Windows 10 1903 поддерживает UTF-8 как системную кодовую страницу. UTF-8 стала стандартом де-факто.

Три языка, одна проблема

Чтобы увидеть, как это работает на практике, я написал одну и ту же программу на трёх современных языках — Rust, Go и Zig. Каждая измеряет строки в байтах и code points, разбирает эмодзи-семью на составляющие и демонстрирует гомоглифы.

Скачать исходники: Rust | Go | Zig

Rust

let s = "Привет 🌍";
println!("Байт: {}", s.len());          // 17
println!("Code points: {}", s.chars().count()); // 8

let y1 = "й";         // U+0439 — одна code point
let y2 = \u{0306}"; // U+0438 + U+0306 — две code points
println!("{}", y1 == y2); // false

let family = "👨\u{200D}👩\u{200D}👧\u{200D}👦";
println!("Байт: {}", family.len());          // 25
println!("Code points: {}", family.chars().count()); // 7

Rust гарантирует на уровне системы типов, что String и &str содержат валидный UTF-8. Попытка создать строку с невалидными байтами — ошибка компиляции или паника в рантайме. Оператор среза &word[0..1] по кириллической строке вызовет панику, потому что байт 1 приходится на середину двухбайтовой последовательности.

Go

s := "Привет 🌍"
fmt.Println(len(s))                        // 17 (байт)
fmt.Println(utf8.RuneCountInString(s))     // 8 (code points)

hello := "Здравствуйте"
fmt.Println(hello[:1]) // «�» — половина буквы, невалидный UTF-8

Go использует тип rune (алиас для int32) как абстракцию code point. Конструкция for _, r := range s автоматически декодирует UTF-8, а невалидные последовательности заменяет на U+FFFD (replacement character). Срез hello[:1] не вызывает панику, в отличие от Rust, — Go молча возвращает невалидные байты.

Zig

const s = "Привет 🌍";
// s.len = 17 (байт)

// Для подсчёта code points нужен Utf8View
var view = try std.unicode.Utf8View.init(s);
var iter = view.iterator();
while (iter.nextCodepoint()) |cp| { ... }

// Прямой доступ — это байт, не символ
// s[0] = 0xD0 (первый байт буквы «П»)

Zig — самый низкоуровневый из трёх. Строка — это []const u8, обычный слайс байтов. Язык не гарантирует валидность UTF-8; это ответственность программиста. Для безопасной итерации по code points есть std.unicode.Utf8View, но графемные кластеры, нормализация и коллация — только через внешние библиотеки.

Сравнение

RustGoZig
Внутренняя кодировкаUTF-8UTF-8UTF-8
Валидность гарантированада (тип str)нетнет
len возвращаетбайтыбайтыбайты
Тип code pointchar (u32)rune (int32)u21
Графемные кластеры в stdнетнетнет
Нормализация в stdнетнетнет

Ни один из трёх языков не предоставляет в стандартной библиотеке работу с графемными кластерами или нормализацию. Для Rust есть крейты unicode-segmentation и unicode-normalization. Для Go — пакет golang.org/x/text. Для Zig — внешняя библиотека zg.

Нормализация: почему "café" != "café"

Одну и ту же букву в Unicode можно записать несколькими способами. Буква «й» может быть одной code point U+0439 (Cyrillic Small Letter Short I) — это предсоставленная (precomposed) форма. А может быть комбинацией U+0438 «и» + U+0306 «комбинируемый бреве» — это разложенная (decomposed) форма. На экране обе выглядят одинаково. При побайтовом сравнении — это разные строки.

То же самое с "café". Буква «é» — это либо U+00E9 (precomposed), либо U+0065 «e» + U+0301 «комбинируемый акут» (decomposed). macOS исторически предпочитает NFD (разложенную форму) в именах файлов, в то время как большинство других систем используют NFC (предсоставленную). Файл, созданный на Mac и скопированный на Linux, может перестать находиться по имени.

Unicode определяет четыре формы нормализации:

  • NFC (Canonical Decomposition, followed by Canonical Composition) — разложить, затем собрать обратно в предсоставленные формы. Самая распространённая;
  • NFD (Canonical Decomposition) — полностью разложить. Используется macOS для имён файлов;
  • NFKC и NFKD — то же, но с дополнительным приведением совместимых вариантов: «fi» (лигатура) превращается в «fi», «①» — в «1».

Практическое правило: если вы принимаете текст от пользователя и потом ищете его в базе данных — нормализуйте в NFC при записи. Иначе запрос "café" не найдёт запись "café", хотя для человека это одно и то же слово.

Гомоглифы: буквы-двойники и безопасность

Кириллическая «а» (U+0430) и латинская «a» (U+0061) выглядят одинаково в большинстве шрифтов. Кириллическая «о» и латинская «o», кириллическая «р» и латинская «p», кириллическая «с» и латинская «c» — у них одинаковые глифы, но разные code points. Это гомоглифы: визуально идентичные символы из разных письменностей.

Примеры из демонстрационного кода подтверждают это:

Латинская  'a' = U+0061
Кириллическая 'а' = U+0430
Выглядят одинаково, но == возвращает false

Для программиста это означает, что apple.com и аpple.com — разные домены. Второй начинается с кириллической «а». В адресной строке браузера вы их не отличите. Именно это эксплуатируют IDN-гомоглифные атаки: злоумышленник регистрирует домен с кириллическими буквами, визуально неотличимый от легитимного, и отправляет жертву туда фишинговым письмом.

Для борьбы с этим ICANN и производители браузеров ввели правила для интернационализированных доменных имён (IDN): если домен содержит символы из нескольких письменностей, браузер показывает его в виде Punycode (xn--...) вместо красивого юникодного имени. Но правила не идеальны и не покрывают все случаи — особенно когда все символы принадлежат одной письменности, но подменяют другую.

Unicode Consortium поддерживает файл confusables.txt — таблицу символов, которые можно перепутать визуально. На его основе работают инструменты проверки IDN и антифишинговые системы.

Trojan Source: код, который лжёт

В ноябре 2021 года исследователи из Кембриджского университета опубликовали работу «Trojan Source: Invisible Vulnerabilities» (CVE-2021-42574). Атака использует символы управления направлением текста (BiDi overrides) — U+202A, U+202B, U+202C, U+202E и другие — чтобы код выглядел в редакторе иначе, чем его видит компилятор.

Unicode поддерживает двунаправленный текст: в одной строке могут соседствовать английский (слева направо) и арабский (справа налево). Символы BiDi override меняют направление отображения фрагмента текста. Они невидимы, но влияют на порядок, в котором редактор показывает буквы.

Злоумышленник может написать код, который в текстовом редакторе выглядит как безобидная проверка доступа, а на самом деле содержит уязвимость. Компилятор читает исходники побайтово и не замечает подвоха — BiDi-символы для него просто часть строкового литерала или комментария. Были продемонстрированы рабочие атаки на C, C++, C#, JavaScript, Java, Rust, Go и Python.

Вторая связанная CVE — CVE-2021-42694 — использует гомоглифы в именах функций. Функция checkAccess и функция сheckAccess (где «с» — кириллическая) — это два разных идентификатора, но при code review человек их не отличит.

После публикации компиляторы и линтеры начали предупреждать о наличии BiDi-символов в исходном коде. Rustc выдаёт предупреждение, GCC и Clang добавили -Wbidi-chars, GitHub подсвечивает BiDi override в diff-ах. Проблема не решена окончательно, но осведомлённость выросла.

GlassWorm: невидимый код в открытых репозиториях

В 2025-2026 годах тема Unicode-атак на цепочки поставок вышла на новый уровень. Кампания GlassWorm — пожалуй, самый масштабный пример злоупотребления Unicode в open source.

Механизм: злоумышленники прячут исполняемый код внутри символов из Private Use Area (PUA) — диапазона code points, зарезервированного для частного использования. Эти символы невидимы (отображаются как пробел нулевой ширины) во всех основных редакторах кода, терминалах и интерфейсах code review. Скрытый JavaScript-декодер извлекает из PUA-символов байты и передаёт их в eval(). Вторая стадия запрашивает адрес управляющего сервера из кошелька Solana — блокчейн-инфраструктуру невозможно отключить или заблокировать.

Масштаб: с марта 2025 по март 2026 года были скомпрометированы более 400 репозиториев на GitHub, вредоносные пакеты обнаружены в npm, VS Code Marketplace и Open VSX. В марте 2026 года прошла массовая волна — 151 репозиторий за неделю через force-push ребейзов, сохранявших оригинальные сообщения коммитов и авторство. Никаких Pull Request, никаких следов в истории.

Среди пострадавших — репозитории Wasmer, пакеты @aifabrix/miso-client, 72 расширения Open VSX. Атака эволюционировала в самораспространяющегося червя, использующего CI/CD-пайплайны как канал амплификации.

GlassWorm — наглядная демонстрация того, что невидимые символы Unicode — это не теоретическая угроза, а реальный вектор атаки промышленного масштаба.

Ещё одна опасность: невидимые инструкции для ИИ

В 2025 году появился новый класс атак — невидимый prompt injection. Символы из блока Unicode Tags (U+E0000–U+E007F) невидимы для человека, но обрабатываются языковыми моделями как обычный текст. Злоумышленник вставляет в документ или сообщение скрытые инструкции, которые ИИ-ассистент выполняет без ведома пользователя.

Уязвимость была подтверждена в нескольких системах, включая Google Jules — ИИ-агент для написания кода. Особенность атаки: ни один человек-рецензент не заметит скрытых инструкций при просмотре текста.

Турецкая «İ»: одна буква, которая ломает программы

В большинстве языков мира прописная форма латинской «i» — это «I». В турецком и азербайджанском — всё иначе:

ОперацияСтандартнаяТурецкая
'i'.toUpperCase()I (U+0049)İ (U+0130)
'I'.toLowerCase()i (U+0069)ı (U+0131)

Четыре разных символа вместо двух. Если программа сравнивает строки через toLowerCase() без указания локали, а на машине установлена турецкая локаль, то "FILE".toLowerCase() вернёт "fıle" вместо "file", и поиск файла не сработает.

Эта ошибка преследует индустрию десятилетиями. В ноябре 2024 года Kotlin 2.1 наконец исправил баг, из-за которого toLowerCase() использовал системную локаль по умолчанию — компиляция проектов ломалась на машинах турецких разработчиков. В Rust соответствующий issue #72966 остаётся открытым: стандартная библиотека не поддерживает локале-зависимые операции с регистром. VS Code по состоянию на 2025 год всё ещё подвержен этой проблеме.

Правило простое: для программной логики (сравнение идентификаторов, HTTP-заголовков, ключей конфигурации) используйте ординальное сравнение (ordinal), игнорирующее локаль. Локале-зависимые операции — только для отображения текста пользователю.

Выводы

Unicode — одно из величайших инженерных достижений в истории IT. Стандарт, который позволяет записать в одном документе шумерскую клинопись, тибетское письмо, математические формулы и семью из четырёх эмодзи. Но за этой универсальностью скрывается сложность, о которой большинство программистов не подозревает.

Вот что стоит запомнить.

len() возвращает байты, а не символы. Ни в одном из рассмотренных языков — Rust, Go, Zig — функция длины не возвращает то, что человек считает «количеством символов». Это не баг, а сознательное решение: подсчёт графемных кластеров — дорогая операция, требующая знания таблиц Unicode.

Равенство строк — не то, чем кажется. Две визуально идентичные строки могут быть неравны из-за разной нормализации. Нормализуйте входные данные при записи, а не при чтении.

Unicode — это вектор атаки. Гомоглифы, BiDi override, невидимые символы PUA, Unicode Tags — всё это используется для фишинга, отравления кода и prompt injection. Если ваш код обрабатывает пользовательский ввод, фильтруйте и валидируйте.

Ни один язык не решил задачу полностью. UTF-8 стала стандартом хранения, но работа с графемными кластерами, нормализация и коллация (сортировка с учётом языка) — везде требуют внешних библиотек. Это не недоработка: Unicode слишком сложен и слишком часто обновляется, чтобы встраивать его целиком в стандартную библиотеку языка.

Текст — это не «просто текст». Это одна из самых сложных структур данных, с которыми работает программист. И чем глубже вы погружаетесь в кроличью нору Unicode, тем яснее это понимаете.

Источники

Предыдущий
Наверх