PC PRÁCTICO

Compiladores (y II)

Análisis sintáctico de un compilador
Aprendemos el sentido de la herramienta Bison
En esta nueva entrega sobre compiladores para lenguajes de programación, repasaremos cómo tratar los tokens que devolvíamos con la herramienta Flex, usando para ello Bison, lo cual será una gran ayuda. A continuación, tras realizar el análisis sintáctico de un fichero fuente, habremos construido nuestro primer compilador.
l igual que ocurría con la anterior entrega del mes de abril (nº129, pag 241-243), nos centraremos en los aspectos más prácticos y olvidaremos un poco la siempre aburrida teoría (aunque sentimos deciros que una vez acabada la serie, si queréis profundizar más en este mundillo, deberéis trabajar también la parte teórica). Bison no es otra cosa que una herramienta mejorada de una aplicación anterior llamada Yacc. De esta forma, el código escrito para Yacc es compatible con Bison, aunque no a la inversa. Para empezar, el programa va a tomar un fichero de texto con el formato mostrado en el Código 1.

A

Intentaremos verlo más claro con un ejemplo. Para ello construiremos un analizador para un lenguaje que define expresiones aritméticas. Una posible entrada, al igual que en la entrega anterior, se puede ver en el Código 2.

En primer lugar, debemos escribir nuestro fichero fuente en Flex para realizar el análisis léxico, es decir, detectar las piezas relevantes del lenguaje y pasarlas al anali-

zador sintáctico. Para ello, si observamos con detenimiento el fuente del Código 3, advertiremos varias diferencias con respecto al incluido en el capítulo anterior. Aun-

PC PRÁCTICO

Compiladores (y II)

que el resultado práctico sigue siendo el mismo, el lenguaje C generado por Flex utilizando esta versión va a ser más eficiente.

I El primer fuente Ahora viene el momento de «meterle mano» al Bison. La parte que realiza el análisis semántico de nuestro lenguaje la encontraremos en el Código 4.

No hemos de olvidar dónde nos encontramos en este momento: nuestro fichero fuente ha pasado por el analizador léxico (Flex) y éste lo está «troceando» en tokens para pasárselos al sintáctico, es decir, Bison. Así pues, este último está recibiendo, en forma de tokens, cadenas como LEER, ESCRIBIR, IDENT, etc. Por otra lado, es necesario diferenciar los símbolos terminales de los no terminales. Ello es posible gracias a que, por convenio, los primeros aparecen en mayúscula (LEER, por ejemplo) o, si están formados por un sólo carácter, encerrados entre comillas simples (’...’). Los segundos se presentan en minúscula. Por otra parte, mientras que éstos pueden derivar en otros símbolos, de cualquiera de los dos tipos, los primeros (como su propio nombre indica) no pueden generar otros nuevos. Ahora ya estamos preparados para estudiar nuestro código. En el argot de compiladores, al conjunto de reglas que hemos escrito se le denomina gramática del lenguaje. En primer lugar veremos, en la línea 6 del Código 4, cómo se le indica a Bison cuáles son los símbolos terminales de nuestra gramática. A partir de estas definiciones, Bison genera automáticamente, en un fichero de cabecera «.h» (Código 5), una serie de macros en las que asocia un número entero a cada token. De esta forma, podremos olvidarnos de crear (y, lo que es mejor, de mantener) dicho fichero nosotros mismos.

calculadora se compone de una instrucción (línea 12) o de una entrada y una instrucción (línea 11). Pero, ¿qué es una entrada? Si aplicamos la definición recursiva veremos que en esas tres simples líneas, sencillamente, establecemos que un programa está formado por una o más instrucciones. Si proseguimos, en las líneas 14 a 17, contemplaremos el significado de instrucción. Hemos decidido que habrá tres tipos de instrucciones: de lectura (leer(y)), de escritura (escribir(var2*y)) o de asignación (x = (1+2)*3). Detengámonos ahora, por ejemplo, en la instrucción asignación en las líneas 22 y 23. En ellas queda reflejado que ésta es un IDENT (identificador) —en este punto debemos recordar que es Flex quien decide qué parte del código fuente es un identificador y lo retorna, según lo definido en el Código 1— seguido de un signo igual (=) y de una expresión. Por último, tenemos expresión (olvidémosnos de momento de la línea 28, del símbolo MENOS y de la instrucción %prec). En ella, especificamos que una expresión puede estar formada por dos expresión separadas por un signo +, *, etc. Pero además de esto también puede tratarse de un IDENT o un NÚMERO (ambos definidos en Flex). De nuevo estamos utilizando aquí las propiedades de la recursividad.

Flex en acción procesando el fichero de entrada y pasándole tokens a Bison. I Gramáticas y producciones Pero vayamos al meollo de la cuestión, al comienzo de la gramática. Observemos atentamente las líneas 11, 12 y 13 del Código 4. Comprendiendo la sintaxis aquí utilizada habremos entendido gran parte del funcionamiento de Bison. Como podéis ver, estamos ante una herramienta realmente sencilla pero a la vez extremadamente potente. Estas líneas son las que designan una regla, compuesta a su vez por varias producciones. No debemos preocuparnos demasiado por estos nuevos términos, simplemente hemos de saber que la gramática de un lenguaje está formada por un conjunto de producciones o reglas. Volviendo a dichas líneas, en ellas estamos definiendo el principio de nuestro lenguaje de forma recursiva. En esas dos producciones (separadas por el símbolo «|») estamos diciendo que el lenguaje de nuestra I Ensayo y error Para terminar de aclarar el funcionamiento de Flex y Bison acudiremos a un ejemplo práctico de una entrada a nuestro programa. Imaginemos que el fichero de entrada contiene la siguiente línea: x = 5 + 3;. Recordemos que, en primer lugar, actuará el código C creado por Flex, el cual, gracias al código que hemos escrito, pasará a Bison los siguientes tokens: un IDENT (por el carácter x), el signo igual (=), un IDENT (caracter 5), el signo más (+), otro IDENT (caracter 3) y, por último, el punto y coma (;). Cuando se empieza a ejecutar el código C creado por Bison, en primer lugar recibe el token IDENT. Bison comenzará su recorrido recursivo por la línea 11 e intentará encajar los tokens que recibe en las reglas que hemos definido. Hemos de añadir que, aunque Bison es muy potente y eficaz, éste se guía por el método de ensayo y error.

Pero antes de profundizar en éste, debemos hacer un inciso con el fin de aclarar algunos conceptos. La función del código anterior es describir la estructura que presenta el lenguaje que queremos reconocer. Debemos recordar que, una vez compilado el fichero anterior con la herramienta Bison, se genera un archivo en lenguaje C. Tanto Flex como Bison son sólo utilidades intermedias cuyo resultado pasaremos a código máquina para construir nuestro compilador.

PC PRÁCTICO

Compiladores (y II)

En concreto, utiliza un autómata de pila para intentar encajar los tokens en nuestras reglas. Si observamos la gráfica de lo que intenta hacer la herramienta (ver recuadro aclaratorio sobre cómo funcionan los autómatas de pila), constataremos que en primera instancia intenta comprobar si estamos ante una instrucción de lectura. Como la prueba fracasa, vuelve a insistir con la siguiente posibilidad, una instrucción de escritura. Evidentemente tampoco tiene éxito, continuando con una de asignación para concluir.

Los autómatas de pila
Como hemos indicado en el texto de este artículo, Bison tiene su fundamento teórico en los autómatas de pila. En concreto implementa un método llamado LR(1), que viene a utilizar una cadena de entrada que se recorre siempre de izquierda a derecha (Left to Right). Estos analizadores son del tipo bottom-up, pues parten de la cadena de entrada (en nuestro ejemplo, x = 5 + 3;) e intentan reducirla a lo que se denomina axioma de la gramática. En nuestro ejemplo el axioma principal de la gramática lo podemos ver en la línea 11 del codigo 4 y es el símbolo no terminal entrada. En la imagen que ilustra este recuadro, vemos una representación de un autómata de pila. Esquema de un autómata de pila. Y el esquema de funcionamiento es el siguiente: a) Continuamente mira si lo que hay en las últimas casillas de la pila concuerda con la parte derecha de alguna de las reglas de producción de la gramática analizada. b) Si existe concordancia, elimina de la cima de la pila esa cadena y la cambia por la parte izquierda de la regla de producción. A esto se le llama reducción. c) Si no existe concordancia alguna, lee un carácter más de la entrada y lo apila. A esta acción se le suele denominar desplazamiento. Sí usando este proceso conseguimos agotar el contenido de la cinta de entrada y en la cima de la pila queda el axioma de la gramática, la palabra es reconocida. En otro caso no lo es. Veamos al autómata en acción intentando reconocer la sentencia if-then-else que proporcionan la mayoría de los lenguajes de programación. Así observaremos cuál es la estructura de esta instrucción y cómo se comporta ante una posible entrada. Hemos llegado al axioma de la gramática y hemos agotado la cinta de entrada, por lo que la cadena introducida pertenece a nuestro lenguaje. Para tomar la decisión en cada momento sobre qué acción realizar (según lo que tengamos en la cinta de entrada y en la pila) existen una serie de algoritmos no demasiado complejos pero que van más allá de la intención de este artículo. Así pues, Bison hace uso de estos algoritmos para procesar los tokens de entrada e intentar llegar al axioma de la gramática. En el caso de nuestro lenguaje calculadora de ejemplo, intentará llegar a entrada (Código 4, línea 11).

¿Es la entrada x = 5 + 3; una instrucción de lectura?

¿Es la entrada x = 5 + 3; una instrucción de asignación? Una vez llegados a este punto, podríamos preguntarnos cuál es la salida por pantalla del programa. En el caso de que nuestro fichero fuente sea correcto (es decir, si no hemos infringido las reglas del lenguaje) no habrá ninguna salida. Recordemos que Bison sólo comprueba si una determinada

PC PRÁCTICO

Compiladores (y II)

entrada es acorde a una gramática. Suponiendo que el reconocedor generado por Bison detecte un error, automáticamente llama a la función yyerror(). Su implementación suele incluirse en la zona del archivo de entrada, justo detrás de los %% que finalizan la zona de reglas. En nuestro modelo usamos la variable yytext que contiene la última cadena reconocida, averiguando de esta forma dónde está el error. I Gramáticas con atributos Hasta ahora hemos conseguido usar Bison y Flex para reconocer si una entrada se ajusta o no a unas determinadas reglas. A continuación, descubriremos cómo son capaces de evaluar ciertos atributos asociados a los elementos reconocidos. De momento, Flex tan sólo le pasa a Bison nuestros tokens, pero únicamente como un número identificador y, a efectos prácticos, todos los números e identificadores son iguales. Por tanto, vamos a intentar recuperar esa información que habíamos perdido en Flex y que denominaremos atributos. Lo primero que tendremos que especificar es de qué tipos podrán ser. Para ello, crearemos el fichero «yystype.h» (Código 9) donde definiremos un tipo llamado YY_parse_STYPE.

Direcciones de interés
Cumplido nuestro objetivo de iniciar a los lectores en este apasionante mundo de los compiladores, hemos creído oportuno desechar una nueva entrega con los aspectos referentes al análisis semántico. El marcado carácter práctico que caracteriza esta sección, incompatibiliza la espesa teoría necesaria para comprender a fondo este tema. Por eso, nos damos por satisfechos al concluir con esta última entrega que contiene material más que suficiente para crear nuestras propias aplicaciones de manera sencilla y sobre todo práctica. Sin embargo, si queréis ampliar información, hay varias páginas web que se pueden visitar para conocer más sobre cómo realizar un compilador. Recomendamos la de la asignatura «Procesadores de Lenguaje II» de 4º de Ingeniería Informática de la Facultad de Informática y Estadística de la Universidad de Sevilla, impartida por el departamento de Lenguajes y Sistemas Informáticos, en www.lsi.us.es/~corchu/doc/teaching/pl2.html. Ahí podréis curiosear las prácticas del curso, así como bajaros algunas de las herramientas citadas. Otras páginas interesantes son www.compilerconnection.com y www.goof.com/pcg.

Éste tan sólo sirve para indicar que los atributos de los símbolos podrán ser bien valor de tipo int (ideal para asociarlo a un número entero), o bien nombre de tipo char*. Podremos especificar cualquier tipo válido en C e incluso los incluidos en librerías propias. Para que el analizador léxico y el sintáctico sean conscientes de la existencia del tipo YY_parse_STYPE, incluiremos el fichero «yystype.h» en las secciones %header del analizador léxico y sintáctico. Además, en la zona de definiciones del fuente Bison será necesario añadir la siguiente línea: %define STYPE YY_parse_STYPE (Código 10). Bison interpretará que el YY_parse_STYPE definido contiene los posibles tipos de los atributos de los símbolos.

Una vez hecho esto, debemos especificar qué símbolos tienen atributos y de qué tipo son (de entre los definidos en YY_parse_STYPE). Bison sólo permite definir un atributo por símbolo, apoyándose para ello en los nombres de los atributos definidos en la instrucción unión (en nuestro primer ejemplo valor y texto). Existen dos formas de establecer los atributos de los símbolos, dependiendo de si éstos son terminales o no. Para los primeros utilizaremos la instrucción %token asignando a una serie de símbolos un determinado atributo. Así, por ejemplo, en el Código 11 indicamos que el terminal NÚMERO tiene un atributo valor de tipo int, mientras el símbolo IDENT tiene un atributo nombre de tipo char*. Para los símbolos no terminales utilizaremos la instrucción %type. De esta forma, la línea %type expresión indica que el símbolo no terminal expresión tiene un atributo valor tipo int.

*yylval. Con ella conseguimos que yylex tenga un parámetro nuevo denominado yylval del tipo YY_parse_STYPE *. El nuevo prototipo de la función será ahora yylex(YY_parse_STYPE * ). Desde este momento, ya podemos devolver atributos asociados a los tokens. Para ello, justo antes de hacerlo (acción return asociada a una expresión regular) debemos asignarle al nuevo parámetro (yylval) el valor que queremos asociar (al token). Así, para calcular adecuadamente los atributos valor y texto de los símbolos terminales NÚMERO e IDENT, respectivamente, tendríamos que hacer algo similar a lo efectuado en el Código 12.

I Cálculo de atributos Para calcular los atributos de los que hemos hablado debemos distinguir si se trata de símbolos terminales o no. En el caso de los primeros, el cálculo debe hacerse mediante Flex, el análisis léxico. Para ello, necesitamos una vía de comunicación, que se consigue definiendo un parámetro extra para la función yylex(). Recordemos que ésta era la función principal creada por Flex y a la que llamábamos desde nuestro main(). Esta definición se hace con la siguiente instrucción situada en la zona reservada a tal efecto en el código fuente interpretado por Flex: %define LEX_PARAM YY_parse_STYPE

Tan sólo una observación importante: cuando queramos devolver la cadena de caracteres yytext, hemos de asegurarnos antes de duplicarla ya que, si devolviésemos directamente yytext, el atributo de IDENT iría variando a medida que se modificase la cadena yytext (lo que ocurre con el reconocimiento de nuevos tokens). Por tanto, ahora hemos conseguido que, al reconocer cada uno de los tokens anteriores, no perdamos información importante como el lexema o el valor del número que hemos reconocido.

PC PRÁCTICO

Compiladores (y II)

La especificación del cálculo de los atributos de los símbolos no terminales se vincula a las producciones de la gramática. Antes que nada, necesitamos aclarar la notación para nombrar los atributos de los símbolos de una producción. Ésta se ajusta a dos normas: el atributo del símbolo de la parte izquierda de una regla se nombra con $$, mientras que el del símbolo n-ésimo de la parte derecha se nombra con $n. Con esta notación, ya podemos especificar las acciones a llevar a cabo para calcular los atributos (denominadas acciones semánticas por los teóricos). Éstas se colocarán entre llaves ({...}) al final de cada producción gramatical y se escriben en lenguaje C. Veamos un pequeño ejemplo (Código 13) donde calcularemos los atributos de las expresiones. Con el fin de guardar los valores de las distintas variables y recuperarlas posteriormente, hemos usado una pequeña biblioteca de almacenamiento. Así, en la línea 40 del Código 15 utilizamos CambiaValor($1,$3);. Ésta se encarga de almacenar en una variable (cuyo nombre vendrá dado por el valor del atributo char * del símbolo IDENT) el valor del atributo int del símbolo expresión. I Conclusiones Ya hemos conseguido construir nuestro primer pequeño compilador. Lo más destacable es la sencillez y longitud del código que hemos necesitado escribir para llegar a nuestro objetivo gracias a las dos herramientas utilizadas. Pensemos por un momento la cantidad de líneas de código C que hubieran sido necesarias para llegar al mismo resultado sin la ayuda de Flex y Bison . Además, el mantenimiento de este código permite incluir nuevas instrucciones a nuestro lenguaje mucho más fácilmente que si hubiéramos escrito código C directamente. Aun con esto, escribir un compilador para un lenguaje algo más complejo no es tan directo como los ejemplos aquí mostrados. Se requiere el uso de técnicas más sofisticadas, como por ejemplo el cálculo de los atributos de forma que sea posible construir una representación en forma de árbol del código de entrada. Algo que nunca dijimos que fuera sencillo. Eduardo Villalobos Fernández (eduardo@imaginatica.us.es)

En primer lugar, éste debe comprender la notación anteriormente citada. Así, en la línea 5 (la producción expresión : NÚMERO ) el atributo de la izquierda de la regla es $$ y el de NÚMERO $1. Este último es un símbolo terminal, al que Flex habrá calculado un atributo llamado valor de tipo int para él (líneas 19 y 20 del Código 12). En esta línea 5 estamos, en definitiva, reconociendo, por ejemplo, una expresión del tipo x = 5;. Por tanto, para calcular el atributo de expresión (símbolo no terminal cuyo atributo también se llama valor) simplemente le asignamos el contenido del atributo valor de NÚMERO, como podemos observar en la línea 6. Veamos ahora las líneas 1 y 2. En ellas estamos reconociendo expresiones del tipo x = 3 + 7; o y = x + 4;. Recordemos que a la parte izquierda de la producción se la nombra como $$. A la primera expresión se la nombra como $1 y a la segunda como $3 al ocupar el símbolo + el lugar de $2. El tratamiento, como podremos observar, es extremadamente simple: sólo necesitamos asignarle a $$ la suma de las dos expresiones. I Lenguaje calculadora Ya estamos listos para escribir el código de nuestro lenguaje calculadora no sólo para que lea nuestro fichero de entrada realizando el análisis léxico y sintáctico, sino para que también vaya realizando los cálculos oportunos (Código 14 para el analizador léxico, 15 para el sintáctico y 16 para el programa principal con la función main).