Introducción al análisis sintáctico con ANTLR En esta práctica trabajaremos con ANTLR a nivel sintáctico. En las prácticas anteriores ya hemos visto los elementos básicos de la notación. En ésta, nos centraremos fundamentalmente en la manera en la que se comunican analizadores léxicos y sintácticos, la forma en la que deben escribirse las gramáticas y la resolución de cierto tipo de ambigüedades sintácticas. Un ejemplo simple
He aquí un analizador sintáctico simple, es capaz de reconocer expresiones aritméticas con números enteros y reales, se incluye además el analizador léxico en el mismo fuente: /////////////////////////////// // Analizador léxico /////////////////////////////// class Analex extends Lexer; BLANCO : (' '|'\t'|"\r\n") {$setType(Token.SKIP);}; protected DIGITO : '0'..'9'; NUMERO : (DIGITO)+('.'(DIGITO)+)?; OPERADOR : '+'|'-'|'/'|'*'; PARENTESIS : '('|')' ; SEPARADOR : ';' ; /////////////////////////////// // Analizador sintáctico /////////////////////////////// class Anasint extends Parser; instrucciones : (expresion ";")* ; expresion : exp_mult (("+"|"-") exp_mult)* ; exp_mult : exp_base (("*"|"/") exp_base)* ; exp_base : NUMERO | "(" expresion ")" ;
El analizador sintáctico generado se implementará a través de la clase Anasint (tal y como hemos indicado en el fuente) que será una subclase de LLkParser que es la que implementa los aspectos genéricos de los reconocedores sintácticos. La clase Anasint ofrece un método por cada símbolo terminal de la gramática, dichos métodos serán los encargados de reconocer el lenguaje asociado a los símbolos correspondientes. Probando el analizador sintáctico
Para poder probar el analizador sintáctico utilizaremos una clase principal que cree los analizadores léxico y sintáctico, y que llame al método encargado de reconocer el símbolo principal de la gramática (anasint.instrucciones()):
///////////////////////////////////// // Procesador.java (clase principal) ///////////////////////////////////// import java.io.*; import antlr.*; public class Procesador { public static void main(String args[]) { try { FileInputStream fis =new FileInputStream(args[0]); Analex analex = new Analex(fis); Anasint anasint = new Anasint(analex); anasint.instrucciones(); }catch(ANTLRException ae) { System.err.println(ae.getMessage()); }catch(FileNotFoundException fnfe) { System.err.println("No se encontró el fichero"); } } }
Análisis léxico y sintáctico en fuentes separados. Exportación de vocabularios
Una de las ventajas de ANTLR es que permite especificar todos los analizadores en un único fuente o en fuentes separados. Esto permite que adaptemos nuestra especificación al tamaño del problema. Si el lenguaje es muy simple podremos incluir todos los analizadores en un fuente y no tendremos que preocuparnos de comunicarlos. Si por el contrario el lenguaje es complicado podremos especificar los analizadores por separados y comunicarlos a través de una interfaz. El hecho de generar una implementación orientada a objetos hace que las interfaces sean muy simples y claras. El analizador sintáctico invocará al método nextToken del analizador léxico cada vez que requiera un token y recibirá como respuesta un objeto de la clase Token (de la subclase CommonToken o de otra que le indiquemos tal y como vimos en la práctica dedicada al análisis léxico). La información más importante de un objeto de la clase Token viene dada por su atributo type. Este atributo sirve para identificar el tipo de token , y es de tipo entero para optimizar las múltiples comparaciones que ha de hacer el analizador sintáctico al procesar los tokens recibidos desde el analizador léxico. Se denomina vocabulario al conjunto de tokens , junto con su codificación, presentes en un fichero de gramática. ANTLR codifica los vocabularios a través de dos ficheros: una interfaz java y un fichero de texto. La interfaz java contiene un atributo entero para cada token inicializado con el valor que a la postre servirá de codificación del tipo de token. Esta interfaz es el elemento clave en la coordinación de dos analizadores ya que si ambos la implementan compartirán la información codificada en ella. La interfaz para el analizador sintáctico del ejemplo anterior sería:
// $ANTLR 2.7.2: "Anasint.g" -> "Anasint.java"$ public interface AnasintTokenTypes { int EOF = 1; int NULL_TREE_LOOKAHEAD = 3; // ";" = 4 // "+" = 5 // "-" = 6 // "*" = 7 // "/" = 8 int NUMERO = 9; // "(" = 10 // ")" = 11 }
Tal y como se indica en la primera línea del fichero esta interfaz se ha obtenido a partir de la compilación del fichero Anasint.g (que contiene sólo la especificación del analizador sintáctico). El nombre de la interfaz se obtiene añadiendo el prefijo TokenTypes al nombre del analizador. El otro fichero utilizado en la codificación de vocabulario tiene el mismo nombre que el de la interfaz pero su extensión es ".txt". En él además del token y del número que lo representa se incluye también una cadena de caracteres con el nombre del token (siempre que lo tenga). Esta última información se utiliza en el informe de errores cuando además del tipo de token se necesita conocer también su nombre. En nuestro caso el fichero AnasintTokenTypes.txt tendría el siguiente contenido: // $ANTLR 2.7.2: Anasint.g -> AnasintTokenTypes.txt$ Anasint // output token vocab name ";"=4 "+"=5 "-"=6 "*"=7 "/"=8 NUMERO=9 "("=10 ")"=11
Si especificamos los analizadores léxico y sintáctico en un mismo fuente ANTLR se encarga de generar un vocabulario común para los dos. Sin embargo, si los analizadores están en fuentes distintos tenemos que preocuparnos de que ambos trabajen con el mismo vocabulario. Esto se consigue con la opción importVocab que permite añadir tokens de otro vocabulario a los definidos en un analizador. Dado que los tokens que realmente nos interesan son los especificados en el analizador sintáctico importaremos el vocabulario desde el análisis sintáctico hacia el léxico:
/////////////////////////////// // Analex.g: Analizador léxico /////////////////////////////// class Analex extends Lexer; options { importVocab = Anasint; } // Reglas
De esta forma el vocabulario común a los dos analizadores será el del sintáctico, contenido en la interfaz AnasintTokenTypes.java. ¿Cómo escribir buenas gramáticas?
Los tres esquemas de reglas más usuales en la descripción de lenguajes, son el esquema lista, el esquema agregado y el esquema elección. La descripción de estos esquemas es la siguiente: Esquema lista: se aplica cuando la entrada que se quiere reconocer consta de una secuencia de elementos de una misma categoría. Por ejemplo una lista de números, una lista de instrucciones, una lista de identificadores. En ANTLR se dispone de los operadores + y * para especificar listas de uno o más elementos y listas de cero o más elementos. Un ejemplo de lista sería:
lista : (elemento)*;
Esquema agregado: se aplica cuando la entrada que se quiere reconocer consta siempre de un número fijo de elementos. Por ejemplo una instrucción de asignación o un programa compuesto por tres secciones. Las reglas que definen este tipo de construcciones sintácticas son del tipo: agregado : componente1 componente2 componente3;
con tantos componentes como sean necesarios.
Esquema elección: se aplica para agrupar distintas opciones en la definición de la estructura sintáctica de una determinada entrada. Por ejemplo los distintos tipos de instrucciones en un lenguaje de programación o los distintos tipos de datos en la declaración de una variable. Las reglas que definen una elección son del tipo: eleccion : opcion1 | opcion2 | opcion3 ;
El proceso de escritura de una gramática consiste en identificar a qué tipo de esquema obedece un determinado elemento del lenguaje y especificarlo con las reglas correspondientes. Lo aconsejable es aplicar estos esquemas paso a paso, lo que dará lugar a una gramática más clara y legible. En este sentido hay que ser cuidadoso con las facilidades
(propias de la notación EBNF) que ANTLR nos proporciona para escribir gramáticas compactas ya que su abuso puede dar lugar a gramáticas excesivamente crípticas. Por ejemplo: entrada : ((IDENT ("," IDENT)* "=")? NUMERO ("," NUMERO)* ";");
La anterior genera el mismo lenguaje que la siguente, que es mucho más clara y fácil de escribir y comprender: entrada : (asignacion)*; asignacion : (idents)? numeros ";"; idents : IDENT ("," IDENT)* "="; numeros : NUMERO ("," NUMERO)*;
Predicados sintácticos
Para los problemas que pueden aparecer cuando una gramática no cumple la condición LL(k) las soluciones serán similares que se aplicaron en la especificación del analizador léxico cuando había tokens con prefijos comunes:
Ampliar el número de símbolos de anticipación con la opción k, ó utilizar predicados sintácticos para resolver localmente las reglas que provocan conflictos.
Tomemos como ejemplo el típico problema no-LL(1) que supone determinar si el primer identificador de una instrucción se corresponde con un nombre de función (en una llamada) o un nombre de variable (en una asignación). La gramática de partida sería: instruccion : asignacion | llamada ... ; asignacion : IDENT ":=" expr ";"; llamada : IDENT "(" expr ")" ";";
La solución pasa por aprovechar el hecho de que los dos primeros símbolos de una instrucción son siempre IDENT ":=", y se expresaría de la siguiente forma con un predicado sintáctico asociado a la regla conflictiva: instruccion : (IDENT ":=") => asignacion | llamada ... ;
ANTLR utiliza el predicado para realizar una simulación del análisis previa a la toma de la decisión. De esta forma el analizador predictivo puede contar, siempre que así lo indiquemos, con más información para determinar la regla que tiene que aplicar. Precedencia y asociatividad
Uno de los pocos lenguajes para los que no es fácil escribir una gramática aplicando la metodología vista en el apartado anterior es el de las expresiones (aritméticas, lógicas, regulares, etc.). Esto se debe fundamentalmente a la posibilidad de utilizar operadores infijos (es decir, que se mezclan entre los operandos) que además pueden estar encuadrados en distintos niveles de prioridad. Si escribimos una gramática para un lenguaje de expresiones y no tenemos en cuenta este tipo de cuestiones seguramente obtendremos como resultado una gramática ambigua. Para las expresiones con sumas y multiplicaciones la siguiente gramática adolece de este problema: expresion : expresion "+" expresion | expresion "*" expresion | NUMERO ;
Por ejemplo la expresión 2+2*2 podría ser analizada como (2+2)*2 ó como 2+(2*2). Veamos cómo obtener a partir de esta gramática otra que resuelva esta ambigüedad y además pueda ser procesada sin problemas por un reconocedor descendente. Para empezar hay que escribir la gramática de forma que no aparezca ninguna recursión por la izquierda. Para ello basta con pensar que una expresión no es más que una lista de números en la que se utiliza como separadores + ó *, visto así una posible solución sería: expresion : NUMERO (("+"|"*") NUMERO)*;
Esta gramática ya resuelve la cuestión de la recursión por la izquierda, aunque todavía no es una solución definitiva ya que no tiene en cuenta para nada la prioridad de los operadores. No es que sea ambigua, en realidad lo que ocurre es que utiliza un mecanismo demasiado simple para determinar en qué orden han de aplicarse las operaciones: aplica primero las que están más a la izquierda. Si se construye el árbol de derivación para la entrada 2+2*2+2*2, se observará que la aplicación de las operaciones propuesta por la gramática es ((((2+2)*2)+2)*), totalmente distinta a la que nos interesaría que es ((2+(2*2))+(2*2)). De todas formas la idea no es del todo mala ya que resuelve bien las situaciones en las que hay que aplicar varias veces el mismo operador, como 2-2-2 que debe ser interpretada de izquierda a derecha, o sea ((2-2)-2) en lugar de (2-(2-2)). En realidad lo que le falta a la gramática anterior es estratificar la aplicación de operaciones, de manera que se obligue a aplicar unas antes que otras. Así, si consideramos que una expresión es una lista de sumandos, donde cada sumando es el resultado de multiplicar una lista de números la gramática qued aría como sigue: expresion: sumando ("+" sumando)*; sumando: NUMERO ("*" NUMERO)*;
Esta gramática sí puede ser considerada ya una solución ya que no es ambigua, aplica antes las multiplicaciones y después las sumas, y además en caso de encontrar varios operadores iguales los asocia de izquierda a derecha. Podemos aprovechar los dos niveles establecidos por las reglas expresion y sumando para incluir operadores que tengan una prioridad similar (la división igual que la multiplicación y la resta igual que la suma): expresion: sumando (("+"|"-") sumando)*; sumando: NUMERO (("*"|"/") NUMERO)*;
Si queremos incluir paréntesis tan sólo tenemos que establecer un nivel nuevo para que no coincida con la prioridad de los niveles anteriores. Cambiaremos también los nombres de los símbolos porque a medida que aumentamos el número de niveles es más difícil encontrar nombres significativos: expr : expr_mult (("+"|"-") expr_mult)*; expr_mult : expr_base (("*"|"/") expr_base)*; expr_base : NUMERO | "(" expr ")" ;
Ejercicios 1. Compilar y probar el analizador léxico-sintáctico que aparece en el enunciado. 2. Especificar los analizadores léxico y sintáctico del e jercicio anterior en fuentes separados. Compilarlos y probar el funcionamiento. 3. Ampliar el ejercicio anterior para que se admitan identificadores, asignaciones, el operador de cambio de signo, los operadores lógicos &&, || y !, y los operadores relacionales >,<,>=,<=,== y !=. 4. Escribir un analizador sintáctico para los elementos d el lenguaje C presentes en el siguiente ejemplo, las palabras reservadas están resaltadas en negrita:
(a
=b) b=2; a=a+1; }
while
Aplicar el reconocedor a la siguiente entrada errónea: (a=b) b=2; a=a+1;
while
} {
¿Se informa del error? Para solucionar este problema hay que emplear de manera explícita el token EOF en la gramática. 5. Extender el ejercicio anterior con los elementos presentes en el siguiente ejemplo: main(void ) { int a, b; scanf("%d",&b); a=1; while (a=b) { printf("punto medio %d\n",a); break; } a++; }
void
}
6. Ampliar el ejercicio anterior para que el reconocedor procese también los tipos char y float, los literales carácter y real (por ejemplo 'a' y 1.09) en las expresiones, la declaración y llamada de funciones, los operadores del apartado 3 y el condicional aritmético (por ejemplo a>3?2:1 ). 7. Escribir un analizador sintáctico para los elementos de XML presentes en el siguiente ejemplo: La isla del tesoro Robert L. Stevenson Juventud Yo que he servido al Rey de Inglaterra Bohumil Hrabal Destino