diff options
author | robot-piglet <robot-piglet@yandex-team.com> | 2025-06-26 18:51:26 +0300 |
---|---|---|
committer | robot-piglet <robot-piglet@yandex-team.com> | 2025-06-26 19:01:41 +0300 |
commit | 6dcbedeea16a317e0ec6f3f8f1ec3afd2352bb23 (patch) | |
tree | 2fb00cb599df614ef08a1a498bc5b39ba937aae0 /library/cpp/regex | |
parent | 633c9e434ac33037ba01b36a8f2937034903fe9f (diff) | |
download | ydb-6dcbedeea16a317e0ec6f3f8f1ec3afd2352bb23.tar.gz |
Intermediate changes
commit_hash:de861c421a4d799c8ac2ece7a8542f74dd5fa180
Diffstat (limited to 'library/cpp/regex')
-rw-r--r-- | library/cpp/regex/pire/README.md | 213 |
1 files changed, 213 insertions, 0 deletions
diff --git a/library/cpp/regex/pire/README.md b/library/cpp/regex/pire/README.md new file mode 100644 index 00000000000..69418fbbbaa --- /dev/null +++ b/library/cpp/regex/pire/README.md @@ -0,0 +1,213 @@ +## Что это +Pire (Perl Incompatible Regular Expressions) -- это библиотека, заточенная на быструю проверку текста большим количеством регулярных выражений. Она умеет проверять, матчится ли текст паттерну, но умеет делать это действительно быстро (350 Мб/с вне зависимости от сложности регулярки). Если же паттернов более одного, их можно объединять, выполняя за один проход проверку примерно десятью паттернами (с той же скоростью). + +Pire может давать гарантии на скорость работы (на нижнем уровне она просматривает входную строку один раз, подряд, без откатов, и тратит (на x86 и x86_64) по 5 инструкций на символ), так что в принципе может использоваться в realtime-задачах. + +Расплачиваться за такую скорость приходится ограниченной функциональностью. В pire нету сложных перловых конструкций типа условных регулярок, backtracking-а, lookahead-а. Кроме того, pire вообще не занимается захватом фрагментов сматчившегося текста (хотя в ограниченном варианте такой захват можно реализовать, см.ниже). + +Вся теория, которая делает такую скорость возможной, описана в the Dragon Book, настоятельно рекомендуемой для изучения. + + + +## Fast start + + +```cpp +Pire::Scanner CompileRegexp(const char* pattern) +{ + // Переводим шаблон в UCS4 + std::vector<wchar32> ucs4; + Pire::Encodings::Utf8().FromLocal(pattern, pattern + strlen(pattern), std::back_inserter(ucs4)); + // или другая кодировка + + return Pire::Lexer(ucs4.begin(), ucs4.end()) + .AddFeature(Pire::Features::CaseInsensitive()) // если хочется нечувствительность к регистру + .SetEncoding(Pire::Encodings::Utf8()) // Устанавливаем кодировку, в которой будет приходить проверяемый текст + .Parse() // Разбираем шаблон + .Surround() // если мы не хотим логику PCRE_ANCHORED + .Compile<Pire::Scanner>(); // Компилируем регулярку +} + +bool Matches(const Pire::Scanner& scanner, const char* ptr, size_t len) +{ + return Pire::Runner(scanner) + .Begin() // Начало строки + .Run(ptr, len) // Строка + .End(); // Конец строки + // Оно неявно кастится к bool +} +``` + + + +## Как вызывать match + +Скомпилированная регулярка хранится в *сканере* (т.е. конечном автомате, представленом в виде, оптимизированном для прохода по строке). В Pire есть несколько сканеров (как минимум Scanner, SlowScanner и SimpleScanner), работа с ними во многом совпадает. + +У каждого сканера есть тип State, хранящий состояние автомата, и методы ` Initialize(State&) `, отдающий начальное состояние автомата, ` Next(State&, char) `, переводящий автомат в следующее состояние и ` Final(const State&) `, сообщающий, является ли данное состояние допускающим. Соответственно, чтобы проверить строчку на соответствие паттерну, необходимо вызвать ` Initialize() `, ` Next() ` на каждую букву, и ` Final() `, чтобы проверить, соответствует ли строчка паттерну. + +Существует метод ` Pire::Run(const Scanner&, Scanner::State&, const char* begin, const char* end) `, осуществляющий соптимизированный вызов ` Scanner::Next() ` на каждый символ диапазона. + +В паттерне могут попадаться символы начала и конца строки (` ^ ` и ` $ `). При компиляции они заменяются специальными символьными символами (sentinels) -- BeginMark и EndMark. + +Если одну и ту же строчку требуется прогнать через два сканера, то можно воспользоваться специальной функцией ` Pire::Run(const Scanner1&, const Scanner2&, Scanner1::State&, Scanner2::State&, const char* begin, const char* end) `, которая работает несколько быстрее, чем два последовательных вызова обычной ` Run() `\. + +Наконец, есть специальный класс ` RunHelper `, облегчающий работу с циклом Initialize-Step-Run-Step-Final и позволяющий записать его в виде одного выражения (см.пример). + +Существуют ещё четыре функции, полезные при организации лексического разбора: ` LongestPrefix() `, ` LongestSuffix() `, ` ShortestPrefix() ` и ` ShortestSuffix() `. Они возвращают наибольший/наименьший префикс/суффикс строки, допускаемый паттерном. Для использования в ` LongestSuffix() ` и ` ShortestSuffix() ` автомат надо предварительно "вывернуть наизнанку" (при помощи ` Fsm::Reverse() `). + + + +## Склейка сканеров + +Как уже говорилось, время проверки строки на соответствие паттерну не зависит от размера паттерна, таким образом с точки зрения скорости работы лучше иметь несколько сложных регулярок, чем много простых. В то же время иногда возникает необходимость проверить текст достаточно большим количеством регулярок, многие из которых могут быть очень простыми. В таком случае существует возможность объединения нескольких сканеров в один. + +Сканеры объединяются функцией ` Pire::Scanner::Glue() `. В результате получается сканер, который за один проход проверяет строку на соответствие всем содержащимся в нём паттернам. Общее количество паттернов возвращает ` Scanner::RegexpsCount() `. Вместо ` Final() ` имеет смысл вызвать ` AcceptedRegexps(const State&) `, возвращающая пару итераторов (с семантикой ` [begin, end) `), указывающую на диапазон номеров регулярок, которым соответствует прочитанная строка. +Номера начинаются с нуля, при склейке двух сканеров все номера правого сдвигаются на количество регулярок в левом. + +Склеенные сканеры в свою очередь можно склеить между собой. В то же время процесс склейки нельзя повторять до бесконечности, потому как количество состояний в полученном автомате растёт экспоненциально с каждым добавленным паттерном. Иногда имеет смысл ограничить размер полученного автомата, указав ненулевой параметр ` maxSize ` в ` Glue() `. В таком случае при превышении указанного размера возвращается пустой автомат (` Size() == 0 `, ` Empty() == true `). + + + +## Разбор регулярки + +До сих пор речь велась о том, как гонять по регулярке уже готовый сканер, но не говорилось о том, откуда этот сканер взять. Самый простой способ получить сканер -- это сконструировать его из строки, содержащей регулярное выражение, воспользовавшись классом ` Pire::Lexer `\. + +Строка, задающая регулярное выражение, имеет стандартный синтаксис, похожий на POSIX regexps. Поддерживаются стандартные возможности (` a|b `, ` a* `, ` a+ `, ` . `, ` [a-z] `, ` a{3} `, ` a{3,5} `, ` a{3,} `) и классы символов (` \w ` (буква), ` \W ` (не буква), ` \d ` (цифра), ` \D ` (не цифра), ` \s ` (whitespace), ` \S ` (не whitespace)). Кроме того, добавлены операции ` a&b ` (пересечение, см. ` Fsm::operator & `) и ` ~a ` (инверсия, см. ` Fsm::Complement `). + +Класс ` Lexer ` принимает регулярку в кодировке ` UCS-4 `, как диапазон из ` wchar32 ` (или любых других типов, неявно преобразовывающихся к wchar32). Таким образом, если регулярка задана как ` const char* regexp ` и все символы в ней есть в ` Latin-1 `, можно просто вызвать ` lexer.Assign(regexp, regexp + strlen(regexp)) `, но если она задана в ` UTF-8 ` или ` KOI8-R ` -- то его для начала нужно явно перевести в ` UCS-4 `\. + +С помощью вызова ` lexer.SetEncoding() ` можно указать кодировку, в которой будут поступать строки для сопоставления с регуляркой (ещё раз: она *не* указывает кодировку самой регулярки!). Это необходимо, поскольку сканер работает уже не на уровне символов, а на уровне байтов, а, скажем, точка (один любой символ) в ` UTF-8 ` -- это весьма нетривиальная конструкция. + +Тонкая настройка лексера возможна посредством добавления туда "фич" (` Pire::Feature `), которые можно добавлять к лексеру вызовами ` lexer.AddFeature() `. Скажем, нечувствительность к регистру реализована фичей ` Pire::CaseInsensitive `\. + +После настройки лексера следует вызвать ` lexer.Parse() `, который вернёт разобранный конечный автомат (` Pire::Fsm `). Его можно скомпилировать в сканер, вызвав ` Fsm::Compile<Pire::Scanner>() `\. + +Иногда шаблон регулярки оказывается слишком сложным, чтобы его можно было скомпилировать в детерминированный автомат разумного размера. В этом случае ` Fsm::Compile() ` раскрутит исключение. Если хочется обрабатывать такую ситуацию, может иметь смысл явно вызвать ` Fsm::Determine(size_t maxSize) ` и, если он возвратил ` false `, не пытаться компилировать автомат в ` Pire::Scanner `, а ограничиться ` Pire::SlowScanner `-ом (см. далее). + + + +## Ручное построение автомата + +Кроме разбора шаблона регулярного выражения, автомат можно строить непосредственно, для чего служит класс ` Pire::Fsm `. Наиболее полезные его методы: + + - Конструктор по умолчанию -- создаёт автомат, допускающий пустую строку. + - ` MakeFalse() ` создаёт автомат, не допускающий ни одной строки. + - ` Size() ` возвращает размер автомата (количество состояний). + - ` Append(unsigned char c) ` добавляет в автомат переход для матча символа c. + - ` AppendStrings(const vector<string>&) ` добавляет в автомат переходы для матча хотя бы одной из переданных строк. + - ` operator + (const Fsm&) ` возвращает конкатенацию автоматов (допускающую строки, делящиеся на две части так, что первая допускается первым автоматом, а вторая -- вторым). + - ` operator | (const Fsm&) ` возвращает объединение (union) авмоматов (допускающее строки, допускаемые хотя бы одним автоматом). + - ` operator * () ` возвращает итерацию автоматов (допускающую строки, представляющие собой повторение строки, допускаемой исходным автоматом, 0 или более раз (т.н. звезда Клини)). + - ` operator ~ () ` возвращает инверсию автомата (допускающего строки, которые не допускаются исходным автоматом). + - ` operator & (const Fsm&) ` возвращает пересечение автоматов (допускающее строки, которые допускаются обоими автоматами). + - ` operator * (size_t n) ` возвращает автомат, допускающий строки, представляющие собой повторение строки, допускаемой исходным автоматом, ровно n раз. + - ` Surrounded() ` возвращает автомат, допускающий любые строки, содержащие в себе строку, допускаемую исходным автоматом (т.е. добавляет в начало и в конец по ##/.*/##). + - Также ` operator += `, ` operator |= `, ` Iterate `, ` Complement `, ` operator &= `, ` operator *= `, и ` Surround `, двойственные к семи выше описанным. + - ` Reverse() ` возвращает автомат, допускающий строки, являющиеся зеркальным обращением строк, допускаемых исходным автоматом. + +Возможно комбинированное построение автомата (например, ` (lexer1.Parse() | lexer2.Parse()).Compile<Scanner>() `). Таким образом возможно, например, прочитать из файла список паттернов, скомпилировать их в ` Fsm `'ы и объединить их все в один большой ` Fsm `, который скомпилировать в сканер. + + + +## Сканеры + +В Pire существует три сканера: Scanner, SimpleScanner и SlowScanner. + + - Scanner -- это основная рабочая лошадка всего Pire. До сих пор речь велась именно о нём. + - SimpleScanner -- это более простая версия сканера, пригодная для проверки текста одной несложной регуляркой. Она не поддерживает склейки автоматов, и её таблица переходов несколько больше, чем в Scanner-е, зато SimpleScanner работает приблизительно на треть быстрее. + - SlowScanner -- работает крайне медленно, но не требует детерминизации автомата и, таким образом, может использоваться при матче сложных конструкций типа ` /x.{50}$/ `, которые не могут быть скомпилированы в Scanner. + +Нужный тип сканера может быть получен вызовом нужной специализации метода ` Fsm::Compile() ` (или явным конструированием нужного сканера из ` Fsm `-а). + + + +## Кодировки + +"Из коробки" Pire поддерживает кодировки ` Latin-1 ` и ` UTF-8 `. Внутри Аркадии -- ещё и ` KOI8-R ` и ` Windows-1251 `. Нужные экземпляры классов ` Pire::Encoding ` могут быть получены вызовами соответствующих функций из namespace ` Pire::Encodings `\. + +При желании можно добавить поддержку других кодировок, понаследовавшись от ` Pire::Encoding `. В наследованном классе следует переопределить методы: + - ` wchar32 FromLocal(const char*& begin, const char* end) ` читает и возвращает очередной символ входного потока, продвигает begin. В случае невалидной последовательности на входе кидает исключение. + - ` std::string ToLocal(wchar32) ` возвращает байтовое представление символа в данной кодировке. Если символ в данной кодировке непредставим, возвращает пустую строку. + - ` AppendDot(Fsm&) ` добавляет к конечному автомату фрагмент, допускающий один любой символ в данной кодировке. + +После этого экземпляр такого класса можно передать в ` lexer.SetEncoding() ` наравне со встроенными. + + + +## Сериализация, mmap() и inlining +Скомилированные регулярки могут быть сохранены в ` std::ostream ` или загружены из ` std::istream ` (для Аркадии -- ` TOutputStream ` и ` TInputStream ` соответственно) путем вызова ` Scanner::Save() ` и ` Scanner::Load() ` (аналогично для других сканеров). + +Если сканер был сохранен в файл, а файл потом при-mmap()-лен в память, то вместо вызова ` Scanner::Load() ` можно воспользоваться ` Scanner::Mmap() `, который не будет выполнять копирования таблицы переходов (которая может быть очень большой). Mmap() возвращает указатель на первый байт после сериализованного представления регулярки. + +Следует, однако, учесть, что начало сканера должно находиться в памяти по адресу, выровненному по границе машинного слова. Если в файл пишется ещё что-то кроме регулярок (сами представления регулярок всегда занимают целое количество машинных слов), следует позаботиться о выравнивании самостоятельно или воспользоваться классами ` AlignedInput ` и ` AlignedOutput `, предоставляющими нужную функциональность. + +Сериализованное представление регулярки непереносимо между архитектурами (даже между x86 и x86_64). При попытке прочитать/приммапить регулярку, сериализованную на другой архитектуре, будет exception. + +Если же в коде есть необходимость захардкодить регулярку, то можно, конечно, написать ` static const Scanner = Pire::Lexer("...").Parse().Compile() `, но в таком варианте компиляция регулярки будет происходить при каждом запуске программы, что может быть достаточно неприятным, особенно если шаблон достаточно сложный. Чтобы избежать таких задержек, регулярку можно скомпилировать в сканер, сериализовать его, подставить сериализованное представление в код в виде строкового литерала, а затем вызвать на нём ` Scanner::Mmap() `. Всё это делает программа ` pire_inline `, которая принимает на вход файл с кодом на C++, ищет там все вхождения ` PIRE_REGEXP("pattern", "flags") ` вне комментариев и строк и заменяет их на выражение, возвращающее автомат с готовой предвычисленной таблицей переходов. + +В строчке с флагами могут находиться следующие символы: + - i -- нечувствительность к регистру; + - u -- скомпилировать регулярку в ` UTF-8 `; + - s -- обрамить регулярку ` .* ` с каждой стороны; + - a -- включить поддержку операторов ` & ` и ` ~ ` в регулярках (см.выше); + - g -- выполнить преобразование ` fsm = ~fsm.Surrounded() + fsm ` (для нужд ` Scan() `). + - r -- выполнить ` fsm.Reverse() ` (для нужд ` LongestSuffix() ` / ` ShortestSuffix() `). + + + +## Расширения Pire + +Если вам нужна какая-нибудь функциональность, в Pire отсутствующая, есть вероятность, что её можно реализовать, воспользовавшись существующими в Pire точками расширения или спустившись на уровень ниже. + + + +### Ещё раз про Fsm + +Fsm -- это конечный автомат с выходом. Соответственно, в нём есть множество состояний (пронумерованных целыми числами от ` 0 ` до ` Fsm::Size() - 1 `), для каждого состояния и каждой буквы задано множество состояний, в которые по этой букве из этого состояния можно перейти (возможно, это множество пусто), одно состояние объявлено начальным, а часть состояний отмечены допускающими. + +Для манипуляции этими данными у Fsm есть функции: + - ` Size(), Resize() ` + - ` Destinations(), Connected(), Connect() ` + - ` Initial(), SetInitial() ` + - ` Finals(), IsFinal(), SetFinal(), ClearFinal() ` + +Кроме того, с каждым состоянием и каждым переходом автомата может быть ассоциировано битовое поле флагов. Флаги переходов называются *выходами* (outputs, поскольку реализуют классический КА с выходом), для работы с ними предназначены функции ` Output() `, ` SetOutput() ` и ` ClearOutputs() `. Флаги состояний называются *тегами* и поддерживаются функциями ` Tag() ` и ` SetTag() `\. + +При детерминизации или ином объединении состояний все флаги объединяются побитовым ИЛИ. + +Ещё ряд функций не принадлежит никакой группе: + - ` Divert(size_t from, size_t to, size_t dest) ` разрывает все переходы из ` from ` в ` to ` и перенаправляет их в состояние ` dest `. Все флаги, которыми были помечены переходы, переносятся на новые переходы. + - ` Import(const Fsm&) ` копирует внешний автомат внутрь данного. Все его состояния сдвигаются на количество состояний в данном. Сами состояния никак не подсоединяются. + - ` DeadStates() ` возвращает набор состояний, из которых недостижимо ни одно из допускающих состояний. + - ` RemoveEpsilons() ` ликвидирует в автомате все epsilon-переходы (переходы из одного состояния в другое по пустой строке). + + + +### Ещё раз про Lexer и Feature + +Основная функция ` Lexer `-а -- это разбирать входную строку на последовательность термов (` Pire::Term `). + +Feature -- это возможность изменить поведение лексера. У фичи есть три основные возможности: + - Обработать фрагмент паттерна каким-либо своим способом, вернув терм (функции ` Accepts() ` и ` Lex() `); + - Изменить уже выделенный терм (` Alter() `); + - Изменить выделенный фрагмент конечного автомата, заключенный в круглые скобки (` Parenthesized() `). + +Если фича разбирает фрагмент паттерна, то она должна реализовывать функцию ` Accepts(wchar32) `, которая возвращает ` true `, если передаваемый символ является началом последовательности символов, воспринимаемой фичей, и функцию ` Lex() `, которая собственно и осуществляет лексический разбор (если ` Accepts() ` вернула ` false `, ` Lex() ` вызвана не будет). Внутри фичи доступны стандартные функции работы с входным потоком: ` GetChar() `, ` PeekChar() ` и ` UngetChar() `. Если ` Accepts() ` вернула ` true `, а уже внутри ` Lex() ` стало понятно, что последовательность не подходит, можно положить её всю обратно посредством ` UngetChar() `, а затем вернуть пустой ` Term() `, показав тем самым лексеру, что фича отказалась обрабатывать поток. + +В лексере может быть несколько фич, они отсортированы по приоритету и объединены в цепочку. ` Lex() ` и ` Accepts() ` вызываются от большего приоритета к меньшему до тех пор, пока какая-нибудь фича не обработает входной поток с получением терма. Этот терм пропускается в обратном порядке через ` Feature::Alter() `\. + +При чтении закрывающей круглой скобки фрагмент автомата, соответствующий находящемуся в скобках фрагменту регулярки, прогоняется через ` Feature::Parenthesized() `, опять-таки в порядке возрастания приоритетов. + +Простой пример использования: нечувствительность к регистру сделана именно фичей, у которой метод ` Alter() ` изменяет каждый возвращаемый диапазон символов, добавляя к каждому символу из диапазона его в верхнем и нижнем регистре. + + + +### Пример: CapturingScanner +Скажем, нам нужен ограниченный захват одного фрагмента в тексте (например, матчить текст шаблону ` /adv_id\s*=\s*['"]([a-z0-9]+)['"]/ ` и извлекать собственно ID). + - Заводим два флага для переходов: ` BeginCapture ` и ` EndCapture `\. + - Пишем фичу, в которой отслеживаем все скобки. ` Lex() ` возвращает собственно скобку и при этом отсчитывает нужную скобку по порядку. + - После того, как нужная закрывающая скобка найдена, в ` Parenthesized() ` фича получает автомат, который матчит то, что в скобках. Расширяем этот автомат на два состояния, переносим делаем новым начальным состоянием предпоследнее, единственным допускающим -- последнее, и соединяем их со старым начальным и допускающими состояниями epsilon-переходами. Эти переходы отмечаем соответственно флагами ` BeginCapture ` и ` EndCapture `\. + - Пишем собственный сканер, который при переходе, отмеченном ` BeginCapure ` или ` EndCapture `, отмечает соответствующую позицию во входном потоке. + Пример, где всё это реализовано, находится в ` extra/capture.h ` и ` extra/capture.cpp `\. + |