it-swarm.it

Dovrei usare un generatore di parser o devo rotolare il mio codice lexer e parser personalizzato?

Quali specifici vantaggi e svantaggi di ogni modo di lavorare su una grammatica del linguaggio di programmazione?

Perché/quando dovrei rotolare il mio? Perché/quando dovrei usare un generatore?

83
Maniero

Ci sono davvero tre opzioni, tutte e tre preferibili in diverse situazioni.

Opzione 1: generatori di parser o "devi analizzare un po 'di lingua e vuoi solo farlo funzionare, dannazione"

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 stai meglio usando 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:

  • È (di solito) abbastanza facile scrivere una specifica, in particolare se il formato di input non è troppo strano (l'opzione 2 sarebbe meglio se lo fosse).
  • Si finisce con un lavoro molto facilmente gestibile che è facilmente comprensibile: una definizione grammaticale di solito scorre molto più naturale del codice.
  • I parser generati da buoni generatori di parser sono generalmente molto più veloci del codice scritto a mano. Il codice scritto a mano può essere più veloce, ma solo se conosci le tue cose - questo è il motivo per cui i compilatori più usati usano un parser scritto a mano ricorsivo.

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.

Opzione 2: parser scritti a mano o "vuoi creare il tuo parser personale e ti interessa essere di facile utilizzo"

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, utilizzare 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).

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.

Opzione 3: generatori di parser scritti a mano, o "stai cercando di imparare molto da questo progetto e non ti dispiacerebbe finire con un pezzo di codice che puoi riutilizzare molto"

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 disegno 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 con il 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 di lexer possono essere più o meno veloci delle 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 a 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. Implementazioni avanzate di un parser Earley sono state mostrate essere circa due volte più lente di altri tipi di parser.

In cambio di quel colpo di velocità, hai la possibilità di analizzare any tipo di grammatica, anche ambigua. Ciò significa che non devi mai preoccuparti se il tuo parser ha una ricorsione a sinistra o cosa sia 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 di codice arbitrari 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.

78
Alex ten Brink

Se non hai mai, mai scritto 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 il il prossimo tempo di cui hai bisogno un parser.

Vorrei anche suggerire di provare a leggere http://compilers.iecc.com/crenshaw/ in quanto ha un atteggiamento molto concreto verso come farlo.

22
user1249

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!

14
Macneil

Opzione 3: (Avvia il tuo generatore di parser)

Solo perché c'è un motivo per non usare ANTLR , bisonte , Coco/R , Grammatica , JavaCC , Lemon , Parboiled , SableCC , Quex , etc - ciò non significa che dovresti immediatamente rotolare il tuo parser + lexer.

Identifica why tutti questi strumenti non sono abbastanza validi - 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.

10
Peter Boughton

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. Esistono alcuni interpreti LISP leggeri, alcuni incorporabili.

8
John R. Strohm

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, è stato quasi 10 anni fa, quindi non me lo ricordo esattamente - circa 1000 righe 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. (Naturalmente 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 carattere abbastanza semplice [~ # ~] bnf [~ # ~] 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 funzione yylex(), 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.

VOGLIAMO ANCHE AGGIUNGERE: questa non è una ricetta per scrivere parser in generale, solo un esempio di come ha funzionato in una situazione particolare.

6
azheglov

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.

3
GrandmasterB

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 lemon parser , 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 per niente 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 :)

3
Tim Post

Hai considerato 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 potente IDE - 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 che mai. Ma costruire un generatore per un DSL buono e semplice è una delle parti più facili dell'esercizio.

Leggendolo, direi che i giorni in cui ho scritto il tuo parser sono finiti ed è meglio usare una delle librerie disponibili. Una volta padroneggiata la 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 includere il commento (e la domanda rivista)

Vantaggi del rotolamento personale

  1. Avrai il parser e otterrai tutta quella bella esperienza di pensiero attraverso una complessa serie di problemi
  2. Potresti inventare qualcosa di speciale a cui nessun altro ha pensato (improbabile ma sembri un tipo intelligente)
  3. Ti terrà occupato con un problema interessante

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

  1. Eviterai di reinventare la ruota (accetti un problema comune nella programmazione)
  2. Puoi concentrarti sul risultato finale (il tuo nuovo linguaggio brillante) e non preoccuparti troppo di come viene analizzato, ecc
  3. Vedrai la tua lingua in azione molto più velocemente (ma la tua ricompensa sarà inferiore perché non era tutto te)

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.

3
Gary Rowe

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 di treetop per l'esplorazione iniziale.

2
philosodad

Perché non fork un generatore di parser open source e renderlo tuo? Se non usi i 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 lo 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 lunghi switch-cases, che possono aumentare le dimensioni del codice sorgente a meno che tu .gitignore.

Ecco due esempi dei miei parser personalizzati:

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

1
Ming-Tang

"Dovrei usare questa collaudata" ruota "o reinventarla?"

0
JBRWilkinson