Escuela Técnica Superior de Ingeniería

 

Grado en Ingeniería Informática

Procesadores de Lenguajes

Curso 2022/2023

 

Práctica 9
Análisis semántico de cabecera

 

OBJETIVOS

 

  • Describir la estructura de análisis en dos pasos del compilador de Tinto.
  • Describir el analizador semántico del compilador de Tinto dedicado a extraer la información de cabecera de las librerías y funciones de Tinto.
  • Describir el formato de especificación semántica en JavaCC.
  • Describir la implementación en JavaCC del analizador semánticos del compilador de Tinto dedicado a extraer la información de cabecera de las librerías y funciones de Tinto.

 

CÓDIGO A UTILIZAR

 

El código de esta práctica es el siguiente:

El contenido está formado por los paquetes tinto, tinto.ast, tinto.parser y tinto.parserjj.

El paquete tinto contiene la clase TintoCompiler, que representa la clase principal de la aplicación.

El paquete tinto.ast describe el árbol de sintaxis abstracta del compilador de tinto y fue explicado en la práctica anterior.

El paquete tinto.parser contiene 9 clases, de las cuales 6 corresponden al analizador léxico de Tinto (descrito en la práctica 2). El resto corresponde a las clases SintaxException (descrita en la práctica 4), SemanticException y TintoHeaderParser.

El paquete tinto.parserjj contiene la descripción de los analizadores semánticos utilizando la herramienta JavaCC. El paquete contiene la clase SemanticException (idéntica a la incluida en el paquete tinto.parser) y el fichero TintoHeaderParser.jj. El resto son clases generadas automáticamente por JavaCC.

 

 

EL ANÁLISIS EN DOS PASOS DEL COMPILADOR DE TINTO

 

El lenguaje Tinto no utiliza ficheros de cabecera que permitan predeclarar el contenido de una librería, es decir, no existen ficheros ".h" o ".hpp". Esto provoca que no sea posible compilar un fichero Tinto en una única pasada. La razón es que si se realiza una única pasada, el compilador sólo conoce el contenido de la parte de fichero que ya ha analizado, pero no puede saber el contenido de lo que le queda por analizar. Si en el código de una función A() aparece una llamada a una función B() desconocida, el compilador no puede saber si se trata de un error o si la función B() está definida en la parte de fichero que le queda por analizar.

La solución aplicada en el compilador de Tinto es realizar dos pasadas. La primera pasada se hace con un analizador llamado TintoHeaderParser. El objetivo de esta primera pasada es obtener la declaración de las funciones definidas en el fichero analizado, pero sin analizar el cuerpo de estas funciones. El resultado de esta primera pasada es una estructura de datos tinto.ast.struct.LibraryDeclaration que contiene la lista de funciones descrita en el fichero. Estas funciones son objetos tinto.ast.struct.Function en los que el campo body se deja vacío, es decir, no se incluye la lista de instrucciones de la función. La clase principal, TintoCompiler, ejecuta el analizador TintoHeaderParser sobre el fichero "Main.tinto" y sobre todos los ficheros importados desde él, almacenando los objetos tinto.ast.struct.LibraryDeclaration obtenidos dentro de la tabla de símbolos (tinto.ast.struct.SymbolTable).

La segunda pasada se realiza con el analizador TintoParser. En esta segunda pasada se parte de la tabla de símbolos obtenida en la primera pasada y se vuelven a analizar los ficheros ".tinto" ya estudiados. Al comenzar el análisis de un fichero se declara el objeto LibraryDeclaration correspondiente como biblioteca activa (activeLibrary) dentro de la tabla de símbolos. Cada vez que se alcanza la definición de una función en el fichero, se busca el objeto tinto.ast.struct.Function correspondiente que se creó en la primera pasada. En esta segunda pasada sí se analiza el cuerpo de la función, obteniendo la lista de instrucciones (tinto.ast.statement.Statement) de la función y almacenándola en el campo body.

El resultado de las dos pasadas es una tabla de símbolos donde los objetos tinto.ast.struct.LibraryDeclaration contienen toda la información de los ficheros Tinto de entrada.

 

 

LA CLASE tinto.parser.SemanticException

 

La clase tinto.parser.SemanticException permite describir errores de tipo semántico. El compilador de Tinto define un total de 13 errores semánticos diferentes.

El código de la clase comienza con la definición de 13 constantes que representan el código de cada tipo de error. La clase contiene un único campo que almacena el mensaje de error a mostrar. Este mensaje de error se construye a partir del código del error y de la información de la fila y columna del token de referencia del error. El método público toString() devuelve el mensaje de error almacenado. El método privado getExplanationForCode(int code) devuelve el mensaje de error asociado a cada código.

 

 

LA CLASE tinto.parser.TintoHeaderParser

 

La clase tinto.parser.TintoHeaderParser desarrolla un analizador sintáctico descendente recursivo similar al presentado en la práctica 4 pero limitado a la información de cabecera del lenguaje. Para ello se sustituye el análisis del símbolo FunctionBody por un método que lee un bloque de código entre llaves.

El analizador desarrollado contiene técnicas de sincronizacion de errores similares a las presentadas en la práctica 6. En particular, el método skipTo() permite sincronizar el análisis a ciertos tokens. Para desarrollar el esquema de tratamiento de errores se han incluido tanto métodos del tipo parse...()), que analizan sintácticamente un símbolo de la gramática, como métodos del tipo try...() que permiten sincronizarse en caso de error en algún método parse.

La gramática BNF que describe la cabecera de los ficheros Tinto es la siguiente:

  • CompilationUnit  ::=  ImportClauseList  LibraryDecl

  • ImportClauseList  ::=  ImportClause  ImportClauseList

  • ImportClauseList  ::=  lambda

  • ImportClause  ::=  import  identifier  semicolon

  • LibraryDecl  ::=  library  identifier  lbrace  FunctionList  rbrace

  • FunctionList  ::=  FunctionDecl  FunctionList

  • FunctionList  ::=  lambda

  • FunctionDecl  ::=  Access   FunctionType   identifier   ArgumentDecl   FunctionBody

  • Access ::= public 

  • Access ::=  private 

  • FunctionType  ::=  Type

  • FunctionType  ::=  void

  • Type ::= int 

  • Type  ::=  char 

  • Type  ::=  boolean

  • ArgumentDecl  ::=  lparen  ArgumentList  rparen

  • ArgumentList  ::=  Argument   MoreArguments

  • ArgumentList  ::=  lambda

  • MoreArguments  ::=  comma  Argument  MoreArguments

  • MoreArguments  ::=  lambda

  • Argument  ::=  Type  identifier

  • FunctionBody  ::= lbrace StatementList rbrace

La clase tinto.parser.TintoHeaderParser desarrolla no solo un análisis sintáctico sino también un análisis semántico. Esto quiere decir que al mismo tiempo que se realiza el análisis sintáctico, las funciones de análisis van generando las estructuras del árbol de sintaxis abstracta que representan la biblioteca analizada. Para ello es necesario incluir una serie de funciones que realizan verificaciones semánticas y acciones semánticas:

  • boolean verifyLibraryName(Token, LibraryDeclaration) : verificación semántica que comprueba que el nombre del fichero Tinto corresponde con el nombre de la biblioteca.

  • boolean verifyNonDuplicatedFunction(Token, Function, LibraryDeclaration) : verificación semántica que comprueba que una cierta función no haya sido definida previemente, es decir, que no se repitan funciones con los mismos nombres y tipos de datos.

  • boolean verifyNonDuplicatedArgument(Token, Function) : verificación semántica que comprueba que en una función no se repiten argumentos con el mismo nombre.

  • void actionLibraryFunction(int , int , Token , Vector<Variable> , LibraryDeclaration) : acción semántica que crea una función de una biblioteca.

  • void actionAddArgument(int, Token, Vector<Variable>) : acción semántica que añade un argumento a una lista de argumentos.

Para realizar el análisis semántico es necesario modificar los métodos asociados a cada símbolo para añadir las verificaciones y acciones semánticas y para devolver los datos correspondientes. A continuación se describen las diferentes funciones de análisis:

  • LibraryDeclaration tryCompilationUnit(String): analiza el símbolo inicial. Añade el tratamiento de errores al análisis del símbolo CompilationUnit, devolviendo el mismo objeto que genera la función parse...() correspondiente.

  • LibraryDeclaration parseCompilationUnit(String): analiza el símbolo inicial. Devuelve como atributo sintetizado el objeto LibraryDeclaration que describe la biblioteca analizada. El argumento se utiliza para comprobar que el nombre de la biblioteca coincide con el nombre del fichero analizado.

  • void tryImportClauseList(Vector<String>): añade el tratamiento de errores al análisis del símbolo ImportClauseList. Este símbolo tiene como atributo heredado un vector de strings (imported) que permite almacenar los nombres incluidos en las claúsulas import.

  • void parseImportClauseList(Vector<String>): analiza la lista de bibliotecas importadas. Utiliza ecomo atributo heredado un vector de strings (imported) que permite almacenar los nombres incluidos en las claúsulas import.

  • void tryImportClause(Vector<String>): añade el tratamiento de errores al análisis del símbolo ImportClause. Utiliza un vector de strings como atributo heredado para almacenar el nombre de la biblioteca importada.

  • void parseImportClause(Vector<String>): analiza una cláusula de importación. Utiliza el vector de strings como atributo heredado para añadirle la biblioteca importada.

  • LibraryDeclaration tryTintoDecl(String, Vector<String>): añade el tratamiento de errores al análisis del símbolo TintoDecl. Tiene dos atributos heredados (el nombre de la biblioteca y un vector con los nombres de las bibliotecas importadas) y un atributo sintetizado (un objeto LibraryDeclaration con la descripción de la biblioteca analizada).

  • LibraryDeclaration parseTintoDecl(String, Vector<String>): analiza el cuerpo de una biblioteca de Tinto, sea normal o nativa.

  • LibraryDeclaration tryLibraryDecl(String, Vector<String>): analiza el cuerpo de la biblioteca incluyendo el tratamiento de errores.  La función crea el objeto LibraryDeclaration con el nombre indicado y le añade la lista de biblitecas importadas. A continuación lanza el métod parse...() para analizar el símbolo LibraryDecl, pasándole como atributo heredado el objeto LibraryDeclaration.

  • void parseLibraryDecl(LibraryDeclaration): analiza el cuerpo de la biblioteca. Utiliza el objeto LibraryDeclaration como atributo heredado. Verifica que el identificador analizado corresponde con el nombre de la biblioteca (que se extrajo del nombre del fichero ".tinto") y traslada el objeto como atributo heredado del símbolo FunctionList.

  • void tryFunctionList(LibraryDeclaration): añade el tratamiento de errores al analisis del símbolo FunctionList. Utiliza como atributo heredado el objeto LibraryDeclaration que describe la biblioteca que se está analizando y lo pasa a la función parseFunctionList().

  • void parseFunctionList(LibraryDeclaration): analiza la lista de funciones definidas en la biblioteca. Utiliza el objeto LibraryDeclaration para pasarlo como atributo heredado al símbolo FunctionDecl, que le añadirá la función que analice.

  • void tryFunctionDecl(LibraryDeclaration): añade el tratamiento de errores al análisis del símbolo FunctionDecl.

  • void parseFunctionDecl(LibraryDeclaration): analiza una función definida en la biblioteca y ejecuta la acción semántica actionLibraryFunction() para crear un objeto Function y añadirlo al objeto LibraryDeclaration (que recibe como atributo heredado).

  • LibraryDeclaration tryNativeDecl(String, Vector<String>): analiza el cuerpo de la biblioteca nativa incluyendo el tratamiento de errores.  El funcionamiento es similar al de las bibliotecas normales salvo la llamada al método setNative() del objeto LibraryDeclaration.

  • void parseNativeDecl(LibraryDeclaration): analiza el cuerpo de la biblioteca nativa utilizando el objeto LibraryDeclaration como atributo heredado. Verifica que el identificador analizado corresponde con el nombre de la biblioteca (que se extrajo del nombre del fichero ".tinto") y traslada el objeto como atributo heredado del símbolo NativeFunctionList.

  • void tryNativeFunctionList(LibraryDeclaration): añade el tratamiento de errores al analisis del símbolo NativeFunctionList.

  • void parseNativeFunctionList(LibraryDeclaration): analiza la lista de funciones definidas en la biblioteca nativa.

  • void tryNativeFunctionDecl(LibraryDeclaration): añade el tratamiento de errores al análisis del símbolo NativeFunctionDecl.

  • void parseNativeFunctionDecl(LibraryDeclaration): analiza una función definida en la biblioteca nativa y ejecuta la acción semántica actionLibraryFunction() para crear un objeto Function y añadirlo al objeto LibraryDeclaration (que recibe como atributo heredado).

  • int tryAccess(): añade el tratamiento de errores al anáisis del símbolo Access. Genera como atributo sintetizado el código correspondiente al tipo de acceso encontrado.

  • int parseAccess(): analiza el símbolo Access. Genera como atributo sintetizado el código correspondiente al tipo de acceso encontrado: Access.PUBLIC_ACCESS o Access.PRIVATE_ACCESS.

  • int tryFunctionType(): añade el tratamiento de errores al análisis del símbolo FunctionType. Devuelve como atributo sintetizado el código del tipo de dato de la función.

  • int parseFunctionType(): analiza el tipo de dato de un método. Devuelve el código del tipo de dato como atributo sintetizado.

  • int tryType(): añade el tratamiento de errores al análisis del símbolo Type. Devuelve como atributo sintetizado el código del tipo de dato.

  • int parseType(): analiza un tipo de dato simple. Devuelve el código del tipo como atributo sintetizado.

  • Vector<Variable> tryArgumentDecl(): añade el tratamiento de errores al análisis del símbolo ArgumentDecl. Genera como atributo sintetizado un vector de objetos Variable que describe los argumentos de la función analizada.

  • Vector<Variable> parseArgumentDecl(): analiza los argumentos de un método. Genera como atributo sintetizado un vector de objetos Variable que describe los argumentos de la función analizada.

  • void tryArgumentList(Vector<Variable>): añade el tratamiento de errores al análisis del símbolo ArgumentList. Utiliza como atributo heredado un vector de objetos Variable al que irá añadiendo los argumentos declarados en la función que se esté analizando.

  • void parseArgumentList(Vector<Variable>): analiza la lista de argumentos de una función. Utiliza un vector de objetos Variable como atributo heredado. El proceso de análisis consistirá en añadir a este vector las declaraciones de los argumentos encontrados.

  • void tryArgument(Vector<Variable>): añade el tratamiento de errores al análisis del símbolo Argument. Utiliza como atributo heredado un vector de objetos Variable al que añadirá la definición del argumento analizado.

  • void parseArgument(Vector<Variable>): analiza un argumento de una función. Para ello recoge la información respecto al tipo de dato y el identificador del argumento y ejecuta la acción semántica  actionAddArgument(). Esta acción se encarga de verificar que el identificador no está duplicado y de construir y añadir un objeto Variable con la descripción del argumento analizado.

  • void tryMoreArguments(Vector<Variable>): añade el tratamiento de errores al análisis del símbolo MoreArguments. Utiliza como atributo heredado el vector de objetos Variable que almacena la información de los argumentos de la función analizada.

  • void parseMoreArguments(Vector<Variable>) : analiza el resto de lista de argumentos de una función. Utiliza el vector de objetos Variable como atributo heredado con la lista de argumentos analizados.

  • void parseFunctionBody(): analiza el cuerpo de una función. El analizador de cabecera no realiza ninguna acción semántica en este punto, así que la función se limita a recorrer un bloque de instrucciones, contando las llaves abiertas y cerradas para detectar el final del bloque de instrucciones.

 

 

EL ANÁLISIS SEMÁNTICO EN LA HERRAMIENTA JAVACC

 

Como se comentó en la práctica 3, el código de una especificación en JavaCC está formado por cuatro secciones. La primera sección corresponde a las opciones de la herramienta. La segunda sección describe el nombre del analizador a generar y parte del código de la clase que debe generar el parser (el resto se genera automáticamente). La tercera sección describe la especificación léxica de la gramática, que se utiliza para crear la clase que desarrolla el analizador léxico (conocido como TokenManager). La cuarta sección incluye la descripción sintáctica de la gramática en formato EBNF.

La descripción semántica supone realizar cambios en la segunda sección y en la cuarta sección. En la segunda sección se pueden incluir funciones auxiliares que desarrollen verificaciones semánticas o acciones como las presentadas en el apartado anterior ( verifyNonDuplicatedFunction(), actionAddArgument() ). En la cuarta sección se modifica la descripción sintáctica para transformarla en una descripción semántica. Esto supone incluir atributos heredados, atributos sintetizados y acciones semánticas.

La especificación sintáctica en JavaCC está formada por definiciones del tipo:

  void simbolo() throws Excepcion1, Excepcion2 :
   { }
   {
     descripción_EBNF
   }

Los atributos heredados se definen como argumentos de llamada al símbolo (por ejemplo, String name). El atributo sintetizado se define como el tipo de dato que devuelve el símbolo en lugar de void. El primer bloque permite declarar código a ejecutar antes de analizar el símbolo y generalmente se utiliza para declarar variables locales. Las acciones semánticas se escriben entre llaves dentro de la descripción EBNF. Para acceder a los atributos sintetizados de un símbolo se utilizan asignaciones a variables locales (por ejemplo, library = tryLibraryDecl(name, imported) ). Los componentes léxicos tienen asociado un atributo sintetizado de tipo Token (por ejemplo, tid = <IDENTIFIER>). Es necesario incluir las instrucciones return para devolver los atributos sintetizados.

A continuación se muestra un ejemplo correspondiente al símbolo FunctionDecl, que se ha modificado para aceptar un atributo heredado (library), recoger la información generada como atributos sintetizados por los símbolos Access, FunctionType y ArgumentDecl, recoger la información del token IDENTIFIER y ejecutar la acción semántica actionLibraryFunction().


  void parseFunctionDecl(LibraryDeclaration library) :
  {
    int acc;
    int type;
    Token tid;
    Vector<Variable> args;
  }
  {
    acc = tryAccess()
    type = tryFunctionType()
    tid = <IDENTIFIER>
    args = tryArgumentDecl()
    FunctionBody()
    {
      actionLibraryFunction(acc,type,tid,args,library);
    } 
  }

El paquete tinto.parserjj incluye la descripción en JavaCC del analizador semántico para la cabecera de los ficheros Tinto y del analizador semántico para el cuerpo de los métodos de los ficheros Tinto. Con respecto al analizador semántico para la cabecera (TintoHeaderParser.jj), se han añadido las mismas funciones de verificaciones semánticas y acciones semánticas comentadas en el apartado anterior. La descripción EBNF se ha enriquecido para considerar la semántica asociada al lenguaje (atributos heredados, sintetizados y aciones semánticas). El análisis del símbolo FunctionBody se ha definido por medio de un bloque JAVACODE dedicado a recorrer el bloque de instrucciones de la función sin realizar ningún tipo de comprobación sobre este contenido.