Трендовые github проекты в нашем телеграм канале. Подпишись → Две точки над «е»: где строки ломаются в проде
Каждый раз, когда код сравнивает строки, кто-то решает, какие различия считать существенными. Для поиска удобно, чтобы запрос «береза» находил «берёзу». Для логинов, адресов и других идентификаторов та же логика опасна: система начинает считать одинаковыми разные значения. Буква «ё» хорошо показывает весь набор проблем сразу — от длины строки в Python до функциональных индексов в PostgreSQL и фишинговых логинов.
Одна «ё» — две последовательности кодовых точек
В Unicode буква «ё» записывается двумя способами:
- одним символом
U+0451; - парой: кириллическая «е» (
U+0435) плюс комбинируемая трема (U+0308).
На экране разницы нет, в UTF-8 это разные байты. Разложенная форма появляется в реальных данных, например при импорте имён файлов: HFS+ исторически приводила имена к канонически разложенной форме. APFS хранит имя в исходной нормализации, а для поиска и сравнения считает хеш нормализованной формы. Если строки попадают в приложение из разных источников без согласования, в базе окажутся оба варианта.
Проверка в Python:
import sys
import unicodedata as ud
print("Python:", sys.version)
print("Unicode database:", ud.unidata_version)
samples = {
"precomposed": "ё",
"decomposed": "ё",
"plain_e": "е",
}
for name, value in samples.items():
print(name, [f"U+{ord(ch):04X}" for ch in value])
print(" NFC:", [f"U+{ord(ch):04X}" for ch in ud.normalize("NFC", value)])
print(" NFD:", [f"U+{ord(ch):04X}" for ch in ud.normalize("NFD", value)])
Обе записи «ё» под NFC превращаются в ['U+0451'], под NFD — в ['U+0435', 'U+0308']. Это канонические формы из UAX #15.
Отсюда два практических следствия:
len()в Python считает кодовые точки. Графемные кластеры (UAX #29), то есть то, что пользователь видит как один символ, он не учитывает. ДляU+0451получится 1, для разложенной формы — 2. Срез[:1]по разложенной строке оставит голую «е» и выбросит точки. Обрезка ника или превью до N символов по такой строке даёт искажённый текст.- Нормализация Unicode «ё» в «е» не превращает. Простая
U+0435остаётся собой и в NFC, и в NFD. NFC/NFD согласуют две записи одной и той же буквы, а «е» и «ё» для них разные буквы. Слияние «е» и «ё» происходит только тогда, когда вы сами вводите такое правило.
Регулярка, которая не пропускает «ёж»
Отдельная ловушка для валидаторов:
import re
print(re.fullmatch(r"[а-яА-Я]+", "ёж")) # None
ё (U+0451) и Ё (U+0401) лежат вне диапазонов U+0430–U+044F и U+0410–U+042F. Форма регистрации с таким паттерном отклонит Алёну и Фёдора. Варианты исправления: явно дописать [а-яА-ЯёЁ] или описать допустимые символы через Unicode-свойства. Поведение других regex-движков стоит проверять отдельно, переносить этот вывод на них без теста нельзя.
unaccent в PostgreSQL 18: полезная потеря информации
Для поиска склейка «е/ё» часто полезна: пользователь вводит «все», в документе «всё». Цена — компромисс между полнотой и точностью. Полнота растёт, а пары вроде «мел/мёл» или «совершенный/совершённый» начинают совпадать.
В PostgreSQL 18 (ветка REL_18_STABLE) файл contrib/unaccent/unaccent.rules содержит правила Ё → Е и ё → е. Там же есть односимвольная запись для U+0308 без замены — такие записи удаляют знак. В итоге стандартный unaccent сводит к «е» обе формы «ё», даже без предварительного NFC.
CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT unaccent('ё') AS from_yo,
unaccent('е') AS from_ye,
unaccent('ё') = unaccent('е') AS equal_after_unaccent;
SELECT unaccent('мёл') = unaccent('мел') AS semantic_collision;
Оба сравнения дают true. unaccent(U&'\0435\0308') тоже возвращает 'е'.
Ограничения, о которых стоит знать заранее:
- Само расширение ничего не меняет в существующих таблицах и запросах. Это фильтрующий словарь и функция, которые подключают явно.
- Функции
unaccentобъявлены какSTABLE: результат зависит от словаря и внешнего файла правил. В выражении функционального индекса нужнаIMMUTABLE-функция, поэтому напрямуюunaccentтуда не встанет. Обёртка, помеченнаяIMMUTABLEбез оговорок, рискованна: если файл правил поменяется, индекс тихо разойдётся с фактическим результатом функции. - Одноаргументная
unaccent(text)ищет словарьunaccentв своей схеме. Если расширение установлено в нестандартную схему, имена нужно квалифицировать.
Пример поисковой конфигурации:
CREATE TEXT SEARCH CONFIGURATION article_unaccent (COPY = pg_catalog.simple);
ALTER TEXT SEARCH CONFIGURATION article_unaccent
ALTER MAPPING FOR hword, hword_part, word
WITH unaccent, pg_catalog.simple;
SELECT to_tsvector('article_unaccent', 'берёза')
@@ plainto_tsquery('article_unaccent', 'береза') AS found;
SELECT to_tsvector('article_unaccent', 'мёл')
@@ plainto_tsquery('article_unaccent', 'мел') AS semantic_collision;
Основа здесь simple, потому что у pg_catalog.russian своя особенность: в стоп-листе russian.stop есть «все», но нет «всё», а Snowball проверяет стоп-лист до стемминга. Пара «все/всё» в стандартной русской конфигурации обрабатывается несимметрично. С simple видно поведение unaccent без влияния стеммера и стоп-слов; в боевой конфигурации эту асимметрию придётся учитывать.
Омоглифы: обратная проблема
С «ё» система намеренно склеивает разные символы. С омоглифами наоборот: компьютер видит разные строки, человек — одинаковые. paypal и pаypаl с двумя кириллическими а (U+0430) в большинстве шрифтов неотличимы. UTS #39 приводит эту пару как классический пример mixed-script confusables.
Расчёт на то, что от подмены защитит NFKC, не оправдывается:
import unicodedata as ud
latin = "paypal"
mixed = "pаypаl"
for form in ("NFC", "NFD", "NFKC", "NFKD"):
print(form, ud.normalize(form, latin) == ud.normalize(form, mixed))
Все четыре формы дают False. NFKC/NFKD сводят лигатуры и римские цифры к общей форме, но латинскую a в кириллическую а не превращают. unaccent здесь тоже бесполезен: он применяет заданные замены диакритики и визуальное сходство не ищет.
Смешение кириллицы и латиницы бывает легитимным, поэтому тотальный запрет не подходит. UTS #39 предлагает два механизма:
- Restriction Levels — для каждого типа идентификатора задаются допустимые скрипты и их сочетания: Single Script, Highly Restrictive, Moderately Restrictive.
- Confusable skeleton — внутренняя форма строки для сравнения идентификаторов на визуальное сходство.
Skeleton — только сигнал риска. Его нельзя показывать пользователю и нельзя хранить вместо исходного логина. Таблицы confusables меняются между версиями Unicode: если skeleton кешируется в базе, фиксируйте версию алгоритма и пересчитывайте значения при обновлении.
Четыре разных представления одной строки
Одной normalize() на всё не хватит. Удобнее явно разделить назначения:
- Оригинал — то, что ввёл пользователь, без поисковых преобразований. Источник для отображения и аудита.
- Каноническая форма — NFC там, где нужно согласовать
U+0451иU+0435 U+0308. «е» и «ё» остаются разными. - Поисковый ключ — отдельное поле или индекс по правилам поиска:
unaccent, NFC +unaccent, регистр, стемминг. Распространённый приём — индексировать нормализованное и точное представления вместе: первое расширяет выдачу, второе даёт точные совпадения и повышает их в ранжировании. - Проверка идентификатора — Restriction Level и confusables при регистрации и смене имени. Правила допустимых символов, нормализации и уникальности должны совпадать на регистрации, входе и проверке дублей.
Как ведут себя пары строк по разным критериям:
| Пара | == | После NFC | unaccent / NFC + unaccent | UTS #39 |
|---|---|---|---|---|
U+0451 и U+0435 U+0308 | false | true | true | — |
| «ё» и «е» | false | false | true | — |
| «мёл» и «мел» | false | false | true | — |
paypal и pаypаl | false | false | false | skeleton совпадает |
Главное правило из таблицы: поисковый ключ никогда не используется как ключ идентичности, а skeleton — как нормализованное имя. Если unaccent-колонка случайно попадёт в UNIQUE-ограничение, «Алёна» и «Алена» станут одним пользователем. Если сравнивать логины только через NFKC, pаypаl зарегистрируется рядом с paypal.