Видел у Ильяхова видео о незначительности использования буквы «е» вместо «ё». Автор говорил, что это не важно. Поинт в том, что стоит использовать букву «ё» только если это влияет на смысл.
Помните, в школе регулярно давали списки из ~10-20 слов, перевод которых нужно было выучить к следующему уроку? Одна конструкция давалась мне особенно сложно.
Возможно, учителя старших классов по сей день не договорились, «настоящее совершенное» или «настоящее совершённое».
Суть в том, что Present Perfect обозначает действие, которое уже завершилось. То есть, правильно будет «настоящее совершённое».
Но вопрос: как переводится «perfect»? Синк эбаут ит, как говорится.
Так если действительно использовать букву «ё» только там, где это важно по смыслу, не уколет ли вам глаз это правило, обобщенное на весь текст? Где часть «ё» — «ёфицирована», а часть — через «е» (или, как многие говорят, через «х»).
При разработке мы постоянно решаем, какие различия в данных считать существенными, а какие игнорировать ради удобства.
Например, поисковику может и полезно считать «е» и «ё» одинаковыми: пользователь найдёт нужный документ, даже если не поставил две точки. Но ту же логику уже не получится применить к логинам, адресам или другим идентификаторам.
Компьютер не знает, какие различия считать существенными. Это определяем мы. И иногда цена решения — потерянная информация, некорректная идентификация пользователя или забытая уязвимость системы.
Заставляя компьютер считать два разных символа одинаковыми, мы не только упрощаем обработку данных. Мы принимаем решение о том, какую информацию система может безнаказанно потерять.
Одна буква — два канонически эквивалентных представления
Первый сюрприз ждёт нас ещё до того, как вообще начнём сравнивать «е» и «ё».
Даже сама буква «ё» в Unicode записывается двумя разными способами:
-
как символ
U+0451; -
как последовательность из двух кодовых точек: обычной кириллической «е» (
U+0435) и комбинируемого диакритического знака — двух точек над буквой (U+0308).
На экране оба варианта выглядят одинаково. Но в UTF-8 это разные последовательности байтов, а на уровне Unicode — разные последовательности точек. Такая «раздвоенная» буква берётся в реальной жизни, например, при импорте названий файлов из разных файловых систем. Согласно документации Apple по APFS, исторически HFS+ приводила имена к канонически разложенной форме, поэтому могла быть реальным источником последовательности «е» + U+0308. А APFS сохраняет исходную нормализацию имени на диске, но вычисляет хеш нормализованной формы, чтобы операции поиска и сравнения имён не зависели от формы записи. Если же строка пришла в веб-приложение из внешнего интерфейса или файловой системы без согласования, внутри базы легко окажутся оба варианта.
Вот так это выглядит в Python:
import sysimport unicodedata as udprint("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)])
Здесь работают две стандартные формы канонической нормализации из Unicode Standard Annex #15 (UAX #15): NFD раскладывает канонически эквивалентный символ, а NFC собирает его обратно. Первые два варианта (precomposed и decomposed) приводятся формой NFC к ['U+0451'], а при NFD оба дают ['U+0435', 'U+0308'].
При этом важно учитывать две практические детали:
-
Стандартная функция
len()в Python считает кодовые точки, а не графемные кластеры (UAX #29), которые приближённо соответствуют тому, что пользователь воспринимает как один символ. Поэтому для готовой «ё» (U+0451) функцияlen()вернёт 1, а для разложенной последовательностиU+0435+U+0308— уже 2. Если обрезать разложенную строку срезом[:1], он оставит только базовую «е» (U+0435) и отбросит комбинируемые точки (U+0308). -
Обычная Unicode-нормализация не превращает «ё» в «е». В примере выше
plain_e(U+0435) остаётсяU+0435и в NFC, и в NFD. Это разные буквы: NFC и NFD здесь не стирают смысловое различие, а лишь согласуют две канонически эквивалентные записи одной буквы.
Потеря различия между «е» и «ё» возникает тогда, когда мы сами вводим прикладное правило преобразования или сопоставления, которое игнорирует эту разницу.
Потеря различия бывает полезна
Представим поисковый сценарий: пользователь вводит «все», а в документе написано «всё». Для многих задач полезно находить такой документ не только ради двух точек.
Но унифицирование упирается в компромисс между полнотой (Recall) и точностью (Precision). То есть, помогает находить больше релевантных документов, но добавляет лишние результаты там, где две точки меняют смысл. Наглядный пример такой цены — пара «мел» и «мёл», или «совершенный» и «совершённый» из вступления. Эффект всегда зависит от корпуса, запроса и ранжирования.
Посмотрим, как это устроено на реальном примере в PostgreSQL 18.
В файле стандартных правил расширения unaccent для ветки REL_18_STABLE (contrib/unaccent/unaccent.rules) прямо заложены преобразования Ё → Е и ё → е. Более того, в том же файле unaccent.rules есть отдельная односимвольная запись для комбинируемого знака трема (U+0308) без символа замены — по документации PostgreSQL, такие записи удаляют диакритический знак. Это значит, что стандартный unaccent сводит к 'е' и монолитную 'ё' (U+0451), и разложенную последовательность 'е' + U+0308 (U+0435 U+0308) даже без предварительного вызова NFC.
Важно точно понимать роль инструмента: unaccent — фильтрующий словарь и функция преобразования текста, которые можно подключить к полнотекстовой поисковой конфигурации. Наличие расширения в базе автоматически не переписывает таблицы и не меняет любой поиск. Кроме того, функции unaccent в PostgreSQL 18 объявлены с категорией изменчивости STABLE, а не IMMUTABLE, так как их результат зависит от словаря и внешнего файла правил. Поэтому их нельзя напрямую использовать в выражении функционального индекса, требующем IMMUTABLE-функции. Делать для неё безоговорочную IMMUTABLE-обёртку рискованно: при изменении файла правил индекс может тихо разойтись с фактическим результатом функции.
Проверим отдельное преобразование строки и смысловую коллизию:
CREATE EXTENSION IF NOT EXISTS unaccent;SELECT unaccent('ё') AS from_yo, unaccent('е') AS from_ye, unaccent('ё') = unaccent('е') AS equal_after_unaccent;SELECT unaccent('мёл') AS from_mel_yo, unaccent('мел') AS from_mel_ye, unaccent('мёл') = unaccent('мел') AS semantic_collision;
По идее, первый запрос возвращает 'е' для обоих символов и true при сравнении (тот же результат 'е' функция unaccent(U&'435308') возвращает и для разложенной формы за счёт удаления U+0308). Второй запрос наглядно показывает смысловую коллизию: unaccent('мёл') и 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;
Оба запроса возвращают true при установленном расширении и доступном через текущий search_path словаре unaccent. Одноаргументная функция unaccent(text) использует словарь unaccent в той же схеме, где сама функция. При другой схеме установки имена обязательно квалифицируются.
Почему в демонстрации выше взята конфигурация pg_catalog.simple, а не pg_catalog.russian? В стандартном списке стоп-слов russian.stop в PostgreSQL 18 есть слово «все», но нет слова «всё», а словарь Snowball проверяет стоп-лист ещё до запуска стеммера. Поэтому пара «все/всё» в стандартной русской конфигурации обрабатывается несимметрично. Использование pg_catalog.simple позволяет изолировать действие unaccent от русского стемминга и стоп-слов и увидеть поведение правила замены в чистом виде.
Когда визуально похожие символы становятся угрозой
С «е» и «ё» ситуация, когда система намеренно объединяет разные символы. Бывает и обратная проблема: компьютер считает символы разными, хотя человек их визуально почти не различает.
Сравним латинскую строку paypal и строку pаypаl, в которой две буквы a заменены кириллическими «а» (U+0430). В зависимости от шрифта и интерфейса они выглядят почти неотличимо, хотя состоят из разных кодовых точек.
Стандарт Unicode Technical Standard #39 (UTS #39) приводит эту пару как классический пример mixed-script confusables. В переводе — «смешанные скриптовые омоглифы». Запоминайте, хе-хе. Так называют как раз ситуацию, когда в строке встречаются символы из разных систем письма (скриптов), которые визуально очень похожи друг на друга.
Это уже не случай «е/ё», а родственный, но отдельный механизм. Иногда разработчики рассчитывают, что от подобных подмен при регистрации защитит стандартная нормализация Unicode — например, совместимые формы NFKC или NFKD. Проверим это коротким тестом на Python:
import unicodedata as udlatin = "paypal"mixed = "pаypаl" # две кириллические «а» U+0430for form in ("NFC", "NFD", "NFKC", "NFKD"): print(form, ud.normalize(form, latin) == ud.normalize(form, mixed))
Результат для всех четырёх форм — False.
Ни NFC/NFD, ни NFKC/NFKD не обнаруживают визуально похожие символы из разных скриптов. Даже NFKC и NFKD, которые сводят некоторые совместимые варианты к общей форме (например, отдельные лигатуры или римские цифры), не превращают латинскую a в кириллическую а. Правила unaccent также решают другую задачу — они применяют заданные замены диакритики, а не ищут визуальное сходство.
При этом нельзя сказать, что любая смесь кириллицы и латиницы вредоносная: смешанные скрипты бывают вполне легитимными. Практическая защита по UTS #39 строится не на слепом запрете любого смешения, а на двух механизмах:
-
Restriction Levels (уровни ограничений). Для конкретного типа идентификатора определяются допустимые скрипты и их сочетания. Например, уровни Single Script, Highly Restrictive или Moderately Restrictive в зависимости от того, какие языки и сочетания ожидаются в системе.
-
Проверка через confusable skeleton. Это промежуточная внутренняя форма строки, предназначенная исключительно для сравнения идентификаторов на визуальную схожесть.
Здесь надо соблюдать ограничение стандарта: confusable skeleton используется только как внутренний сигнал риска. Его нельзя показывать пользователю или сохранять в качестве канонического имени или логина вместо исходной строки. Кроме того, таблицы confusables в Unicode обновляются между версиями стандарта — если вы сохраняете skeleton для быстрых проверок, нужно фиксировать версию алгоритма и пересчитывать значения при обновлении базы Unicode.
Почему одной normalize() недостаточно
Одна строка в системе может участвовать в нескольких операциях обработки. Это не означает, что для каждого текстового атрибута обязательны четыре физических поля в базе или один жёстко фиксированный конвейер. Стоит разделить назначение каждого правила и чётко определить, где его допустимо использовать:
-
Вход и хранение оригинала. Оригинальную последовательность, введённую пользователем, нужно сохранять без перезаписи поисковыми преобразованиями — она остаётся источником для отображения и аудита.
-
Каноническая проекция. Там, где системе нужна канонизация, отдельно формируется NFC-представление для согласования канонически эквивалентных Unicode-последовательностей (
U+0451иU+0435 U+0308). NFC не сливает «е» и «ё». -
Поисковая проекция. Отдельное поле или индекс, построенный по правилам конкретной поисковой задачи (например, unaccent или NFC + unaccent в связке с приведением регистра и стеммингом). Распространённый архитектурный паттерн — multi-field-подход, когда индексируются и нормализованное, и точное представления: нормализованное расширяет полноту, а точное используется для точного совпадения или повышения Precision при ранжировании.
-
Проверка безопасности идентификатора. На регистрации и смене имени применяются политика допустимых символов и письменностей (Restriction Level) и проверка confusables. При этом правила допустимых символов, нормализации и уникальности должны быть строго согласованы на регистрации, входе и при проверке дублей, а поисковый ключ никогда не должен использоваться как ключ идентичности.
Если свести все разобранные сценарии в единую проверочную матрицу, разница между четырьмя критериями видна сразу:
Пара строк
По кодовым точкам (==)
После NFC
Поисковый ключ (unaccent / NFC + unaccent)
Проверка UTS #39
«ё» (U+0451) и «е» + U+0308 (U+0435 U+0308)
false
true
true
не основной критерий для этого теста
«ё» (U+0451) и «е» (U+0435)
false
false
true
не основной критерий для этого теста
«мёл» и «мел»
false
false
true
не основной критерий для этого теста
«paypal» и «pаypаl»
false
false
false
mixed-script confusables (skeleton совпадает)
Отдельная деталь, о которой стоит помнить при написании валидаторов: в Python символьный диапазон [а-яА-Я] не включает буквы «ё» и «Ё», поскольку кодовые точки U+0451 и U+0401 находятся вне диапазонов U+0430–U+044F и U+0410–U+042F:
import reprint(re.fullmatch(r"[а-яА-Я]+", "ёж")) # None
Не обобщайте без проверки это поведение на все существующие regex-движки, но при валидации кириллицы нужно либо явно учитывать ё/Ё ([а-яА-ЯёЁ]), либо опираться на явную Unicode-политику.
Точное сравнение строк, каноническая эквивалентность, поисковое сопоставление и обнаружение визуальных подмен решают разные задачи: поисковый ключ — не идентификатор, а confusable skeleton — не нормализованное имя и не значение для отображения.
Вернёмся к двум точкам
Нет универсального правила, которое декларирует важность или несущественность различий в написании — всё зависит от конкретной задачи и последствий выбранного преобразования.
При работе со СМИ в 2N9HT, или, например, над SERM, постоянно приходится учитывать, как разные системы находят и сопоставляют упоминания брендов и персон: разное написание, «е/ё» и нормализация меняют результат.
Наверное, у каждого на Хабре есть воспоминания о том, как попытка сделать условные поиск, или авторизацию умнее приводила к неожиданным дублям, коллизиям или уязвимостям.
Поделитесь историей, какие «невидимые» различия в строках ломали вам прод?
Источник: habr.com