Незаметные достоинства регулярных выражений в Python

от автора

image

В стандартной библиотеке Python есть немало кошмарных модулей, но этого нельзя сказать о модуле re. Несмотря на его преклонный возраст и многолетнее отсутствие обновлений, я считаю этот модуль одним из лучших среди всех динамических языков.

Python — один из немногих динамических языков, в которых отсутствует встроенная поддержка регулярных выражений, но это компенсируется проработанной базовой системой (с точки зрения API). В то же время он весьма причудлив. К примеру, поведение написанного на Python парсера может вас удивить. Если вы попытаетесь в ходе импорта профилировать Python, то, скорее всего, 90% времени вы проведёте в работе с модулем re.

Старый, но проверенный

Модуль регулярных выражений в Python был разработан давно и по умолчанию используется в стандартной библиотеке. Если не считать Python 3, то этот модуль никак не развивался с момента своего появления, за исключением внедрения поддержки Unicode. И по сей день в нём некорректно работает перечисление участников (member enumeration) (посмотрите, что возвращает функция dir() у объекта шаблона регулярного выражения).

«Старость» модуля играет ему на руку в том смысле, что он не меняется в зависимости от версии Python и доказал свою надёжность. Мне не раз приходилось что-то переделывать только потому, что вносились изменения в модуль регулярных выражений. А если учесть, сколько этих самых выражений мне приходится писать, неизменность модуля не может не радовать.

Интересная деталь: парсер и компилятор написаны на Python, а матчер — на C. Это означает, что в случае необходимости мы можем передавать внутренние структуры из парсера в компилятор без полного парсинга регулярных выражений. В документации это не описано, но работает.

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

Итеративное сравнение

Несомненно, лучшей особенностью регулярных выражений в Python является чёткое различие между сравнением и поиском. Этим могут похвастаться далеко не все движки. В частности, вы можете использовать индекс для смещения проверки совпадения, но сам объект совпадения останется привязанным к определённой позиции. Например, можно сделать нечто подобное:

>>> pattern = re.compile('bar') >>> string = 'foobar' >>> pattern.match(string) is None True >>> pattern.match(string, 3) <_sre.SRE_Match object at 0x103c9a510> 

Это крайне полезное свойство при создании лексеров. Ведь вы можете использовать символ ^ для обозначения начала всей строки. Нужно просто увеличить индекс для последующего сравнения. Это также означает, что можно обойтись без нарезки самих строк, тем самым экономится куча памяти и не тратятся ресурсы на многократное копирование строк. Хотя нельзя сказать, что в целом по этим критериям Python выделяется в лучшую сторону.

Помимо сравнения, в Python можно осуществлять поиск, то есть пропускать до тех пор, пока не будет обнаружено совпадение:

>>> pattern = re.compile('bar') >>> pattern.search('foobar') <_sre.SRE_Match object at 0x103c9a578> >>> _.start() 3 

Несовпадение — это тоже совпадение

Важной проблемой является дороговизна обработки ситуации, когда совпадения отсутствуют. Допустим, нам нужно написать токенизатор для wiki-подобного языка, скажем Markdown. Между обозначающими форматирование токенами содержится большое количество текста, который тоже нужно обработать. И когда мы определяем wiki-синтаксис между необходимыми токенами, нам приходится обрабатывать ещё больше токенов. Как нам решить эту проблему?

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

rules = [     ('bold', re.compile(r'\*\*')),     ('link', re.compile(r'\[\[(.*?)\]\]')), ]  def tokenize(string):     pos = 0     last_end = 0     while 1:         if pos >= len(string):             break         for tok, rule in rules:             match = rule.match(string, pos)             if match is not None:                 start, end = match.span()                 if start > last_end:                     yield 'text', string[last_end:start]                 yield tok, match.group()                 last_end = pos = match.end()                 break         else:             pos += 1     if last_end < len(string):         yield 'text', string[last_end:] 

Решение не идеальное и не самое быстрое. Чем меньше совпадений, тем ниже производительность, поскольку мы продвигаемся по одному символу за раз, а цикл выполняется в интерпретируемом коде Python. Также этот метод не слишком гибок: для каждого токена у нас есть только совпадающий текст, а если используются группы токенов, то код придётся переработать.

Существует ли какое-то более удобное решение? Что, если бы можно было дать команду движку просканировать на наличие каких-то регулярных выражений на выбор?

Вопрос интересный. В целом именно это мы и делаем, когда пишем регулярки с подшаблонами: (a|b). В этом случае осуществляется поиск либо a, либо b. Так что можно сформировать из всех имеющихся регулярок одну огромную и сравнивать уже с ней. Но обратной стороной такого решения является то, что в конце концов мы обязательно запутаемся со всеми этими группами.

Сканер

Последние лет 15 в движке регулярных выражений существует один недокументированный инструмент: сканер. Это свойство объекта шаблонов SRE, при котором движок после обнаружения совпадения продолжает искать следующее. Есть даже недокументированный класс re.Scanner, работающий поверх сканера шаблонов SRE, предоставляющего интерфейс несколько более высокого уровня.

К сожалению, сам по себе сканер, находящийся в модуле re, не слишком полезен с точки зрения ускорения работы с «несовпадениями». Но если проанализировать его исходный код, то становится ясно, что сканер реализован поверх примитивов SRE. Работает он следующим образом: берёт список регулярок и соответствующие callback’и. Каждому совпадению он сопоставляет callback с образцом и на основе этого создаёт результирующий список. Под капотом это реализовано с помощью создания SRE-шаблона и объектов подшаблонов. В принципе, он создаёт более крупную регулярку, не осуществляя её парсинг. Вооружившись этими знаниями, что мы можем сделать с данным кодом?

from sre_parse import Pattern, SubPattern, parse from sre_compile import compile as sre_compile from sre_constants import BRANCH, SUBPATTERN   class Scanner(object):      def __init__(self, rules, flags=0):         pattern = Pattern()         pattern.flags = flags         pattern.groups = len(rules) + 1          self.rules = [name for name, _ in rules]         self._scanner = sre_compile(SubPattern(pattern, [             (BRANCH, (None, [SubPattern(pattern, [                 (SUBPATTERN, (group, parse(regex, flags, pattern))),             ]) for group, (_, regex) in enumerate(rules, 1)]))         ])).scanner      def scan(self, string, skip=False):         sc = self._scanner(string)          match = None         for match in iter(sc.search if skip else sc.match, None):             yield self.rules[match.lastindex - 1], match          if not skip and not match or match.end() < len(string):             raise EOFError(match.end()) 

Например, вот что:

scanner = Scanner([     ('whitespace', r'\s+'),     ('plus', r'\+'),     ('minus', r'\-'),     ('mult', r'\*'),     ('div', r'/'),     ('num', r'\d+'),     ('paren_open', r'\('),     ('paren_close', r'\)'), ])  for token, match in scanner.scan('(1 + 2) * 3'):     print (token, match.group()) 

Если лексический анализ не удастся, то выскочит ошибка EOFError. Но если указать skip=True, тогда не поддающиеся анализу части будут пропускаться, что облегчает процесс создания таких вещей, как лексические анализаторы wiki-синтаксиса.

Сканирование с промежутками

Для обозначения участков, которые должны быть пропущены, мы можем использовать match.start() и match.end(). Пример:

scanner = Scanner([     ('bold', r'\*\*'),     ('link', r'\[\[(.*?)\]\]'), ])  def tokenize(string):     pos = 0     for rule, match in self.scan(string, skip=True):         hole = string[pos:match.start()]         if hole:             yield 'text', hole         yield rule, match.group()         pos = match.end()     hole = string[pos:]     if hole:         yield 'text', hole 

Исправление групп

Раздражает тот факт, что наши групповые индексы не являются локальными по отношению к нашему собственному регулярному выражению, в отличие от комбинированного. То есть, например, нельзя обратиться с помощью индекса к группе, если есть правило (a|b). Для этого придётся повозиться с классом, выступающим в виде обёртки для SRE-образца, позволяющим настроить индексы и имена групп. Если вас интересуют подробности, то можете изучить вариант реализации подобной обёртки на GitHub’е, наряду с некоторыми примерами использования.

ссылка на оригинал статьи http://habrahabr.ru/post/274349/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *