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


24

Sto lentamente lavorando per terminare la mia laurea, e questo semestre è Compilers 101. Stiamo usando il Dragon Book . Tra poco nel corso e stiamo parlando dell'analisi lessicale e di come può essere implementata tramite automi finiti deterministici (di seguito, DFA). Imposta i tuoi vari stati lexer, definisci le transizioni tra di essi, 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'altra) e un'istruzione switch per gestire tutti i terminali nonché l'invio alle tabelle di transizione se in uno stato non terminale.

La teoria va bene e bene, ma come qualcuno che ha effettivamente scritto codice per decenni, l'implementazione è vile. Non è testabile, non è gestibile, non è leggibile, ed è un dolore e mezzo per il debug. Peggio ancora, non riesco a vedere come sarebbe pratico da remoto se la lingua fosse in grado di utilizzare UTF. Avere circa un milione di voci nella tabella di transizione per stato non terminale diventa affrettato 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 è nota in anticipo (espressioni regolari?)? O forse qualcosa che gestisce tutti i casi, anche se soluzioni più specifiche funzioneranno meglio per grammatiche più specifiche?

( nota: possibile duplicato " Perché usare un approccio OO invece di un'istruzione switch gigante? " è vicino, ma non mi interessa OO. Un approccio funzionale o un approccio imperativo anche più sano con funzioni autonome andrebbe bene.)

E per esempio, considera una lingua che ha solo identificatori, e quelli sono identificatori [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 NextTokenrifattorizzato nella propria funzione una volta che si hanno più destinazioni dall'inizio del DFA.


5
un patrimonio di antichi (1977) Principi di progettazione di compilatori ? 40 anni fa, lo stile di programmazione era molto diverso
moscerino

7
Come implementeresti le transizioni degli stati DFA? E di cosa si tratta terminali e non terminali, "non terminali" di solito si riferisce alle regole di produzione nella grammatica, che verrebbero dopo l'analisi lessicale.

10
Quelle tabelle non sono pensate per essere leggibili dagli umani, ma sono pensate per essere utilizzabili dal compilatore e per funzionare molto rapidamente. È facile saltare attorno a un tavolo guardando avanti nell'input (ad esempio per catturare la ricorsione a sinistra, anche se in pratica la maggior parte delle lingue è costruita per evitarlo).

5
Se una parte della tua irritazione proviene dal saper fare un lavoro migliore e dalla mancanza della capacità di ottenere feedback o apprezzamento per un approccio che preferiresti, dato che decenni nell'industria ci addestrano ad aspettarsi feedback e, a volte, apprezzamento, forse dovresti scrivere la tua migliore implementazione e pubblicarla su CodeReview.SE per ottenerne un po 'per la tua tranquillità.
Jimmy Hoffa,

7
La risposta semplice è perché il lexer è di solito implementato come una macchina a stati finiti e generato automaticamente dalla grammatica - e una tabella di stato è, non sorprendentemente, rappresentata più facilmente e in modo compatto come una tabella. Come per il codice oggetto, il fatto che non sia facile per gli umani lavorare è irrilevante perché gli umani non ci lavorano; cambiano la fonte e generano una nuova istanza.
keshlam,

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 utility 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 righe di codice. Il codice procedurale scritto a mano per l'analisi lessicale di qualsiasi linguaggio moderatamente interessante tende ad essere sia inefficiente che difficile da mantenere.


4
Non sono sicuro di suggerirlo all'ingrosso. Le espressioni regolari tratteranno lingue arbitrarie (regolari). Non ci sono approcci migliori quando si lavora con lingue specifiche? Il libro tocca gli approcci predittivi ma poi li ignora negli esempi. Inoltre, dopo aver fatto un ingenuo analizzatore per C # anni fa, non ho trovato tremendamente difficile mantenerlo. Inefficiente? certo, ma non terribilmente, data la mia abilità in quel momento.
Telastyn,

1
@Telastyn: è quasi impossibile andare più veloce di un DFA guidato da una tabella: ottenere il personaggio successivo, cercare lo stato successivo nella tabella di transizione, cambiare stato. Se il nuovo stato è terminale, emettere un token. In C # o Java qualsiasi approccio che prevede la creazione di stringhe temporanee sarà più lento.
Kevin Cline,

@kevincline - certo, ma nel mio esempio non ci sono stringhe temporanee. Anche in C sarebbe solo un indice o un puntatore che passa attraverso la stringa.
Telastyn,

6
@JimmyHoffa: sì, le prestazioni sono decisamente rilevanti nei compilatori. I compilatori sono veloci perché sono stati ottimizzati per l'inferno e ritorno. Non micro-ottimizzazioni, semplicemente non svolgono lavori inutili come la creazione e l'eliminazione di oggetti temporanei non necessari. Nella mia esperienza, la maggior parte del codice commerciale di elaborazione del testo fa un decimo del lavoro di un moderno compilatore e impiega dieci volte più tempo per farlo. Le prestazioni sono enormi quando si elabora un gigabyte di testo.
Kevin Cline,

1
@Telastyn, quale "approccio migliore" avevi in ​​mente e in che modo ti aspetti che sia "migliore"? Dato che disponiamo già di strumenti lessicali ben testati e che producono parser molto veloci (come altri hanno già detto, i DFA guidati da tabella sono molto veloci), ha senso usarli. Perché dovremmo voler inventare un nuovo approccio speciale per una lingua specifica, quando potremmo semplicemente scrivere una grammatica lessicale? La grammatica lex è più mantenibile e il parser risultante ha maggiori probabilità di essere corretto (dato quanto sono testati lex e strumenti simili).
DW

7

La motivazione per quel particolare algoritmo è in gran parte il fatto che si tratta di un esercizio di apprendimento, quindi cerca di stare vicino all'idea di un DFA e di mantenere stati e transizioni molto espliciti nel codice. Di norma, nessuno scriverà comunque manualmente questo codice - useresti uno strumento per generare codice da una grammatica. E quello strumento non si preoccuperebbe della 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.


7

Il ciclo interno di:

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

ha molti vantaggi prestazionali. Non ci sono rami in questo, 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 Dragon Book.

In pratica, oltre agli studenti CS che studiano i lexer, nessuno deve implementare (o eseguire il debug) quel ciclo interno perché fa parte della piastra di caldaia fornita con lo strumento che costruisce la transitiontabella.


5

Dalla memoria, - è da molto tempo che non leggo il libro e sono abbastanza sicuro di non aver letto l'ultima edizione, di sicuro non ricordo qualcosa che assomiglia a Java - quella parte è stata scritta con il codice dovrebbe essere un modello, la tabella viene riempita con un lexer come generatore di lexer. Sempre dalla memoria, c'era una sezione sulla compressione delle tabelle (di nuovo dalla memoria, era scritta in modo tale da essere applicabile anche ai parser guidati da tabelle, quindi forse più avanti nel libro di quello che hai ancora visto). Allo stesso modo, il libro che ricordo ha assunto un set di caratteri a 8 bit, mi aspetterei una sezione sulla gestione di un set di caratteri più grande nelle edizioni successive, probabilmente come parte della compressione della tabella. Ho fornito un modo alternativo di gestirlo come risposta a una domanda SO.

C'è un sicuro vantaggio in termini di prestazioni nell'avere dati a circuito stretto guidati nell'architettura moderna: è abbastanza compatibile con la cache (se hai compresso le tabelle) e la previsione del salto è il più perfetta possibile (un errore alla fine del lessico, forse uno perdere lo switch inviando al codice che dipende dal simbolo; ciò presuppone che la decompressione della tabella possa essere eseguita con salti prevedibili). Spostare quella macchina a stati in codice puro ridurrebbe le prestazioni di previsione del salto e forse aumenterebbe la pressione della cache.


2

Dopo aver esaminato il Dragon Book in precedenza, il motivo principale per avere leve e i parser guidati da una tabella è che è possibile utilizzare espressioni regolari per generare il lexer e BNF per generare il parser. Il libro tratta anche di come funzionano gli strumenti come lex e yacc e in modo da sapere come funzionano questi strumenti. Inoltre, è importante elaborare alcuni esempi pratici.

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

Speriamo che il tuo istruttore ti permetta anche di usare lex e yacc (a meno che non sia una classe di livello universitario e non riesci a scrivere lex e yacc).


0

In ritardo alla festa :-) I token sono abbinati a espressioni regolari. Dal momento che ce ne sono molti, hai il motore multi regex, che a sua volta è DFA gigante.

"Peggio ancora, non riesco a vedere come sarebbe pratico da remoto se la lingua fosse in grado di utilizzare UTF."

È irrilevante (o trasparente). Inoltre UTF ha delle belle proprietà, le sue entità non si sovrappongono neppure parzialmente. Ad 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. Quale modo migliore per scriverlo rispetto all'array 2d?

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.