Gramática PEG recursiva esquerda

Mencionei a recursão esquerda como uma pedra de tropeço várias vezes, e é hora de descobrir isso. O principal problema é que um analisador com uma descida recursiva esquerda falha instantaneamente devido a um estouro de pilha.



Considere esta regra gramatical hipotética:


expr: expr '+' term | term 

Se implementássemos esse pedaço de gramática no método analisador recursivo esquerdo, obteríamos algo como o seguinte:


 def expr(): if expr() and expect('+') and term(): return True if term(): return True return False 

Assim, expr() começa com uma chamada para expr() , que começa com uma chamada para expr() , que começa com uma chamada ... Isso só pode terminar com um estouro de pilha, expresso como uma exceção RecursionError .


A solução tradicional é reescrever a gramática. Nas partes anteriores, fiz exatamente isso. De fato, a regra gramatical acima pode ser reescrita da seguinte maneira:


 expr: term '+' expr | term 

No entanto, na etapa de construção da árvore de análise, sua forma seria diferente. Isso pode arruinar a situação se adicionarmos o operador '-' à gramática (já que a - (b - c) não a - (b - c) o mesmo que (a - b) - c ). Isso geralmente é resolvido com funções PEG mais poderosas, como agrupamento e iteração, e podemos reescrever a regra acima como:


 expr: term ('+' term)* 

Na verdade, é assim que a gramática Python atual é escrita para o analisador pgen (que tem os mesmos problemas com as regras recursivas à esquerda).


No entanto, há um pequeno problema: como operadores como '+' e '-' (em Python) são na maioria binários, quando analisamos algo como a + b + c , temos que passar pelo resultado da análise (que essencialmente uma lista de ['a', '+', 'b', '+', 'c'] ) para criar uma árvore de análise recursiva esquerda (que seria algo parecido com isto [['a', '+', 'b'] , '+', 'c'] ).


A gramática recursiva esquerda original já sugere a associatividade desejada, portanto seria bom gerar um analisador diretamente desse formulário. E nós podemos! Um leitor apontou um bom truque com provas matemáticas fáceis de implementar. Agora vou tentar explicar.


Vejamos um exemplo de entrada foo + bar + baz . A árvore de análise que gostaríamos de obter corresponde a (foo + bar) + baz . Isso requer três chamadas recursivas à esquerda para a função expr() : uma corresponde ao operador de nível superior '+' (ou seja, o segundo); mais um - para o operador interno '+' (ou seja, o primeiro); e a terceira é a escolha da segunda alternativa (isto é, term ).


Como não sou bom em desenhar diagramas reais usando ferramentas especiais, mostrarei isso aqui usando arte ASCII:


 expr------------+------+ | \ \ expr--+------+ '+' term | \ \ | expr '+' term | | | | term | | | | | 'foo' 'bar' 'baz' 

A idéia é que, na função expr() , precisamos de um “oráculo” que nos diga se deve escolher a primeira alternativa (ou seja, a chamada recursiva para expr() ) ou a segunda (ou seja, o term() chamada). Na primeira chamada para expr() oráculo deve nos dizer para seguir a primeira alternativa ( expr() ); na segunda chamada (recursiva) - da mesma forma, mas na terceira deve nos solicitar a chamada term() . No código, ficará assim:


 def expr(): if oracle() and expr() and expect('+') and term(): return True if term(): return True return False 

Como escrever tal oráculo? Vamos ver ... Podemos tentar rastrear o número de chamadas expr() esquerda (recursivas) na pilha de chamadas e compará-lo com o número de operadores '+' na expressão a seguir. Se a pilha de chamadas for mais profunda que o número de instruções, o oráculo deve retornar falso (forçar-nos a selecionar o term() ). Mal posso esperar para implementar isso com sys._getframe() , mas existe uma maneira melhor: vamos virar a pilha de chamadas!


A idéia é que começemos com uma chamada em que o oráculo retorne falso e salve o resultado. Isso nos dá a sequência expr() -> term() -> 'foo' . (Ele deve retornar uma árvore de análise para o term inicial, ou seja, 'foo' . O código acima retorna True , mas na segunda parte da série de artigos eu já mostrei como retornar a árvore de análise.) Esse oráculo é fácil de implementar, pois deveria basta retornar False na primeira chamada - não é necessário verificar ou espiar no futuro.


Então chamamos expr() novamente, e desta vez o oráculo retorna True , mas, em vez da chamada recursiva esquerda para expr() , substituímos o resultado salvo pela chamada anterior. E como o operador esperado '+' e o próximo token adequado também estão presentes, isso nos dará uma árvore de análise para foo + bar .


Mais uma vez, repetimos o algoritmo e, novamente, tudo acontece: desta vez, obtemos uma árvore de análise para a expressão completa, e é realmente recursiva à esquerda ( (foo + bar) + baz ).


Em seguida, repetimos o algoritmo novamente. Mas desta vez, embora o Oracle retorne True e o resultado salvo da chamada anterior também esteja disponível, não há mais operador '+' e a primeira alternativa falha. Assim, tentamos a segunda opção, que obtém sucesso, e encontra apenas o termo inicial ( 'foo' ). Esse resultado é pior que o obtido na primeira alternativa, portanto, nesta etapa, paramos e salvamos a análise mais longa (ou seja, (foo + bar) + baz ).


Para transformar isso em código funcional, modifiquei um pouco o algoritmo para combinar a chamada oracle() com a chamada recursiva esquerda para expr() . Vamos chamá-lo de oracle_expr() . Código:


 def expr(): if oracle_expr() and expect('+') and term(): return True if term(): return True return False 

A seguir, escreveremos um wrapper que implementa a lógica descrita acima. Ele usa uma variável global (não se preocupe, vou me livrar dela mais tarde). A função oracle_expr() lê a variável global e o wrapper a controla:


 saved_result = None def oracle_expr(): if saved_result is None: return False return saved_result def expr_wrapper(): global saved_result saved_result = None parsed_length = 0 while True: new_result = expr() if not new_result: break new_parsed_length = <calculate size of new_result> if new_parsed_length <= parsed_length: break saved_result = new_result parsed_length = new_parsed_length return saved_result 

O código, é claro, é terrível, mas pelo menos transmite a essência do algoritmo. Vamos refatorá-lo para que possamos nos orgulhar disso.


O entendimento mais importante (que me pertence, embora provavelmente não seja o primeiro a perceber isso) é que podemos usar o cache de memorização em vez de uma variável global. Nele, armazenaremos o resultado de uma chamada para outra. Então nos livramos de uma função separada oracle_expr() , porque podemos gerar uma chamada padrão para expr() independentemente de estar em uma posição recursiva à esquerda ou à direita.


Portanto, precisamos de um decorador @memoize_left_rec separado, usado apenas para regras recursivas à esquerda. Ele chama a função oracle_expr() , puxando o valor armazenado do cache de memorização e contém um loop que chama a função expr() várias vezes, até que cada novo resultado seja comparável a uma porção cada vez maior dos dados de entrada que o anterior. E, é claro, uma vez que cada posição de entrada e cada método de análise são armazenados em cache separadamente, não se preocupa com o retorno ou algumas regras recursivas (por exemplo, na gramática de brinquedos que usei, tanto expr quanto term são deixados recursivos).


Outra das vantagens do protótipo que criei na terceira parte é que facilita verificar se o novo resultado é maior que o antigo: o método mark() retorna o índice na matriz de tokens de entrada, para que possamos usá-lo em vez de parsed_length .


Omiti a prova de por que esse algoritmo sempre funciona, por mais insana que seja a gramática. Na verdade, eu nem li. Vejo que isso funciona para casos simples, como expr na minha gramática de brinquedos, bem como para casos um pouco mais complexos (por exemplo, usando a recursão esquerda oculta por trás de elementos opcionais em uma alternativa ou usando a recursão mútua entre várias regras). A situação mais difícil que me lembro na gramática Python ainda é resolvida por esse algoritmo, então confio apenas no teorema e nas pessoas que o provaram.


Vamos escrever o código de batalha.


Primeiro, o gerador do analisador deve determinar quais regras são deixadas recursivas. Este é um problema resolvido na teoria dos grafos. Não vou mostrar o algoritmo aqui e, de fato, quero simplificá-lo ainda mais. Suponho que as únicas regras recursivas à esquerda na gramática são diretamente recursivas à esquerda, como expr em nossa gramática de brinquedos. Em seguida, para verificar a recursividade à esquerda, basta procurar uma alternativa que comece com o nome da regra atual:


 def is_left_recursive(rule): for alt in rule.alts: if alt[0] == rule.name: return True return False 

Agora vamos mudar o gerador do analisador para que, para regras recursivas à esquerda, gere outro decorador. Lembre-se de que na terceira parte, @memoize todos os métodos do analisador no @memoize . Agora faremos uma pequena alteração no gerador para que, para as regras recursivas à esquerda, usemos @memoize_left_rec e, em seguida, implementaremos a mágica no decorador memoize_left_rec . O restante do gerador e outros códigos não precisam ser alterados! (Embora eu tenha que mexer no código de visualização)


Para referência, aqui está o decorador original @memoize , copiado da parte 3. Lembre-se de que self é uma instância do Parser que possui um atributo de memo (inicializado com um dicionário vazio) e os métodos mark() e reset() que obtêm e definem a posição atual tokenizer:


 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 

O decorador @memoize lembra as chamadas anteriores para cada posição no fluxo de entrada - para cada posição na matriz (lenta) de tokens de entrada, há um dicionário de memo separado. As quatro primeiras linhas de memoize_wrapper dedicadas a obter o dicionário de memo correto.


E aqui está @memoize_left_rec . Somente o ramo else é um pouco diferente da implementação em @memoize acima:


 def memoize_left_rec(func): def memoize_left_rec_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: # Prime the cache with a failure. memo[key] = lastres, lastpos = None, pos # Loop until no longer parse is obtained. while True: self.reset(pos) res = func(self, *args) endpos = self.mark() if endpos <= lastpos: break memo[key] = lastres, lastpos = res, endpos res = lastres self.reset(lastpos) return res return memoize_left_rec_wrapper 

Provavelmente é interessante como isso funciona para o método expr() . Vamos ver como o seguinte código será executado:


  @memoize_left_rec def expr(self): pos = self.mark() if ((expr := self.expr()) and self.expect('+') and (term := self.term())): return Node('expr', [expr, term]) self.reset(pos) if term := self.term(): return Node('term', [term]) self.reset(pos) return None 

No exemplo de análise foo + bar + baz .


Sempre que você chama a função expr() , a chamada é "conectada" pelo decorador que está procurando a chamada anterior na posição atual. Na primeira chamada, chegamos ao ramo else , onde chamamos repetidamente a função decorada expr() . Obviamente, voltaremos ao decorador primeiro, mas desta vez já existe algum valor no cache, portanto a recursão é interrompida.


O que acontece depois? O valor inicial do cache é calculado nesta linha:


  # Prime the cache with a failure. memo[key] = lastres, lastpos = None, pos 

Isso leva ao fato de que expr() decorado retorna None , após o qual o primeiro if em expr() cai (quando expr: = self.expr() ). Ou seja, passamos para o segundo if , que reconhece com sucesso o term (em nosso exemplo, 'foo' ) e expr retorna uma instância de Node . De onde estamos voltando? Para o while no decorador. Atualizamos o cache de memorização com um novo resultado (a instância do Node ) e, em seguida, executamos a próxima iteração.


expr() é chamado novamente e, desta vez, a chamada recursiva interceptada retorna a instância em cache do Node (term) e prossegue para a chamada em expect('+') . Está tudo em ordem, pois agora estamos no primeiro operador '+' . Depois disso, procuramos um termo que também seja bem-sucedido (encontrado 'bar' ).


Portanto, agora expr() , já tendo reconhecido foo + bar , retorna while , que executa as mesmas ações: atualiza o cache de memorização com um novo resultado (mais longo) e inicia a próxima iteração.


Este jogo é repetido novamente. Novamente, a chamada recursiva interceptada expr() recupera um novo resultado (desta vez foo + bar ) do cache, e esperamos ver outro '+' (segundo) e outro term ( 'baz' ). Criamos um Node representa (foo + bar) + baz e o retornamos while , que o coloca no cache e se repete novamente.


Mas agora vamos seguir outro ramo do algoritmo. Esperamos encontrar outro '+' , mas não o encontramos! Portanto, essa chamada para expr() retorna para sua segunda alternativa e retorna apenas term . Quando isso ocorre antes do while , esse resultado é mais curto que o anterior. Portanto, ele interrompe e retorna um resultado mais longo ( (foo + bar) + baz ) para quem iniciou a chamada expr() (por exemplo, a chamada de statement() não é mostrada aqui).


Portanto, é aqui que a história de hoje termina: implementamos com sucesso a recursão esquerda em um analisador PEG. Na próxima semana, pretendo discutir a adição de “ações” à gramática, o que nos permitirá personalizar o resultado retornado pelo método analisador para essa alternativa (em vez de sempre retornar uma instância do Node ).


Se você quiser brincar com o código, confira o repositório do GitHub . Também adicionei um código de visualização para recursão à esquerda, mas não estou muito satisfeito com isso, então não fornecerei um link para ele aqui.


Licença para este artigo e código citado: CC BY-NC-SA 4.0

Source: https://habr.com/ru/post/pt471986/


All Articles