miércoles, 22 de febrero de 2017

Reescribiendo PSeInt (parte 2.b): implementación

En la parte 2.a expliqué la estructura general del proceso de parseo e interpretación, indicando cuales serán las cuatro etapas y sus responsabilidades. En esta entrega, algunos detalles más sobre cómo implemento esto a nivel de clases y patrones de diseño.

Nota: Probé varios diseños diferentes. Intento explicar directamente la solución final, ya que necesitaría otros 15 posts y detalles que ya ni me acuerdo para contar toda la historia. El punto es que muchos de los "por qués" del modelo actual se explican en errores de modelos previos, así que algunas cosas que puedan sonarles arbitrarias tal vez tengan una explicación.

El tokenizado es responsabilidad de una clase LineParser. Esta clase toma un string con una línea de pseudocódigo fuente y lo va seccionando en "tokens". Hay un struct Token que representa una de estas partes. Pero LineParser no retorna un arreglo de Tokens, sino que tiene un método para ir obteniéndolos de a uno bajo demanda, como si fuera un stream. Esto evita tener que analizar toda la línea cuando solo interesa algo que está al principio o a medio camino

    LineParser lp(una_linea_de_pseudocódigo);
    while (!lp.Finished()) {
        Token tok = lp.GetNext();
        switch (tok.GetType()) {
            case KEYWORD: ....
            case OPERATOR: ....
            case STRING: ....
            ...       
        }
    }
Ejemplo de programa cliente para la clase LineParser

Luego entra en juego la clase InstructionParser, que invoca a LineParser para obtener los Tokens de una línea y tratar de identificar con ellos las instrucciones y sus partes. Tiene un método general Process que corta la primer palabra para determinar la instrucción, y luego delega a métodos específicos de cada instrucción el resto del análisis. De modo que en esta clase están la mayoría de las verificaciones de la gramática del lenguaje, agrupadas por instrucción.

La clase InstructionParser solo identifica, pero no procesa directamente ella las instrucciones o los errores. Todo lo que identifica se envía a otra clase, que llamaremos genéricamente InstructionProcessor. Por ejemplo, para la ejecución, el InstructionProcessor irá armando una representación del algoritmo adecuada para tal fin (algo como un AST). De modo que la verdadera ejecutora (clase Executor, la última clase) haga su trabajo simplemente recorriendo esa representación.

Secuencia de pasos completa (y simplificada) hasta la ejecución.

Envolviendo todo esto, habrá una clase Parser que (ignoraré por el momento pero) se encargará principalmente de mantener en un lugar común la configuración del sistema (el perfil de lenguaje y otras opciones internas similares), y de ofrecer una interfaz simplificada (como una especie de "facade") para todas estas funcionalidades.


Ahora: ¿cómo conectar las partes? Ya vimos cómo InstructionParser podría hacer uso de LineParser para obtener un stream de Tokens. Luego, debe derivar las instrucciones analizadas a un InstructionProcessor. Esto se puede hacer con polimorfismo. En particular, aquí utilizo polimorfismo estático (templates en lugar de métodos virtuales), ya que en algunos casos de uso InstructionProcessor podría no hacer nada para ciertas instrucciones, y quiero que el compilador optimice esas llamadas para evitar pagar por lo que no uso.

    InstructionParser iparser(...);
    InstructionPrinter printer; // un tipo de InstructionProcessor...
     // ...que solo muestra las instrucciones detectadas en...
     // ...pantalla, para depuración
    iparser.Process(printer,src_line); // Process es...
    //     template<typename InstructionProcessor>
    //     void Process(InstructionProcessor &processor,
    //                  const std::string &source_line);
Ejemplo de uso de InstructionParser con un InstructionProcessor.

Así se pueden "conectar" diferentes InstructionProcessors. Y además (notar el pasaje por referencia no const), el InstructionProcessor puede mantener un estado interno (por ejemplo, el AST que va armando) y yo puedo ver o modificar ese estado entre linea y linea procesada. Algo similar al patrón "builder".

Finalmente, el AST generado puede recorrerse con diferentes fines, como ejecutar, generar un diagrama de flujo, exportar a otros lenguajes, etc. Para esto habrá algo similar al patrón "visitor", donde estas posibilidades serán los "visitantes", y el AST será la estructura "visitada". Solo que el AST/InstructionProcessor se encargará él de organizar el recorrido para nuevamente utilizar polirmofismo estático al invocar al "visitante", por el mismo motivo que antes.

    InstructionParser iparser(...); // para el tokenizado y analisis
    ASTBuilder ast_builder; // para la construccion del AST
    for( auto linea_de_codigo : pseudocodigo_completo )
        iparser.Process(ast_builder, linea_de_codigo);
    AST &the_ast = ast_builder.GetResult();
    Executor the_executor(...); // para la ejecución del algoritmo
    the_ast.Execute(the_executor);

Ejemplo final (simplificado) del proceso completo.

Aún quedan algunos problemas importantes por comentar. Llevado a la práctica, por las reglas del pseudolenguaje, la separación entre etapas no siempre es tan clara o 100% posible. En la tercera y última entrega de la segunda parte (2.c), más problemas y soluciones como para cerrar este tema.

No hay comentarios:

Publicar un comentario en la entrada