2025, Oct 19 08:16
Римские числа в тексте без пустых совпадений: регулярное выражение для Python и PCRE2
Надёжный шаблон для римских чисел в регистронезависимом режиме без нулевых совпадений. Готовые regex для Python и PCRE2, разбор причин и оптимизация.
Извлекать римские цифры из обычного текста кажется простой задачей, пока регулярное выражение незаметно не начинает давать совпадения нулевой длины. Такие пустые срабатывания засоряют результаты и неудобны в дальнейшей обработке, особенно когда нужно уложиться в одно выражение, работающее в регистронезависимом режиме в Python или PCRE2. Ниже — точное решение, которое устраняет пустые совпадения, не ломая задуманное поведение.
Постановка задачи
Рассмотрим пример, на котором проверяется поиск римских чисел в обычных предложениях и в отдельных строках:
Charles I was a bad king, I was not.
Charles X was a good one.
Who was Louis XVI?
The year is MCMXCIX, the month is June.
Do you need an X-ray, do you think?
My friends Cil and Cleo met me for coffee.
MCMLXIX
Один из более надёжных шаблонов для канонических римских чисел:
(?=\b[MCDXLVI]+\b)M{0,4}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})(?!-)\b
В регистронезависимом режиме он корректно находит римские числа, но при этом выдаёт два пустых совпадения: прямо перед X в X-ray и прямо перед Cil. Положительная проверка вперёд допускает эти позиции, потому что следующие символы слова — это римские буквы, однако центральная часть шаблона может законно ничего не поглотить, и движок фиксирует совпадение нулевой длины в этих местах.
Почему появляются пустые совпадения
Начальная проверка утверждает лишь, что дальше идут римские буквы, но не гарантирует, что шаблон их действительно съест. Тело шаблона состоит из необязательных частей вроде M{0,4}, D?C{0,3} и т. п., каждая из которых допускает пустое совпадение. Когда завершающее условие это позволяет, всё выражение может сработать, не сдвинув позицию в тексте. Именно это и происходит перед X в X-ray и перед Cil: заглядывание вперёд «видит» подходящие буквы, тело выбирает пустые ветки, конечная граница проходит — и получается совпадение нулевой длины.
Решение
Суть в том, чтобы конечное ограничение подтверждало: те римские буквы, которые мы «увидели» в начале, действительно были поглощены. Замените проверку «граница слова и не дефис» на утверждение, запрещающее последующие буквенно-цифровые символы и дефисы. Так конец совпадения смещается от символов, которые заметила проверка вперёд.
Замените хвост с (?!-)\b на (?![\w-]). После этого начальную проверку можно упростить: достаточно утверждать, что мы стоим на границе слова перед символом слова.
Обновлённый шаблон:
\b(?=\w)M{0,4}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})(?![\w-])
Теперь движок не будет сообщать о совпадении, пока компоненты римского числа не поглотят те символы слова, которые были «замечены» впереди, — пустые срабатывания исчезают.
Небольшая оптимизация (по желанию)
Альтернативы для вычитательных пар можно записать компактнее, не меняя поведение. Например, CM|CD сократить до C[MD], XC|XL до X[CL], а IX|IV до I[XV]. Если вам ближе такой стиль, эквивалентный и более краткий вариант:
\b(?=\w)M{0,4}(?:C[MD]|D?C{0,3})(?:X[CL]|L?X{0,3})(?:I[XV]|V?I{0,3})(?![\w-])
Почему это важно
Совпадения нулевой длины коварны: они завышают количество находок, усложняют последующую обработку и могут вызывать неожиданные эффекты в циклах сканирования. Если вы опираетесь на одно регулярное выражение для извлечения римских чисел из текста, важно гарантировать, что шаблон не способен сработать, не потребив символы, — это избавляет от трудноуловимых ошибок.
Итоги
Если регулярное выражение начинается с «мягкой» проверки вперёд, а его основная часть состоит из необязательных или допускающих пустоту фрагментов, оно может сработать, не потребив ввод. Свяжите начальные и конечные утверждения так, чтобы в конце явно исключались продолжающиеся буквенно-цифровые символы (и здесь — дефисы), принуждая тело шаблона поглощать то, что «увидело» начало. Для случая с римскими числами в Python или PCRE2 в регистронезависимом режиме достаточно \b(?=\w) в начале и (?![\w-]) в конце — так вы аккуратно избавитесь от пустых совпадений, сохранив нужные попадания внутри обычного текста.