Perché implementare un lexer come array 2d e switch gigante?

24

Sto lentamente lavorando per finire la mia laurea, e questo semestre è Compilatori 101. Stiamo usando il Libro del Drago . A breve nel corso e stiamo parlando di analisi lessicale e di come può essere implementato tramite automi finiti deterministici (di seguito, DFA). Imposta i tuoi vari stati lexer, definisci le transizioni tra loro, ecc.

Ma sia il professore che il libro propongono di implementarli tramite tabelle di transizione che equivalgono a un gigantesco array 2d (i vari stati non terminali come una dimensione, e i possibili simboli di input come l'altro) e una dichiarazione switch per gestire tutti dei terminali nonché invio alle tabelle di transizione se in uno stato non terminale.

La teoria è buona, ma come qualcuno che ha scritto codice per decenni, l'implementazione è vile. Non è testabile, non è manutenibile, non è leggibile ed è un dolore e mezzo per eseguire il debug. Peggio ancora, non riesco a vedere come sarebbe remoto se la lingua fosse UTF in grado. Avere un milione circa di voci della tabella di transizione per stato non terminale diventa spiacevole in fretta.

Quindi qual è il problema? Perché il libro definitivo sull'argomento dice di farlo in questo modo?

Il sovraccarico delle chiamate di funzione è davvero così tanto? È qualcosa che funziona bene o è necessario quando la grammatica non è conosciuta in anticipo (espressioni regolari?)? O forse qualcosa che gestisce tutti i casi, anche se soluzioni più specifiche funzioneranno meglio con grammatiche più specifiche?

( note: possibile duplicato " Perché usare un approccio OO invece di una gigantesca istruzione switch? " è vicino, ma non mi interessa OO. Un approccio funzionale o un approccio imperativo ancora più salutare con funzioni standalone sarebbe bene.)

E per esempio, considera un linguaggio che ha solo identificatori e quegli identificatori sono [a-zA-Z]+ . Nell'implementazione di DFA, otterrai qualcosa del tipo:

private enum State
{
    Error = -1,
    Start = 0,
    IdentifierInProgress = 1,
    IdentifierDone = 2
}

private static State[][] transition = new State[][]{
    ///* Start */                  new State[]{ State.Error, State.Error (repeat until 'A'), State.IdentifierInProgress, ...
    ///* IdentifierInProgress */   new State[]{ State.IdentifierDone, State.IdentifierDone (repeat until 'A'), State.IdentifierInProgress, ...
    ///* etc. */
};

public static string NextToken(string input, int startIndex)
{
    State currentState = State.Start;
    int currentIndex = startIndex;
    while (currentIndex < input.Length)
    {
        switch (currentState)
        {
            case State.Error:
                // Whatever, example
                throw new NotImplementedException();
            case State.IdentifierDone:
                return input.Substring(startIndex, currentIndex - startIndex);
            default:
                currentState = transition[(int)currentState][input[currentIndex]];
                currentIndex++;
                break;
        }
    }

    return String.Empty;
}

(sebbene qualcosa che gestisca correttamente la fine del file)

Rispetto a quello che mi aspetterei:

public static string NextToken(string input, int startIndex)
{
    int currentIndex = startIndex;
    while (currentIndex < startIndex && IsLetter(input[currentIndex]))
    {
        currentIndex++;
    }

    return input.Substring(startIndex, currentIndex - startIndex);
}

public static bool IsLetter(char c)
{
    return ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));
}

Con il codice in NextToken rielaborato nella sua funzione una volta che hai più destinazioni dall'inizio del DFA.

    
posta Telastyn 01.10.2014 - 16:04
fonte

6 risposte

16

In pratica queste tabelle sono generate da espressioni regolari che definiscono i token della lingua:

number := [digit][digit|underscore]+
reserved_word := 'if' | 'then' | 'else' | 'for' | 'while' | ...
identifier := [letter][letter|digit|underscore]*
assignment_operator := '=' | '+=' | '-=' | '*=' | '/=' 
addition_operator := '+' | '-' 
multiplication_operator := '*' | '/' | '%'
...

Abbiamo avuto programmi di utilità per generare analizzatori lessicali dal 1975 quando è stato scritto lex .

In pratica stai suggerendo di sostituire le espressioni regolari con il codice procedurale. Questo espande un paio di caratteri in un'espressione regolare in diverse linee di codice. Il codice procedurale manoscritto per l'analisi lessicale di qualsiasi linguaggio moderatamente interessante tende ad essere sia inefficiente che difficile da mantenere.

    
risposta data 01.10.2014 - 17:58
fonte
7

La motivazione per il particolare algoritmo è in gran parte che si tratta di un esercizio di apprendimento, quindi cerca di rimanere vicino all'idea di un DFA e mantenere gli stati e le transizioni molto espliciti nel codice. Di norma, nessuno in realtà scriverebbe manualmente questo codice - useresti uno strumento per generare codice da una grammatica. E a questo strumento non interessa la leggibilità del codice perché non è un codice sorgente, è un output basato sulla definizione di una grammatica.

Il tuo codice è più pulito per qualcuno che mantiene un DFA scritto a mano, ma un po 'più lontano dai concetti insegnati.

    
risposta data 01.10.2014 - 20:22
fonte
7

Il ciclo interno di:

                currentState = transition[(int)currentState][input[currentIndex]];
                currentIndex++;
                break;

ha molti vantaggi in termini di prestazioni. Non ci sono rami in tutto ciò, perché fai esattamente la stessa cosa per ogni carattere di input. Le prestazioni del compilatore possono essere controllate dal lexer (che deve operare su una scala di ogni carattere di input). Questo era ancora più vero quando è stato scritto il Libro del Drago.

In pratica, a parte gli studenti di CS che studiano i lexer, nessuno deve implementare (o eseguire il debug) quel ciclo interno perché fa parte dello schema che viene fornito con lo strumento che crea la tabella transition .

    
risposta data 01.10.2014 - 23:32
fonte
5

Dalla memoria, - è da tanto tempo che non leggo il libro, e sono abbastanza sicuro di non aver letto l'ultima edizione, di sicuro non ricordo qualcosa di simile a Java - quella parte è stato scritto con il codice che si intende essere un modello, la tabella viene riempita con un lexer come generatore di lexer. Ancora dalla memoria, c'era una sezione sulla compressione della tabella (di nuovo dalla memoria, era scritta in modo tale che era applicabile anche ai parser basati su tabella, quindi forse più nel libro di quanto non si sia visto finora). Allo stesso modo, il libro che ricordo ha assunto un set di caratteri a 8 bit, mi aspetterei una sezione su come gestire un set di caratteri più grande nelle edizioni successive, probabilmente come parte della compressione della tabella. Ho dato un modo alternativo per gestirlo come risposta a una domanda SO.

C'è un certo vantaggio in termini di prestazioni nell'avere dati di loop limitati nell'architettura moderna: è abbastanza cache friendly (se hai compresso le tabelle), e la previsione di salto è il più perfetta possibile (una mancanza alla fine del lexem , forse una mancanza per lo switch che invia al codice che dipende dal simbolo, supponendo che la decompressione della tabella possa essere eseguita con salti prevedibili). Spostare quella macchina a stati in codice puro ridurrebbe le prestazioni di previsione di salto e forse aumenterebbe la pressione cache.

    
risposta data 01.10.2014 - 17:59
fonte
2

Avendo già lavorato su Dragon Book in precedenza, il motivo principale per avere leve e parser gestiti da tabelle è che puoi usare espressioni regolari per generare il lexer e BNF per generare il parser. Il libro spiega anche come funzionano gli strumenti come lex e yacc e in modo che tu sappia come funzionano questi strumenti. Inoltre, per te è importante elaborare alcuni esempi pratici.

Nonostante molti commenti, non ha nulla a che fare con lo stile di codice che è stato scritto negli anni '40, '50, '60 ..., ha a che fare con la comprensione pratica di ciò che gli strumenti fanno per te e cosa devi fare per farli funzionare. Ha tutto a che fare con la comprensione fondamentale di come i compilatori lavorano sia da un punto di vista teorico che pratico.

Spero che il tuo istruttore ti permetta di usare lex e yacc (a meno che non si tratti di un corso di livello post-laurea e tu possa scrivere lex e yacc).

    
risposta data 16.08.2016 - 19:18
fonte
0

In ritardo alla festa :-) I token sono abbinati alle espressioni regolari. Poiché ci sono molti di loro, hai il motore multi regex, che a sua volta è gigante DFA.

"Peggio ancora, non riesco a vedere come sarebbe pratico da remoto se la lingua fosse compatibile con UTF."

È irrilevante (o trasparente). Oltre a UTF ha una buona proprietà le sue entità non si sovrappongono neanche parzialmente. Per esempio. il byte che rappresenta il carattere "A" (dalla tabella ASCII-7) non viene più utilizzato per nessun altro carattere UTF.

Quindi, hai un singolo DFA (che è multi-regex) per l'intero lexer. Quanto è meglio annotarlo rispetto alla matrice 2d?

    
risposta data 17.08.2016 - 08:36
fonte

Leggi altre domande sui tag