Estoy trabajando lentamente para terminar mi carrera, y este semestre es Compiladores 101. Estamos usando el Libro del Dragón . Poco después del curso, estamos hablando del análisis léxico y de cómo se puede implementar a través de autómatas finitos deterministas (en adelante, DFA). Configure sus diversos estados lexer, defina transiciones entre ellos, etc.
Pero tanto el profesor como el libro proponen implementarlos a través de tablas de transición que equivalen a una matriz 2d gigante (los diversos estados no terminales como una dimensión y los posibles símbolos de entrada como la otra) y una declaración de interruptor para manejar todos los terminales así como el envío a las tablas de transición si se encuentra en un estado no terminal.
La teoría está muy bien, pero como alguien que realmente ha escrito código durante décadas, la implementación es vil. No es comprobable, no es mantenible, no es legible, y es un dolor y medio para depurarlo. Peor aún, no puedo ver cómo sería remotamente práctico si el lenguaje fuera UTF. Tener un millón de entradas en la tabla de transición por estado no terminal se vuelve muy rápido.
Entonces, ¿cuál es el trato? ¿Por qué el libro definitivo sobre el tema dice hacerlo de esta manera?
¿Es realmente tan elevada la sobrecarga de las llamadas a funciones? ¿Es esto algo que funciona bien o es necesario cuando la gramática no se conoce con anticipación (expresiones regulares)? ¿O tal vez algo que maneje todos los casos, incluso si las soluciones más específicas funcionarán mejor para gramáticas más específicas?
( nota: el posible duplicado " ¿Por qué usar un enfoque OO en lugar de una declaración de interruptor gigante? " está cerca, pero no me importa el OO. Un enfoque funcional o incluso un enfoque imperativo más sensato con funciones independientes estaría bien).
Y por ejemplo, considere un lenguaje que solo tiene identificadores, y esos identificadores son [a-zA-Z]+
. En la implementación de DFA, obtendría algo como:
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;
}
(aunque algo que manejaría correctamente el final del archivo)
En comparación con lo que esperaría:
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 el código NextToken
refactorizado en su propia función una vez que tenga múltiples destinos desde el inicio de DFA.