Опережающие и ретроспективные проверки: lookahead и lookbehind
Проверки lookahead и lookbehind добавляют условие «только если рядом есть такой-то текст», не включая его в совпадение: пароль с тремя требованиями — одной регуляркой, разряды 1 000 000 — одной заменой.
Редакция Питоники
Восемь уроков назад вы искали в тексте цифры, слова и группы. Но у поиска всегда был один слепой угол: условие без захвата. Как найти слово, которое стоит перед двоеточием, но не тащить само двоеточие в результат? Как проверить, что после числа не идёт « руб», не отказываясь от поиска чисел? Как заставить одну регулярку проверить в пароле и строчную букву, и заглавную, и цифру — если каждая из них может стоять где угодно?
Для всего этого существуют проверки: опережающие и ретроспективные. Они не матчат символы, а задают условия позиции — в этом смысле они ближе к якорям ^ и \b из урока про якоря и границы слова, чем к обычным классам символов. Скобки тут тоже не группы: в отличие от скобок из урока про группы, захват не происходит. Сейчас разберём все четыре конструкции и соберём две задачи, которые без них решаются уродливо: пароль-валидация и разбиение чисел на разряды.
Что такое опережающая проверка (?=...) и почему она ничего не съедает?
Конструкция (?=...) читается так: «в этом месте дальше должен стоять текст, подходящий под образец, — но в совпадение он не входит». Опережающая проверка — это экзаменатор: он стоит в коридоре, смотрит, куда вы идёте, но сам в комнату не заходит. Посмотрим на разнице между тремя способами достать ключи конфига перед двоеточием.
import re
config = "host: pythonika.ru\nport: 443\ndebug: True"
print(re.findall(r"\w+(?=:)", config))
print(re.findall(r"\w+:", config))
print(re.search(r"\w+(?=:)", config).group())
print(re.search(r"\w+(?=:)", config).span())
['host', 'port', 'debug'] ['host:', 'port:', 'debug:'] host (0, 4)
Сравните первые две строки вывода. Шаблон \w+: захватил двоеточие в каждое совпадение — и вам пришлось бы отрезать его потом методом .rstrip(":"). Шаблон \w+(?=:) вернул чистые ключи. А span() расставляет точки над i: совпадение занимает позиции с 0 по 4, то есть ровно четыре символа слова host. Двоеточие стоит на позиции 4 — проверка до него дошла, но не включила.
Как проверить пароль несколькими условиями одной регуляркой?
Классика форм регистрации: пароль должен содержать строчную букву, заглавную и цифру, и быть не короче восьми символов. Проблема в том, что буквы могут стоять где угодно — «q1W2e3» и «Pass12» валидны, а порядок разный. Проверки решают элегантно: каждую ставим в начало строки, и все они оглядываются на весь текст с одной и той же позиции.
import re
PASSWORD = re.compile(r"(?=.*[a-z])(?=.*[A-Z])(?=.*\d)\S{8,}")
for pwd in ["qwerty", "Qwerty123", "QWERTY123", "Qwerty 123", "q1w2e3r4"]:
print(pwd, "->", "ок" if PASSWORD.fullmatch(pwd) else "слабый")
qwerty -> слабый Qwerty123 -> ок QWERTY123 -> слабый Qwerty 123 -> слабый q1w2e3r4 -> слабый
Читаем шаблон по шагам. Движок встаёт в начало строки и сдаёт три экзамена подряд: (?=.*[a-z]) — «где-то дальше есть строчная буква», (?=.*[A-Z]) — «где-то есть заглавная», (?=\d) — точнее (?=.*\d) — «где-то есть цифра». Каждый экзамен сканирует строку вперёд и возвращается назад, не потратив ни одного символа. Только после трёх «принято» начинает работать \S{8,} — единственный кусок, который реально матчит текст целиком. fullmatch из урока про методы поиска гарантирует, что строка исчерпана. Тот же приём работает для любого набора требований: «есть буква и спецсимвол», «нет трёх одинаковых символов подряд» — каждое условие становится своей проверкой, и список растёт без запутанных вложенных if.
Когда нужно негативное опережение (?!...)?
Негативное опережение (?!...) переворачивает условие: «дальше НЕ должно быть текста по образцу». Задача: из строки с покупками достать все числа, кроме цен в рублях.
import re
text = "Чай 50 руб, кофе 120, сахар 200 рублей"
print(re.findall(r"\d+(?! руб)", text))
['5', '120', '20']
Что произошло? «50» испортилось в «5», «200» — в «20». Жадный \d+ сначала схватил всё число, проверка увидела « руб» и провалилась. Тогда включился возврат из урока про жадные и ленивые квантификаторы: \d+ отдал одну цифру назад, и проверка (?! руб) встала уже внутри числа — перед «0». Там условие «дальше нет руб» внезапно истинно, и движок счастлив: он нашёл «5» в середине пятидесяти.
import re
text = "Чай 50 руб, кофе 120, сахар 200 рублей"
print(re.findall(r"\b\d+\b(?! руб)", text))
['120']
Спасла граница слова \b. Теперь \d+ обязан начинаться и заканчиваться на границе, а между цифрами границ нет — отступить на цифру назад движок не может. Заметьте и вторую деталь: «200 рублей» тоже отсеялась, хотя это «рублей», а не «руб». Проверка смотрит ровно на следующие четыре символа, и « руб» там стоит. Для этой задачи это удача, но держите в голове: (?! руб) отсекает всё, что начинается с « руб».
Что делает ретроспективная проверка (?<=...)?
Ретроспективная проверка (?<=...) — зеркало опережающей: она смотрит не вперёд, а назад, и требует, чтобы перед позицией стоял текст по образцу. (?<=@)\w+ — «слово, слева от которого собака». Извлекаем домен из email, не захватывая собаку, и сравниваем со старым добрым способом через группу.
import re
mails = "Пишите на anna@pythonika.ru или boris@example.com"
print(re.findall(r"\w+(?=@)", mails))
print(re.findall(r"(?<=@)\w+", mails))
print(re.findall(r"@(\w+)", mails))
orders = "Заказ 42 отправлен, заказ 57 в сборке"
print(re.findall(r"(?<=заказ )\d+", orders))
['anna', 'boris'] ['pythonika', 'example'] ['pythonika', 'example'] ['57']
Первые три способа дают одинаковые списки, но механизм разный: группа из третьей строки возвращает содержимое скобок, а проверка — всё совпадение целиком, просто короткое. Для findall это одно и то же, а вот в search разница выпрет наружу: у шаблона с проверкой .group() вернёт pythonika, у шаблона с группой — @pythonika, и домен живёт в .group(1). Практическое следствие: проверку стоит предпочесть группе, когда условие — про окружение, а не про сами данные, и вам не придётся потом отрезать лишний символ у каждого результата.
И заметьте: из двух заказов нашёлся только 57. «Заказ 42» написан с большой буквы, а шаблон требует строчное «заказ ». Как отменить чувствительность к регистру — тема следующего урока про флаги re.IGNORECASE и MULTILINE, а пока просто запомните: проверки смотрят на текст с точностью до символа.
Как работает негативная ретроспекция (?<!...)?
Негативная ретроспекция (?<!...) запрещает текст слева. Хороший пример — выбрать из финансовой сводки только положительные числа, не трогая убытки с минусом. Плюс разберём парные проверки на номерах версий: мажорная часть стоит перед точкой, минорная — после.
import re
release = "Python 3.12 и 3.11, Django 5.0, pip 24.2"
print(re.findall(r"\d+(?=\.)", release))
print(re.findall(r"(?<=\.)\d+", release))
money = "Прибыль 500, убыток -70, бонус 30"
print(re.findall(r"(?<!-)\b\d+", money))
['3', '3', '5', '24'] ['12', '11', '0', '2'] ['500', '30']
Первая пара: \d+(?=\.) оставила мажорные версии «3», «3», «5», «24» — точки не захватились; (?<=\.)\d+ — минорные. Негативная ретроспекция в третьей строке отработала хитро: у «-70» цифра «7» стоит сразу после минуса и отвергается, а движок, шагнув на «0», упирается в отсутствие границы слова — \b между «7» и «0» не пускает начаться новому совпадению. Снова та же мысль: проверки дружат с якорями.
Почему lookbehind в Python требует фиксированной ширины?
У ретроспективной проверки в Python есть жёсткое правило, которого нет у опережающей: образец внутри (?<=...) должен иметь заранее известную длину. Движку нужно встать на несколько символов назад, и он не готов угадывать, сколько именно. Попытка написать «после „руб“ или „рублей“» одной проверкой заканчивается ошибкой компиляции.
import re
re.compile(r"(?<=руб|рублей) \d+")
re.error: look-behind requires fixed-width pattern
Как превратить 1000000 в 1 000 000 одной заменой?
Задача-визитка проверок: расставить пробелы между разрядами числа. Формулировка условия красивая: «вставь пробел здесь, если слева цифра, а справа до конца числа — количество цифр, кратное трём». Без проверок такое выражают циклом с индексами и остатками; с ними — одной заменой.
import re
report = "Выручка 1000000, расходы 99000, прибыль 901000"
print(re.sub(r"(?<=\d)(?=(\d{3})+(?!\d))", " ", report))
Выручка 1 000 000, расходы 99 000, прибыль 901 000
Обе проверки нулевой длины, поэтому re.sub из урока про замену вставляет пробел между цифрами, не удаляя ничего. Идём по числу 1000000: после «1» справа шесть цифр — \d{3} срабатывает дважды, вставляем пробел; после «10» справа пять цифр — кратность не сошлась; после «100» — четыре; после «1000» — три, вставляем. Нюанс, на котором спотыкаются: соблазн заменить хвост (?!\d) якорем $ — и тогда число перед запятой перестаёт обрабатываться, потому что тройки упираются не в конец строки, а в знак препинания. Хвост (?!\d) гарантирует, что тройки дошли ровно до конца числа, а не до конца вселенной.
| Синтаксис | Как читается | Смотрит | Пример из урока |
|---|---|---|---|
| (?=...) | дальше должен быть этот текст | вперёд | \w+(?=:) — слово перед двоеточием |
| (?!...) | дальше этого текста быть не должно | вперёд | \b\d+\b(?! руб) — не цена |
| (?<=...) | перед этим местом был такой текст | назад | (?<=@)\w+ — домен после собаки |
| (?<!...) | перед этим местом такого не было | назад | (?<!-)\b\d+ — без отрицательных |
Что дальше
Вы научились ставить условия позициям: опережение, ретроспекция, их негативные версии — и применили их к паролю, ценам и разрядам чисел. В финале проверок есть мелкая заноза: «заказ» и «Заказ» для регулярки — разные слова. Чинится это флагами, и следующий урок — re.IGNORECASE, MULTILINE, DOTALL и VERBOSE — про них. Потом будет практикум по email, телефонам и датам, где проверки встречаются в каждом третьем шаблоне, а завершит раздел проект — парсер логов сервера.
Проверка не потребляет текст: она добавляет шаблону глаза, но не руки.
Сначала предскажи ответ в голове — это главный навык программиста.
import re
print(re.findall(r"\b\w+(?=!)", "стоп! вперёд! тихо"))
import re
text = "10 руб, 5, 100 руб"
print(re.findall(r"\b\d+\b(?! руб)", text))
import re
print(re.findall(r"(?<=a)\d", "a1 b2 a3"))
1. Что вернёт re.findall(r"\w+:", "port: 443")?
2. Сколько текста потребляет проверка (?=...)?
3. Почему в шаблоне \d+(?! руб) число «50» в тексте «50 руб» превратилось в «5»?
4. Какой шаблон скомпилируется без ошибки?
5. Что делает хвост (?!\d) в шаблоне разрядов (?<=\d)(?=(\d{3})+(?!\d))?
В редакторе две строки: список заказов и список платежей. Достаньте номера заказов — числа, перед которыми стоит «заказ » с пробелом, — и отдельно числа-возвраты: цифры, после которых НЕ идёт « руб». Выведите два списка в формате из заготовки.
Чем lookahead отличается от обычной группы в регулярных выражениях?
Группа (...) захватывает текст: он попадает в результат поиска и в группы match-объекта. Проверка (?=...) ничего не захватывает — она только требует, чтобы дальше стоял подходящий текст, и потому span() у неё нулевой длины. Проверку выгодно использовать, когда условие не должно попадать в результат: ключи перед двоеточием, слова перед знаком препинания.
Почему в Python нельзя написать lookbehind переменной длины?
Движок модуля re при ретроспекции отступает назад на фиксированное число символов, поэтому образец внутри (?<=...) обязан иметь заранее известную ширину — иначе re.compile выбрасывает ошибку look-behind requires fixed-width pattern. Обходы: альтернативы одинаковой длины, объединение нескольких проверок через (?:...|...) или перенос условия в группу с захватом. Опережающая проверка (?!...) переменной длины разрешена.
Как одной регуляркой проверить сложный пароль в Python?
Поставить несколько опережающих проверок подряд с начала строки: r"(?=.*[a-z])(?=.*[A-Z])(?=.*\d)\S{8,}" — каждая требует своего класса символов где угодно в строке, а затем \S{8,} проверяет длину. Проверять лучше через re.fullmatch. Такой шаблон заменяет четыре отдельных if и работает в любой форме регистрации.
Как расставить пробелы между разрядами числа: 1000000 -> 1 000 000?
Одной заменой: re.sub(r"(?<=\d)(?=(\d{3})+(?!\d))", " ", s). Проверка (?<=\d) разрешает вставку только между цифрами, а (?=(\d{3})+(?!\d)) требует, чтобы справа до конца числа укладывалось целое число троек. Важен именно хвост (?!\d): вариант с привязкой к концу строки ломается, когда число стоит в середине текста рядом с запятой.
Понравился урок? Сошлитесь на него
«Опережающая проверка — это экзаменатор: он стоит в коридоре, смотрит, куда вы идёте, но сам в комнату не заходит.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
re · Урок 4
Жадные и ленивые квантификаторы: почему regex съедает лишнее
Жадный .* тянет от первого совпадения до последнего и съедает полстраницы HTML. Ленивый .*? останавливается у ближайшего ограничителя: два знака разницы — совсем другой результат.
re · Урок 8
Якоря и границы слова в регулярках: ^, $, \b
Якоря ^ и $ не ищут символы — они проверяют позицию, а \b находит слово целиком и не трогает подстроки. Три приёма, после которых шаблоны становятся точными.
re · Урок 10
Флаги в регулярных выражениях: re.IGNORECASE, MULTILINE, DOTALL, VERBOSE
Четыре флага, которые меняют поведение целого движка: поиск в любом регистре, якоря на каждой строке, точка с переводом строки и шаблоны с комментариями вместо «полотна».
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
pytest · Урок 10
Числа с плавающей точкой: pytest.approx
0.1 + 0.2 в Python не равно 0.3 — и обычный assert на float падает непонятно почему. pytest.approx сравнивает числа с допуском и возвращает тестам спокойный сон.
как сравнивать дробные числа python
pytest · Урок 9
Проверка исключений: pytest.raises
Хорошая функция на плохом входе не молчит, а бросает исключение — и тест обязан это проверять. pytest.raises делает это одной строкой внутри with.
pytest.raises проверка исключенийpytest проверка исключения
re · Урок 1
Регулярные выражения Python с нуля: первый шаблон и модуль re
Первый шаблон, который находит любую дату в тексте, а не одну конкретную: import re, re.search, raw-строки и объект Match — всё, чтобы перестать бояться регулярки.
регулярные выражения pythonregex python