Escuela Técnica Superior de Ingeniería

 

Grado en Ingeniería Informática

Procesadores de Lenguajes

Curso 2022/2023

 

Práctica 6
Tratamiento de errores

 

OBJETIVOS

 

  • Describir las técnicas de tratamiento de errores de los analizadores descendentes recursivos.
  • Describir la implementación del analizador sintáctico del lenguaje Tinto con tratamiento de errores.
  • Describir el formato Javacode de especificación sintáctica en JavaCC.
  • Describir las técnicas de tratamiento de errores incluidas en JavaCC.
  • Describir la implementación en JavaCC del analizador sintáctico del lenguaje Tinto con tratamiento de errores.

 

CÓDIGO A UTILIZAR

 

El código de esta práctica contiene dos versiones del analizador léxico y sintáctico de tinto a las que se les ha añadido el tratamiento de errores. La versión programada a mano se encuentra en el paquete tinto.parser mientras que la versión en JavaCC se encuentra en el paquete tinto.parserjj.

 

SINCRONIZACIÓN DE ERRORES

 

La implementación de un analizador sintáctico descendente recursivo basado en gramáticas LL(1) que se describió en la práctica 4 permite comprobar si un determinado fichero de entrada es correcto o no. En caso de error, el analizador aborta su ejecución lanzando una excepción y se informa de que el resultado es incorrecto. Cuando el fichero de entrada contiene varios errores, el analizador solo detecta e informa del primero de ellos. En un proceso de compilación, esto obliga al programador a corregir los errores uno a uno sin saber de antemano cuantos errores hay en total. Para detectar todos los errores sintácticos que contiene una entrada es necesario aplicar técnicas de tratamiento de errores que permitan al analizador sincronizarse de nuevo tras un error para poder seguir analizando la entrada.

El análisis descendente permite desarrollar técnicas de tratamiento de errores fácilmente por medio de tokens de sincronismo. La técnica consiste en analizar cada símbolo de la gramática considerando que va a ser correcto y, en caso de que se detecte un error sintáctico, avanzar en la cadena de tokens hasta consumir el token que debería estar al final del símbolo o alcanzar el token que corresponda al principio del siguiente símbolo. Para controlar los errores, el analizador sintáctico debe incorporar dos campos que lleven el control del número de errores detectados (errorCount) y el mensaje de error a mostrar agrupando todos los errores detectados (errorMsg). Al detectar un nuevo error hay que lanzar el método catchError(), que incrementa el número de errores y añade la descripción al mensaje de error, y avanzar hasta alguno de los tokens de sincronismo mediante el método skipTo().


    /**
     * Contador de errores
     */
    private int errorCount;

    /**
     * Mensaje de errores
     */
    private String errorMsg;    

    /* Obtiene el número de errores del análisis
     * @return
     */
    public int getErrorCount()
    {
      return this.errorCount;
    }

    /**
     * Obtiene el mensaje de error del análisis
     * @return
     */
    public String getErrorMsg()
    {
      return this.errorMsg;
    }

    /**
     * Almacena un error de análisis
     * @param ex
     */
    private void catchError(Exception ex)
    {
      this.errorCount++;
      this.errorMsg += ex.toString();
    }

    /**
     * Sincroniza la cadena de tokens
     * @param left
     * @param right
     */
    private void skipTo(int[] left, int[] right) 
    {
      boolean flag = false;
      if(prevToken.getKind() == EOF || nextToken.getKind() == EOF) flag = true;
      for(int i=0; i<left.length; i++)
      {
        if(prevToken.getKind() == left[i]) flag = true;
      }
      for(int i=0; i<right.length; i++)
      {
        if(nextToken.getKind() == right[i]) flag = true;
      }

      while(!flag) 
      {
        prevToken = nextToken;
        nextToken = lexer.getNextToken();
        if(prevToken.getKind() == EOF || nextToken.getKind() == EOF) flag = true;
        for(int i=0; i<left.length; i++) 
        {
          if(prevToken.getKind() == left[i]) flag = true;
        }
        for(int i=0; i<right.length; i++)
        {
          if(nextToken.getKind() == right[i]) flag = true;
        }
      }
    }
	      
          

Para incluir el tratamiento de errores en el análisis hay que crear un nuevo método asociado a cada símbolo no terminal de la gramática, de manera que a cada símbolo A se le va a asociar los métodos parseA() y tryA(). El método tryA() envuelve la llamada al método parseA() en un bloque try-catch, de manera que si se produce una excepción sintáctica en parseA() se captura el error y se sincroniza el analizador. Las referencias al símbolo A deben tratarse como llamadas al método tryA(). A continuación se muestra un ejemplo de las funciones asociadas al símbolo ArgumentDecl.


    /**
     * Analiza el símbolo <ArgumentDecl>
     */
    private void tryArgumentDecl() 
    {
      int[] lsync = { RPAREN };
      int[] rsync = { LBRACE };
      try
      {
        parseArgumentDecl();
      }
      catch(Exception ex)
      {
        catchError(ex);
        skipTo(lsync,rsync);
      }
    }

    /**
     * Analiza el símbolo <ArgumentDecl>
     * @throws SintaxException
     */
    private void parseArgumentDecl() throws SintaxException 
    {
      int[] expected = { LPAREN };
      switch(nextToken.getKind()) 
      {
      case LPAREN:
        match(LPAREN);
        tryArgumentList();
        match(RPAREN);
        break;
      default:
        throw new SintaxException(nextToken,expected);
      }
    }
    	      
          

Con respecto a los tokens de sincronismo, una opción sencilla es considerar el conjunto siguientes del símbolo como conjunto right de sincronización. Sin embargo, cuando los tokens que aparecen en este conjunto son tokens que también pueden aparecer en el contenido del símbolo la sincronización puede ser erronea. Esto provoca que el analizador encuentre errores en cascada provocados por una mala sincronización. En general escoger unos tokens de sincronismo adecuados es una tarea compleja y resulta casi imposible evitar que ciertas condiciones produzcan errores en cascada. En cualquier caso, el objetivo no es desarrollar un sincronizador de errores perfecto sino un tratamiento de errores que facilite el proceso de compilación.

 

 

ESPECIFICACIONES SINTÁCTICAS DESCRITAS EN JAVA

 

En las prácticas anteriores hemos comentado la sintaxis básica de JavaCC en cuanto a especificaciones léxicas y sintácticas. Las primeras se basan en declaraciones de cuatro tipos: TOKEN, SKIP, MORE y SPECIALTOKEN. Las especificaciones sintácticas, por su parte, se basan en la descripción de la función asociada a cada símbolo no terminal por medio de una expresión EBNF. Existe un tipo de especificación sintáctica especial que no utiliza el formato de descripción EBNF sino una descripción directa de la función en Java. La sintaxis de este tipo de especificación es la siguiente:

 JAVACODE
 método_Java

Este tipo de descripción sintáctica se suele utilizar para la gestión de errores, ya que permite definir métodos de tipo skipTo(), es decir, métodos para eliminar tokens hasta llegar a un token de un determinado tipo que sirve como token de sincronismo. A continuación se muestran dos ejemplos de definición de este tipo de función.

 JAVACODE
 void skipTo(int kind) {
    Token tok = getToken(0);
    while(tok.kind != EOF && tok.kind != kind) tok = getNextToken();
 }


 JAVACODE
 void skipTo(String st) {
    Token tok = getToken(0);
    while(tok.kind != EOF && !tok.image.equals(st)) tok = getNextToken();
 }

El primer ejemplo define una función de sincronismo basada en el código del token deseado (parámetro kind). Este código es un valor entero que se almacena en el campo kind de los objetos de la clase Token. El segundo ejemplo define una función de sincronismo basada en el lexema del token deseado (parámetro st). Este lexema se almacena en el campo image de los objetos de la clase Token.

El código de estas funciones utiliza los métodos getToken() y getNextToken(), definidos en todos los analizadores sintácticos generados por JavaCC. El método getToken(int i) devuelve el i-ésimo token disponible en el flujo de entrada, de manera que para el valor 0 se devuelve el último token consumido. Este método no supone un avance en la cadena de tokens del flujo de entrada. Por su parte, el método getNextToken() devuelve el siguiente token disponible en el flujo de entrada y lo consume, es decir, lo elimina de la entrada.

Otro aspecto importante del contenido de estas funciones es que pueden utilizar los identificadores léxicos de los tokens como valores enteros, como el identificador EOF en estos ejemplos. Esto es posible porque estos identificadores se utilizan para generar constantes enteras (definidas en la interfaz NombreAnalizadorConstants) que están disponibles en el código del analizador sintáctico.

Las funciones definidas por medio de declaraciones JAVACODE pueden ser utilizadas como símbolos no terminales y, como tales, pueden incluirse en las especificaciones EBNF de otras producciones. Es importante tener en cuenta que con este tipo de definición no es posible calcular el conjunto de predicción, por lo que no deben utilizarse nunca como primer elemento de una definición EBNF, ya que el analizador no sabría si ante un determinado token debe ejecutar ese símbolo o no.

A continuación se presenta una versión más elaborada de la función skipTo(). En este caso se definen dos listas de tokens: left y right. La lista left contiene los tokens de sincronismo que deben quedar a la izquierda del punto de sincronización, es decir, como tokens ya leídos. La lista right contiene los tokens de sincronismo que deben quedar a la derecha del punto de sincronización, es decir, como los siguientes tokens a leer. La función skipTo() consume tokens hasta encontrar el final de entrada o uno de los tokens de sincronismo. Si el token se encuentra en la lista left también se consume. Si se encuentra en la lista right se deja como siguiente token a consumir.

JAVACODE
void skipTo(int[] left, int[] right) {
   Token prev = getToken(0);
   Token next = getToken(1);
   boolean flag = false;
   if(prev.kind == EOF || next.kind == EOF) flag = true;
   for(int i=0; i<left.length; i++) if(prev.kind == left[i]) flag = true;
   for(int i=0; i<right.length; i++) if(next.kind == right[i]) flag = true;

   while(!flag) {
      getNextToken();
      prev = getToken(0);
      next = getToken(1);
      if(prev.kind == EOF || next.kind == EOF) flag = true;
      for(int i=0; i<left.length; i++) if(prev.kind == left[i]) flag = true;
      for(int i=0; i<right.length; i++) if(next.kind == right[i]) flag = true;
   }
}

 

 

TRATAMIENTO DE ERRORES

 

La herramienta JavaCC utiliza dos tipos de excepciones: TokenMgrError y ParseException. El lenguaje Java distingue entre errores y excepciones. Los primeros son subclases de Error y describen problemas graves de los que no se espera una recuperación y el compilador de Java no se exige su tratamiento. Por su parte, las excepciones son subclases de la clase Exception y describen fallos de los que podría ser posible una recuperación, por lo que el compilador exige que sean tratadas en el código.

La herramienta JavaCC asume que una especificación léxica correcta debe contemplar todas las posibilidades de la entrada, por lo que describe los fallos léxicos como errores que almacena en objetos de la clase TokenMgrError. Si este error no es capturado, la ejecución del analizador escribe un mensaje describiendo el error léxico encontrado y la posición (línea y columna) del fichero de entrada donde se ha producido.

Los fallos sintácticos son contemplados como excepciones y almacenados en objetos de la clase ParseException. Si estas excepciones no son tratadas, el analizador termina escribiendo un mensaje del tipo “Encontrado ..., se esperaba uno de los siguientes ...” e informando de la posición del fichero de entrada en la que se ha producido.

Todas las especificaciones sintácticas descritas en notación EBNF pueden generar tanto errores léxicos como excepciones sintácticas. Una producción descrita como “tipo simbolo() : ” genera un método Java con la cabecera “tipo simbolo() throws ParseException”. Teniendo en cuenta que el código añadido en las acciones semánticas puede provocar nuevas excepciones de tipos diferentes, JavaCC incorpora la posibilidad de declarar estas excepciones en la cabecera de las producciones de la siguiente forma:

  tipo identificador(lista_de_parámetros) throws Excepcion1, Excepcion2 :
   { código_JAVA }
   {
     descripción_EBNF
   }

Las excepciones declaradas de esta forma se añaden a ParseException en la cabecera del método Java asociado a la producción en el código del analizador generado por la herramienta. El uso de nuevas excepciones resulta muy útil para describir errores semánticos. La gestión de errores no se limita únicamente a detectarlos y emitir informes de los mismos. JavaCC desarrolla una técnica que permite capturar los errores y tratarlos, permitiendo que el analizador se recupere de estos errores mediante técnicas de sincronismo. Esta técnica se basa en el uso de bloques try-catch en las especificaciones EBNF de una forma muy similar a la utilizada en Java. A continuación se muestra un ejemplo que ilustra la sintaxis de estos bloques try-catch.

  void Sentencia() :
  {}
  {
    try { ( SentenciaIF() | SentenciaWHILE() | SentenciaFOR() ) }
    catch(ParseException e) { informar(e); skipTo(PUNTOYCOMA); }
    catch(TokenMgrError e) { informar(e); skipTo(PUNTOYCOMA); }
  }

Como se puede observar en el ejemplo, los bloques try pueden incluir una especificación EBNF tal y como las veníamos utilizando hasta ahora. El funcionamiento de estos bloques es similar al que desarrolla Java. La descripción EBNF produce un código que puede generar diferente tipo de excepciones. Si alguna de ellas se produce, puede ser capturada por los bloques catch y ser tratada dentro de estos bloques. Generalmente los bloques catch se dedican a almacenar el error en alguna lista interna y ejecutar una función de sincronismo.