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 NextToken
rifattorizzato nella propria funzione una volta che si hanno più destinazioni dall'inizio del DFA.