Qual è la procedura che si segue quando si scrive un lexer basato su una grammatica?


13

Durante la lettura di una risposta alla domanda Chiarimento su grammatiche, Lexer e Parser , la risposta affermava che:

[...] una grammatica BNF contiene tutte le regole necessarie per l'analisi lessicale e l'analisi.

Questo mi è sembrato in qualche modo strano perché fino ad ora avevo sempre pensato che un lexer non fosse affatto basato su una grammatica, mentre un parser era fortemente basato su una. Ero giunto a questa conclusione dopo aver letto numerosi post sul blog sulla scrittura di lexer, e nessuno ha mai usato 1 EBNF / BNF come base per il design.

Se i lexer, così come i parser, si basano su una grammatica EBNF / BNF, come si potrebbe fare per creare un lexer usando quel metodo? Cioè, come costruirò un lexer usando una determinata grammatica EBNF / BNF?

Ho visto molti, molti post che trattano di scrivere un parser usando EBNF / BNF come guida o progetto, ma finora non ne ho mai trovato nessuno che mostri l'equivalente con il design lexer.

Ad esempio, prendi la seguente grammatica:

input = digit| string ;
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
string = '"', { all characters - '"' }, '"' ;
all characters = ? all visible characters ? ;

Come si potrebbe creare un lexer basato sulla grammatica? Potrei immaginare come un parser possa essere scritto da una tale grammatica, ma non riesco a capire il concetto di fare lo stesso con un lexer.

Esistono alcune regole o logiche utilizzate per eseguire un'attività come questa, come per la scrittura di un parser? Francamente, sto iniziando a chiedermi se i progetti di lexer usano una grammatica EBNF / BNF, per non parlare del fatto che sono basati su uno.


1 forma estesa Backus – Naur e forma Backus – Naur

Risposte:


18

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 digite stringsono 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).


Molto bella risposta @amon, grazie. Dovrò impiegare del tempo per digerirlo completamente. Tuttavia, mi chiedevo alcune cose sulla tua risposta. Intorno all'ottavo paragrafo, mostri come potrei modificare la mia grammatica di esempio EBNF in regole per un parser. La grammatica mostrata verrebbe utilizzata anche dal parser? O c'è ancora una grammatica separata per il parser?
Christian Dean,

@Engineer Ho apportato un paio di modifiche. Il tuo EBNF può essere utilizzato direttamente da un parser. Tuttavia, il mio esempio mostra quali parti della grammatica possono essere gestite da un lexer separato. Qualsiasi altra regola verrebbe comunque gestita dal parser principale, ma nel tuo esempio è giusto input = digit | string.
amon,

4
Il grande vantaggio dei parser senza scanner è che sono molto più facili da comporre; l'esempio estremo di ciò sono le librerie combinatore parser, in cui non si fa altro che comporre parser. La composizione di parser è interessante per casi come ECMAScript-incorporato-in-HTML-incorporato-in-PHP-cosparso-con-SQL-con-un-linguaggio-modello-in-cima o esempi-Ruby-incorporato-in-Markdown- commenti-incorporati nella documentazione-Ruby o qualcosa del genere.
Jörg W Mittag,

L'ultimo punto elenco è molto importante, ma ritengo che il modo in cui lo hai scritto sia fuorviante. È vero che i lexer non possono essere utilizzati facilmente con una sintassi basata sul rientro, ma in questo caso l'analisi senza scanner è ancora più difficile. Quindi in realtà vuoi usare un lexer se hai quel tipo di linguaggio, aumentandolo con lo stato rilevante.
user541686

I token di rientro / dedent basati su lexer in stile Python sono possibili solo per linguaggi molto semplici e sensibili al rientro e non sono generalmente applicabili. Un'alternativa più generale sono le grammatiche degli attributi, ma manca il loro supporto negli strumenti standard. L'idea è di annotare ogni frammento di AST con il suo rientro e aggiungere vincoli a tutte le regole. Gli attributi sono semplici da aggiungere con l'analisi combinatrice, che semplifica anche l'esecuzione dell'analisi senza scanner.
amon
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.