Теперь, когда я набросал основу самописного парсера, давайте перейдём к генерации его методов из грамматики, как я и обещал. Также покажу как реализовать packrat-парсер с помощью декоратора @memoize
.
В прошлый раз мы разобрали несколько методов парсера. С некоторыми ограничениями, которые мы снимем чуть позже, их легко генерировать из грамматики автоматически.
Нам нужны две вещи: что-то, что читает грамматику и строит соответствующую структуру данных; и что-то, что берёт эту структуру данных и генерирует синтаксический анализатор. Нам также нужны и другие вспомогательные методы, но они не интересны, так что я их опущу.
Итак, мы создаем простой компилятор компилятора. Я немного упрощу грамматическую нотацию до такой степени, чтобы у нас остались только правила и альтернативы; этого на самом деле достаточно для той игрушечной грамматики, которую я использовал в предыдущих частях:
statement: assignment | expr | if_statement
expr: expr '+' term | expr '-' term | term
term: term '*' atom | term '/' atom | atom
atom: NAME | NUMBER | '(' expr ')'
assignment: target '=' expr
target: NAME
if_statement: 'if' expr ':' statement
Используя полную запись, мы можем описать грамматику как:
grammar: rule+ ENDMARKER
rule: NAME ':' alternative ('|' alternative)* NEWLINE
alternative: item+
item: NAME | STRING
Это наша первая мета-грамматика (грамматика для грамматик), и нашим генератором синтаксического анализатора будет мета-компилятор (компилятор — это программа, которая переводит программы с одного языка на другой; мета-компилятор — это компилятор, где на входе — грамматика, а на выходе — парсер).
Простой способ описать мета-грамматику — использовать только встроенные типы данных: правая часть правила — список списков элементов, каждый из которых может быть просто строкой. (Кстати, мы можем разделить NAME
и STRING
, проверив, является ли первый символ кавычкой)
Для правил я использую простой класс Rule
, и вся грамматика представляет собой список таких объектов. Вот класс Rule
, исключая __repr__
и __eq__
:
class Rule:
def __init__(self, name, alts):
self.name = name
self.alts = alts
А вот класс GrammarParser
, который его использует:
class GrammarParser(Parser):
def grammar(self):
pos = self.mark()
if rule := self.rule():
rules = [rule]
while rule := self.rule():
rules.append(rule)
if self.expect(ENDMARKER):
return rules # <------------- final result
self.reset(pos)
return None
def rule(self):
pos = self.mark()
if name := self.expect(NAME):
if self.expect(":"):
if alt := self.alternative():
alts = [alt]
apos = self.mark()
while (self.expect("|")
and (alt := self.alternative())):
alts.append(alt)
apos = self.mark()
self.reset(apos)
if self.expect(NEWLINE):
return Rule(name.string, alts)
self.reset(pos)
return None
def alternative(self):
items = []
while item := self.item():
items.append(item)
return items
def item(self):
if name := self.expect(NAME):
return name.string
if string := self.expect(STRING):
return string.string
return None
Обратите внимание на использование ENDMARKER
. Там я убеждаюсь, что после последнего правила ничего не осталось (а это может случиться, если в грамматике есть опечатка). Также я указал на место, где метод grammar()
возвращает список правил. Всё остальное очень похоже на класс ToyParser
из прошлой статьи, поэтому я не буду на нём останавливаться. Просто обратите внимание, что item()
возвращает строку, alternative()
возвращает список строк, а переменная alts
внутри rule()
собирает список списков строк. Затем метод rule()
объединяет имя правила (строку) и преобразует его в объект Rule
.
Если мы применим этот алгоритм к нашей игрушечной грамматике, то метод grammar()
вернёт следующий список правил:
[
Rule('statement', [['assignment'], ['expr'], ['if_statement']]),
Rule('expr', [['term', "'+'", 'expr'],
['term', "'-'", 'term'],
['term']]),
Rule('term', [['atom', "'*'", 'term'],
['atom', "'/'", 'atom'],
['atom']]),
Rule('atom', [['NAME'], ['NUMBER'], ["'('", 'expr', "')'"]]),
Rule('assignment', [['target', "'='", 'expr']]),
Rule('target', [['NAME']]),
Rule('if_statement', [["'if'", 'expr', "':'", 'statement']]),
]
Теперь, когда у нас есть часть синтаксического анализа нашего мета-компилятора, давайте сделаем генератор кода. Вместе они образуют элементарный мета-компилятор:
def generate_parser_class(rules):
print(f"class ToyParser(Parser):")
for rule in rules:
print()
print(f" @memoize")
print(f" def {rule.name}(self):")
print(f" pos = self.mark()")
for alt in rule.alts:
items = []
print(f" if (True")
for item in alt:
if item[0] in ('"', "'"):
print(f" and self.expect({item})")
else:
var = item.lower()
if var in items:
var += str(len(items))
items.append(var)
if item.isupper():
print(" " +
f"and ({var} := self.expect({item}))")
else:
print(f" " +
f"and ({var} := self.{item}())")
print(f" ):")
print(f" " +
f"return Node({rule.name!r}, [{', '.join(items)}])")
print(f" self.reset(pos)")
print(f" return None")
Этот код довольно уродливый, но он работает (вроде), и в будущем я в любом случае его перепишу.
Некоторые строки внутри цикла for alt in rule.alts
могут потребовать объяснения: для каждого элемента в альтернативе мы выбираем один из 3х вариантов:
- если элемент является строковым литералом, например
'+'
, мы генерируемself.expect('+')
- если элемент полностью в верхнем регистре, например
NAME
, мы генерируем(name := self.expect(NAME))
- в противном случае, например если это
expr
, мы генерируем(expr := self.expr())
Если существует несколько элементов с одним и тем же именем в одном варианте (например, term '-' term
), то мы добавляем цифру ко второму. Здесь также есть небольшая ошибка, которую я исправлю в следующем эпизоде.
Вот немного результата его работы (весь класс был бы очень скучным). Не беспокойтесь об избыточном коде if (True and
...)
, который необходим, чтобы каждое сгенерированное условие могло начинаться с and
. Компилятор байт-кода Python оптимизирует это.
class ToyParser(Parser):
@memoize
def statement(self):
pos = self.mark()
if (True
and (assignment := self.assignment())
):
return Node('statement', [assignment])
self.reset(pos)
if (True
and (expr := self.expr())
):
return Node('statement', [expr])
self.reset(pos)
if (True
and (if_statement := self.if_statement())
):
return Node('statement', [if_statement])
self.reset(pos)
return None
...
Обратите внимание на декоратор @memoize
: я ввёл его, чтобы перейти к другой теме: использование мемоизации, чтобы сделать сгенерированный парсер достаточно быстрым.
Вот функция memoize()
, реализующая этот декоратор:
def memoize(func):
def memoize_wrapper(self, *args):
pos = self.mark()
memo = self.memos.get(pos)
if memo is None:
memo = self.memos[pos] = {}
key = (func, args)
if key in memo:
res, endpos = memo[key]
self.reset(endpos)
else:
res = func(self, *args)
endpos = self.mark()
memo[key] = res, endpos
return res
return memoize_wrapper
Как и в других декораторах, она содержит вложенную функцию, которая заменит (или обернёт) декорируемую, например, метод statement()
класса ToyParser
. Поскольку оборачиваемая функция является методом, враппер также фактически является методом: его первый аргумент называется self
и ссылается на экземпляр ToyParser
, для которого вызывается декорированный метод.
Враппер кэширует результат вызова метода для каждой входной позиции — вот почему он называется packrat-парсер! [прим. пер. packrat — "воришка", однако в рускоязычных источниках этот термин не переводится] Кэш — это словарь словарей, который хранится в экземпляре Parser
. Ключ внешнего словаря — позиция в потоке входных данных; я добавил также self.memos = {}
в Parser .__ init__()
для его инициализации. Внутренние словари добавляются по мере необходимости; их ключи состоят из метода и его аргументов. (В текущем дизайне нет аргументов, но мы могли бы мемоизировать функцию expect()
, у которой он есть, весьма тривиально)
Результат работы метода парсера представляется в виде кортежа, поскольку там действительно два значения: собственно результат (для наших сгенерированных методов это Node
для совпавшего правила) и указатель на текущую позицию во входном потоке, которую мы получаем из self.mark()
. Таким образом, мы кэшируем как возвращаемое значение (res
), так и новую позицию (endpos
) во внутреннем словаре с мемоизированными значениями. При последующих вызовах того же метода синтаксического анализа с теми же аргументами в той же позиции входных данных мы будем брать их из кэша. Для этого достаточно всего лишь перевести указатель на позицию ввода с помощью self.reset()
и посмотреть в кэш.
Также важно кэшировать и отрицательные результаты — на самом деле большинство вызовов будут негативными. В этом случае возвращаемое значение равно None
, а позиция ввода не изменяется. Вы можете добавить assert
, чтобы проверить это.
Примечание. В Python принято реализовывать кэш в локальной переменной в функции memoize()
. В нашем случае так не получится: как я выяснил в самом конце отладки, каждый экземпляр Parser
обязан иметь свой собственный кэш. Тем не менее, вы можете избавиться от вложенных словарей, используя (pos
, func
, args
) в качестве ключа.
На следующей неделе я подготовлю код и трейсы, чтобы показать, как всё это на самом деле собирается и выполняется при разборе примера программы. Я всё еще в поисках лучшего способа визуализации совместной работы буфера токенизации, парсера и кэша. Возможно, мне удастся создать анимированный gif в ASCII вместо того, чтобы просто выводить листинги трассировки в виде текста.
Лицензия на эту статью и приведенный код: CC BY-NC-SA 4.0