Совместимые с Perl механизмы регулярных выражений: как реализовано?

1

Каков базовый подход, используемый perl, python, java и vim и т.д. Для реализации синтаксического анализа с регулярными выражениями?

Не подход к умному формальному языку (т.е. NFA, DFA); ни компараторы парсера (например, 14-строчный регулярный двигатель).

Я посмотрел на источник реализации java регулярных выражений стиля perl, но его сложные функции (например, обратные ссылки) и эффективность (например, подстрока подстроки Boyer-Moore) затруднили понимание того, как это работает в принципе.

РЕДАКТИРОВАТЬ Различные источники говорят, что речь идет о "обратном отслеживании" (например, регулярное соответствие выражений может быть простым и быстрым, курсы формальных методов), но неясно, на что именно отвлекается... это способ оценить NFA? Может ли это быть сделано из АСТ регулярного выражения напрямую?

Что на самом деле делают jaj/perl/python regex?

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

Теги:
parsing

3 ответа

6

В механизмах регулярного выражения есть два общих подхода.

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

  • Регулярные выражения могут быть интерпретированы механизмом обратного отслеживания. Если альтернатива в шаблоне терпит неудачу, это может вернуться к последней точке принятия решения и попробовать другой подход. Это чрезвычайно гибко и (с дополнительными функциями, такими как recursion + named subpatterns) может анализировать гораздо более крупный класс формальных языков (формально, что-то вроде набора грамматик LL (*)). Это очень похоже на парсер PEG. Большой недостаток: из-за обратного отслеживания регулярное выражение может принимать экспоненциальное время - даже без дополнительных дополнительных функций.

Кроме того, в механизмах регулярных выражений есть дополнительные оптимизации, такие как первый поиск постоянных подстрок в шаблоне, поскольку это более эффективно, чем запуск любого типа регулярного выражения (любой может даже иметь возможность использовать векторизованные инструкции ЦП). Если есть точка выбора между несколькими константными строками, они могут быть легко скомпилированы в структуру данных trie (фактически, простой конечный автомат). Это может уменьшить количество обратного отслеживания.

Регулярное выражение, демонстрирующее разницу между конечными автоматами и обратным следом, представляет собой a*a*a*a*a*b узор на строке aaaaaaaaaaaaaaacb. Конечный автомат легко может видеть, что этот шаблон не будет соответствовать из-за c на входе. Но у механизма обратного отслеживания теперь есть много точек решения, где он может пробовать разные длины для каждого подшаблона a*. Двигатели Regex, такие как Perl или re модуль в Python, в этом случае экспоненциальны, т.е. Занимают очень много времени, - добавьте еще a вкладку, чтобы она заняла больше времени. Это позволяет совершать интересные атаки на отказ в обслуживании, если ненадежные пользователи могут предоставлять произвольные регулярные выражения. Для ненадежного ввода следует использовать только двигатели с регулярным выражением, основанные на конечных автоматах, например RE2 от Google.

  • 0
    Спасибо, не могли бы вы рассказать, как работает возврат? Когда автоматы обсуждаются, они не охватывают детали. Например, сопоставление регулярных выражений может быть простым и быстрым, а также курсы автоматов Уллмана . Сохраняете ли вы состояние (например, индекс curr char) на каждом узле, и для $ ab $ оба должны совпадать, а для $ a + b $ - совпадать? Я чувствую некоторую хитрую складку, где это не сработает ... возможно, для сопоставления пустой строки? (по желанию ? )
  • 0
    @hyperpallium Ваша первая ссылка подробно описывает конечные автоматы (NFA, DFA), они также представлены во многих курсах в стиле «теоретической информатики 101». На каждом этапе выполнения DFA вы потребляете ровно один входной символ. Возврат - это своего рода поиск в глубину по всем возможным анализам, пока вы не найдете тот, который соответствует вводу. Один из способов сделать это - search(regex_state, string_pointer) с помощью рекурсивной функции search(regex_state, string_pointer) . Например, если регулярное выражение является чередованием регулярных выражений a...|b...|c... , функция поиска будет пробовать каждую альтернативу до совпадения.
Показать ещё 18 комментариев
3

Регулярное выражение в Perl 2 было взято у Генри Спенсера.

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

  • 0
    Вы, вероятно, хотели связать этот URL ( s/blob/tree/ в вашем)?
  • 0
    Спасибо! Это чрезвычайно просто, но имеет дополнительные функции и эффективность, и In some spots I've traded clarity for efficiency, so don't blame Henry for some of the lack of readability. Я мог бы попытаться протолкнуть его, но я думаю, что мне действительно нужен ELI5, детский сад, простой кодекс.
Показать ещё 1 комментарий
1

Отслеживание NFA

Различные двигатели, по-видимому, сначала создают (или "компилируют") NFA из регулярного выражения, затем выполняют NFA, следуя переходам из состояния в состояние и возвращаясь к предыдущему состоянию, когда маршрут терпит неудачу. Обратите внимание: обратное отслеживание выполняется на NFA, а не на регулярном выражении.

NFA - это своего рода автомат с графиком узлов (состояний), связанных направленными дугами (переходами). Дуга помечена символом; когда этот символ отображается в тексте, эта дуга сопровождается. NFA - это недетерминированные конечные автоматы. "Недетерминированная" часть означает, что две дуги, выходящие из узла, могут иметь одну и ту же метку, так что оба одновременно выполняются. Также могут быть эпсилонные переходы (ε, представляющие пустую строку - никакой символ), которые всегда выполняются без ввода входного символа.

Откат означает, что когда мы приходим к выбору, мы проверяем состояние (где мы находимся в автоматах и в тексте). Затем сделайте выбор. Если это не сработает, мы вернемся или "отступим" к этой контрольной точке и сделаем следующий выбор. Подобно навигации в пещере, когда они могут проходить глубже и глубже через ряд вилок, прежде чем мы достигнем тупика, "вложенные" контрольно-пропускные пункты.

Анализ исходного кода Java

Построение NFA

Процесс создания NFA из регулярного выражения называется "строительство Томпсона". Для получения дополнительной информации см. Статью MJD или соответствие регулярному выражению может быть простым и быстрым (примерно 1/5 пути, раздел "Преобразование регулярных выражений в NFA"). Там также статья Wikipedia Thompson.

Рекурсивное схождение Java ( java/util/regex/Pattern.java) анализирует регулярное выражение с помощью методов expr() (alternation |), sequence(), atom(), clos() (необязательно ? И star *), генерируя NFA как объекты Node со следующим полем для перехода к другому узлу. Существует много разных подклассов узлов.

Примечания: (1) expr() вызывает последовательность(), которая возвращает с так называемым "двойным возвратом" - он явно возвращает голову и хвост через глобальный корень поля; по сути, кортеж (головной, хвостовой). (2) Нет объекта Последовательности; вместо этого объекты в последовательности образуют связанный список со следующими полями.

проведение

переходов НКА пересекается с MATCH() методами. Метод соответствия на одном подклассе Node вызывает метод match() на следующем, так что путь через автомат является рекурсивным вызовом. Последний узел является объектом LastNode, который проверяет, что весь текст был использован.

checkpointing Аргументы match() включают индекс я в текст. Он также имеет неявный аргумент объекта, на который он вызван. Вместе этот индекс и объект представляют текущее состояние, а вызов matvch() эффективно контролирует его, поэтому мы можем вернуться к нему позже.

backtracking Метод match возвращает логическое значение, соответствует ли совпадение или нет. Если true, цепочка возвратов возвращается к первоначальному вызову (выталкивая все контрольные точки, как бы быстро отбрасывая наши шаги назад). Но если false (из-за несоответствия символа или заканчивающегося текста или достижения LastNode пока текст остается слева), происходит обратное отслеживание.

Простейшим обратным следом является чередование. Код в BranchMatch (строка 4599 выше) пытается сделать первый выбор; если он терпит неудачу, второй вариант выбора; и так далее. Если все варианты не выполняются, верните false.

Педагогическая реализация ELI5

Хотя реализация Java может быть самым простейшим из движков, потому что наименее оптимизированная, она по-прежнему очень сложна! Существует много функций PCRE, они обрабатывают unicode, много явных оптимизаций (например, подстрока подстроки Boyer-Moore), но также и многие незначительные оптимизации в потоке самого кода, из-за чего трудно учиться. Следовательно, следующее упрощение ELI5, используя только последовательность и чередование (т.е. Даже звездную или факультативную, поэтому на самом деле не регулярные выражения).

public class MyPattern {
  public static void main(String[] args) {
    // ab|ac         // non-deterministic
    Node eg =
      new Branch(
        new Sequence( new Single('a'), new Single('b') ),
        new Sequence( new Single('a'), new Single('c') )
      );
    re = new Sequence(re, new LastNode());
    System.out.println( re.match(0, "ac") );
  }

  abstract static class Node {
    abstract void setNext(Node next);
    abstract boolean match(int i, String s);
  }

  static class LastNode extends Node {
    void setNext(Node next){ throw new RuntimeException("don't call me"); }
    boolean match(int i, String s) {
      return i==s.length();
    }
  }

  static class Single extends Node {
    Node next;
    char ch;
    Single(char ch) { this.ch = ch; }
    void setNext(Node next) { this.next = next; }
    boolean match(int i, String s) {
      return i<s.length()&& s.charAt(i)==ch&& next.match(i+1, s);
    }
  }

  static class Branch extends Node {
    Node left, right;
    Branch(Node l, Node r) { this.left=l; this.right=r; }
    void setNext(Node next) {
      left.setNext(next);
      right.setNext(next);
    }
    boolean match(int i, String s) {
      return left.match(i, s) || right.match(i, s);
    }
  }

  static class Sequence extends Node {
    Node left, right;
    Sequence(Node l, Node r) {
      this.left=l;
      this.right=r;
      left.setNext(right);
    }
    void setNext(Node next) { right.setNext(next); }
    boolean match(int i, String s) {
      return left.match(i, s);
    }
  }

}

доказательство

Чтобы быть доказанным: приведенный выше код анализирует подмножество регулярных выражений (т.е. Без звезды или необязательно). В следующем "регулярном выражении" этот ограниченный смысл.

Сначала мы докажем, что построенный NFA из регулярного выражения эквивалентен; то, что код строит эквивалентный NFA; и, наконец, что разбор партирования эквивалентен этому.

Доказательство: регулярное выражение для NFA

Начнем с того, что правильное преобразование обычного регулярного выражения в NFA является правильным.

определить регулярное выражение

Стандартное определение значения регулярного выражения выражается через набор выражений, которые он генерирует (не то, что он анализирует). Это множество называется языком, и мы говорим, что L (R) - язык регулярного выражения R.

Очень полезно иметь возможность выражать все возможные регулярные выражения таким же образом (как язык), потому что тогда мы можем игнорировать детали фактического регулярного выражения, которое сгенерировало его.

  • literal a : L (a) → {a} - задает набор этого однобуквенного слова

  • чередование: L (A | B) → L (A) UL (B) - всякий раз, когда два регулярных выражения A и B объединены вместе, мы легко можем выработать подготовленный язык. Предполагая, что мы знаем язык каждого из выражений, оба могут быть сгенерированы, поэтому результирующий язык является просто объединением их языков.

  • последовательность: L (AB) → L (A) XL (B) - X означает все комбинации: слова в L (A), за которым следует слово в L (B).

Язык регулярного выражения может быть создан путем нахождения языков операций, образующих его (после абстрактного дерева синтаксиса).

в NFA

NFA регулярного выражения A обозначается как N (A). Сгенерированное слово представляет собой последовательность символов, встречающихся в результате прохождения переходов через NFA от стартового узла к выходному узлу. Его язык L (N (A)) является множеством всех таких слов, которые он может генерировать, следуя всем путям.

Определение его как языка позволяет нам сравнивать NFA с регулярными выражениями.

  • Последовательность: NFA для AB строится путем последовательного размещения N (A) и N (B): последний узел N (A) и первый узел из N (B) становятся одним и тем же узлом:

       N(A)     N(B)
    O------->O------->O
    

Возможные пути через это: от стартового узла (слева), любой путь можно взять через N (A) в средний узел; то любой путь можно взять через N (B) в выходной узел справа. Это дает тот же язык, что и L (AB) = L (A) XL (B)

(Предположим, что A и B уже выполнены правильно, т.е. L (N (A)) = L (A) и L (N (B)) = L (A)).

  • чередование: NFA для A | B строится путем объединения начальных узлов N (A) и N (B), а выходные узлы N (A) и N (B):

      _______
     /  N(A) \
    O         >O
     \_______/
        N(B)
    

Это объединяет возможные пути, так же как L (A | B) → L (A) UL (B), и поэтому получается тот же язык: L (N (A | B)) = L (A | B).

(Опять же, мы предполагаем, что A и B уже выполнены правильно, L (N (A)) = L (A) и L (N (B)) = L (A)).

  • literal a : NFA для некоторого символа a - это просто переход от начального к выходному узлу с меткой:

         a
    O-------->O
    

Набор слов, создаваемых всеми возможными путями через этот NFA, просто {a} - множество только одного однобуквенного слова a. Это то же самое, что и для регулярного выражения a или L (N (a)) = {a} = L (a).

Таким образом, по мере того как мы создаем NFA из регулярного выражения оператор оператора, язык NFA совпадает с регулярным выражением на каждом этапе пути.

Доказательство: кодовые конструкции NFA

Чтобы доказать, что приведенный выше код создает NFA, мы сначала докажем, что метод setNext() устанавливает всю последнюю дугу из NFA.

lemma: setNext() устанавливает все выходные дуги

Объект узла представляет собой узел в NFA. Выходные дуги - все переходы в выходной узел NFA. В коде они представлены следующим полем, ссылающимся на другой узел. Мы хотим показать, что вызывающий метод setNext() будет устанавливать все такие дуги выхода.

  • последовательность: Выходные дуги NFA N (AB) являются выходными дугами N (B) (поскольку все дуги из N (A) идут в N (B), а не в выходной узел).

Для объекта Sequence (представляющего N (AB)), вызов right.setNext() устанавливает все его дуги выхода, предполагая, что метод setNext() правого узла компонента (представляющий N (B)) работает правильно.

  • чередование: выходные дуги NFA N (A | B) являются объединением выходных дуг N (A) и N (B).

Для объекта Branch (представляющего N (A | B)) вызовы как left.setNext(), так и right.setNext() задают все его выходные дуги, снова предполагая, что эти методы работают правильно (слева и справа узлы, представляющие N ( A) и N (B)).

  • literal a : Выходная дуга NFA N (a) - это просто одна дуга, обозначенная a.

Для одного объекта (представляющего N (a)) установка поля затем устанавливает свою выходную дугу.

Объединив три выше, вызов setNode на объекте Node установит все дуги выхода.

Доказательство: кодовые конструкции NFA

Чтобы доказать это без усложнения анализа и обратного отслеживания, мы пока не будем использовать match(), но добавим новый метод gen(), который выводит язык NFA.

В коде переход от одного объекта узла к другому представлен первым вызовом метода во втором. Передача управления другому объекту узла представляет собой переход на другой узел.

  • sequence: В объекте Sequence дуги записи должны подключаться к левому узлу. Это делается методом gen(), вызывающим left.gen().

В конструкторе выходные дуги левого узла соединены с правым узлом, вызывая left.setNext (справа). Здесь инициируются вызовы setNode(), потому что последовательность - это единственное место, где указывается понятие "следующее".

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

  • чередование: в объекте Branch дуги записи соединены как с левым, так и с правым узлом, по gen() вызывает как left.gen(), так и right.gen().

  • буквальный: В одном узле, управление переходом к следующему узлу с next.gen().

После того, как выражение построено, все выходные дуги подключены к объекту LastNode, добавив в качестве последовательности: new Sequence (re, new LastNode()).

Вот методы gen(). Они записывают буквенный символ, встречающийся на пути переходов, и печатаются, когда управление достигает LastNode.

abstract Node:
  abstract void gen(String path);
LastNode:
  void gen(String path) { System.out.println(path); }
Single:
  void gen(String path) { next.gen(path+ch); }
Branch:
  void gen(String path) { left.gen(path); right.gen(path); }
Sequence:
  void gen(String path) { left.gen(path); }

Таким образом, вызов gen ("") на объекте Node будет распечатывать его язык.

Разбор и откат

В некотором смысле метод gen() выше всегда "отступает" в Branch, потому что он сначала возвращается в левый узел, а когда он возвращается, состояние восстанавливается: мы возвращаемся к одному и тому же объекту Branch, который представляет узел NFA, и состояние сгенерированного слова также является тем, что было до левой рекурсии. Когда он возвращается в правый узел, он как будто в первый раз.

Изменение "backtracking" в совпадении метода состоит в том, чтобы не продолжать рекурсию, если искомое слово уже сгенерировано. Это не изменяет, какие слова принимаются.

Изменение для синтаксического анализа состоит в том, чтобы проверить символ слова по символу и отказаться от проспекта, когда символ не соответствует, а не продолжать путь до LastNode. Один из способов для символа "не соответствовать" - не существовать, т.е. Когда текст слишком короткий. Это проверка я <s.length() в Single.match().

И наоборот, в тексте может быть слишком много символов. Это проверяется LastNode.match(), проверяя, что все символы в тексте были прочитаны, с я == s.length().

Ещё вопросы

Сообщество Overcoder
Наверх
Меню