Implementação do Analisador PEG

Inspirado apenas por uma compreensão parcial do PEG, decidi tentar implementá-lo. O resultado pode não ser o melhor entre os analisadores de PEG de uso geral - já existem muitos deles (por exemplo, o TatSu é escrito em Python e gera código Python) - mas essa é uma boa maneira de entender o PEG. No futuro, quero substituí-lo pela implementação atual do analisador no CPython.



Nesta seção, lancei as bases para entender o trabalho do analisador, usando um exemplo de uma implementação auto-escrita simples da gramática de brinquedos de um artigo anterior.


(A propósito, como experimento, não coloco links no meu texto. Se você não entende alguma coisa, basta pesquisar no Google. :-)


Normalmente, um PEG usa um analisador de descida recursiva com um buffer ilimitado para retornar. Aqui está uma gramática de brinquedo de um artigo anterior:


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 

O analisador super-abstrato da descida recursiva para esse idioma definirá sua função para cada regra na qual as alternativas serão entendidas. Por exemplo, para statement , teríamos esta função:


 def statement(): if assignment(): return True if expr(): return True if if_statement(): return True return False 

Obviamente, este é um exemplo simplista demais: omite detalhes importantes, por exemplo, o que é fornecido à entrada dessa função e qual será o resultado de sua execução.


Vamos começar com os argumentos. Os analisadores clássicos usam um tokenizador separado, que divide a entrada (arquivo ou linha de texto) em uma série de tokens, como palavras-chave, identificadores (nomes), números e operadores. Os analisadores PEG (como outros analisadores modernos como o ANTLR) geralmente combinam tokenização e análise, mas, para o meu projeto, decidi deixar um tokenizador separado.


A tokenização de Python é bastante complicada, por isso não quero implementá-la nas regras do PEG. Por exemplo, você deve acompanhar o recuo (isso requer uma pilha dentro do tokenizer); O processamento de novas linhas em Python também é interessante (elas são significativas, exceto aquelas incluídas entre colchetes). Muitos tipos de cadeias também causam alguma complexidade. Em resumo, não tenho queixas sobre o tokenizer Python existente, então quero deixá-lo como está. A propósito, o CPython possui dois tokenizadores: o interno, que é usado pelo analisador, é escrito em C, e a biblioteca padrão, que é uma cópia exata implementada em Python puro. Isso será útil no meu projeto.


Um tokenizer clássico geralmente possui uma interface simples que consiste em uma única função get_token() . Cada vez, ele retorna o próximo token na sequência de entrada, analisando um grupo de caracteres. O módulo de tokenize do CPython não é exceção: sua API principal é um gerador que emite um token de cada vez. Cada token é um objeto do tipo TypeInfo , que possui vários campos, o mais importante dos quais é o tipo de token (por exemplo, NAME , NUMBER , STRING ) e seu valor de string é o conjunto de caracteres em que consiste (por exemplo, abc , 42 ou "Hello, world" ). Existem também campos adicionais. Por exemplo, para o índice de token no fluxo de entrada, que é útil em mensagens de erro.


Um tipo especial de token é ENDMARKER , que indica que o final do arquivo de entrada foi atingido. O gerador cairá se você o ignorar e tentar obter o próximo token.


Mas eu estava distraído. Como realizamos retornos ilimitados? A reversão da lista de tokens exige que você se lembre da posição no código-fonte e reanalise a partir desse ponto. A API do tokenizer não nos permite mover o ponteiro, mas você pode capturar o fluxo de tokens em uma matriz e reproduzi-lo a partir daí, o que faremos. Você também pode repetir isso com itertools.tee() , mas isso provavelmente é menos eficaz em nosso caso, se você observar os avisos na documentação.


Suponho que você possa simplesmente tokenizar todas as entradas da lista primeiro e depois usá-las como entradas para o analisador. Mas se houver um token inválido no final do arquivo (por exemplo, uma linha com uma cotação de fechamento ausente) e também houver um erro de sintaxe no arquivo, você receberá uma mensagem de erro do tokenizer. Acredito que isso seja ruim para o usuário, pois um erro de sintaxe pode ser a principal causa de uma linha inválida. Portanto, tenho requisitos ligeiramente diferentes para o tokenizer, em particular, ele deve ser implementado como uma lista lenta.


A API principal é muito simples. O objeto Tokenizer encapsula uma matriz de tokens e posição nessa matriz. Ele tem três métodos principais:


  • get_token() retorna o próximo token, movendo o ponteiro (ou lê o próximo token da fonte, se estivermos no final do buffer do token);
  • mark() retorna a posição atual no buffer;
  • reset(pos) define a posição no buffer (o argumento deve ser obtido da mark() ).

Adicionamos uma função auxiliar peek_token() , que retorna o próximo token sem alterar a posição no buffer.


É assim que a base da classe Tokenizer parece:


 class Tokenizer: def __init__(self, tokengen): """Call with tokenize.generate_tokens(...).""" self.tokengen = tokengen self.tokens = [] self.pos = 0 def mark(self): return self.pos def reset(self, pos): self.pos = pos def get_token(self): token = self.peek_token() self.pos += 1 return token def peek_token(self): if self.pos == len(self.tokens): self.tokens.append(next(self.tokengen)) return self.tokens[self.pos] 

Aqui, algo é omitido por simplicidade (por exemplo, os nomes dos métodos e variáveis ​​da instância devem começar com um sublinhado), mas esse é apenas um protótipo da API do Tokenizer .


O analisador também deve se tornar uma classe para que statement() , expr() etc. poderia ser implementado como métodos. O tokenizer se tornará uma variável de instância, mas não quero que os métodos do analisador chame diretamente get_token() . Em vez disso, implementamos o método wait() na classe Parser , que pode ser bem-sucedido ou falhar, assim como o método do analisador. O argumento para a função wait() é o token esperado: uma sequência de caracteres (por exemplo, + ) ou um tipo de token (por exemplo, NAME ). O tipo do valor de retorno ainda não é importante, voltarei a ele depois de discutir o resultado do trabalho do analisador.


Deixe que as funções da regra gramatical retornem apenas True ou False . Isso é bom para a ciência da computação teórica (aí o analisador responde à pergunta " Essa é uma string válida na linguagem?"), Mas não para nós. Nossa tarefa é criar um AST. Então, vamos reescrever esse código para que cada método de análise retorne um objeto Node em caso de sucesso ou None em caso de falha.


A classe Node pode ser muito simples:


 class Node: def __init__(self, type, children): self.type = type self.children = children 

Aqui, type determina o tipo do nó AST (por exemplo, add ou if ) e os descendentes são uma lista de nós e tokens (instâncias TokenInfo ). Isso é suficiente para o compilador gerar código ou executar outras análises, como verificação de tipo estático ou fiapos. Embora, no futuro, eu gostaria de mudar a forma como a AST é apresentada.


Para se encaixar nesse esquema, o método expect() deve retornar um objeto TokenInfo em caso de sucesso e None em caso de falha. Para poder reverter para tokens anteriores, envolvo as chamadas para os métodos mark() e reset() do tokenizer (aqui a API não muda). Aqui está o que aconteceu:


 class Parser: def __init__(self, tokenizer): self.tokenizer = tokenizer def mark(self): return self.tokenizer.mark() def reset(self, pos): self.tokenizer.reset(pos) def expect(self, arg): token = self.tokenizer.peek_token() if token.type == arg or token.string == arg: return self.tokenizer.get_token() return None 

Mais uma vez: omiti alguns detalhes, mas isso já está funcionando.


Agora, preciso introduzir um requisito importante para os métodos do analisador. Todos devem retornar o Node , colocando o tokenizer após o último token da regra gramatical que reconheceram; None e deixe a posição do tokenizer inalterada. Se o método do analisador ler vários tokens e depois cair, deverá restaurar a posição do tokenizer. Para isso, mark() e reset() são destinados. Observe que expect() também obedece a esta regra.


Então, aqui está um esboço do analisador real. Aqui eu uso o operador morsa do Python 3.8 ( := ):


 class ToyParser(Parser): def statement(self): if a := self.assignment(): return a if e := self.expr(): return e if i := self.if_statement(): return i return None def expr(self): if t := self.term(): pos = self.mark() if op := self.expect("+"): if e := self.expr(): return Node("add", [t, e]) self.reset(pos) if op := self.expect("-"): if e := self.expr(): return Node("sub", [t, e]) self.reset(pos) return t return None def term(self): # Very similar... def atom(self): if token := self.expect(NAME): return token if token := self.expect(NUMBER): return token pos = self.mark() if self.expect("("): if e := self.expr(): if self.expect(")"): return e self.reset(pos) return None 

Omiti a implementação de alguns métodos para que o leitor tivesse a oportunidade de praticar a si próprio. Isso é realmente melhor do que apenas ler sobre como esse analisador é implementado. No final, geraremos esse código automaticamente a partir da gramática. Constantes como NAME e NUMBER são importadas do módulo de token da biblioteca padrão. Isso nos vincula ainda mais à implementação atual do tokenizer Python. Se queremos criar um analisador PEG generalizado, precisamos encontrar maneiras de evitar isso.


Observe também que estou um pouco enganado. O método expr deve ser recursivo à esquerda, mas tornei o analisador recursivo à direita, porque o analisador de descida recursiva não funciona com regras gramaticais recursivas à esquerda. Isso pode ser corrigido, mas ainda é o tema de algumas pesquisas científicas, e eu gostaria de falar sobre isso separadamente. Lembre-se de que essa implementação não é 100% consistente com nossa gramática simplificada.


As principais coisas que eu quero que você entenda até agora:


  • As regras gramaticais correspondem aos métodos do analisador e, quando uma regra gramatical se refere a outra, chama um método de outra regra.
  • Quando uma sequência de tokens pode ser interpretada de maneira diferente, os métodos correspondentes do analisador são chamados um após o outro.
  • Quando uma regra gramatical se refere a um token, o método chama a função expect() .
  • Se o analisador reconhecer com êxito sua regra gramatical na posição atual, ele retornará o nó AST correspondente; se ele não conseguir reconhecer sua regra gramatical, ele retornará None .
  • Os métodos do analisador devem redefinir explicitamente a posição do tokenizer quando parar de analisar depois de usar um ou mais tokens (direta ou indiretamente, invocar outro método de análise bem-sucedido). Isso é aplicável não apenas quando uma das opções é rejeitada para prosseguir para a próxima, mas também quando a análise é rejeitada como um todo.

Se todos os métodos de análise obedecerem a essas regras, não será necessário agrupar cada uma nas chamadas mark() e reset() . Isso pode ser provado por indução.


Além disso, é tentador tentar se livrar de chamadas explícitas para mark() e reset() usando o gerenciador de contexto e a instrução with , mas não funcionará: você não deve chamar reset() se tiver êxito! Como uma correção adicional, você pode tentar usar exceções para o fluxo de controle para que o gerenciador de contexto saiba se o tokenizer deve ser redefinido (acho que o TatSu está fazendo algo semelhante). Por exemplo, algo como isto:


  def statement(self): with self.alt(): return self.assignment() with self.alt(): return self.expr() with self.alt(): return self.if_statement() raise ParsingFailure 

Em particular, uma pequena escada de if em atom() para reconhecer uma expressão entre colchetes pode ser escrita como:


  with self.alt(): self.expect("(") e = self.expr() self.expect(")") return e 

Mas me parece muito "mágico" - ao ler esse código, você deve se lembrar de que cada método de análise (incluindo wait() ) pode gerar uma exceção. E que essa exceção é capturada e ignorada pelo gerenciador de contexto na instrução with . Isso é bastante incomum, embora realizável (retornando True de __exit__ ). No entanto, meu objetivo final é gerar código em C, não em Python, e em C não há nenhuma instrução with para alterar o fluxo de controle.


De qualquer forma, aqui estão alguns tópicos para as seguintes partes:


  • geração de métodos analisadores a partir da gramática;
  • análise de pacotes (memorização);
  • Recursos de EBNF como (x | y) , [xy ...] , x* , x+ ;
  • rastreamento (para depurar um analisador ou gramática);
  • Recursos de PEG como lookahead e cut;
  • como lidar com regras recursivas à esquerda;
  • Geração de código C

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

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


All Articles