jueves, 13 de febrero de 2014

Polimorfismo en psexport

Psexport es uno de los módulos de PSeInt, el que se encarga de traducir un algoritmo en pseudocódigo a lenguajes de programación reales como C, C++, Java, PHP, etc. En realidad hace la mitad del trabajo. Traducir implica primero entender qué es lo que está escrito en un lenguaje, para luego ver cómo escribirlo en el otro. La primer parte la hace el mismísimo intérprete, generando un archivo intermedio "premasticado" para que psexport lea más fácilmente y dedique sus mayores esfuerzos a ver cómo sería mejor escribirlo en ese otro lenguaje. Inicialemente, ese otro lenguaje era solo C++, pero recientemente se añadieron unos cuantos más. La orientación a objetos y particularmente el polimorfismo hicieron que pueda programar todas esas nuevas traducciones con muy poco esfuerzo. Así que en este post les voy a mostrar un ejemplo del tan valioso y no siempre bien ponderado polimorfismo.

Psexport entonces recibe un código preformateado que es mucho más simple de interpretar que el original (es mucho más simple determinar cuales son las instrucciones, cuales sus argumentos, el alcance de los bucles, etc). La versión que tenía hasta hace poco, que solo convertía a C++, levantaba esa información en un vector de instrucciones ya analizadas, y luego una por una las intentaba traducir. Había un conjunto de funciones sencillas para "levantar" la información, y otro conjunto más complejo para hacer las conversiones. Hay una función traducir general que arma el esqueleto básico de un programa C++ y por cada instrucción del vector invoca según su tipo a otras funciones más específicas (hay una para cada tipo de instrucción o estructura de control). Estas hacían el verdadero trabajo de traducción, y a su vez, se apoyaban en la estructura de memoria del propio interprete. El intérprete tiene una clase Memoria que guarda los tipos de las variables, sus dimensiones, y otros detalles del parseo. Y tiene además un conjunto de funciones que analizan las expresiones para determinar los tipos y cargar la información en esa clase Memoria. El traductor utiliza estas funciones del núcleo del intérprete para cargar la información en Memoria, a partir de las expresiones que ve en los argumentos de las instrucciones. Luego utiliza esa misma información para las declaraciones de variables por ejemplo, o para saber cómo traducir cuando las traducciones dependen del tipo de dato del argumento.


La imagen ilustra la circulación de la información. El main manda a cargar el código y llama a traducir. Traducir hace lo general y redirige las instrucciones particulares a funciones particulares. Estas, mientras traducen, van cargando la memoria (y consultándole la información cargada previamente si es necesario). Al final, lo que queda de la función traducir utiliza la información de la memoria para completar el programa con las declaraciones de variables. La siguiente imagen, muestra algo similar pero utilizando un par de clases. El primer cambio fue colocar la parte que depende del lenguaje de salida (la función traducir y las funciones particulares de cada tipo de instrucción) en una clase. De esta forma, se puede reemplazar esa clase por otra para generar la salida en otro lenguaje. Habrá entonces varias clases con la misma interfaz que implementen distintas traducciones, una por cada lenguaje de salida. Y aquí se hace más obvio que lo mejor es colocar esa interfaz genérica en una clase base (ExporterBase), y utilizando polimorfismo hacer las distintas implementaciones de los distintos lenguajes en clases hijas separadas (CppExporter, JavaExporter, VbExporter, etc.).


Así, el programa tiene una instancia de una de estas clases gestionada por un puntero que se crea al iniciar. Para agregar un lenguaje nuevo solo tengo que cambiar el switch que crea la instancia agregando un caso, y crear la clase. Pero el resto del main, la carga de datos, la evaluación de expresiones, el manejo de la memoria, etc se comparte sin cambios. Además, muchos lenguajes comparten sintaxis comunes para ciertas cosas. Por ejemplo, las estructuras de control son casi iguales en java, c, c++ y php. Entonces, puedo implementar tres de ellas heredando de la cuarta. CppExporter, que hereda de ExporterBase (la interfaz), tiene sus métodos también declarados como virtuales, de forma que pude hacer por ejemplo PhpExporter como herencia de CppExporter y no tuve que reimplementar la traducción de las estructuras de control. Aprovechando la herencia en múltiples niveles también puedo implementar, por ejemplo, la clase que exporta a HTML heredando de la que exporta a JavaScript (ya que la versión HTML no es más que el código JavaScript embebido dentro de un documento HTML casi vacio).


Por último, hay otro detalle importante. Tomemos como ejemplo C, donde las variables se leen diferente según su tipo. Si empiezo a traducir y me encuentro por primera vez con una variable N en un "Leer N", no voy a saber de qué tipo es, y voy a leer asumiendo algún tipo arbitrario. Si más adelante esa variable aparece como tamaño de un arreglo, o como expresión de control en un Según, sabré que era numérica y entera, pero ya será tarde para cambiar la lectura. Entonces, es conveniente primero analizar todo el código en busca de las variables y sus tipos, y luego hacer las traducciones. Con este esquema, donde ExporterBase hace gran parte del trabajo genérico, pude hacer una nueva herencia (TiposExporter) que se encarga solo de esto, y es ejecutada antes que la que realmente escribe las traducciones. Así que primero se crea una instancia de ExporterBase, y se le pasa el algoritmo para que cargue todo lo que pueda en Memoria. Luego se crea una instancia de la clase que realmente traduce (por ejemplo, de CppExporter si la salida es en C++), y se le pasa también el mismo algoritmo, de la misma forma.


Este es entonces un ejemplo de uso del polimorfismo para generar un programa cliente de una instancia de una clase, al que solo le importa la interfaz. Así, se le puede cambiar la implementación que hay por detrás y dejar que el compilador se encargue de que eso sea transparente. Además, la herencia (utilizada para reaprovechar código común, más que para representar relaciones de herencia reales en todos los casos) me permite agregar nuevos lenguajes basándome en los ya existentes. Como siempre digo, los programadores somos haraganes (debemos serlo). Queremos escribir la menor cantidad de código posible. A veces, para lograrlo hay que pensar un buen rato el diseño antes de teclear. Pero cuando (después de varios intentos) el código se reutiliza seguido y al programa se le pueden agregar funcionalidades completas haciendo cambios no estructurales y acotados, es señal de que vamos por buen camino.

1 comentario:

  1. Acá uso "traducir" para referirme a la conversión de un algoritmo en pseudocódigo a uno más o menos equivalente en un lenguaje de programación real como Java o C++. Pero no hablo de traducir entre lenguajes refiriendome a idiomas o localización, como traducir de español a inglés. poeditor es para lo segundo, pero no se trata de eso psexport

    ResponderEliminar