I Lexer sono solo semplici parser che vengono utilizzati come ottimizzazione delle prestazioni per il parser principale. Se abbiamo un lexer, il lexer e il parser lavorano insieme per descrivere il linguaggio completo. I parser che non hanno uno stadio di lexing separato sono talvolta chiamati "senza scanner".
Senza i lexer, il parser dovrebbe operare su una base carattere per carattere. Poiché il parser deve archiviare metadati su ogni elemento di input e potrebbe dover pre-calcolare tabelle per ogni stato di elemento di input, ciò comporterebbe un consumo di memoria inaccettabile per grandi dimensioni di input. In particolare, non è necessario un nodo separato per carattere nell'albero della sintassi astratto.
Poiché il testo carattere per carattere è abbastanza ambiguo, ciò comporterebbe anche molta più ambiguità che è noiosa da gestire. Immagina una regola R → identifier | "for " identifier
. dove l' identificatore è composto da lettere ASCII. Se voglio evitare l'ambiguità, ora ho bisogno di un lookahead di 4 caratteri per determinare quale alternativa dovrebbe essere scelta. Con un lexer, il parser deve solo controllare se ha un token IDENTIFICATORE o FOR: un lookahead a 1 token.
Grammatiche a due livelli.
I Lexer funzionano traducendo l'alfabeto di input in un alfabeto più conveniente.
Un parser senza scanner descrive una grammatica (N, Σ, P, S) in cui i non terminali N sono i lati sinistri delle regole nella grammatica, l'alfabeto Σ è ad es. Caratteri ASCII, le produzioni P sono le regole nella grammatica e il simbolo iniziale S è la regola di livello superiore del parser.
Il lexer ora definisce un alfabeto di token a, b, c,…. Ciò consente al parser principale di utilizzare questi token come alfabeto: Σ = {a, b, c, ...}. Per il lexer, questi token sono non terminali e la regola di avvio S L è S L → ε | una S | b S | c S | ... cioè: qualsiasi sequenza di token. Le regole nella grammatica del lexer sono tutte regole necessarie per produrre questi token.
Il vantaggio prestazionale deriva dall'esprimere le regole del lexer come un linguaggio regolare . Questi possono essere analizzati in modo molto più efficiente rispetto alle lingue senza contesto. In particolare, le lingue regolari possono essere riconosciute nello spazio O (n) e nel tempo O (n). In pratica, un generatore di codice può trasformare un tale lexer in tabelle di salto altamente efficienti.
Estrarre i token dalla tua grammatica.
Per toccare il tuo esempio: le regole digit
e string
sono espresse a livello di carattere per carattere. Potremmo usarli come token. Il resto della grammatica rimane intatto. Ecco la grammatica lexer, scritta come una grammatica lineare a destra per chiarire che è normale:
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 ? - '"' ;
Ma poiché è normale, di solito utilizziamo le espressioni regolari per esprimere la sintassi del token. Ecco le definizioni di token sopra come regex, scritte usando la sintassi di esclusione della classe di caratteri .NET e le classi POSIX:
digit ~ [0-9]
string ~ "[[:print:]-["]]*"
La grammatica per il parser principale contiene quindi le restanti regole non gestite dal lexer. Nel tuo caso, questo è solo:
input = digit | string ;
Quando i lexer non possono essere usati facilmente.
Quando progettiamo una lingua, di solito ci preoccupiamo che la grammatica possa essere separata in modo pulito in un livello lexer e un livello parser e che il livello lexer descriva un linguaggio regolare. Questo non è sempre possibile.
Quando si incorporano le lingue. Alcuni linguaggi consentono di interpolare codice in stringhe: "name={expression}"
. La sintassi dell'espressione fa parte della grammatica senza contesto e pertanto non può essere tokenizzata da un'espressione regolare. Per risolvere questo, ricombiniamo il parser con il lexer o introduciamo token aggiuntivi come STRING-CONTENT, INTERPOLATE-START, INTERPOLATE-END
. La regola grammaticale per una stringa potrebbe allora apparire come: String → STRING-START STRING-CONTENTS { INTERPOLATE-START Expression INTERPOLATE-END STRING-CONTENTS } STRING-END
. Naturalmente l'espressione può contenere altre stringhe, il che ci porta al problema successivo.
Quando i token potrebbero contenere l'un l'altro. Nei linguaggi simili a C, le parole chiave sono indistinguibili dagli identificatori. Ciò è risolto nel lexer dando la priorità alle parole chiave rispetto agli identificatori. Una tale strategia non è sempre possibile. Immagina un file di configurazione in cui Line → IDENTIFIER " = " REST
, dove il resto è qualsiasi carattere fino alla fine della riga, anche se il resto sembra un identificatore. Una linea di esempio sarebbe a = b c
. Il lexer è davvero stupido e non sa in quale ordine possono verificarsi i token. Quindi, se diamo la priorità a IDENTIFICATORE rispetto a REST, il lexer ci darebbe IDENT(a), " = ", IDENT(b), REST( c)
. Se diamo la priorità a REST su IDENTIFICATORE, il lexer ci darebbe REST(a = b c)
.
Per risolvere questo, dobbiamo ricombinare il lexer con il parser. La separazione può essere mantenuta in qualche modo rendendo pigro il lexer: ogni volta che il parser ha bisogno del token successivo, lo richiede dal lexer e dice al lexer l'insieme di token accettabili. In effetti, stiamo creando una nuova regola di livello superiore per la grammatica lexer per ogni posizione. Qui, ciò comporterebbe le chiamate nextToken(IDENT), nextToken(" = "), nextToken(REST)
e tutto funziona bene. Ciò richiede un parser che conosca il set completo di token accettabili in ogni posizione, il che implica un parser dal basso verso l'alto come LR.
Quando il lexer deve mantenere lo stato. Ad esempio, il linguaggio Python delimita i blocchi di codice non con parentesi graffe, ma per rientro. Esistono modi per gestire la sintassi sensibile al layout all'interno di una grammatica, ma queste tecniche sono eccessive per Python. Al contrario, il lexer controlla il rientro di ciascuna riga ed emette token INDENT se viene trovato un nuovo blocco rientrato e token DEDENT se il blocco è terminato. Questo semplifica la grammatica principale perché ora può far finta che quei token siano come parentesi graffe. Tuttavia, il lexer deve ora mantenere lo stato: l'attuale rientro. Ciò significa che il lexer tecnicamente non descrive più un linguaggio normale, ma in realtà un linguaggio sensibile al contesto. Fortunatamente questa differenza non è rilevante nella pratica e il lexer di Python può ancora funzionare in tempo O (n).