Risposte:
Ci sono davvero tre opzioni, tutte e tre preferibili in diverse situazioni.
Supponiamo che ti venga chiesto di creare un parser per alcuni formati di dati antichi ADESSO. O hai bisogno che il tuo parser sia veloce. Oppure hai bisogno che il tuo parser sia facilmente gestibile.
In questi casi, probabilmente è meglio usare un generatore di parser. Non devi armeggiare con i dettagli, non devi avere un sacco di codice complicato per funzionare correttamente, devi solo scrivere la grammatica a cui l'input aderirà, scrivere un po 'di codice di gestione e presto: parser istantaneo.
I vantaggi sono evidenti:
C'è una cosa che devi fare attenzione con i generatori di parser: a volte puoi rifiutare le tue grammatiche. Per una panoramica dei diversi tipi di parser e come possono morderti, potresti iniziare qui . Qui puoi trovare una panoramica di molte implementazioni e dei tipi di grammatiche che accettano.
I generatori di parser sono belli, ma non sono molto user friendly (l'utente finale, non tu). In genere non è possibile fornire buoni messaggi di errore, né è possibile fornire il ripristino degli errori. Forse la tua lingua è molto strana e i parser rifiutano la tua grammatica o hai bisogno di un controllo maggiore di quello che ti dà il generatore.
In questi casi, usare un parser di discesa ricorsiva scritto a mano è probabilmente il migliore. Mentre farlo correttamente può essere complicato, hai il controllo completo sul tuo parser in modo da poter fare tutti i tipi di cose carine che non puoi fare con i generatori di parser, come i messaggi di errore e persino il recupero degli errori (prova a rimuovere tutti i punti e virgola da un file C # : il compilatore C # si lamenterà, ma rileverà comunque la maggior parte degli altri errori indipendentemente dalla presenza di punti e virgola).
Anche i parser scritti a mano di solito funzionano meglio di quelli generati, supponendo che la qualità del parser sia abbastanza alta. D'altra parte, se non riesci a scrivere un buon parser - di solito a causa di (una combinazione di) mancanza di esperienza, conoscenza o progettazione - allora le prestazioni sono generalmente più lente. Per i lexer è vero il contrario: i lexer generati generalmente usano ricerche di tabelle, rendendole più veloci di (la maggior parte) di quelle scritte a mano.
Per quanto riguarda l'educazione, scrivere il proprio parser ti insegnerà più che usare un generatore. Devi scrivere codice sempre più complicato dopo tutto, inoltre devi capire esattamente come analizzare una lingua. D'altra parte, se vuoi imparare come creare la tua lingua (quindi, acquisire esperienza nella progettazione della lingua), è preferibile l'opzione 1 o l'opzione 3: se stai sviluppando una lingua, probabilmente cambierà molto, e le opzioni 1 e 3 ti offrono un momento più facile.
Questo è il percorso che sto percorrendo attualmente: scrivi il tuo generatore di parser. Sebbene altamente non banale, farlo probabilmente ti insegnerà di più.
Per darti un'idea di cosa significhi fare un progetto come questo, ti parlerò dei miei progressi.
Il generatore di lexer
Ho creato prima il mio generatore di lexer. Di solito progetto software a partire da come verrà usato il codice, quindi ho pensato a come volevo poter usare il mio codice e ho scritto questo pezzo di codice (è in C #):
Lexer<CalculatorToken> calculatorLexer = new Lexer<CalculatorToken>(
new List<StringTokenPair>()
{ // This is just like a lex specification:
// regex token
new StringTokenPair("\\+", CalculatorToken.Plus),
new StringTokenPair("\\*", CalculatorToken.Times),
new StringTokenPair("(", CalculatorToken.LeftParenthesis),
new StringTokenPair(")", CalculatorToken.RightParenthesis),
new StringTokenPair("\\d+", CalculatorToken.Number),
});
foreach (CalculatorToken token in
calculatorLexer.GetLexer(new StringReader("15+4*10")))
{ // This will iterate over all tokens in the string.
Console.WriteLine(token.Value);
}
// Prints:
// 15
// +
// 4
// *
// 10
Le coppie stringa-token di input vengono convertite in una struttura ricorsiva corrispondente che descrive le espressioni regolari che rappresentano usando le idee di una pila aritmetica. Questo viene quindi convertito in un NFA (automa finito non deterministico), che a sua volta viene convertito in un DFA (automa finito deterministico). È quindi possibile abbinare le stringhe al DFA.
In questo modo, hai una buona idea di come funzionano esattamente i lexer. Inoltre, se lo fai nel modo giusto, i risultati del tuo generatore lexer possono essere all'incirca veloci quanto le implementazioni professionali. Inoltre, non si perde alcuna espressività rispetto all'opzione 2 e non c'è molta espressività rispetto all'opzione 1.
Ho implementato il mio generatore di lexer in poco più di 1600 righe di codice. Questo codice fa funzionare quanto sopra, ma genera comunque il lexer al volo ogni volta che avvii il programma: ad un certo punto aggiungerò il codice per scriverlo sul disco.
Se vuoi sapere come scrivere il tuo lexer, questo è un buon punto di partenza.
Il generatore di parser
Quindi scrivi il tuo generatore di parser. Mi riferisco di nuovo qui per una panoramica dei diversi tipi di parser - come regola generale, più possono analizzare, più sono lenti.
La velocità non è un problema per me, ho scelto di implementare un parser Earley. Le implementazioni avanzate di un parser Earley hanno dimostrato di essere circa due volte più lente di altri tipi di parser.
In cambio di quel colpo di velocità, hai la possibilità di analizzare qualsiasi tipo di grammatica, anche ambigua. Ciò significa che non devi mai preoccuparti se il tuo parser ha una ricorsione a sinistra o se è un conflitto di riduzione del turno. Puoi anche definire le grammatiche più facilmente usando grammatiche ambigue se non importa quale albero di analisi è il risultato, ad esempio che non importa se analizzi 1 + 2 + 3 come (1 + 2) +3 o come 1 + (2 + 3).
Ecco come può apparire un pezzo di codice usando il mio generatore di parser:
Lexer<CalculatorToken> calculatorLexer = new Lexer<CalculatorToken>(
new List<StringTokenPair>()
{
new StringTokenPair("\\+", CalculatorToken.Plus),
new StringTokenPair("\\*", CalculatorToken.Times),
new StringTokenPair("(", CalculatorToken.LeftParenthesis),
new StringTokenPair(")", CalculatorToken.RightParenthesis),
new StringTokenPair("\\d+", CalculatorToken.Number),
});
Grammar<IntWrapper, CalculatorToken> calculator
= new Grammar<IntWrapper, CalculatorToken>(calculatorLexer);
// Declaring the nonterminals.
INonTerminal<IntWrapper> expr = calculator.AddNonTerminal<IntWrapper>();
INonTerminal<IntWrapper> term = calculator.AddNonTerminal<IntWrapper>();
INonTerminal<IntWrapper> factor = calculator.AddNonTerminal<IntWrapper>();
// expr will be our head nonterminal.
calculator.SetAsMainNonTerminal(expr);
// expr: term | expr Plus term;
calculator.AddProduction(expr, term.GetDefault());
calculator.AddProduction(expr,
expr.GetDefault(),
CalculatorToken.Plus.GetDefault(),
term.AddCode(
(x, r) => { x.Result.Value += r.Value; return x; }
));
// term: factor | term Times factor;
calculator.AddProduction(term, factor.GetDefault());
calculator.AddProduction(term,
term.GetDefault(),
CalculatorToken.Times.GetDefault(),
factor.AddCode
(
(x, r) => { x.Result.Value *= r.Value; return x; }
));
// factor: LeftParenthesis expr RightParenthesis
// | Number;
calculator.AddProduction(factor,
CalculatorToken.LeftParenthesis.GetDefault(),
expr.GetDefault(),
CalculatorToken.RightParenthesis.GetDefault());
calculator.AddProduction(factor,
CalculatorToken.Number.AddCode
(
(x, s) => { x.Result = new IntWrapper(int.Parse(s));
return x; }
));
IntWrapper result = calculator.Parse("15+4*10");
// result == 55
(Nota che IntWrapper è semplicemente un Int32, tranne per il fatto che C # richiede che sia una classe, quindi ho dovuto introdurre una classe wrapper)
Spero che tu veda che il codice sopra è molto potente: qualsiasi grammatica che puoi inventare può essere analizzata. È possibile aggiungere nella grammatica bit arbitrari di codice in grado di eseguire molte attività. Se riesci a far funzionare tutto questo, puoi riutilizzare il codice risultante per svolgere molte attività molto facilmente: immagina di costruire un interprete da riga di comando usando questo pezzo di codice.
Se non hai mai scritto mai un parser, ti consiglio di farlo. È divertente e impari come funzionano le cose e impari ad apprezzare lo sforzo che i generatori di parser e lexer ti salvano dal fare la prossima volta che hai bisogno di un parser.
Vorrei anche suggerire di provare a leggere http://compilers.iecc.com/crenshaw/ in quanto ha un atteggiamento molto concreto verso come farlo.
Il vantaggio di scrivere il proprio parser di discesa ricorsivo è che è possibile generare messaggi di errore di alta qualità sugli errori di sintassi. Utilizzando i generatori di parser, è possibile effettuare produzioni di errori e aggiungere messaggi di errore personalizzati in determinati punti, ma i generatori di parser semplicemente non corrispondono alla potenza di avere il controllo completo sull'analisi.
Un altro vantaggio di scrivere il tuo è che è più facile analizzare una rappresentazione più semplice che non ha una corrispondenza uno a uno con la tua grammatica.
Se la tua grammatica è fissa e i messaggi di errore sono importanti, prendi in considerazione l'idea di crearne uno tuo, o almeno di utilizzare un generatore di parser che ti dia i messaggi di errore di cui hai bisogno. Se la tua grammatica è in continua evoluzione, dovresti invece considerare l'utilizzo di generatori di parser.
Bjarne Stroustrup parla di come ha usato YACC per la prima implementazione di C ++ (vedi The Design and Evolution of C ++ ). In quel primo caso, avrebbe voluto invece scrivere il suo parser di discesa ricorsivo!
Opzione 3: nessuno dei due (ruota il tuo generatore di parser)
Solo perché c'è un motivo per non usare ANTLR , bisonte , Coco / R , Grammatica , JavaCC , Lemon , Parboiled , SableCC , Quex , ecc. , Ciò non significa che dovresti immediatamente rotolare il tuo parser + lexer.
Identifica perché tutti questi strumenti non sono abbastanza buoni - perché non ti consentono di raggiungere il tuo obiettivo?
A meno che tu non sia sicuro che le stranezze nella grammatica che stai affrontando siano uniche, non dovresti semplicemente creare un singolo parser personalizzato + lexer per questo. Invece, crea uno strumento che creerà ciò che desideri, ma può anche essere utilizzato per soddisfare le esigenze future, quindi rilascialo come software libero per impedire ad altre persone di avere lo stesso problema.
Il rolling del tuo parser ti costringe a pensare direttamente alla complessità della tua lingua. Se la lingua è difficile da analizzare, probabilmente sarà difficile da capire.
All'inizio c'era molto interesse nei generatori di parser, motivati da una sintassi linguistica altamente complicata (alcuni direbbero "torturati"). JOVIAL fu un esempio particolarmente negativo: richiese due simboli, in un momento in cui tutto il resto richiedeva al massimo un simbolo. Ciò ha reso la generazione del parser per un compilatore JOVIAL più difficile del previsto (poiché la divisione General Dynamics / Fort Worth ha imparato a fatica quando hanno procurato i compilatori JOVIAL per il programma F-16).
Oggi la discesa ricorsiva è universalmente il metodo preferito, perché è più facile per gli autori di compilatori. I compilatori di discendenza ricorsiva premiano fortemente la progettazione di un linguaggio semplice e pulito, in quanto è molto più facile scrivere un parser a discesa ricorsiva per un linguaggio semplice e pulito che per un linguaggio contorto e disordinato.
Infine: hai preso in considerazione l'idea di incorporare la tua lingua in LISP e lasciare che un interprete LISP faccia il lavoro pesante per te? AutoCAD lo ha fatto e ha scoperto che ha reso la loro vita molto più semplice. Ci sono alcuni interpreti LISP leggeri là fuori, alcuni incorporabili.
Ho scritto un parser per un'applicazione commerciale una volta e ho usato yacc . Esisteva un prototipo in competizione in cui uno sviluppatore scriveva tutto a mano in C ++ e funzionava circa cinque volte più lentamente.
Per quanto riguarda il lexer per questo parser, l'ho scritto interamente a mano. Ci sono voluti - scusate, era quasi 10 anni fa, quindi non mi ricordo con precisione - circa 1000 linee in C .
Il motivo per cui ho scritto a mano il lexer è stata la grammatica di input del parser. Era un requisito, qualcosa che la mia implementazione del parser doveva rispettare, al contrario di qualcosa che avevo progettato. (Ovviamente l'avrei progettato diversamente. E meglio!) La grammatica era fortemente dipendente dal contesto e persino il lessico dipendeva dalla semantica in alcuni punti. Ad esempio un punto e virgola potrebbe far parte di un token in un posto, ma un separatore in un posto diverso - basato su un'interpretazione semantica di alcuni elementi che sono stati analizzati in precedenza. Quindi, ho "seppellito" tali dipendenze semantiche nel lexer scritto a mano e questo mi ha lasciato con un BNF abbastanza semplice che era facile da implementare in Yacc.
AGGIUNTO in risposta a Macneil : yacc fornisce un'astrazione molto potente che consente al programmatore di pensare in termini di terminali, non terminali, produzioni e cose del genere. Inoltre, durante l'implementazione della yylex()
funzione, mi ha aiutato a concentrarmi sulla restituzione del token corrente e non preoccuparmi di ciò che era prima o dopo. Il programmatore C ++ ha lavorato a livello di personaggio, senza il beneficio di tale astrazione e ha finito per creare un algoritmo più complicato e meno efficiente. Abbiamo concluso che la velocità più lenta non aveva nulla a che fare con il C ++ stesso o le librerie. Abbiamo misurato la velocità di analisi pura con i file caricati in memoria; se avessimo un problema di buffering dei file, yacc non sarebbe il nostro strumento preferito per risolverlo.
VUOI ANCHE AGGIUNGERE : questa non è una ricetta per scrivere parser in generale, solo un esempio di come ha funzionato in una situazione particolare.
Dipende interamente da ciò che devi analizzare. Riesci a tirare il tuo più velocemente di quanto potresti colpire la curva di apprendimento di un lexer? Le cose da analizzare sono abbastanza statiche da non pentirti della decisione in seguito? Trovi le implementazioni esistenti troppo complesse? In tal caso, divertiti a farlo da solo, ma solo se non stai evitando una curva di apprendimento.
Ultimamente mi è piaciuto molto il parser al limone , che è probabilmente il più semplice e facile che io abbia mai usato. Per rendere le cose facili da mantenere, le uso solo per la maggior parte delle esigenze. SQLite lo utilizza e alcuni altri progetti importanti.
Ma non mi interessa affatto i lexer, al di là di loro non mi ostacolano quando ne ho bisogno (uno, quindi, il limone). Potresti esserlo, e in tal caso, perché non crearne uno? Ho la sensazione che tornerai a usarne uno esistente, ma gratta il prurito se devi :)
Dipende dal tuo obiettivo.
Stai cercando di imparare come funzionano i parser / compilatori? Quindi scrivi il tuo da zero. Questo è l'unico modo in cui impareresti davvero ad apprezzare tutti i dettagli di ciò che stanno facendo. Ne ho scritto uno negli ultimi due mesi, ed è stata un'esperienza interessante e preziosa, in particolare i momenti "ah, ecco perché la lingua X fa questo ..." momenti.
Hai bisogno di mettere insieme qualcosa rapidamente per un'applicazione entro una scadenza? Quindi forse usa uno strumento parser.
Hai bisogno di qualcosa su cui vorresti ampliare nei prossimi 10, 20, forse anche 30 anni? Scrivi il tuo e prenditi il tuo tempo. Ne varrà la pena.
Hai preso in considerazione l' approccio al workbench del linguaggio Martin Fowlers ? Citando l'articolo
Il cambiamento più evidente che un workbench linguistico apporta all'equazione è la facilità di creazione di DSL esterni. Non è più necessario scrivere un parser. Devi definire una sintassi astratta, ma in realtà è un passaggio di modellazione dei dati piuttosto semplice. Inoltre, il tuo DSL ottiene un IDE potente, anche se devi dedicare un po 'di tempo a definire quell'editor. Il generatore è ancora qualcosa che devi fare, e il mio senso è che non è molto più facile di quanto non sia mai stato. Ma poi costruire un generatore per un DSL buono e semplice è una delle parti più facili dell'esercizio.
Leggendolo, direi che i giorni in cui hai scritto il tuo parser sono finiti ed è meglio usare una delle librerie disponibili. Dopo aver acquisito padronanza della libreria, tutti i DSL creati in futuro trarranno vantaggio da tale conoscenza. Inoltre, gli altri non devono imparare il tuo approccio all'analisi.
Modifica per coprire il commento (e la domanda rivista)
Vantaggi del rotolamento personale
Quindi, in breve, dovresti farlo da solo quando vuoi davvero scavare in profondità nelle viscere di un problema seriamente difficile che ti senti fortemente motivato a padroneggiare.
Vantaggi dell'utilizzo della libreria di qualcun altro
Pertanto, se si desidera un risultato finale rapido, utilizzare la libreria di qualcun altro.
Nel complesso, ciò si riduce alla scelta di quanto si desidera possedere il problema, e quindi la soluzione. Se lo vuoi tutto, fai il tuo.
Il grande vantaggio di scrivere il tuo è che saprai come scrivere il tuo. Il grande vantaggio dell'uso di uno strumento come yacc è che saprai come utilizzare lo strumento. Sono un fan delle cime degli alberi per l'esplorazione iniziale.
Perché non fork un generatore di parser open source e renderlo tuo? Se non usi generatori di parser, il tuo codice sarà molto difficile da mantenere, se hai apportato grandi cambiamenti alla sintassi della tua lingua.
Nei miei parser, ho usato espressioni regolari (intendo, stile Perl) per tokenizzare e usare alcune funzioni di convenienza per aumentare la leggibilità del codice. Tuttavia, un codice generato dal parser può essere più veloce creando tabelle di stato e long switch
- case
s, che possono aumentare le dimensioni del codice sorgente a meno che tu non lo .gitignore
faccia.
Ecco due esempi dei miei parser scritti su misura:
https://github.com/SHiNKiROU/DesignScript - un dialetto BASIC, perché ero troppo pigro per scrivere lookahead in notazione array, ho sacrificato la qualità del messaggio di errore https://github.com/SHiNKiROU/ExprParser - Un calcolatore di formule. Nota gli strani trucchi di metaprogrammazione
"Dovrei usare questa collaudata" ruota "o reinventarla?"