Ho fatto molte ricerche negli ultimi giorni, per capire meglio perché esistono queste tecnologie separate e quali sono i loro punti di forza e di debolezza.
Alcune delle risposte già esistenti hanno accennato ad alcune delle loro differenze, ma non hanno fornito il quadro completo e sembrano essere in qualche modo supponente, motivo per cui questa risposta è stata scritta.
Questa esposizione è lunga, ma importante. abbi pazienza con me (o se sei impaziente, scorri fino alla fine per vedere un diagramma di flusso).
Per comprendere le differenze tra Parser Combinator e Parser Generators, è necessario innanzitutto comprendere la differenza tra i vari tipi di analisi esistenti.
parsing
L'analisi è il processo di analisi di una serie di simboli secondo una grammatica formale. (In Informatica), l'analisi viene utilizzata per consentire a un computer di comprendere il testo scritto in una lingua, in genere creando un albero di analisi che rappresenta il testo scritto, memorizzando il significato delle diverse parti scritte in ciascun nodo dell'albero. Questo albero di analisi può quindi essere utilizzato per una varietà di scopi diversi, come tradurlo in un'altra lingua (utilizzata in molti compilatori), interpretando le istruzioni scritte direttamente in qualche modo (SQL, HTML), consentendo a strumenti come Linters
di fare il loro lavoro , ecc. A volte, un albero di analisi non è esplicitamentegenerato, ma piuttosto l'azione che deve essere eseguita su ciascun tipo di nodo nella struttura viene eseguita direttamente. Ciò aumenta l'efficienza, ma sott'acqua esiste ancora un albero di analisi implicito.
L'analisi è un problema difficile dal punto di vista computazionale. Ci sono stati oltre cinquant'anni di ricerche su questo argomento, ma c'è ancora molto da imparare.
In parole povere, ci sono quattro algoritmi generali per consentire a un computer di analizzare l'input:
- Analisi LL. (Analisi senza contesto, analisi dall'alto verso il basso.)
- Analisi LR. (Analisi senza contesto, analisi dal basso.)
- Analisi PEG + Packrat.
- Earley Parsing.
Si noti che questi tipi di analisi sono descrizioni teoriche molto generali. Esistono diversi modi per implementare ciascuno di questi algoritmi su macchine fisiche, con diversi compromessi.
LL e LR possono solo guardare le grammatiche senza contesto (vale a dire; il contesto attorno ai token scritti non è importante per capire come vengono utilizzati).
L'analisi PEG / Packrat e l'analisi Earley sono usate molto meno: Earley-parsing è bello in quanto può gestire molte più grammatiche (comprese quelle che non sono necessariamente senza contesto) ma è meno efficiente (come affermato dal drago libro (sezione 4.1.1); non sono sicuro che queste affermazioni siano ancora accurate).
Parsing Expression Grammar + Packrat-parsing è un metodo che è relativamente efficiente e può anche gestire più grammatiche sia di LL che di LR, ma nasconde ambiguità, come verrà rapidamente toccato di seguito.
LL (derivazione da sinistra a destra, estrema sinistra)
Questo è probabilmente il modo più naturale di pensare all'analisi. L'idea è quella di guardare il token successivo nella stringa di input e quindi decidere quale delle possibili chiamate ricorsive multiple multiple dovrebbe essere presa per generare una struttura ad albero.
Questo albero è costruito "dall'alto verso il basso", il che significa che iniziamo dalla radice dell'albero e viaggiamo le regole grammaticali nello stesso modo in cui viaggiamo attraverso la stringa di input. Può anche essere visto come la costruzione di un equivalente "postfix" per il flusso di token "infix" che viene letto.
I parser che eseguono l'analisi in stile LL possono essere scritti in modo molto simile alla grammatica originale che è stata specificata. Ciò rende relativamente semplice la comprensione, il debug e il miglioramento. I combinatori di parser classici non sono altro che "pezzi lego" che possono essere messi insieme per costruire un parser in stile LL.
LR (derivazione da sinistra a destra, estrema destra)
L'analisi LR viaggia dall'altra parte, dal basso verso l'alto: ad ogni passo, gli elementi superiori nello stack vengono confrontati con l'elenco della grammatica, per vedere se potevano essere ridotti
a una regola di livello superiore nella grammatica. In caso contrario, il token successivo dal flusso di input viene spostato e posizionato in cima allo stack.
Un programma è corretto se alla fine finiamo con un singolo nodo nello stack che rappresenta la regola di partenza dalla nostra grammatica.
Guarda avanti
In uno di questi due sistemi, a volte è necessario sbirciare più token dall'input prima di poter decidere quale scelta fare. Questo è il (0)
, (1)
, (k)
o (*)
-syntax vedete dopo i nomi di questi due algoritmi generali, come LR(1)
o LL(k)
. k
di solito sta per "tutto ciò di cui ha bisogno la tua grammatica", mentre di *
solito sta per "questo parser esegue backtracking", che è più potente / facile da implementare, ma ha una memoria e un tempo di utilizzo molto più elevati rispetto a un parser che può semplicemente continuare ad analizzare linearmente.
Si noti che i parser in stile LR hanno già molti token nello stack quando potrebbero decidere di "guardare avanti", quindi hanno già più informazioni su cui inviare. Ciò significa che spesso hanno bisogno di meno "lookahead" di un parser in stile LL per la stessa grammatica.
LL vs. LR: ambiguità
Leggendo le due descrizioni precedenti, ci si potrebbe chiedere perché esiste l'analisi in stile LR, poiché l'analisi in stile LL sembra molto più naturale.
Tuttavia, l'analisi in stile LL presenta un problema: ricorsione sinistra .
È molto naturale scrivere una grammatica come:
expr ::= expr '+' expr | term
term ::= integer | float
Ma un parser in stile LL rimarrà bloccato in un ciclo ricorsivo infinito quando analizza questa grammatica: quando prova la possibilità più a sinistra della expr
regola, ricorre nuovamente a questa regola senza consumare alcun input.
Esistono modi per risolvere questo problema. Il più semplice è riscrivere la grammatica in modo che questo tipo di ricorsione non avvenga più:
expr ::= term expr_rest
expr_rest ::= '+' expr | ϵ
term ::= integer | float
(Qui, ϵ sta per 'stringa vuota')
Questa grammatica ora è ricorsiva. Si noti che è immediatamente molto più difficile da leggere.
In pratica, la ricorsione a sinistra potrebbe avvenire indirettamente con molti altri passaggi nel mezzo. Questo rende un problema difficile da cercare. Ma cercare di risolverlo rende la tua grammatica più difficile da leggere.
Come afferma la Sezione 2.5 del Dragon Book:
Sembra che abbiamo un conflitto: da un lato abbiamo bisogno di una grammatica che faciliti la traduzione, dall'altro abbiamo bisogno di una grammatica significativamente diversa che faciliti l'analisi. La soluzione è iniziare con la grammatica per una facile traduzione e trasformarla attentamente per facilitare l'analisi. Eliminando la ricorsione sinistra possiamo ottenere una grammatica adatta all'uso in un traduttore predittivo di discesa ricorsiva.
I parser in stile LR non hanno il problema di questa ricorsione a sinistra, poiché costruiscono l'albero dal basso verso l'alto.
Tuttavia , la traduzione mentale di una grammatica come sopra in un parser in stile LR (che è spesso implementato come un automa a stati finiti )
è molto difficile (e soggetta a errori), poiché spesso ci sono centinaia o migliaia di stati + transizioni di stato da considerare. Questo è il motivo per cui i parser in stile LR sono generalmente generati da un generatore di parser, noto anche come "compilatore del compilatore".
Come risolvere le ambiguità
Abbiamo visto due metodi per risolvere le ambiguità della ricorsione a sinistra sopra: 1) riscrivere la sintassi 2) usare un parser LR.
Ma ci sono altri tipi di ambiguità che sono più difficili da risolvere: cosa succede se due diverse regole sono ugualmente applicabili contemporaneamente?
Alcuni esempi comuni sono:
Sia i parser in stile LL che quelli in stile LR hanno problemi con questi. I problemi con l'analisi delle espressioni aritmetiche possono essere risolti introducendo la precedenza dell'operatore. In modo simile, altri problemi come il Dangling Else possono essere risolti, scegliendo un comportamento di precedenza e attenendosi ad esso. (In C / C ++, ad esempio, il penzolante appartiene sempre al "se" più vicino).
Un'altra "soluzione" a questo consiste nell'utilizzare Parser Expression Grammar (PEG): è simile alla grammatica BNF usata in precedenza, ma nel caso di un'ambiguità, scegli sempre "la prima". Naturalmente, questo non "risolve" realmente il problema, ma piuttosto nasconde l'esistenza di un'ambiguità: gli utenti finali potrebbero non sapere quale scelta fa il parser e questo potrebbe portare a risultati inaspettati.
Ulteriori informazioni che sono molto più approfondite di questo post, incluso il motivo per cui è impossibile in generale sapere se la tua grammatica non ha alcuna ambiguità e le implicazioni di ciò è il meraviglioso articolo di blog LL e LR nel contesto: Perché analizzare gli strumenti sono difficili . Lo consiglio vivamente; mi ha aiutato molto a capire tutte le cose di cui sto parlando in questo momento.
50 anni di ricerca
Ma la vita va avanti. Si è scoperto che i parser "normali" in stile LR implementati come automi a stati finiti spesso necessitavano di migliaia di stati + transizioni, il che rappresentava un problema nella dimensione del programma. Quindi, sono state scritte varianti come Simple LR (SLR) e LALR (Look-ahead LR) che combinano altre tecniche per ridurre l'automa, riducendo il footprint di memoria e del disco dei programmi parser.
Inoltre, un altro modo per risolvere le ambiguità sopra elencate consiste nell'utilizzare tecniche generalizzate in cui, nel caso di un'ambiguità, entrambe le possibilità sono mantenute e analizzate: l'una o l'altra potrebbe non riuscire ad analizzare la linea (nel qual caso l'altra possibilità è la 'corretto'), oltre a restituire entrambi (e in questo modo mostrando l'esistenza di un'ambiguità) nel caso entrambi siano corretti.
È interessante notare che, dopo la descrizione dell'algoritmo LR generalizzato , si è scoperto che un approccio simile poteva essere utilizzato per implementare parser LL generalizzati , che è altrettanto veloce ($ O (n ^ 3) $ complessità temporale per grammatiche ambigue, $ O (n) $ per grammatiche completamente inequivocabili, sebbene con più contabilità di un semplice parser LR (LA), il che significa un fattore costante più elevato) ma consente ancora una volta che un parser sia scritto in uno stile di discesa ricorsivo (dall'alto verso il basso) che è molto più naturale scrivere e debug.
Combinatori di parser, generatori di parser
Quindi, con questa lunga esposizione, stiamo arrivando al centro della domanda:
Qual è la differenza tra Parser Combinator e Parser Generator e quando si dovrebbe usare uno rispetto all'altro?
Sono davvero diversi tipi di animali:
I Parser Combinator furono creati perché le persone scrivevano parser top-down e si resero conto che molti di questi avevano molto in comune .
I generatori di parser sono stati creati perché le persone stavano cercando di costruire parser che non presentassero i problemi dei parser di tipo LL (ovvero i parser di tipo LR), che si sono rivelati molto difficili da eseguire manualmente. Quelli comuni includono Yacc / Bison, che implementa (LA) LR).
È interessante notare che al giorno d'oggi il paesaggio è un po 'confuso:
È possibile scrivere Parser Combinator che funzionano con l' algoritmo GLL , risolvendo i problemi di ambiguità che avevano i classici parser in stile LL, pur essendo leggibili / comprensibili come tutti i tipi di analisi top-down.
I generatori di parser possono anche essere scritti per parser in stile LL. ANTLR fa esattamente questo e usa altre euristiche (Adaptive LL (*)) per risolvere le ambiguità che avevano i classici parser in stile LL.
In generale, la creazione di un generatore di parser LR e il debug dell'output di un generatore di parser in stile LR (LA) in esecuzione sulla grammatica sono difficili, a causa della traduzione della grammatica originale nel modulo LR "rovesciato". D'altra parte, strumenti come Yacc / Bison hanno avuto molti anni di ottimizzazioni e hanno visto molto uso in natura, il che significa che molte persone ora lo considerano come il modo di eseguire l'analisi e sono scettici nei confronti di nuovi approcci.
Quale dovresti usare dipende da quanto è dura la tua grammatica e da quanto deve essere veloce il parser. A seconda della grammatica, una di queste tecniche (/ implementazioni delle diverse tecniche) potrebbe essere più veloce, avere un footprint di memoria più piccolo, avere un footprint del disco più piccolo o essere più estensibile o più facile da debug rispetto alle altre. Il tuo chilometraggio può variare .
Nota a margine: sull'argomento dell'analisi lessicale.
L'analisi lessicale può essere utilizzata sia per i combinatori parser che per i generatori parser. L'idea è di avere un parser "stupido" che sia molto facile da implementare (e quindi veloce) che esegua un primo passaggio sul codice sorgente, rimuovendo ad esempio la ripetizione di spazi bianchi, commenti, ecc. E possibilmente "tokenizzazione" in un modo molto in modo approssimativo i diversi elementi che compongono la tua lingua.
Il vantaggio principale è che questo primo passo rende il vero parser molto più semplice (e quindi probabilmente più veloce). Lo svantaggio principale è che hai una fase di traduzione separata e, ad esempio, la segnalazione degli errori con i numeri di riga e colonna diventa più difficile a causa della rimozione dello spazio bianco.
Un lexer alla fine è "solo" un altro parser e può essere implementato usando una delle tecniche sopra. Per la sua semplicità, spesso vengono utilizzate altre tecniche rispetto al parser principale, e per esempio esistono "generatori di lexer" extra.
Tl; Dr:
Ecco un diagramma di flusso applicabile alla maggior parte dei casi:
javac
, Scala). Ti dà il massimo controllo sullo stato interno del parser, che aiuta a generare buoni messaggi di errore (che negli ultimi anni ...