Una introducción al generador JavaCC José Gabriel Pérez Díez Departamento L.P.S.I. Escuela Universitaria de Informática Universidad Politécnica de Madrid Enero 2010 1 Contenido Principios básicos del generador JavaCC 2 Descripción inicial 2 Instalación y documentación 2 Obtención de un analizador léxico-sintáctico 3 Ejemplo de presentación 4 Analizadores generados 5 Forma de una especificación JavaCC 6 Sección de opciones 6 Sección de ejecución 7 Sección de sintaxis 8 Sección de lexicografía Tareas asociadas a la estructura sintáctica 11 16 Bloque para un símbolo 16 Acciones sintácticas 16 Valor comunicado por un método 18 Comunicación entre los analizadores léxico y sintáctico 22 Tareas asociadas a las piezas sintácticas 25 Acción ligada a una pieza sintáctica 25 Bloque de declaraciones lexicográficas 27 Declaraciones lexicográficas predefinidas 27 Acción lexicográfica común 30 A modo de recapitulación 34 Precisiones sobre el analizador sintáctico generado 36 2 □ Principios básicos del generador JavaCC Descripción inicial El generador JavaCC (Java Compiler Compiler) es una herramienta para generar analizadores de lenguajes; acepta como entrada una especificación de un determinado lenguaje y produce como salida un analizador para ese lenguaje; el analizador generado está escrito en Java. La especificación proporcionada al generador JavaCC puede contemplar distintos aspectos del lenguaje para el que se quiere obtener el analizador: - Características lexicográficas y sintácticas es la forma más frecuente de uso del generador; la especificación proporcionada define las características sintácticas y lexicográficas de un lenguaje y se genera un analizador léxico-sintáctico del lenguaje especificado. - Características lexicográficas en la especificación proporcionada al generador sólo se definen características lexicográficas del lenguaje; con el código generado se puede obtener un analizador lexicográfico. - Características lexicográficas y sintácticas y comprobaciones semánticas también es posible completar una especificación léxico-sintáctica con la inclusión de código Java complementario para que el programa generado (que incorpora adecuadamente ese código auxiliar) pueda hacer un análisis completo (léxico, sintáctico y semántico) del lenguaje especificado. ◊ Instalación y documentación Dado que el código generado por JavaCC está escrito en Java, es necesario disponer de una versión del sistema Java (compilador de Java e intérprete de la Máquina Virtual Java). Son programas de libre distribución y fáciles de conseguir. El generador JavaCC también es un programa de libre distribución; se puede conseguir en: ◊ ▫ la página oficial de JavaCC: https://javacc.dev.java.net la página de la asignatura (se tiene la versión Java Compiler Compiler, version 5.0) Se consigue un fichero empaquetado de nombre javacc-5.0.zip; tras desempaquetar (en un determinado directorio, que puede elegirse como convenga) dicho fichero, se tienen instalados, entre otros, los siguientes ficheros (que son los que interesan para el sistema operativo windows): ···· \javacc-5.0\bin\javacc.bat ···· \javacc-5.0\bin\jjdoc.bat ···· \javacc-5.0\bin\jjtree.bat ▫ los nombre de estos ficheros se corresponden con los nombres de los comandos para llamar a las herramientas instaladas javacc: generador de analizadores jjdoc: productor de documentación jjtree: preprocesador de apoyo para tareas semánticas Para que la llamada a estos comandos pueda realizarse desde cualquier directorio, ha de anotarse el camino (PATH) que lleve hasta ···· \javacc-5.0\bin. También ha de tenerse en cuenta si la instalación del sistema Java se tiene preparada para que el compilador (javac) y el intérprete (java) se puedan ejecutar desde cualquier directorio. Para comprobar si la instalación del generador se ha realizado adecuadamente, se puede llamar desde la línea de comandos al generador javacc, y aparecerá por pantalla una información sobre el uso de dicho comando; la primera línea de esa información es: Java Compiler Compiler Version 5.0 (Parser Generator) 3 Documentación sobre JavaCC Se dispone de abundante documentación relativa a JavaCC; entre otras, se pueden encontrar: ▫ documentación que acompaña a los ficheros de la versión instalada, se tienen diversos ficheros en ···· \javacc-5.0\doc\*.html (en javaccgrm.html se tiene una descripción general del generador) ▫ documentación variada en la red JavaCC Documentation The JavaCC Tutorial Introduction to JavaCC The JavaCC FAQ etc Primera prueba con JavaCC En lo que sigue se expone un “ejemplo de presentación” completo: la generación de un analizador léxicosintáctico para un tipo de expresiones muy sencillo. Se puede empezar el estudio de JavaCC probando su funcionamiento con ese ejemplo. ◊ • Obtención de un analizador léxico-sintáctico Pasos para la generación del analizador 1.- Edición de la especificación (editor de texto plano) vi | edit |∙ ∙ ∙ NombreFichero.jj (el nombre del fichero puede tener cualquier extensión; suele usarse .jj) 2.- Ejecución del generador javacc NombreFichero.jj Si el nombre elegido para la especificación es NombreDeLaEspecif (más adelante se indica la manera de dar un nombre a la especificación), como resultado de la generación se obtiene (además de otros ficheros auxiliares) el fichero NombreDeLaEspecif.java 3.- Compilación del analizador generado javac NombreDeLaEspecif.java Como resultado de la compilación se obtiene (además de otras clases auxiliares) el fichero NombreDeLaEspecif.class • Ejecución del analizador generado Si el nombre del fichero donde se encuentra el texto fuente (escrito en el lenguaje para el que se ha generado el analizador) que se pretende analizar es Programa.len java NombreDeLaEspecif < Programa.len Si se desea que los resultados del análisis, en vez de presentarse por pantalla, queden grabados en un fichero de nombre Salida.dat java NombreDeLaEspecif < Programa.len > Salida.dat 4 ◊ • Ejemplo de presentación Descripción del lenguaje El lenguaje L está formado por las expresiones en las que pueden aparecer: - variables - constantes - operadores + y * Las variables son nombres formados por una única letra (minúscula o mayúscula); las constantes son números enteros de una o más cifras. El espacio y el tabulador pueden estar presentes, pero no tienen ningún significado; los finales de línea tampoco son significativos (una expresión puede codificarse ocupando una o más líneas). La sintaxis de las expresiones se especifica mediante la siguiente gramática: <Expresion> ::= <Termino> { + <Termino> <Termino> ::= <Factor> { * <Factor> <Factor> ::= variable | constante | ( <Expresion> ) • } } Especificación léxico-sintáctica codificada con la notación JavaCC Una manera de escribir la especificación (para la que se ha elegido el nombre ExprMin) de forma que sea aceptada por el generador es: options { Ignore_Case = true; } PARSER_BEGIN (ExprMin) public class ExprMin { public static void main (String[] argum) throws ParseException { ExprMin anLexSint = new ExprMin (System.in); anLexSint.unaExpresion(); System.out.println("Análisis terminado:"); System.out.println ("no se han hallado errores léxico-sintácticos"); } } PARSER_END (ExprMin) void unaExpresion() : { } { expresion() <EOF> } void expresion() : { } { termino() ( "+" } void termino() : { } { factor() ( "*" termino() )* factor() )* 5 } void factor() : { } { <constante> | <variable> | "(" expresion() ")" } TOKEN: { < variable : ["a"-"z"] < constante : > } TOKEN: { ( ["0"-"9"] ) + > } SKIP: { " " | "\t" | "\n" | "\r" } • Obtención del analizador Si la especificación precedente se tiene grabada en un fichero de nombre Ejemplo.jj, para obtener el analizador: - se ejecuta el generador: javacc Ejemplo.jj - se compila el analizador generado: javac ExprMin.java • Ejecución del analizador Si se quiere analizar una expresión grabada en un fichero de nombre PruebaExp.txt: - se ejecuta el analizador obtenido: java ExprMin < PruebaExp.txt Analizadores generados En su funcionamiento más sencillo y habitual, JavaCC genera un analizador sintáctico, complementado con un analizador lexicográfico, para que, conjuntamente, se pueda realizar un análisis léxico-sintáctico de un texto de entrada. ◊ El analizador sintáctico obtenido es, en general, LL(k): descendente y determinista con la consulta de k símbolos por adelantado; si la gramática proporcionada cumple la condición LL(1), se genera un analizador sintáctico descendente-predictivo-recursivo. Más adelante se hacen algunas precisiones sobre esta afirmación. Si la especificación léxico-sintáctica de un lenguaje codificada en JavaCC tiene dado (como indicativo que acompaña a las palabras reservadas PARSER_BEGIN y PARSER_END) el nombre EspLexSin y se tiene grabada en un fichero de nombre Lenguaje.jj, cuando se ejecuta el generador tomando como entrada ese fichero javacc Lenguaje.jj se obtienen los siguientes ficheros (clases) con código Java: ▫ Token.java descripciones para la comunicación entre los analizadores léxico y sintáctico 6 ▫ TokenMgrError.java tratamiento de errores para el análisis lexicográfico ▫ ParseException.java tratamiento de errores para el análisis sintáctico ▫ SimpleCharStream.java componentes para la realización de las tareas de entrada/salida del analizador ▫ EspLexSinConstants.java definición de la representación interna de las piezas sintácticas ▫ EspLexSinTokenManager.java analizador lexicográfico ▫ EspLexSin.java analizador sintáctico Puede apreciarse que hay dos categorías de nombres de ficheros generados: los cuatro primeros nombres citados no dependen del nombre de la especificación considerada, los otros nombres de ficheros se forman a partir del nombre dado a la especificación. □ Forma de una especificación JavaCC (versión simplificada) La forma que se describe en lo que sigue es una versión simplificada; el generador JavaCC admite especificaciones con otras muchas posibilidades no mencionadas en esta introducción. Una especificación para el generador JavaCC puede considerarse dividida en cuatro secciones: Sección de opciones Sección de ejecución Sección de sintaxis Sección de lexicografía Sección de opciones En esta sección, cuya presencia es optativa, se pueden asignar valores a diversos parámetros (llamados opciones) que sirven para configurar ciertas características del funcionamiento del generador o del analizador generado. Cada parámetro (opción) tiene un valor por defecto, que es el que toma cuando no se le asigna explícitamente un valor. Los valores de las opciones también se pueden fijar en la línea de comandos ◊ 7 cuando se ejecuta el generador (lo indicado en la línea de comandos tiene prioridad sobre lo especificado en esta sección de opciones). Algunas de las opciones para las que se puede fijar un valor son: Ignore_Case (valor por defecto: false) indica si en el texto analizado ha de distinguirse o no entre letras minúsculas y mayúsculas Build_Parser (valor por defecto: true) indica si se genera el analizador sintáctico o no Build_Token_Manager (valor por defecto: true) indica si se genera el analizador lexicográfico o no Sanity_Check (valor por defecto: true) indica si se realizan comprobaciones sobre la gramática sintáctica Debug_Parser (valor por defecto: false) indica si se genera una traza para el análisis léxico-sintáctico Error_Reporting (valor por defecto: true) indica si los mensajes de error emitidos son más o menos explicativos Static (valor por defecto: true) con el valor por defecto, los métodos de los analizadores léxico y sintáctico se generan con el descriptor estático (static) Los nombres de las opciones pueden escribirse con letras minúsculas o mayúsculas; los valores de las opciones son, en la mayoría de los casos, un valor entero o un valor lógico. Si se incluye la sección, se empieza poniendo la palabra reservada options; en este caso, al menos ha de ponerse una opción. La forma de esta sección es: options { nombreOpcion1 = valorOpcion1; nombreOpcion2 = valorOpcion2; ∙ ∙ ∙ ∙ nombreOpcionk = valorOpcionk; } Sección de ejecución En esta sección se pone el código Java que contiene la llamada al analizador generado para que se realice el análisis de un determinado texto de entrada. También se establece aquí el nombre de la especificación, que es el nombre que se toma para formar los nombres de parte de los ficheros (clases) generados. ◊ La sección está delimitada por dos palabras reservadas, ambas acompañadas por un mismo nombre (puesto entre paréntesis); ese nombre es el que se da a la especificación. Entre esas dos palabras ha de ponerse una clase directora para el proceso de análisis; el nombre de esa clase directora ha de coincidir con el nombre dado a la especificación. PARSER_BEGIN (NombreDeLaEspecif) 8 ∙ ∙ ∙ ∙ public class NombreDeLaEspecif { ∙ ∙ ∙ ∙ } ∙ ∙ ∙ ∙ PARSER_END (NombreDeLaEspecif) Una posible versión sencilla de la clase directora es: public class NombreDeLaEspecif { public static void main (String[] argum) throws ParseException { NombreDeLaEspecif anLeSi = new NombreDeLaEspecif (System.in); anLeSi.simboloPrograma(); } } ▫ se crea el objeto anLeSi para poder aplicar los métodos de análisis, ▫ se asocia la entrada standard ( System.in ) a la entrada para el analizador generado, ▫ se aplica al objeto creado el método de análisis correspondiente al símbolo inicial de la gramática. Sección de sintaxis En esta sección se describe la sintaxis del lenguaje para el que se desea generar el analizador, usándose para ello una notación parecida a la BNF. En lo que sigue se expone la forma de las producciones tal y como se escriben en JavaCC, poniéndolas en comparación con las producciones de la notación BNF-Ampliada. ◊ • Reglas sintácticas El conjunto de reglas que definen un símbolo no terminal <NombreSimb> ::= | ∙ | α1 α2 ∙ αn se codifica de la siguiente manera: void nombreSimb () : { } { α1-jcc | α2-jcc ∙ ∙ ∙ | αn-jcc } ∙ 9 - el nombre del símbolo no terminal (parte izquierda de las reglas) se escribe como el encabezamiento de un método en Java, sin argumentos y sin valor de devolución (método de tipo void); se sigue, como en Java, la costumbre de escribir los nombres de los métodos empezando con letra minúscula, - el símbolo dos puntos representa la separación entre las partes izquierda y derechas de las reglas (en vez del símbolo ::= de la notación BNF), - el símbolo barra vertical representa la separación entre distintas partes derechas con la misma parte izquierda (igual que en la notación BNF), - después del símbolo dos puntos se pone un bloque de código Java; este código se suele dedicar a la realización de tareas semánticas; si se pretende hacer sólo un análisis sintáctico, el bloque quedará vacío (aunque siempre es obligada su presencia), - el conjunto de las alternativas (partes derechas de las reglas) está delimitado mediante llaves, como si constituyese un bloque de código Java, - αi-jcc representa la codificación de una alternativa (parte derecha), empleándose una notación que se describe a continuación. • Símbolos no terminales en las partes derechas En la notación BNF se pone <NombreSimb>; en la notación JavaCC se escribe nombreSimb() esto es, se pone como si fuese una llamada en Java a un método sin argumentos. • Símbolos terminales en las partes derechas Se considera una clasificación en las piezas sintácticas (símbolos terminales de una gramática sintáctica), distinguiéndose entre piezas sintácticas nominales y anónimas: ▫ son nominales las piezas sintácticas a las que se asocia un nombre en otra parte de la especificación (más adelante se describe la manera de indicar esta asociación); ese nombre asociado a la pieza es el que se usa al escribir las partes derechas de las producciones. En la notación JavaCC un símbolo terminal nominal se escribe poniendo el nombre delimitado por los caracteres < y > <nombreTerminal> ▫ son anónimas las piezas sintácticas que no tienen nombre asociado; no se precisa porque la pieza se representa por su propia secuencia de caracteres (lexema). En la notación JavaCC, un símbolo terminal anónimo se escribe poniendo su lexema delimitado por comillas; por ejemplo ";" • ":=" "<" "<=" "END" Metasímbolos de opcionalidad y de repetición En la notación BNF se emplean los corchetes para indicar opcionalidad; en la notación JavaCC se utilizan también esos mismos símbolos [ α ] en ambas notaciones En la notación BNF se emplean las llaves para indicar una repetición de cero o más veces; en la notación JavaCC se utilizan los paréntesis y un asterisco detrás del paréntesis de cerrar • { α } en notación BNF-Ampliada ( α )* en notación JavaCC Otros metasímbolos de la notación JavaCC 10 La opcionalidad también se puede indicar empleando el metasímbolo de interrogación ( α )? Para indicar repetición de una o más veces se utiliza el metasímbolo operador aditivo suma ( α )+ • Control del final de fichero de entrada Para controlar el final del fichero en el texto analizado, ha de añadirse una regla sintáctica especial que tenga (según la notación BNF) la forma <ProgramaCompleto> ::= <Programa> FF donde <ProgramaCompleto> es un nuevo símbolo (no terminal) auxiliar añadido <Programa> es el símbolo inicial de la gramática sintáctica FF es una representación del final del fichero En JavaCC el final de fichero se escribe con la notación <EOF>, siendo EOF una palabra reservada; la regla sintáctica especial añadida a la especificación quedaría codificada así: void programaCompleto() : { } { programa() <EOF> } • Representación de la producción vacía Las producciones vacías (reglas sintácticas cuya parte derecha es la palabra vacía ε ), no se pueden representar explícitamente en una especificación léxico-sintáctica escrita en JavaCC. Para indicar la presencia de una producción vacía han de aplicarse las posibilidades de la notación BNF-Ampliada; según esta notación, cuando se indica la opcionalidad de un componente, se está especificando implícitamente la palabra vacía (en el caso de que el componente no esté presente). Así pues, si se quisiera escribir en JavaCC la construcción sintáctica expresada por las reglas <Componente> ::= α | ε deberían de considerarse con la notación equivalente <Componente> ::= [ α ] y si hubiera varias alternativas no vacías <Componente> ::= α | β | ε habría que considerarlas como <Componente> ::= [ α | β ] o también podría verse como <Componente> ::= α 11 | [ β ] Como ejemplo, sea la especificación sintáctica de una llamada a una función que puede tener cero o más parámetros separados por comas; aunque no haya parámetros, los paréntesis están presentes. Las siguientes reglas definen esta estructura: <Llamada> ::= id ( <Parametros> ) <Parametros> ::= <Expresion> | { , <Expresion> } ε En JavaCC la definición del símbolo <Parametros> se puede escribir como sigue (nótese que aquí los paréntesis empleados para aplicar el operador * son metasímbolos) void parametros() : { } { [ expresion() } ( "," expresion() )* ] Sección de lexicografía En esta sección se indica la lexicografía del lenguaje para el que se va a generar el analizador; la notación de JavaCC que representa la forma de cada una de las piezas sintácticas es una variante de las bien conocidas expresiones regulares. ◊ • El nombre de las piezas sintácticas nominales Para asociar un nombre a una pieza sintáctica y para, al mismo tiempo, definir la forma de la pieza se emplea la notación TOKEN : { < nombreTerminal : Expresión-Regular > } - TOKEN es una palabra reservada; detrás de ella se pone el separador dos puntos, - nombreTerminal representa el nombre que se quiere asociar a la pieza sintáctica, - Expresión-Regular indica una expresión regular que especifica la forma de la pieza, - el nombre y la expresión se separan mediante el carácter dos puntos y, conjuntamente, se delimitan mediante los caracteres < y >; a su vez, toda la asociación se delimita con llaves. • Expresiones regulares en JavaCC Las expresiones regulares en una especificación JavaCC se escriben de manera parecida a la forma utilizada en otras conocidas notaciones, aunque con ciertas peculiaridades. A continuación se mencionan los aspectos más significativos de las expresiones regulares JavaCC, y algunos ejemplos. Los operadores de las expresiones son: | * + ? unión repetición cero o más veces repetición una o más veces opcionalidad 12 Los paréntesis permiten fijar el orden de aplicación de los operadores. Una peculiaridad de las expresiones regulares JavaCC es que los operadores de repetición y de opcionalidad requieren que el componente al que se aplican esté en todo caso delimitado por paréntesis. Se pueden citar las siguientes características básicas de las expresiones regulares en JavaCC. ▫ Se tiene la posibilidad de definir conjuntos de caracteres poniendo entre corchetes la relación de caracteres separados por comas y cada carácter delimitado por comillas. En un conjunto se puede fijar un rango de caracteres poniendo el primero y el último, separados por el símbolo guión (-). A veces resulta más cómodo indicar los caracteres que no pertenecen al conjunto, para ello se puede definir el complementario de un conjunto poniendo el símbolo ~ delante del corchete inicial. ▫ Para indicar que una secuencia de caracteres colocados consecutivamente constituye por sí misma una expresión regular (o un componente de una expresión), se escribe la secuencia delimitada por comillas. ▫ El símbolo \ se emplea como metacarácter para indicar que el símbolo que está inmediatamente a su derecha se considere como carácter del alfabeto, no como posible metacarácter (operador, delimitador). ▫ En una expresión regular se pueden incluir, como si fueran caracteres, las secuencias de escape, con la misma notación y el mismo significado que en Java o en C. Nótese que en una producción sintáctica la opcionalidad se puede especificar de dos formas: con corchetes o con el símbolo de interrogación; sin embargo, en una expresión regular la opcionalidad sólo se puede especificar mediante el símbolo de interrogación (los corchetes en las expresiones regulares son para definir conjuntos de caracteres). A continuación se muestran ejemplos de expresiones regulares escritas según la notación JavaCC. "END" secuencia de las tres letras mayúsculas que conforman la palabra END "while" secuencia de las cinco letras minúsculas que conforman la palabra while "\n" carácter representativo del fin de línea (secuencia de escape) "\r" carácter representativo del retorno de carro (secuencia de escape) ":" carácter dos puntos ":=" secuencia de dos caracteres (símbolo de asignación en Pascal) ">" carácter representativo del operador de relación mayor ("i" | "I")("f" | "F") palabra “if” escrita con letras minúsculas o mayúsculas (cuatro combinaciones) "if" | "IF" palabra “if” escrita sólo con minúsculas o sólo con mayúsculas (dos combinaciones) 13 "u" | "o" | "i" | "e" | "a" una cualquiera de las vocales minúsculas (pero sólo una de ellas) [ "A", "E", "I", "O", "U" ] una cualquiera de las vocales mayúsculas (pero sólo una de ellas) [ "a" - "z", "ñ" ] una cualquiera de las letras minúsculas, incluyendo la letra ñ [ "a" - "z", "A" - "Z", "0" - "9" ] una cualquiera de las letras o de las cifras decimales ([ "0" - "9" ])+ constante entera (número de una o más cifras) nótese que es incorrecto poner [ 0" - "9" ]+ ( "+" | "-" )?([ "0" - "9" ])+ constante entera, precedida opcionalmente por un signo nótese que la primera cifra ha de seguir inmediatamente al signo (si existe) (["+", "-"])?([ "0" - "9" ])+ representa lo mismo que la expresión anterior [ "a" - "z" ] ([ "a" - "z", "0" - "9" ])* una letra minúscula, seguida de cero o más letras minúsculas o cifras decimales ~["a" - "z", "ñ", "A" - "Z", "Ñ"] cualquier carácter que no sea una letra nótese que la expresión representa un único carácter ~["\n", "\r"] cualquier carácter (un único carácter) excepto el fin de línea y el retorno de carro ~[ ] cualquier carácter del alfabeto (un único carácter) ([ "0" - "9" ])+ ("." ([ "0" - "9" ])+)? constante aritmética, con parte decimal opcional "Los \"modernos\"" secuencia de catorce caracteres Los "modernos" nótese el uso de \ para imponer la condición de carácter del símbolo comillas "\"\\n\"" secuencia de cuatro caracteres "\n" nótese que aquí no se representa la secuencia de escape • Piezas sintácticas internas (“private”) 14 Es posible definir piezas sintácticas auxiliares, llamadas “internas” porque no se comunican al analizador sintáctico, sino que sólo sirven para facilitar la escritura de la forma de las piezas sintácticas que sí se comunican; con el uso de las piezas internas es posible evitar repeticiones de escritura y hacer más legible la especificación de las características lexicográficas. Las piezas internas permiten dar nombre a subexpresiones regulares que, a su vez, pueden emplearse como componentes para escribir expresiones más complejas que incorporan esos nombres. Para indicar que una pieza sintáctica se considere como interna, se pone el símbolo # delante del nombre asociado a la pieza. A continuación se muestra un ejemplo típico de utilización de las piezas internas en una especificación lexicográfica en JavaCC. Se quieren definir tres piezas sintácticas que representan un nombre, una constante entera y una constante decimal; una posible especificación es: TOKEN: { <nombre : ( [ "a" - "z", "A" - "Z", "ñ", "Ñ" ] )+ > } TOKEN: { <cteEntera : ( [ "0" - "9" ] )+ > } TOKEN: { <cteDecimal : ( [ "0" - "9" ] )+ "." ( [ "0" - "9" ] )+ > } Con la inclusión de tres piezas sintácticas internas auxiliares, esta misma especificación podría escribirse de la siguiente manera: TOKEN: { < # letra : [ "a" - "z", "A" - "Z", "ñ", "Ñ" ] > TOKEN: { < # cifra : [ "0" - "9" ] > } TOKEN: { < # numero : ( <cifra> )+ > } TOKEN: { < nombre : ( <letra> )+ > TOKEN: { < cteEntera : <numero> > } } } TOKEN: { < cteDecimal : <numero> "." <numero> > } Los nombres letra, cifra y numero son exclusivos (internos) del analizador lexicográfico y no se pueden emplear como nombres de piezas en la especificación de las reglas sintácticas; cuando esos nombres se usan en la definición de otras piezas, han de escribirse delimitados por los símbolos < y >. • Distinción restringida entre minúsculas y mayúsculas Se tiene la opción Ignore_Case para determinar si en el análisis del texto de entrada se distingue entre las letras minúsculas y las mayúsculas; el valor por defecto false indica que sí se diferencian; si se asigna el valor true a esta opción, entonces no se diferencian en ningún caso, con independencia de que en los patrones que se definan para las piezas sintácticas se pongan minúsculas o mayúsculas. Pero, a veces, conviene que la diferenciación entre minúsculas y mayúsculas no se aplique de manera general a toda la entrada sino en particular a las secuencias de caracteres que se acoplen a ciertos patrones. Para este fin, existe la posibilidad de establecer que, en la expresión regular que define el patrón de una determinada pieza sintáctica, no se diferencie entre minúsculas y mayúsculas; ello se consigue poniendo 15 [ IGNORE_CASE ] inmediatamente detrás de la palabra TOKEN con la que empieza la especificación de la pieza sintáctica en la que se pretende la no distinción entre minúsculas y mayúsculas. Nótese que "ignore_case" tiene dos significados: - es el nombre de un parámetro de la sección de opciones (puede ponerse en minúsculas o mayúsculas), - es una palabra reservada que puede aplicarse (escribiéndola entre corchetes y en mayúsculas) a la definición de una pieza sintáctica. Por ejemplo, sea un lenguaje que, en general, distingue entre minúsculas y mayúsculas, pero en la codificación del exponente de una constante real exponencial puede ponerse la letra e en minúscula o en mayúscula. En la especificación JavaCC para ese lenguaje la opción Ignore_Case ha de tomar el valor false; en la definición de una constante exponencial, son posibles las siguientes formas equivalentes: 1) sin hacer uso de IGNORE_CASE TOKEN : { < # cifras : ( [ "0" - "9" ] )+ > TOKEN : { < # signo : ["+", "-"] > TOKEN : { < constante : <cifras> ("." <cifras>)? } } } ["e", "E"] (<signo>)? <cifras> > 2) aplicando IGNORE_CASE TOKEN : { < # cifras : ( [ "0" - "9" ] )+ > TOKEN : { < # signo : ["+", "-"] > TOKEN [IGNORE_CASE] : { < constante : <cifras> ("." <cifras>)? "e" } • } } (<signo>)? <cifras> > Secuencias de caracteres sin efecto en la estructura sintáctica En los lenguajes de programación suele haber secuencias de caracteres que no forman parte de la estructura sintáctica de un programa; esas secuencias son detectadas por el analizador lexicográfico, pero no son comunicadas al sintáctico, no han de considerarse como piezas, sino que han de saltarse. En JavaCC se pueden definir estas secuencias mediante la palabra reservada SKIP; la notación de una especificación con la palabra SKIP de una secuencia que ha de saltarse es la misma que la notación usada para las especificaciones con la palabra TOKEN de las piezas que han de comunicarse. Los dos casos más habituales de secuencias que no se comunican al analizador sintáctico son: a) los caracteres separadores que no tienen significado en el texto analizado en los lenguajes de codificación en formato libre hay caracteres del texto fuente que han de ignorarse; en la mayoría de los casos, estos caracteres son: el espacio en blanco, el tabulador, el retorno de carro y el fin de línea; esta característica se especifica SKIP : b) los comentarios { " " |"\t" | "\r" |"\n" } 16 en el análisis sintáctico de un programa, los comentarios han de ignorarse; una manera de especificar esta circunstancia es poniendo en una definición SKIP la expresión regular que denota la forma de los comentarios del lenguaje; por ejemplo, para los comentarios de una línea de Java se pondría SKIP : { □ < "//" (~ ["\n", "\r"])* > } Tareas asociadas a la estructura sintáctica JavaCC genera un analizador sintáctico descendente recursivo constituido por un conjunto de métodos: cada símbolo no terminal de la gramática sintáctica tiene asociado un método de análisis (el método tiene el mismo nombre que el símbolo correspondiente). Un método de análisis, además de la realización de la parte del análisis sintáctico que le corresponde, puede llevar a cabo otras tareas complementarias que sirvan para efectuar un análisis más complejo del texto de entrada. Bloque para un símbolo Al describir la forma de las reglas sintácticas de un símbolo no terminal en una especificación JavaCC, ya se ha visto que, detrás de la definición del nombre del símbolo y antes de empezar con las diferentes alternativas que tiene asociadas, se incluye un bloque de código Java (en el ejemplo de presentación este bloque está vacío en todos los símbolos no terminales de la gramática). Cada bloque situado en esa posición puede considerarse ligado al símbolo no terminal en cuya descripción está incluido; el código Java del bloque se traslada literalmente al principio del método generado correspondiente al símbolo no terminal y, por lo tanto, es lo primero que se considera (se ejecuta) cuando, en el transcurso del proceso de análisis, se realiza una llamada al método. Así pues, cada símbolo no terminal siempre tiene asociado un único bloque de código Java –bloque de presencia obligada, aunque pueda estar vacío-; en este bloque se pueden poner declaraciones y sentencias. ◊ Acciones sintácticas Además del bloque asociado a cada uno de los símbolos no terminales de la gramática, se pueden incluir otros bloques de código Java intercalados en cualquiera de las partes derechas de las reglas sintácticas. Los bloques incluidos en las partes derechas se denominan “acciones sintácticas” (parser actions) ya que son trozos de código que pueden considerarse asociados a los puntos de la estructura sintáctica donde están colocados. Una acción sintáctica se puede poner en cualquier posición: al principio, al final o en el medio de la parte derecha de una regla, precedida o seguida de símbolos terminales o no terminales; el código Java de una acción sintáctica se traslada literalmente al método asociado al símbolo no terminal de la parte izquierda de la regla, de manera que se ejecuta cada vez que el analizador transcurre por el punto de la estructura sintáctica donde se ha incorporado la acción (esto es, las acciones sintácticas se incorporan al código del analizador sintáctico). No obstante su nombre, en los bloques de las acciones sintácticas suele ponerse código dedicado a la realización de tareas semánticas. ◊ Si en el bloque asociado a un símbolo no terminal se ponen declaraciones, los nombres ahí definidos son accesibles en todas las acciones sintácticas intercaladas en las partes derechas de las reglas de ese símbolo; la justificación es sencilla: el código del bloque asociado al símbolo se incluye al principio de un método y el código de las acciones pertenece a ese mismo método. ∆ Ejemplo 1 Se considera de nuevo el ejemplo de presentación; ahora se pretende completar el análisis léxico-sintáctico con la realización de las siguientes tareas: - cada vez que se analiza una expresión, un término o un factor, se graba el nombre del símbolo no terminal correspondiente, y 17 - cuando se analiza un término en el que hay operadores multiplicativos, se cuenta su número y se graba el resultado (nótese que se cuentan los operadores de cada término, pero no se acumula la cuenta de todos los operadores de la expresión). Para la realización de estas tareas se añaden bloques de código Java. En la especificación que se describe a continuación puede apreciarse: - para los símbolos no terminales expresion, termino y factor se ha incluido en los bloques correspondientes la operación de grabación de su nombre, que se ejecutará cada vez que se efectúe una llamada al método, - en el bloque asociado a termino se tiene la declaración del contador de operadores (que será una variable local del método), - en la parte derecha de la regla que define la estructura de un término se han incluido dos acciones sintácticas: una para contar la cantidad de operadores (situada detrás de la pieza sintáctica del operador) y otra para grabar la cantidad de operadores del término (situada al final de la parte derecha), - se ha definido el método grabar para facilitar la escritura del código Java. options { Ignore_Case = true; } PARSER_BEGIN (ExprMin) public class ExprMin { public static void main (String[] argum) throws ParseException { ExprMin anLexSint = new ExprMin (System.in); anLexSint.unaExpresion(); System.out.println("Analisis terminado:"); System.out.println ("no se han hallado errores léxico-sintácticos"); } private static void grabar(String nombre) { System.out.println (" -> " + nombre + "\n"); } } PARSER_END (ExprMin) void unaExpresion() : { } { expresion() <EOF> } void expresion() : { grabar("Expresion"); } { termino() ( "+" termino() )* } void termino() : { int nAst = 0; grabar("Termino"); } { 18 factor() ( "*" { nAst++; } factor() )* { if (nAst > 0) System.out.println ("\n Asteriscos: " + nAst + "\n"); } } void factor() : { grabar("Factor"); } { <constante> | <variable> | "(" expresion() ")" } TOKEN: { < variable : ["a"-"z"] TOKEN: { < constante : SKIP: { " " | "\t" | "\n" | "\r" > } ( ["0"-"9"] ) + > } } Cuando se incluyen acciones sintácticas, la legibilidad del aspecto sintáctico de la especificación queda oscurecida; para paliar ese inconveniente, puede añadirse un comentario que muestre la regla sintáctica escueta, sin las acciones; por ejemplo, podría escribirse: void termino() : { int nAst = 0; grabar("Termino"); } { /* factor() ( "*" factor() )* */ factor() ( "*" { nAst++; } factor() )* { if (nAst > 0) System.out.println ("\n Asteriscos: " + nAst + "\n"); } } Valor comunicado por un método En todo lo visto hasta ahora, los métodos de análisis sintáctico generados por JavaCC no devuelven valor alguno al terminar su tarea de análisis, ya que en la especificación siempre se ha declarado el nombre de un método precedido de void (son métodos de tipo void). Pero, en general, un método de análisis sintáctico puede ser de cualquier tipo (primitivo o no); por ello, un método puede devolver un valor de cualquier tipo (de cualquier clase) al método desde el que se efectuó la llamada. ◊ Esta posibilidad de devolución de valores tiene una utilidad fundamental en la implementación de analizadores de lenguajes: permite la comunicación de datos (atributos de las entidades analizadas) entre los diferentes métodos que colaboran en el análisis del programa completo. En una especificación JavaCC, la llamada a un método ocurre cuando en la parte derecha de una regla está el nombre del símbolo no terminal asociado al método. Cuando un método devuelve un valor (tiene un tipo distinto de void), es habitual la recepción del valor devuelto en una variable; en la propia especificación 19 se puede indicar el nombre de la variable a la que se asignará el valor devuelto tras la llamada, para ello se emplea una notación idéntica a la forma que tienen las sentencias de asignación en el lenguaje Java dato = nombreSimb() donde dato es el nombre de la variable a la que se asigna el valor devuelto por el método llamado; esa variable ha de estar oportunamente declarada del mismo tipo que dicho método. ∆ Ejemplo 2 En el mismo ejemplo de presentación se desea, además del análisis léxico-sintáctico de la entrada, contar la cantidad de operadores que hay en la expresión. A continuación se expone una especificación JavaCC que es una posible solución; sólo se muestran las reglas sintácticas (el resto de la especificación es igual que en el ejemplo de presentación). En la especificación que se propone puede apreciarse: - los símbolos no terminales expresion, termino y factor se han declarado de tipo int: los métodos correspondientes devuelven un valor de tipo entero, - el valor devuelto por los métodos sirve para propagar a través de la estructura sintáctica la cuenta de los operadores que hay en el texto analizado, - por tratarse de métodos que devuelven un valor, es preciso incluir las correspondientes sentencias de devolución return. void unaExpresion() : { int nOper; } { nOper = expresion() <EOF> { System.out.println ("Cantidad de operadores = " + nOper); } } int expresion() : { int n, i; } { n = termino() ( "+" { n++; } i = termino() { n = n + i; } )* { return n; } } int termino() : { int n, i; } { n = factor() ( "*" { n++; } i = factor() { n = n + i; } )* { return n; } } int factor() : { int n; } { <constante> | <variable> | "(" n = expresion() ")" } { return 0; } { return 0; } { return n; } La especificación de un factor también podría escribirse de esta otra manera: 20 int factor() : { int n = 0; } { ( <constante> | <variable> | "(" n = expresion() ")" ) { return n; } } A continuación se expone un nuevo ejemplo para ilustrar la utilidad que proporciona el valor devuelto por un método de análisis sintáctico para la propagación (ascendente) de atributos (propiedades de las entidades analizadas). ∆ Ejemplo 3 Se pretende ampliar el analizador del ejemplo de presentación para que se emita un mensaje indicativo cuando la expresión analizada sea una expresión en la que sólo aparecen constantes; en la solución que se muestra sólo se incluyen las reglas sintácticas (en el resto de la especificación no hay modificaciones). void unaExpresion() : { boolean esCte; } { /* expresion() <EOF> */ esCte = expresion() <EOF> { if (esCte) System.out.println("\nExpresion constante\n"); } } boolean expresion() : { boolean esCte, esTambienCte; } { /* termino() ( "+" termino() )* */ esCte = termino() ( "+" esTambienCte = termino() { esCte = esCte && esTambienCte; } )* { return esCte; } } boolean termino() : { boolean esCte, esTambienCte; } { /* factor() ( "*" factor() )* */ esCte = factor() ( "*" esTambienCte = factor() { esCte = esCte && esTambienCte; } { return esCte; } } )* 21 boolean factor() : { boolean esCte; } { <constante> | <variable> | "(" esCte = expresion() ")" { return true; } { return false; } { return esCte; } } • Reconsideración sobre las producciones vacías Anteriormente se ha comentado que para representar una producción vacía en una especificación JavaCC hay que hacer uso de los metasímbolos de opcionalidad; para ilustrar esta idea se ha incluido como ejemplo la especificación de una lista de parámetros compuesta por cero o más expresiones, separadas entre sí por el carácter coma: void parametros() : { } { [ expresion() } ( "," expresion() )* ] Pero si se aprovecha la posibilidad de incorporar acciones sintácticas, se tiene una manera de especificar directamente producciones vacías; para conseguirlo, basta considerar una producción vacía (no tiene nada en su parte derecha) acompañada de una acción sintáctica cuyo bloque de código esté vacío. Según esta posibilidad, la especificación anterior podría también escribirse así: void parametros() : { } { expresion() | { ( "," expresion() )* } } En general, la acción sintáctica asociada a una producción vacía no tiene por qué ser un bloque vacío de código; por ejemplo, si se quiere que el método que analiza los parámetros de una llamada devuelva la cantidad de parámetros encontrados, se podría escribir la siguiente especificación int parametros() : { int cantidad; } { ( expresion() ( "," | { cantidad = 1; } expresion() { cantidad++; } )* { cantidad = 0; } ) { return cantidad; } } y si no quisiera ponerse explícitamente la alternativa de la producción vacía, podría escribirse int parametros() : { int cantidad = 0; } 22 { [ expresion() { cantidad++; } ( "," expresion() { cantidad++; } )* ] { return cantidad; } } □ Comunicación entre los analizadores léxico y sintáctico Cada vez que el analizador sintáctico realiza una llamada al analizador lexicográfico, recibe como resultado de la llamada una representación de la pieza sintáctica encontrada en el texto analizado. En los analizadores generados por JavaCC, la comunicación de la pieza se efectúa mediante un valor de clase Token. Esta clase está definida en el fichero Token.java que se obtiene en todo caso, siempre con el mismo contenido, cualquiera que sea la especificación proporcionada como entrada al generador. Entre los campos de la clase Token se encuentran los siguientes: public int kind número entero que sirve de representación interna de la pieza sintáctica; estos números asociados a las piezas son asignados automáticamente por JavaCC; los valores considerados pueden consultarse en el fichero generado de nombre · · · Constants.java public String image cadena que contiene de la secuencia de caracteres (lexema) que constituyen la pieza sintáctica comunicada public int beginLine, beginColumn, endLine, endColumn posiciones ocupadas (números de línea y columna en el fichero de entrada) por el comienzo y el final del lexema de la pieza sintáctica comunicada La clase Token tiene otros campos y métodos; en el fichero Token.java se tienen comentarios descriptivos sobre todos los campos y métodos que forman parte de la clase; todos los campos de la clase se declaran como public. Ya se ha comentado anteriormente que, cuando un método de análisis sintáctico (asociado a un símbolo no terminal) tiene tipo, el valor devuelto tras una llamada se puede almacenar en una variable, por si se desea utilizar en alguna acción sintáctica; para ello, se escribe valor = nombreSimbolo() (siendo valor el nombre de la variable y nombreSimbolo el nombre del símbolo no terminal). De manera análoga, el objeto de la clase Token representativo de la pieza sintáctica encontrada en la entrada por el analizador lexicográfico puede asignarse a una variable del mismo tipo para tener accesibles las características de la pieza, por si se precisaran en alguna acción sintáctica; para ello, en la parte de la especificación sintáctica donde aparezca la pieza sintáctica se escribe dato = < nombrePieza > donde nombrePieza es el nombre de la pieza sintáctica y dato es el nombre de una variable de tipo Token en la que se anota el valor indicativo de las propiedades de la pieza sintáctica comunicada. También existe otra manera de acceder a las características de la pieza sintáctica que el analizador lexicográfico comunica al analizador sintáctico; en la clase del analizador sintáctico se tiene declarado el campo [ static ] public Token token 23 cada vez que el analizador sintáctico recibe una pieza sintáctica comunicada por el analizador lexicográfico, se deja anotada en el campo token, y ese contenido no se altera hasta que sea proporcionada la siguiente pieza de la entrada. ∆ Ejemplo 4 Se considera una ampliación del ejemplo de presentación; además del análisis de la entrada, se pretende obtener una relación numerada de las variables y las constantes, indicando el número de línea y el número de columna donde están situadas; por ejemplo, si en la entrada se tiene la expresión (grabada en dos líneas). x * (y + 11 ) + 22 en la salida se tendrá 1.- x linea: 1 columna: 1 2.- y linea: 1 columna: 6 3.- 11 linea: 1 columna: 10 4.- 22 linea: 2 columna: 5 En la especificación que se propone como solución puede apreciarse: - en la clase del analizador sintáctico se han incluido métodos auxiliares en los que se usan los nombres image, beginLine, beginColumn, que son campos de un objeto de clase Token, - en la especificación sintáctica de factor se emplea la variable pieza, de tipo Token, para recoger las características de las piezas sintácticas detectadas, - la variable numero hace de contador para numerar las constantes y variables. options { Ignore_Case = true; } PARSER_BEGIN (ExprMin) public class ExprMin { static int numero = 0; private static void grabarLexema (int n, String lexema) { System.out.print(n + ".- " + lexema + " "); } private static void grabarPosicion(int nL, int nC) { System.out.print("linea: " + nL + " "); System.out.println("columna: " + nC + "\n"); } private static void grabarDatosPieza(int n, Token pieza) { grabarLexema(n, pieza.image); grabarPosicion(pieza.beginLine, pieza.beginColumn); } public static void main (String[] argum) throws ParseException { ExprMin anLexSint = new ExprMin (System.in); anLexSint.unaExpresion(); System.out.println("Analisis terminado:"); System.out.println ("no se han hallado errores léxico-sintácticos"); 24 } } PARSER_END (ExprMin) void unaExpresion() : { } { expresion() <EOF> } void expresion() : { } { termino() ( "+" } void termino() : { } { factor() ( "*" } termino() )* factor() )* void factor() : { Token pieza; } { pieza = <constante> { numero++; grabarDatosPieza(numero, pieza); } | pieza = <variable> { numero++; grabarDatosPieza(numero, pieza); } | "(" expresion() ")" } TOKEN: { < variable : ["a"-"z"] TOKEN: { < constante : SKIP: > } ( ["0"-"9"] ) + > } { " " | "\n" | "\t" | "\r" } Si se agrupan las dos acciones sintácticas comunes, la especificación de factor también puede escribirse de esta forma: void factor() : { Token pieza; } { ( pieza = <constante> | pieza = <variable> ) | } { numero++; grabarDatosPieza(numero, pieza); } "(" expresion() ")" 25 Si se accede al campo token de la clase del analizador sintáctico, se puede resolver este ejemplo prescindiendo de la variable pieza: void factor() : { } { ( | <constante> <variable> ) | "(" { numero++; grabarDatosPieza(numero, token); } expresion() ")" } □ Tareas asociadas a las piezas sintácticas Ya se ha visto que es posible asociar bloques de código Java a puntos de la estructura sintáctica para que se realicen ciertas operaciones cuando el analizador sintáctico pase por esa zona de la estructura. También es posible asociar código Java a las piezas sintácticas para que el analizador lexicográfico efectúe determinadas tareas cuando detecte en la entrada analizada piezas que tengan asociado código. Acción ligada a una pieza sintáctica Si se asocia un bloque de código a una pieza sintáctica; se consigue que, cada vez que se detecta en la entrada analizada la presencia de esa pieza, se ejecute en ese preciso momento el código del bloque asociado a la pieza. Estas acciones se denominan “acciones lexicográficas” (lexical actions); el código del bloque se traslada literalmente al código del analizador lexicográfico generado para que se ejecute oportunamente cuando se detecte la pieza ligada al bloque de código. La acción lexicográfica se ejecuta inmediatamente antes que la pieza sea comunicada al analizador sintáctico. ◊ Se puede asociar una acción lexicográfica a una pieza sintáctica declarada con TOKEN y también a una secuencia de caracteres declarada con SKIP; además de éstas, hay otras dos formas de declaraciones lexicográficas (no mencionadas en esta introducción), que también pueden llevar una acción asociada. En una especificación JavaCC, la forma de una pieza sintáctica (o bien de una secuencia de caracteres) se define poniendo su descripción delimitada entre llaves detrás de la indicación TOKEN: (o bien SKIP:); si se quiere asociar una acción lexicográfica a la pieza (o a la secuencia), hay que colocar el bloque de código de la acción a continuación de la descripción de la forma de la pieza (o de la secuencia); el bloque (delimitado por llaves) queda situado entre la descripción y la llave que cierra la especificación. La pieza sintáctica especial que representa el final del fichero (EOF) también puede llevar asociada una acción lexicográfica; para ello, hay que poner <*> TOKEN: { < EOF > { /* código de la acción asociada*/ } } para justificar la presencia de los caracteres <*> delante de la palabra TOKEN hay que basarse en el concepto de “estado lexicográfico” (cuya explicación queda fuera del alcance de esta introducción). ∆ Ejemplo 5 Se considera nuevamente el ejemplo de presentación; como resultado del análisis de una expresión se pretende obtener una relación de los paréntesis contenidos en la expresión, en el mismo orden en que se encuentran; además, por cada salto de línea del texto de entrada, se grabará en la salida el carácter # y 26 se pasará a una nueva línea; también se anotará la frase Final del trabajo cuando se alcance el final del fichero de entrada. Así, por ejemplo, si la entrada analizada es (a + b) * (( x )) * (y + 7) los resultados obtenidos serán () # (())() # ** Final del trabajo ** En la especificación JavaCC que se propone como solución, sólo se muestran las reglas sintácticas y lexicográficas (en el resto no hay cambios); en esta solución puede apreciarse: - las piezas sintácticas correspondientes a los paréntesis se han definido de manera nominal, para poder asociarles una acción lexicográfica, - los caracteres incluidos en la pieza de tipo SKIP se han descompuesto en dos grupos ya que la acción lexicográfica incorporada sólo afecta a uno de ellos. void unaExpresion() : { } { expresion() <EOF> } void expresion() : { } { termino() ( "+" } void termino() : { } { factor() ( "*" } termino() )* factor() )* void factor() : { } { <constante> | <variable> | <pAbrir> expresion() } <pCerrar> TOKEN: { < variable : ["a"-"z"] < constante : > } TOKEN: { ( ["0"-"9"] ) + > } TOKEN: { < pAbrir : "(" > { System.out.print("("); } 27 } TOKEN: { < pCerrar : ")" > { System.out.print(")"); } } <*> TOKEN: { < EOF > { System.out.println ("** Final del trabajo **"); } } SKIP: { "\n" { System.out.print(" #\n"); } } SKIP: { " " | "\t" | "\r" } Bloque de declaraciones lexicográficas Cada definición de una pieza sintáctica puede llevar asociada una acción lexicográfica, que se ejecuta inmediatamente después de detectar en la entrada un lexema que se ajusta a la definición de la pieza; cada acción lexicográfica es un bloque de código Java que puede contener sus propias declaraciones. Pero a veces, interesa tener declaraciones que sean compartidas por todas las acciones lexicográficas, con independencia de la pieza a la que esté asociada cada una de ellas. ◊ Para incluir declaraciones que puedan usarse desde el código asociado a cualquiera de las piezas sintácticas, se dispone de una construcción con el siguiente formato TOKEN_MGR_DECLS : { · · · · } que consta de una palabra reservada, seguida del símbolo dos puntos, seguido de un bloque de código Java (delimitado por llaves); esta declaración se puede colocar en cualquier parte de una especificación JavaCC; si se incluye, sólo puede ponerse una vez. Todas las declaraciones de variables o de métodos puestas en el bloque de declaraciones lexicográficas compartidas son accesibles desde el código de cualquier acción lexicográfica; la justificación es sencilla: el bloque de declaraciones se traslada al principio del código del analizador lexicográfico generado y las acciones lexicográficas también se trasladan a ese analizador. Declaraciones lexicográficas predefinidas De lo expuesto hasta ahora, se aprecia que en una acción lexicográfica asociada a una pieza sintáctica resultan accesibles: - las declaraciones de su propio bloque (si tiene declaraciones), - los componentes del bloque de declaraciones compartidas (si está incluido en la especificación). Además de esto, se tienen otras declaraciones pertenecientes a la clase " · · · TokenManager", que constituye el analizador lexicográfico, que también son utilizables desde cualquiera de las acciones lexicográficas; estas declaraciones pueden considerarse como “predefinidas” ya que son incorporadas automáticamente por el generador. Entre estas declaraciones accesibles se encuentran las siguientes: ◊ [ static ] StringBuffer image contiene la cadena de caracteres representativa de la pieza sintáctica actual que se acaba de reconocer en le entrada; se trata de un campo de la clase y se declara como static cuando el analizador 28 se ha generado con la opción (por defecto) Static = true; si se generase con la opción Static = false, no quedaría declarada como static Token matchedToken variable de clase Token que representa la pieza sintáctica actual, la que se acaba de encontrar en la entrada; se trata de una variable local del método al que se ha trasladado el código de la acción (y, por ello, accesible desde ese código) El campo image puede usarse tanto si la acción lexicográfica está asociada a una pieza declarada con TOKEN como si lo está a una secuencia declarada con SKIP. Sin embargo, la variable matchedToken no puede usarse en una acción asociada a un patrón especificado como SKIP; el motivo es que lo declarado como SKIP, no constituye propiamente una pieza sintáctica ya que no se va a comunicar al analizador sintáctico. ∆ Ejemplo 6 Se propone aquí una nueva solución para el ejemplo nº 4 expuesto anteriormente. Esta nueva solución tal y como se expone aquí admite algunas mejoras, ya que su pretensión sólo es servir de ilustración para algunos aspectos que se acaban de citar (más adelante se propone otra solución alternativa). En la especificación de la solución es posible apreciar esto: - las acciones lexicográficas pueden llevar sus propias declaraciones; las variables nLineaVar, nColumnaVar, nLineaCte y nColumnaCte se declaran dentro de las acciones, - la variable matchedToken a la que se hace referencia en el código de la acción lexicográfica asociada a una variable contiene la información sobre la pieza sintáctica que se acaba de detectar: una variable de la expresión analizada; análogamente con la referencia relativa a una constante, - el nombre image que aparece en las acciones lexicográficas se refiere al campo de la clase ExprMinTokenManager (analizador lexicográfico); nótese que, en efecto, se comunica como un parámetro de clase StringBuffer, - la variable numero se emplea para numerar las constantes y variables encontradas, - los nombres beginLine y beginColumn son campos (públicos) de la clase Token, por ello, se pueden aplicar a un objeto de esa clase. options { Ignore_Case = true; } PARSER_BEGIN (ExprMin) public class ExprMin { public static void main (String[] argum) throws ParseException { ExprMin anLexSint = new ExprMin (System.in); anLexSint.unaExpresion(); System.out.println("Analisis terminado:"); System.out.println ("no se han hallado errores léxico-sintácticos"); } } PARSER_END (ExprMin) void unaExpresion() : 29 { } { expresion() <EOF> } void expresion() : { } { termino() ( "+" } void termino() : { } { factor() ( "*" } termino() )* factor() )* void factor() : { } { <constante> | <variable> | "(" expresion() } ")" TOKEN_MGR_DECLS : { static int numero = 0; static void grabarLexema (int n, StringBuffer lexema) { System.out.print(n + ".- " + lexema + " "); } static void grabarPosicion(int nL, int nC) { System.out.print("linea: " + nL + " "); System.out.println("columna: " + nC + "\n"); } } TOKEN: { < variable : ["a"-"z"] > { int nLineaVar, nColumnaVar; numero++; nLineaVar = matchedToken.beginLine; nColumnaVar = matchedToken.beginColumn; grabarLexema(numero, image); grabarPosicion(nLineaVar, nColumnaVar); } } TOKEN: { < constante : ( ["0"-"9"] ) + { int nLineaCte, nColumnaCte; > 30 numero++; nLineaCte = matchedToken.beginLine; nColumnaCte = matchedToken.beginColumn; grabarLexema(numero, image); grabarPosicion(nLineaCte, nColumnaCte); } } SKIP: { " " | "\n" | "\t" | "\r" } La especificación precedente se puede escribir de otra manera más escueta, como se indica a continuación (sólo se muestran las modificaciones); sobre esta nueva especificación puede mencionarse: - se han eliminado, por innecesarias, las variables declaradas dentro de las acciones lexicográficas, - el nombre image que aparece en el bloque de las declaraciones compartidas se refiere al campo de la clase Token; nótese que, en efecto, se aplica a un objeto de esa clase y se comunica como un parámetro de tipo String. TOKEN_MGR_DECLS : { static int numero = 0; static void grabarLexema (int n, String lexema) { System.out.print(n + ".- " + lexema + " "); } static void grabarPosicion(int nL, int nC) { System.out.print("linea: " + nL + " "); System.out.println("columna: " + nC + "\n"); } static void grabarDatosPieza(int n, Token pieza) { grabarLexema(n, pieza.image); grabarPosicion(pieza.beginLine, pieza.beginColumn); } } TOKEN: { < variable : ["a"-"z"] { > numero++; grabarDatosPieza(numero, matchedToken); } } TOKEN: { < constante : { ( ["0"-"9"] ) + > numero++; grabarDatosPieza(numero, matchedToken); } } Acción lexicográfica común Si hubiera que realizar la misma tarea tras la detección de todas y cada una de las piezas sintácticas, una posibilidad incómoda sería repetir el mismo código en todas las acciones lexicográficas; para evitar tanta reiteración, se tiene la opción Common_Token_Action; su valor por defecto es false, pero si se le asigna el valor true, es posible definir una “acción lexicográfica común” que se escribe una única vez y se aplica a todas las piezas sintácticas. ◊ 31 Cuando la opción Common_Token_Action se establece con el valor true, es obligado incluir el bloque de declaraciones lexicográficas compartidas (TOKEN_MGR_DECLS). Además, en ese bloque se precisa la definición de un método cuyo encabezamiento sea [ static ] void CommonTokenAction(Token t) el código de este método constituye la acción lexicográfica común que se ejecutará después de la detección de cualquier pieza sintáctica; el método tiene un parámetro de tipo Token mediante el que se puede comunicar la información correspondiente a la pieza que se acaba de encontrar en la entrada analizada. Si una pieza sintáctica tiene asociada una acción lexicográfica propia, la acción lexicográfica común (si está definida) se ejecutará inmediatamente después de realizar la acción propia. La acción lexicográfica común se aplica a todas las piezas sintácticas definidas como TOKEN, bien sea nominalmente (en las reglas lexicográficas), o bien anónimamente (incorporadas dentro de las reglas sintácticas); sin embargo, no se aplica cuando la secuencia acoplada se corresponde con una expresión regular especificada como SKIP (nótese que, por el contrario, una especificación SKIP sí puede tener asociada una acción lexicográfica propia). El código del método CommonTokenAction se traslada, como un componente más del bloque de declaraciones compartidas (TOKEN_MGR_DECLS), al principio de la clase del analizador lexicográfico. ∆ Ejemplo 7 Se pretende ahora ampliar el ejemplo de presentación con la obtención de una relación numerada de los lexemas de las piezas sintácticas encontradas en la entrada analizada; en el caso de las variables y de las constantes, antes del lexema se mostrará un indicativo (Var / Const); por ejemplo, si la expresión de la entrada es ( a + 987 ) * b se grabará la salida ( #1 Var a #2 + #3 Const 987 ) #5 * #6 Var b #7 #8 #4 En la solución que se propone a continuación, se muestran la definición de las opciones y las reglas sintácticas y lexicográficas (en el resto no hay modificaciones); puede resaltarse lo siguiente: - las llamadas al método CommonTokenAction se incorporan automáticamente, no hay que incluirlas en la especificación; como parámetro de llamada a este método siempre se emplea el objeto que representa la pieza que se acaba de detectar, - la acción lexicográfica común realiza la actualización del contador de piezas y la grabación del lexema de la pieza detectada, con su número de orden, - la acción lexicográfica común también se aplica a las piezas sintácticas anónimas (paréntesis y operadores) y a la pieza especial que representa el final del fichero (EOF), - las piezas sintácticas <constante> y <variable> tienen asociada una acción lexicográfica propia, que se ejecuta antes de la acción lexicográfica común, - los caracteres que se saltan (SKIP) no están afectados por la acción lexicográfica común. options { Ignore_Case = true; Common_Token_Action = true; 32 } void unaExpresion() : { } { expresion() <EOF> } void expresion() : { } { termino() ( "+" } void termino() : { } { factor() ( "*" } termino() )* factor() )* void factor() : { } { <constante> | <variable> | "(" expresion() } ")" TOKEN_MGR_DECLS : { static int numero = 0; static void grabarIndicativo(String indicativo) { System.out.print(indicativo + " "); } static void grabarLexema (int num, String lexema) { System.out.print(lexema + " #" + num + "\n"); } static void CommonTokenAction(Token pieza) { numero++; grabarLexema(numero, pieza.image); } } TOKEN: { < variable : ["a"-"z"] { } > grabarIndicativo("Var"); } 33 TOKEN: { < constante : { ( ["0"-"9"] ) + grabarIndicativo("Const"); > } } SKIP: { " " | "\n" | "\t" | "\r" } A modo de ejemplo de aplicación de la acción lexicográfica común, en lo que sigue se ofrece una solución para la grabación (en el fichero predefinido de salida) del texto analizado, con las líneas numeradas. [ ∆ Ejemplo 8 Listado del texto analizado ] Se desea completar el ejemplo de presentación con la grabación de un listado, con las líneas numeradas, del texto analizado (nótese que una expresión puede escribirse ocupando varias líneas). No se muestra la especificación sintáctica debido a que no hay ninguna modificación en ella. Los aspectos reseñables de la solución que se propone son: - se usa la variable numLin como contador de líneas, - en la variable linea se van yuxtaponiendo los lexemas de las piezas sintácticas de una línea, esta yuxtaposición se realiza mediante la acción lexicográfica común, - las secuencias reconocidas con los patrones especificados como SKIP no implican la ejecución de la acción lexicográfica común; por ello, su lexema (almacenado en la variable image) ha de yuxtaponerse explícitamente, - hay que separar el patrón correspondiente al fin de línea, para detectar el momento en que ha de grabarse la línea completa cuyo final se ha alcanzado. options { Ignore_Case = true; Common_Token_Action = true; } PARSER_BEGIN (ExprMin) public class ExprMin { public static void main (String[] argum) throws ParseException { ExprMin anLexSint = new ExprMin (System.in); anLexSint.unaExpresion(); System.out.println("\n\nAnalisis terminado:"); System.out.println ("no se han hallado errores léxico-sintácticos"); } } PARSER_END (ExprMin) · · · · · · · · · · · · · TOKEN_MGR_DECLS: { static int numLin = 0; static String linea = ""; 34 static void CommonTokenAction(Token pieza) { linea = linea + pieza.image; } } TOKEN: { < variable : ["a"-"z"] { < constante : > } TOKEN: ( ["0"-"9"] ) + > } SKIP: { < " " | "\t" | "\r" > { linea = linea + image; } } SKIP: { "\n" { numLin++; System.out.println(numLin + ": linea = ""; " + linea); } } □ A modo de recapitulación ↄ Acción lexicográfica. Acción sintáctica acción lexicográfica: bloque de código Java asociado a una pieza sintáctica nominal (TOKEN) o a una secuencia de caracteres (SKIP); se ejecuta cuando se detecta en la entrada la pieza o la secuencia correspondiente; no se puede aplicar a piezas sintácticas anónimas (autodefinidas con comillas), • acción sintáctica: bloque de código Java asociado a un punto de la estructura sintáctica (el punto indicado por el sitio de la producción donde se inserta el bloque); se ejecuta cuando el análisis de la entrada pasa por ese punto de la estructura. • ↄ Valor de una pieza sintáctica comunicada El analizador lexicográfico comunica al analizador sintáctico una pieza a través de un valor de la clase Token (fichero Token.java); un objeto de la clase Token representa las características de la pieza comunicada. En la clase Token, entre otros, se tienen los campos String image lexema de la pieza int beginLine, beginColumn, endLine, endColumn posición del lexema en el fichero analizado (indicada por los números de fila y de columna de su comienzo y de su terminación) ↄ Valor asociado a un símbolo 35 En las producciones que especifican la sintaxis se pueden incluir asignaciones que sirven para dejar anotado el valor asociado a un símbolo: no terminal valor = nombreSimbolo() el método de análisis asociado a nombreSimbolo se ha declarado de un cierto tipo (distinto del tipo void); valor ha de ser una variable del mismo tipo que el método • símbolo terminal dato = < nombrePieza > el valor asignado es el valor de la clase Token representativo de la pieza sintáctica (nominal); dato ha de ser una variable de tipo Token • símbolo ↄ Miradas sobre la pieza sintáctica comunicada La pieza sintáctica comunicada puede mirarse desde cada uno de los dos analizadores: • analizador sintáctico la pieza comunicada (desde el analizador lexicográfico) se tiene anotada en un campo de la clase del analizador sintáctico declarado como (static) public Token token este valor es accesible desde el código de todos los métodos de análisis sintáctico; el contenido del campo varía cuando se recibe una nueva pieza sintáctica • analizador lexicográfico la pieza dispuesta para ser comunicada (al analizador sintáctico) se tiene anotada en una variable local declarada como Token matchedToken este valor es accesible para el código de todas las acciones lexicográficas ↄ El lexema de la pieza comunicada El lexema de la pieza sintáctica comunicada se encuentra disponible en dos sitios distintos que comparten el mismo nombre pero que son de distinto tipo y se usan de distinta manera: • String image es un campo de la clase TOKEN; puede consultarse en relación con un objeto de esa clase que esté disponible • StringBuffer image es un campo de la clase del analizador lexicográfico ( ∙ ∙ ∙ ∙ TokenManager.java); es accesible desde el código de la clase y, por lo tanto, desde todas las acciones lexicográficas (bloques de código que están incorporados al analizador lexicográfico); se trata de un valor disponible tanto para las piezas sintácticas nominales (TOKEN) como para las secuencias que se saltan (SKIP) ↄ Resumen relativo a la lexicografía TOKEN "∙ ∙ ∙" SKIP pieza sintáctica nominal pieza sintáctica anónima secuencia de caracteres que no forman pieza 36 1 2 3 4 acción lexicográfica común acción lexicográfica propia bloque de declaraciones lexicográficas declaraciones lexicográficas predefinidas Commom TokenAction (acción ligada a una pieza) TOKEN_ MGR_DECLS (disponibilidad de uso) TOKEN si si si si "∙ ∙ ∙" si no - - SKIP no si si si ▫ Las indicaciones "si" de las columnas 3 y 4 son consecuencia de las indicaciones de la columna 2. ▫ La acción lexicográfica común se ejecuta después de la acción lexicográfica propia. □ Precisiones sobre el analizador sintáctico generado Puede decirse que, en su funcionamiento más sencillo, cuando no se emplean las posibilidades de examen por adelantado (“lookahead”), JavaCC comprueba que las reglas sintácticas cumplen la condición LL(1) y generan un analizador descendente-predictivo-recursivo. Pero esta apreciación hay que matizarla, ya que el analizador sintáctico generado no se ajusta en sentido estricto al modelo de analizador LL(1). En el modelo de análisis LL(1), el orden del examen de las distintas alternativas de expansión de un símbolo no terminal es indiferente ya que se conocen los símbolos directores de cada una de ellas (que resultan ser conjuntos disjuntos); en la implementación del subprograma de análisis asociado al símbolo no terminal <NombreSimb> cuyas producciones son <NombreSimb> ::= α1 | ∙ | α2 ∙ ∙ αn se podría preguntar en cualquier orden si la pieza actual pertenece al conjunto de símbolos directores de las distintas reglas. Sin embargo, en el analizador sintáctico generado por JavaCC, la implementación del método asociado al símbolo no terminal <NombreSimb> no sigue esa pauta. Si las anteriores reglas sintácticas se transcriben en JavaCC de la forma void nombreSimb () : { } { α1-jcc | α2-jcc ∙ ∙ ∙ | αn-jcc 37 } el método de análisis generado procede de la siguiente manera: ▫ se van considerando las alternativas, sucesivamente, en el mismo orden en el que están especificadas: primero la de α1 (α1-jcc), después la de α2 (α2-jcc), etc, ▫ para cada alternativa seleccionada, se comprueba si la pieza por adelantado coincide con alguno de los símbolos iniciales de la parte derecha, y si hay coincidencia se prosigue el análisis con ella; si no hay coincidencia se pasa a la alternativa siguiente, ▫ siguiendo con este orden, si se llega al final de las alternativas sin haberse encontrado coincidencia alguna, se produce un error sintáctico. En el analizador generado por JavaCC hay una peculiaridad que no se tiene en los analizadores LL(1) estrictamente considerados: la palabra vacía sí se considera como posible símbolo inicial de la parte derecha una regla (esto ocurre siempre que la parte derecha es anulable). De esta peculiaridad se deriva una consecuencia que ha de tenerse en cuenta al escribir la especificación: dado que siempre es posible considerar la palabra vacía como la siguiente pieza por adelantado, si se llega a examinar la alternativa correspondiente a una producción anulable, el analizador generado siempre seleccionará esa alternativa con independencia de la siguiente pieza que esté presente en la entrada que queda por analizar. Así pues, si alguna de las producciones que definen un símbolo no terminal es anulable, es importante el orden en que se escriben las alternativas en una especificación sintáctica JavaCC: el funcionamiento del analizador generado sí depende de la colocación elegida para las reglas. Para ilustrar estas peculiaridades del analizador sintáctico generado por JavaCC, a continuación se expone un ejemplo. ∆ Ejemplo 9 Se quiere obtener un analizador léxico-sintáctico de textos que se ajustan al siguiente formato: una secuencia de uno o más nombres, seguida del símbolo igual, seguido de un número entero; si la secuencia tiene más de un nombre, han de estar separados bien por una coma en todos los casos o bien por el símbolo dos puntos en todos los casos; esto es, son correctas las secuencias uno, dos, tres = 123 exclusivo = 0 Abajo : Arriba = 57 y son incorrectas las secuencias uno, dos : tres = 123 exclusivo inclusive = 0 fuera : dentro : = 56 Una gramática que define las secuencias con este formato es: <Lista> ::= nombre <OtrosNombres> = numero <OtrosNombres> ::= , nombre | : nombre | ε { { , nombre : nombre } } se trata de una gramática que cumple la condición LL(1): - el símbolo director para la alternativa vacía es el símbolo igual; es evidente, pues, que las tres reglas de <OtrosNombres> tienen símbolos directores distintos, 38 - la decisión de abandonar la repetición del análisis de un nuevo nombre (en las dos primeras alternativas de <OtrosNombres>) se puede tomar ante la presencia del símbolo igual. En la implementación de un analizador sintáctico LL(1), en sentido estricto, la selección de la alternativa vacía para expandir el símbolo <OtrosNombres> se haría tras comprobar explícitamente que la pieza sintáctica por adelantado es el símbolo igual. El funcionamiento del analizador generado por JavaCC depende del orden de colocación de las reglas. A continuación se exponen varias posibilidades de especificación y se comenta el analizador generado en cada caso. • Especificación primera Se transcriben literalmente las reglas tal y como se han escrito antes en la notación BNF; nótese que para poder codificar la parte derecha de la regla vacía ha de incluirse una acción sintáctica vacía. PARSER_BEGIN (Igualdad) public class Igualdad { public static void main (String[] argum) throws ParseException { Igualdad analisis = new Igualdad(System.in); analisis.secuencia(); System.out.println("Analisis terminado:"); System.out.println ("no se han hallado errores léxico-sintácticos"); } } PARSER_END (Igualdad) void secuencia() : { } { lista() } void lista() : { } { < nombre > } <EOF> otrosNombres() "=" < numero > void otrosNombres() : { } { "," < nombre > | ":" < nombre > | { } } ( "," ( ":" < nombre > )* < nombre > )* TOKEN : { < nombre : ( [ "a" - "z", "A" - "Z", "ñ", "Ñ" ] )+ > TOKEN : { < numero : ( [ "0" - "9" ] )+ > SKIP : { " " | "\r" | "\n" } } } 39 El analizador generado funciona correctamente puesto que en el método otrosNombres se selecciona la alternativa vacía después de haber comprobado que la pieza por adelantado no es símbolo inicial de ninguna de las dos alternativas precedentes (no es una coma ni un símbolo dos puntos). • Especificación segunda Se considera la misma especificación anterior, pero cambiando el orden de colocación de las reglas: ahora la producción vacía se coloca como primera alternativa (sólo se muestran las partes modificadas). void otrosNombres() : { } { { } | "," < nombre > | ":" < nombre > } ( "," ( ":" < nombre > )* < nombre > )* Con la entrada de esta especificación, el generador JavaCC produce un mensaje en el que se avisa de que las dos alternativas que están colocadas detrás de la alternativa vacía nunca podrán seleccionarse; aun así, se genera un analizador. Pero es un analizador que funciona incorrectamente; así, por ejemplo, la entrada mxa , nyz = 57 se considera incorrecta: después de visto el primer nombre, se intenta aplicar la producción vacía (se espera encontrar el símbolo igual), pero habría que aplicar la regla cuyo símbolo director es la coma. • Especificación tercera Se utilizan las posibilidades de la notación BNF-Ampliada para prescindir de la especificación explícita de la palabra vacía; se aprovecha que JavaCC admite el operador + para indicar repeticiones de una o más veces; la alternativa vacía está implícitamente contemplada en la posibilidad de cero repeticiones que contempla el operador * (sólo se muestran las partes modificadas en la especificación). void otrosNombres() : { } { ( "," < nombre > )+ | ( ":" < nombre > )* } El método otrosNombres() generado hace las comprobaciones en el siguiente orden: - se comprueba si la pieza por adelantado es una coma (símbolo inicial de la primera alternativa), y en este caso, se selecciona la primera regla, - si no es una coma, se comprueba si es un símbolo dos puntos (símbolo inicial de la segunda alternativa), y en este caso, se selecciona la segunda regla, - si no es un símbolo dos puntos, se considera la palabra vacía como símbolo inicial (siempre posible implícitamente) para la segunda alternativa; en este caso, para que no haya error, la pieza por adelantado ha de ser un símbolo igual. Se trata, pues, de un analizador que funciona correctamente. • Especificación cuarta 40 Se pone la misma especificación anterior, pero cambiando el orden de consideración de la regla vacía: ahora se incluye implícitamente en la primera alternativa (sólo se muestran las partes modificadas). void otrosNombres() : { } { ( "," < nombre > )* | ( ":" < nombre > )+ } Para esta especificación, el generador JavaCC emite un aviso en el que se indica que la segunda alternativa nunca se seleccionará; no obstante, se genera un analizador que no funciona como se supone que debería hacerlo; por ejemplo, el análisis de la entrada nyz : mxa = 1 produce un error: después de tratado el primer nombre, se intenta aplicar la producción vacía (correspondiente a la repetición de cero veces contemplada en la primera regla) por lo que no se encuentra el esperado símbolo igual.