Жадные и ленивые регулярные выражения, разница между .* и .*?

1026
Жадные и ленивые регулярные выражения, разница между .* и .*?

Одна короткая строка хорошо показывает проблему, на которой спотыкаются почти все при знакомстве с регулярными выражениями.

<b>один</b><b>два</b>

Я хочу извлечь два отдельных элемента и пишу выражение <b>.*</b>. На вид всё логично. Сначала открывающий тег, затем любой текст, потом закрывающий тег. Но поиск возвращает не два элемента, а один большой фрагмент.

<b>один</b><b>два</b>

Причина в жадности квантификатора *. Конструкция .* старается захватить максимально возможное количество символов, при котором всё регулярное выражение ещё может совпасть. Если написать .*?, квантификатор станет ленивым и будет начинать с минимального количества символов. Но я бы не превращал правило в совет «везде заменяйте .* на .*?». Когда граница поля известна, точный класс символов часто надёжнее обоих вариантов.

Почему .* забирает сразу два фрагмента

Разберём конструкцию по частям. Точка . означает один символ из допустимого для точки набора. Звёздочка * разрешает повторить предыдущий элемент от нуля до любого количества раз.

.*

Такой фрагмент можно приблизительно прочитать как «ноль или больше произвольных символов».

Есть важная оговорка. Точка не всегда означает буквально любой символ. В JavaScript без флага s она не совпадает с переводами строк. В Python действует похожее правило, а для захвата переводов строк нужен режим DOTALL. Поведение точки и квантификаторов подробно описывают MDN и документация модуля Python re.

Теперь посмотрим на исходное выражение.

<b>.*</b>

После первого <b> движок должен определить, сколько символов отдать конструкции .*. Звёздочка по умолчанию жадная, поэтому предпочитает максимально длинный вариант, который позволяет завершить всё выражение.

Для распространённых движков с откатом удобно представить работу следующим образом.

Строка
 <b>один</b><b>два</b>
 
 Шаблон
 <b>.*</b>
 
 1. найден первый <b>
 2. .* забирает максимально много текста
 3. движку ещё нужно сопоставить </b>
 4. при необходимости .* отдаёт символы назад
 5. подходит последний </b>

Получается одно совпадение.

<b>один</b><b>два</b>

Такой возврат к предыдущим вариантам называют backtracking, или откатом. Однако воспринимать описанные шаги как обязательную внутреннюю архитектуру любого regex-движка не надо. Реализации отличаются и могут оптимизировать поиск. Для понимания жадных квантификаторов полезнее держать в голове наблюдаемое правило. Жадный квантификатор предпочитает максимальное число повторений, совместимое с оставшейся частью шаблона.

Последняя часть особенно важна. .* не просто «забирает всё». Если после точки со звёздочкой есть условия, квантификатор обязан оставить текст, который позволит выполнить оставшуюся часть выражения.

Например, выражение

a.*b

для строки

a 123 b 456 b

предпочтёт совпадение от первой a до последней подходящей b.

a 123 b 456 b

Именно поэтому жадные регулярные выражения часто дают неожиданный результат в строках с несколькими одинаковыми разделителями.

Как работает ленивый .*?

Если после квантификатора поставить вопросительный знак, поведение меняется.

.*?

Звёздочка по-прежнему разрешает от нуля повторений, но теперь выбирает минимальное количество, достаточное для совпадения всего шаблона.

Применим ленивый вариант к нашей строке.

<b>.*?</b>

После первого <b> конструкция .*? сначала предпочитает ноль символов. Закрывающего </b> в текущей позиции нет, поэтому совпадение приходится расширять. Движок проходит буквы слова один и останавливается, когда следующая часть шаблона уже может совпасть с первым </b>.

<b>один</b><b>два</b>
    └───┘
    .*?

При глобальном поиске получаем два совпадения.

<b>один</b>
 <b>два</b>

В JavaScript текст между тегами можно забрать захватывающей группой.

const text = '<b>один</b><b>два</b>';
 
 const regex = /<b>(.*?)</b>/g;
 
 for (const match of text.matchAll(regex)) {
     console.log(match[1]);
 }

Результат будет таким.

один
 два

Скобки (.*?) сохраняют найденный между тегами фрагмент в первую захватывающую группу.

Ленивым можно сделать не только *. Тот же принцип работает с другими квантификаторами.

Жадный Ленивый Количество повторений
* *? от нуля
+ +? от одного
? ?? ноль или одно
{2,5} {2,5}? от двух до пяти

Хороший пример даёт строка из шести букв a.

aaaaaa

Выражение a{2,5} при обычном поиске предпочитает пять букв.

aaaaa

Вариант a{2,5}? предпочитает минимально разрешённые две.

aa

Здесь скрывается частая ошибка в объяснениях regex. Ленивый не означает «самый короткий фрагмент во всей строке любой ценой» и тем более не означает «самый быстрый». Ленивость управляет количеством повторений конкретного квантификатора с учётом остальных частей выражения.

Например, шаблон

a.*?z

не остановится после первого символа вслед за a, если там нет z. Квантификатор будет расширяться до первой позиции, где оставшийся фрагмент выражения сможет совпасть.

По той же причине добавление ? не гарантирует ускорения. Производительность зависит от всего шаблона, входных данных и конкретного regex-движка. Ленивый квантификатор решает вопрос выбора длины совпадения, а не автоматически устраняет дорогой поиск.

В логах и HTML лучше сначала искать настоящую границу

HTML особенно хорошо демонстрирует разницу между жадным и ленивым поиском.

<p>первый</p><p>второй</p>

Жадный вариант

<p>.*</p>

может вернуть оба элемента одним совпадением. Ленивый

<p>.*?</p>

при глобальном поиске разделит два простых элемента.

Однако здесь легко сделать неправильный следующий вывод и начать разбирать произвольный HTML через .*?. Для заранее известного короткого фрагмента регулярка может быть вполне удобна. Для полноценного HTML-документа подход быстро становится хрупким. В разметке встречаются вложенные элементы, атрибуты, комментарии, необязательные закрывающие теги, script, style и другие конструкции со своими правилами разбора. Если нужна структура документа, я выберу HTML-парсер или DOM API.

В логах проблема выглядит ещё нагляднее.

msg="login failed" user="alex" status="denied"

Шаблон

msg=".*"

может растянуться от кавычки перед login failed до последней кавычки строки.

msg="login failed" user="alex" status="denied"

Ленивый вариант уже лучше соответствует задаче.

msg=".*?"

Результат

msg="login failed"

Но формат лога даёт ещё более точную подсказку. Если внутри значения двойные кавычки запрещены, искать «что угодно как можно меньше» вообще не требуется.

msg="[^"]*"

Конструкция [^"] означает любой символ, кроме двойной кавычки. Шаблон описывает не стратегию поиска, а реальную структуру поля.

Такой приём обычно предпочитаю ленивой точке. Если известно, какой символ завершает поле, проще запретить его внутри значения.

До запятой
 [^,]*
 
 До закрывающей скобки
 [^)]*
 
 Внутри двойных кавычек
 "[^"]*"

Но точный класс тоже нельзя копировать механически. Допустим, формат разрешает экранированные кавычки.

msg="он сказал "привет""

Простой [^"]* воспримет первую экранированную кавычку как конец значения. Для формата с обратным слешем потребуется учитывать экранированные символы, например в подходящем regex-движке шаблон может выглядеть так.

msg="(?:.|[^"])*"

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

Как выбирать между .*, .*? и точным классом

Я пользуюсь простой последовательностью. Сначала спрашиваю, знаю ли я, какие символы разрешены внутри искомого фрагмента. Если знаю, стараюсь описать их явно. Только если содержимое действительно свободное, перехожу к точке и выбираю подходящую жадность.

Задача Вариант Комментарий
Взять максимально длинный допустимый фрагмент .* Подходит, если именно такое поведение нужно
Остановиться на ближайшем подходящем ограничителе .*? Удобно при свободном содержимом
Читать до известного запрещённого символа [^"]* Обычно точнее описывает формат
Разобрать полноценный HTML HTML-парсер Regex лучше оставить для локальных контролируемых фрагментов

Для закрепления достаточно сравнить три выражения на исходной строке.

Строка
 <b>один</b><b>два</b>
 
 
 Жадный вариант
 <b>.*</b>
 
 Результат
 <b>один</b><b>два</b>
 
 
 Ленивый вариант
 <b>.*?</b>
 
 Результаты при глобальном поиске
 <b>один</b>
 <b>два</b>
 
 
 Точный вариант для текста без вложенных тегов
 <b>[^<]*</b>
 
 Результаты
 <b>один</b>
 <b>два</b>

Последний шаблон выглядит самым строгим, но работает только при нашем допущении. Если внутри <b> появится другой тег, регулярка перестанет подходить.

<b>один <i>очень</i> важный текст</b>

Поэтому универсального победителя среди .*, .*? и отрицательного класса нет. Выбирать нужно по структуре данных.

Жадные и ленивые регулярные выражения

Главное правило я бы сформулировал так. Жадный квантификатор предпочитает максимальное число повторений, ленивый предпочитает минимальное, но оба обязаны удовлетворить всему регулярному выражению. Если настоящую границу поля можно описать явно, лучше описать её. Такой regex обычно легче читать, тестировать и поддерживать, а случайное «почему регулярка съела половину файла» встречается заметно реже.

Alt text
Обращаем внимание, что все материалы в этом блоге представляют личное мнение их авторов. Редакция SecurityLab.ru не несет ответственности за точность, полноту и достоверность опубликованных данных. Вся информация предоставлена «как есть» и может не соответствовать официальной позиции компании.
02.10.26 Москва
Форум по информационной безопасности и ИТ
Каждый ход имеет значение
GISГАЗИНФОРМСЕРВИС
Присоединиться
18+
Реклама. Рекламодатель ООО «Газинформсервис», ОГРН: 1047833086099

Реклама 18+
Рекламодатель
Реклама. АО «Позитив Текнолоджиз». ИНН 7718668887. 18+
Сайт рекламодателя: ptsecurity.com
Реклама