Lexers são apenas analisadores simples que são usados como uma otimização de desempenho para o analisador principal. Se temos um lexer, o lexer e o analisador trabalham juntos para descrever o idioma completo. Os analisadores que não possuem um estágio de lexing separado às vezes são chamados de "sem scanner".
Sem lexers, o analisador teria que operar caractere por caractere. Como o analisador precisa armazenar metadados sobre cada item de entrada e pode precisar pré-calcular tabelas para cada estado do item de entrada, isso resultaria em consumo de memória inaceitável para tamanhos de entrada grandes. Em particular, não precisamos de um nó separado por caractere na árvore de sintaxe abstrata.
Como o texto caractere por caractere é bastante ambíguo, isso também resultaria em muito mais ambiguidade que é chata de manusear. Imagine uma regra R → identifier | "for " identifier
. onde identificador é composto de letras ASCII. Se eu quiser evitar ambiguidade, agora preciso de um cabeçote de quatro caracteres para determinar qual alternativa deve ser escolhida. Com um lexer, o analisador apenas precisa verificar se possui um token IDENTIFIER ou FOR - um visualizador de 1 token.
Gramáticas de dois níveis.
Os Lexers trabalham traduzindo o alfabeto de entrada para um alfabeto mais conveniente.
Um analisador sem scanner descreve uma gramática (N, Σ, P, S) em que os não terminais N são os lados esquerdos das regras da gramática, o alfabeto Σ é, por exemplo, caracteres ASCII, as produções P são as regras da gramática , e o símbolo inicial S é a regra de nível superior do analisador.
O lexer agora define um alfabeto de tokens a, b, c,…. Isso permite que o analisador principal use esses tokens como alfabeto: Σ = {a, b, c,…}. Para o lexer, esses tokens são não terminais e a regra de início S L é S L → ε | um S | b S | c S | …, Ou seja: qualquer sequência de tokens. As regras da gramática lexer são todas necessárias para produzir esses tokens.
A vantagem de desempenho vem da expressão das regras do lexer como uma linguagem regular . Eles podem ser analisados com muito mais eficiência do que as linguagens sem contexto. Em particular, idiomas regulares podem ser reconhecidos em O (n) espaço e O (n) tempo. Na prática, um gerador de código pode transformar esse lexer em tabelas de salto altamente eficientes.
Extraindo tokens da sua gramática.
Para tocar no seu exemplo: as regras digit
e string
são expressas em um nível de caracter por caracter. Nós poderíamos usá-los como tokens. O restante da gramática permanece intacto. Aqui está a gramática lexer, escrita como uma gramática linear à direita para deixar claro que é regular:
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
string = '"' , string-rest ;
string-rest = '"' | STRING-CHAR, string-rest ;
STRING-CHAR = ? all visible characters ? - '"' ;
Mas como é regular, normalmente usamos expressões regulares para expressar a sintaxe do token. Aqui estão as definições de token acima como regexes, escritas usando a sintaxe de exclusão da classe de caracteres .NET e as classes POSIX:
digit ~ [0-9]
string ~ "[[:print:]-["]]*"
A gramática do analisador principal contém as regras restantes não tratadas pelo lexer. No seu caso, isso é apenas:
input = digit | string ;
Quando lexers não podem ser usados facilmente.
Ao projetar um idioma, geralmente cuidamos para que a gramática possa ser separada de maneira limpa em um nível de lexer e um analisador, e que o nível de lexer descreva um idioma regular. Isto nem sempre é possível.
Ao incorporar idiomas. Algumas línguas permitem interpolar código em strings: "name={expression}"
. A sintaxe da expressão faz parte da gramática livre de contexto e, portanto, não pode ser simbolizada por uma expressão regular. Para resolver isso, recombinamos o analisador com o lexer ou introduzimos tokens adicionais como STRING-CONTENT, INTERPOLATE-START, INTERPOLATE-END
. A regra de gramática para uma string pode então olhar como: String → STRING-START STRING-CONTENTS { INTERPOLATE-START Expression INTERPOLATE-END STRING-CONTENTS } STRING-END
. Obviamente, a Expressão pode conter outras strings, o que nos leva ao próximo problema.
Quando os tokens poderiam se conter. Nos idiomas do tipo C, as palavras-chave são indistinguíveis dos identificadores. Isso é resolvido no lexer, priorizando palavras-chave sobre identificadores. Essa estratégia nem sempre é possível. Imagine um arquivo de configuração em que Line → IDENTIFIER " = " REST
, onde o resto seja qualquer caractere até o final da linha, mesmo que o restante pareça um identificador. Uma linha de exemplo seria a = b c
. O lexer é realmente burro e não sabe em que ordem os tokens podem ocorrer. Portanto, se priorizarmos o IDENTIFIER em vez do REST, o lexer nos fornecerá IDENT(a), " = ", IDENT(b), REST( c)
. Se priorizarmos REST sobre IDENTIFIER, o lexer nos forneceria REST(a = b c)
.
Para resolver isso, precisamos recombinar o lexer com o analisador. A separação pode ser mantida um pouco, tornando o lexer preguiçoso: cada vez que o analisador precisa do próximo token, ele o solicita e diz ao lexer o conjunto de tokens aceitáveis. Efetivamente, estamos criando uma nova regra de nível superior para a gramática lexer de cada posição. Aqui, isso resultaria nas chamadas nextToken(IDENT), nextToken(" = "), nextToken(REST)
e tudo funcionaria bem. Isso requer um analisador que conheça o conjunto completo de tokens aceitáveis em cada local, o que implica um analisador de baixo para cima, como LR.
Quando o lexer tem que manter o estado. Por exemplo, a linguagem Python delimita os blocos de código não por chaves, mas por indentação. Existem maneiras de lidar com a sintaxe sensível ao layout dentro de uma gramática, mas essas técnicas são um exagero para o Python. Em vez disso, o lexer verifica o recuo de cada linha e emite tokens INDENT se um novo bloco recuado for encontrado, e DEDENT tokens se o bloco tiver terminado. Isso simplifica a gramática principal porque agora pode fingir que esses tokens são como chaves. O lexer, no entanto, agora precisa manter o estado: o recuo atual. Isso significa que o lexer tecnicamente não descreve mais uma linguagem comum, mas na verdade uma linguagem sensível ao contexto. Felizmente, essa diferença não é relevante na prática, e o lexer do Python ainda pode funcionar em O (n) tempo.
input = digit | string
.