http://www.albertnogues.com 1 Compilando un compilador de Java
Compilando un compilador de Java Introducción. Haciendo una práctica de la universidad en PCCTS, nos tocaba implementar un compilador con todas sus fases. Análisis sintáctico, léxico, semántico, y la generación de código. (La parte de optimización de código solo se daba a nivel teórico). Usábamos árboles AST (Abstract Syntax Tree) para guardar los datos y las estructuras intermedias que íbamos generando. Aquí os presentaré una versión muy simplificada para los que alguna vez os habéis preguntado como implementar un compilador. De echo la implementación que haré aquí namás reconocerá expresiones muy básicas como sumas y restas. Y también asignaciones. Así nos evitaremos muchos problemas que tendríamos si aceptaramos funciones, procedimientos, declaracion de tipos estructurados, paso de parámetros por valor o referencia … La herramienta que usaremos se llama ANTLR, que es la versión actualizada de PCCTS i esta, es basada en java en lugar de C++. El tutorial está basado en la versión 3.0 de antlr. Es una versión nueva con cambios significativos respecto a la versión 2.X, pero mejor empezar con una versión que tenga más recorrido. A parte de antlr necesitaremos un programa para diseñar la gramática. Optaremos por ANTLRWorks. Lo podemos descargar aquí: http://antlr.org/works/index.html Con este .jar ya tenemos también antlr ya que viene incluido dentro del paquete. Hay que tener presente que necesitamos necesitamos también tener la máquina de java instalada en nuestro pc. Una vez descargado, damos doble click y debería arrancar el programa. Sino desde la consola con: java –jar Nombredelfichero.jar Nombredelfichero.jar Ahora ya estamos en disposición de empezar a diseñar la gramática.
Pasos previos. Bien, antes de empezar a diseñar nuestra gramática, os explicaré un poco la estructura de un fichero .g o gramática gramática de antlr. Para arrancar nuestro compilador, dado que usamos java y todo se traducirá a este lenguaje podemos optar por meter todo el código del compilador dentro del fichero .g en el sitio que pertoque o escribirlo en ficheros aparte. Si lo hacemos en ficheros aparte deberemos compilar desde la línea de comandos todos los .java que hayamos añadido. Como lo que quiero es hacerlo entendible, escribiremos todo el código en un solo fichero. Con esto conseguiremos que desde ANTLRWorks podamos generar todos los ficheros .java y arrancar una línea de comandos con nuestro compilador.
http://www.albertnogues.com 2 Compilando un compilador de Java
La estructura del fichero de gramática es la siguiente: grammar SimpleCalc;
tokens { PLUS = '+' ; MINUS = '-' ; }
@members { public static void main(String[] args) throws Exception { SimpleCalcLexer lex = new SimpleCalcLexer(new ANTLRFileStream(args[0])); CommonTokenStream tokens = new CommonTokenStream(lex); SimpleCalcParser parser = new SimpleCalcParser(tokens); try { parser.expr(); } catch (RecognitionException e) { e.printStackTrace(); } } }
/*-----------------------------------------------------------------* PARSER RULES *------------------------------------------------------------------*/ expr : ID '=' op {System.out.println($ID.text +"="+ $op.value);}; op returns [int value] : e=factor {$value = $e.value;} ( PLUS e=factor {$value += $e.value;} | MINUS e=factor {$value -= $e.value;} )* ;
factor returns [int value] : NUMBER {$value = Integer.parseInt($NUMBER.text);};
/*-----------------------------------------------------------------* LEXER RULES *------------------------------------------------------------------*/ ID : ('a'..'z'|'A'..'Z')+ ;
http://www.albertnogues.com 3 Compilando un compilador de Java
NUMBER
: (DIGIT)+ ;
WHITESPACE : ( '\t' | ' ' | '\r' | '\n'| '\u000C' )+ { $channel = HIDDEN; } ; fragment DIGIT : '0'..'9' ;
Bueno como seguramente pensaréis, esta es la gramática que he usaré para el ejemplo. De hecho no la he diseñado yo. Es muy parecida a una que hay en los ejemplos de la página de antlr aquí: http://www.antlr.org/wiki/display/ANTLR3/Five+minute+introduction+to+ANTLR+3 o bien aquí: http://www.antlr.org/wiki/display/ANTLR3/Expression+evaluator
He sombreado los bloques para que se vean las partes diferenciadas del fichero. La primera parte es la cabecera del fichero. Aquí hemos de prestar atención de darle el mismo nombre que tiene el fichero. ¿Os recuerda a java esto, no? Pues básicamente la idea es la misma. La clase debe llamarse igual que el nombre del fichero, sino, no compilará. El segundo bloque es la declaración de tokens. Aquí debemos añadir todos los elementos que queremos que reconozca nuestra gramática. En nuestro caso el signo + y el signo – El tercer bloque @ members contiene el código java de nuestra aplicación. En nuestro caso hemos colocado un main. De hecho el programa funcionará igual sin este código el problema es que no podremos llegar a ver nada si el texto de entrada es aceptado. Lo que hace este código es leer un fichero de texto especificado en la línea de comandos cuando arrancamos nuestro compilador, eso es args[0]. Dentro del bloque try, llamamos a la primera regla del programa (parser.expr()), esto desencadenará el inicio del programa. El cuarto bloque es las reglas del parser, es decir puramente la gramática. Aquí podemos ver como una expresión es un ID (identificador, o nombre de variable) el token igual ´=´ (si, ya sé que lo podríamos haber definido en el apartado de tokens, pero para que vierais que también podemos ponerlo directamente aquí) y una operación (la definimos más abajo). Lo que vemos entre { } es el código java que he escrito yo. Basicamente nos imprimirá por pantalla las expresiones que vaya reconociendo. La siguiente regla es la operación. Tenemos un factor OPERANDO factor. El factor en nuestro caso será un entero, lo podemos ver en la tercera regla. Lo que hace es almacenar en una variable valor temporal, el resultado de sumar o restar los dos factores. La tercera regla, almacena en una variable llamada valueel resultado de parsear la lectura del fichero a un Entero. El cuarto bloque es la declaración de los tipos en nuestro caso un ID (identificador de variable) es cualquier nombre de la a-Z que contenga al menos una letra. Esto es debido a la cláusala positiva de Klenee (+). El mismo razonamiento para los números, para los espacios en blanco y los dígitos.
http://www.albertnogues.com 4 Compilando un compilador de Java
Me he dejando por comentar un par de cosas adrede, porque quería que quedaran claras. Los campos disponen de varios valores (como si fueran una struct) estos son .value y .text. Se entiende que value es un entero y text un campo para almacenar strings, como los ID.
Generando código y compilando. El siguiente paso es generar el código del parser y del lexer, es decir, de la parte del compilador que se encargará de leer la entrada y mirar que sea correcta a nivel sintáctico, y el lexer que es el encargado de realizar las operaciones y comprobar que todo funcione correctamente. Para generar el código de ámbos, en el ANTLRWorks, vamos a Generate Generate Code. Esto nos creará dos ficheros java que hemos de compilar. El siguiente paso como he dicho es compilar el programa. Para ello abrimos una consola y escribimos: javac SimpleCalcLexer.java SimpleCalcParser.java Si todo ello funciona correctamente, como debería, nos generará dos ficheros class con el mismo nombre que los java. Podemos arrancar nuestro compilador con: java SimpleCalcparser test.txt El fichero test.txt lo he creado yo con un editor de texto. Simplemente se trata del fichero de nuestro programa. En mi caso lo que he escrito es: A=3+6 Y el resultado de la ejecución del programa es: A=9 Por tanto, como podemos comprobar ha realizado correctamente la operación.
Probando el compilador. Vamos a probar de toquetear un poco nuestro programa. Primero de todo intentamos ver que pasa si cambiamos en nuestro fichero de código nuestra asignación por esta otra: A=3+v Rápidamente nuestro compilador se quejará y nos dará un error como este:
Como podemos ver se queja de que no reconoce el token v en esa expresión. Es decir un error de missmatch o sea que no coincide lo que espera con lo que le llega. Obvio ya que hemos definido las sumas como sumas de enteros y v no es ningún entero, ya que es un ID. Otras cosas curiosas que podemos observar, aparte de lo limitado que es para el reconocimiento de expresiones, es que el programa no tiene memoria. Si os fijáis en el código
http://www.albertnogues.com 5 Compilando un compilador de Java
lo veréis. No os preguntáis ¿donde se almacena el valor de nuestros cálculos? Si miráis otra vez el código veréis que el proceso que hacemos cada vez que se encuentra una expresión bien formada (entiéndase por una expresión correctamente aceptada) es simplemente imprimirla por pantalla. No guardamos ningún registro de ella. Esto tiene fácil solución. Veremos cómo arreglar estos problemillas y completar un poco nuestro rudimentario compilador.
Mirando más allá. Como hemos comentado antes, queremos mejorar nuestro compilador para que, además de guardar un registro de nuestras asignaciones, nos permita incorporar sumas de variables (ID) declaradas en nuestro programa. El primer paso será guardar el valor de una asignación. Es necesario para el siguiente paso, ya que si queremos usarla más tarde, primero la hemos de tener grabada. ¿Lógico no? Antes de nada, vamos a modificar la gramática para que nos admita mas de una asignación. Si os fijáis la regla expr, nuestra primera regla silo reconoce una expresión. Para ello cambiaremos el nombre de expr por asig y antes de esta regla añadiremos una nueva regla llamada expr. El código será algo así:
Con esto ya habremos conseguido reconocer un conjunto de 1…* asignaciones. Para guardar las variables usaremos una tabla Hash. Java ya nos proporciona una estructura de datos de este tipo. Se llama HashMap. En la directiva @members debemos añadir nuestra estructura. Quedaría algo así: HashMap variables = new HashMap();
Hemos de importar ahora la clase HashMap. Para esto antlr dispone de un nuevo espacio para indicar los imports de clases java. Añadiremos el siguiente código justo antes de la directiva @members @header { import java.util.HashMap; }
Con este cambio, solo nos faltará modificar las reglas de la gramática. En concreto, la regla asig, aparte de que nos muestre la asignación aceptada, haremos que nos guarde el resultado en la tabla de variables. Dentro del código java entre llaves, añadimos el siguiente: asig : ID '=' op {System.out.println($ID.text +"="+ $op.value); variables.put($ID.text, new Integer($op.value));};
http://www.albertnogues.com 6 Compilando un compilador de Java
Como veis hemos añadido una instrucción para que nos añada en la tabla la variable, con su valor entero. Si probáis de ejecutar el código no veréis ningún cambio, salvo que en el main imprimáis la tabla hash. El siguiente paso es modificar la regla factor para que nos permita operar también con variables (ID). factor : |
returns [int value] NUMBER {$value = Integer.parseInt($NUMBER.text);} ID { Integer v = (Integer)variables.get($ID.text); if ( v!=null ) $value = v.intValue(); else System.err.println("Variable no definida: "+$ID.text); };
Si observáis hemos añadido una regla nueva con una disyunción (|). Esta regla busca la variable ID, en la tabla de variables, la parsea a un entero. Si no la encuentra imprime un mensaje de error “Variable no definida”. Para probarlo usaré este juego de pruebas simple: v=7+2 a=3+v El resultado debería ser V=9 y a=12. Veamos si es correcto:
Pues funciona perfectamente. Vamos a probar ahora de añadir una variable que no exista. Cambiamos el juego de pruebas anterior por este: v=7+2 a=3+vr Y vemos como el resultado no es satisfactorio:
Si os fijáis la ultima suma sí que se realiza, pero la variable inexistente se toma como valor = 0. Esto lo podríamos cambiar con un simple ajuste en el código que trata la variable no encontrada.
http://www.albertnogues.com 7 Compilando un compilador de Java
Ampliaciones. Bien, hasta aquí ya tenemos algo más presentable. Ahora podríamos mejorarlo añadiendo la generación de un árbol AST y recorriéndolo, con lo que podríamos hacer lo mismo que hemos hecho pero más aproximado a lo que hace un compilador real. Se puede seguir ampliando con más operaciones. Podéis probar de añadir otras operaciones de enteros, como multiplicación, división. El siguiente paso sería añadir algún nuevo tipo. Podríamos añadir tipos de coma flotante, pero ya se complicaría mucho más, ya que deberíamos mostrar errores cuando intentáramos dejar el resultado real en un entero, es decir no admitir la pérdida de precisión. Otra ampliación interesante podría ser añadir el tipo booleano. Esto nos permitiría añadir las operaciones boleanas AND, OR, NOT, y también añadir algunas mas expresiones enteras que tienen resultados booleanos, como la IGUALDAD, MAYOR, MENOR, MENOR IGUAL, MAYOR IGUAL, DIFERENTE… De hecho no sería necesario añadir el tipo booleano para jugar con estas expresiones. Podríamos usar el 0 entero como el falso y el 1 por ejemplo como cierto. Los últimos pasos ya serían la declaración de tipos estructurados, añadir soporte para declaración y uso de funciones y sus correspondientes paso por valor, referencia…