unidad V ARBOL AST

Árboles de sintaxis abstracta (ASTs)


ANTLR

Los ASTs (Abstract Syntax Trees, o Árboles de Sintaxis Abstracta) sirven para

manejar la información semántica de un código. La forma más eficiente de manejar la

información proveniente de un lenguaje de programación es la forma arbórea; por éso la

estructura de datos elegida es un árbol. Además, construyendo ASTs a partir de un texto

podemos obviar mucha información irrelevante; si un AST se construye bien, no habrá

que tratar con símbolos de puntuación o azúcar sintáctica en el nivel semántico.

Al contrario que los flujos, una estructura en árbol puede especificar la relación

jerárquica entre los símbolos de una gramática.




Los ASTs pueden intervenir en varias fases del análisis: como producto del

análisis sintáctico, como elemento intermedio en sucesivos análisis semánticos y como

entrada para la generación de código.
La forma normal de representar un árbol en ANTLR utiliza una notación basada


en LISP. Por ejemplo:

#(A B C)


Es un árbol con “A” en la raíz, y los hijos B y C.

Esta notación puede anidarse para describir árboles de estructuras más complejas. Así:

#(A B #(C D E))
 
En esta práctica estudiaremos los mecanismos que proporciona ANTLR para


crear árboles de sintaxis abstracta. Estos aspectos (junto con la notación utilizada para

recorrer dichos árboles) constituyen la parte más original de la herramienta, la solución

aportada para describir dichos árboles a partir de la especificación sintáctica es

realmente imaginativa y da lugar a descripciones bastante claras y compactas.

Construcción del árbol de sintaxis abstracta

ANTLR no requiere de una especificación adicional para definir los árboles de

sintaxis abstracta. En su lugar inserta una serie de símbolos en la propia gramática

concreta que establecen qué elementos merece la pena mantener en árbol de sintaxis

abstracta y a partir de ahí lo genera de forma automática. De esta forma con tan sólo

enriquecer la especificación del análisis sintáctico se tiene resuelta la especificación y

construcción del árbol de sintaxis abstracta. La siguiente especificación construye el

árbol de sintaxis abstracta para el lenguaje de las expresiones que venimos usando como

ejemplo en las prácticas de la asignatura:

///////////////////////////////

// Analizador sintáctico

///////////////////////////////

class Anasint extends Parser;

options

{

buildAST = true;

}

tokens

{

LISTA_INST;

}

instrucciones : (expresion ";"!)* EOF!

{##=#(#[LISTA_INST,"LISTA_INST"],##);}

;

expresion : exp_mult (("+"^
"-"^) exp_mult)* ;

exp_mult : exp_base (("*"^
"/"^) exp_base)* ;

exp_base : NUMERO


"("! expresion ")"!

;
A simple vista la especificación anterior puede parecer un poco críptica, pero


una vez que se comprende el significado de los distintos elementos en juego se descubre

que resulta muy cómodo especificar la construcción de árboles con ellos:

􀂃 La sección options incluye la instrucción buildAST=true. De esta

forma se activa la construcción automática del árbol de sintaxis abstracta.

􀂃 La sección tokens permite añadir nuevos tokens a los ya definidos en el

análisis léxico. En este caso serán tokens virtuales que servirán como raíz

a árboles para los que en la gramática no hay ningún token que pueda

desempeñar esta función. Esto suele ser muy común en las listas, como

ocurre en el ejemplo con LISTA_INST.

􀂃 Cuando un símbolo va seguido del operador ! se considerará no

relevante y no se incluirá en el árbol de sintaxis abstracta.

􀂃 El operador ^ indica que el símbolo al que acompaña debe ser la raíz del

árbol. Si en una regla no se destaca ningún símbolo como raíz, el árbol

generado será una secuencia de hermanos sin padre.

􀂃 Se puede deshabilitar la construcción automática y sustituirla por otra.

Esto es lo que se hace en la regla de instrucciones para dotar de un

padre a la secuencia de expresion.

􀂃 Con el elemento #[LISTA_INST,"LISTA_INST"] se crea un árbol

cuya raíz es el token ficticio LISTA_INST. La operación #(...)

toma una lista de árboles y construye un árbol en el que el primer

elemento hace de raíz.

􀂃 El elemento ## hace referencia al árbol generado de forma automática

para el símbolo de la parte izquierda de la regla. También se podría

referenciar con el nombre del símbolo precedido de # (en el ejemplo

sería #instrucciones).

¿Qué se puede hacer con el árbol?

En la próxima práctica veremos la notación que ANTLR proporciona para

especificar recorridos de árboles de sintaxis abstracta. Pero antes de llegar a eso

podemos aprovecharnos de distintas facilidades ya implementadas para al menos

comprobar que los árboles construidos se corresponden con los que realmente

queremos.

El comportamiento de los árboles de sintaxis abstracta se establece a través de la

interfaz AST. Por defecto los árboles son objetos de la clase CommonAST que

implementa dicha interfaz, y que a su vez es subclase de BaseAST. La clase BaseAST

implementa todos los métodos de la interfaz AST salvo los relacionados con la

información contenida en las raíces que son implementados en CommonAST (lo que

permite extender esta información con facilidad). Algunos de los métodos de que

disponen los árboles de sintaxis abstracta son:

􀂃 AST getFirstChild(): devuelve el primer hijo de un nodo interno de

un árbol.

􀂃 AST getNextSibling(): devuelve el hermano derecho de un árbol.

􀂃 boolean equals(AST t): comprueba si las raíces de ambos árboles

son iguales.

􀂃 boolean equalsTree(AST t): comprueba si los árboles son iguales.

􀂃 boolean equalsTreePartial(AST t): comprueba si t es una

versión ampliada del árbol que llama al método.

􀂃 boolean equalsList(AST t): igual que equalsTree pero admite

como entrada una lista de árboles (un árbol sin raíz).

􀂃 boolean equalsListPartial(AST t): igual que el método

equalsTreePartial pero admite como entrada una lista de árboles.

􀂃 String getText(): obtiene el texto asociado a la raíz.

􀂃 int getType(): obtiene el tipo asociado a la raíz (habitualmente será un

tipo de token).

􀂃 String toString(): convierte la raíz del árbol a una cadena.

􀂃 String toStringTree(): convierte el árbol completo en una cadena.

􀂃 String toStringList(): igual que toStringTree pero admite

como entrada una lista de árboles.

Con todos estos métodos ya podemos empezar a manipular árboles, lo único que

necesitamos es acceder al árbol construido durante el análisis sintáctico, eso lo

conseguiremos gracias al método getAST de la clase Anasint. Por ejemplo, la

siguiente clase principal recupera el árbol y lo imprime haciendo uso del método

toStringTree:

/////////////////////////////////////

// Procesador.java (clase principal)

/////////////////////////////////////

import java.io.*;

import antlr.collections.AST;

import antlr.*;

public class Procesador {

public static void main(String args[]) {

try {

FileInputStream fis =

new FileInputStream(args[0]);

Analex analex = null;

Anasint anasint = null;

AST arbol = null;

analex = new Analex(fis);

anasint = new Anasint(analex);

anasint.instrucciones();

arbol = anasint.getAST();

System.out.println(arbol.toStringTree());

}catch(ANTLRException re) {

System.err.println(re.getMessage());

}catch(FileNotFoundException fnfe) {

System.err.println("No existe el fichero");

}

}

}

Si ejecutamos este programa tras el reconocimiento se mostrará en pantalla una

versión textual del árbol construido. Por ejemplo para la entrada:

1+2+3;

4*5+6;

La salida sería la siguiente:

( LISTA_INST ( + ( + 1 2 ) 3 ) ( + ( * 4 5 ) 6 ) )

Esto nos puede servir de ayuda cuando los textos reconocidos son pequeños,

pero para entradas de mayor tamaño resulta muy complejo interpretar correctamente la

versión textual del árbol Afortunadamente ANTLR proporciona otra vía para comprobar

el contenido de los árboles. Se trata de la clase ASTFrame incluida en el paquete

antlr.debug.misc, que muestra el árbol en una ventana SWING. Para crear un

objeto de la clase ASTFrame debemos especificar el título de la ventana y el árbol a

representar. Una vez creado el objeto podremos visualizar el árbol con el método

setVisible. Si por ejemplo queremos crear una ventana cuyo título sea el nombre

del fichero que estamos analizando deberíamos hacer algo así:

ASTFrame frame= new ASTFrame(args[0],arbol);

frame.setVisible(true);

Lo que daría lugar a una ventana como la siguiente:


Los distintos árboles y subárboles podrán plegarse y desplegarse, por lo que

puede comprobarse la estructura de un árbol con gran facilidad incluso en el caso de

árboles muy grandes.

Ampliando la clase CommonAST

De la misma forma que en su momento fuimos capaces de extender la clase

CommonToken para incluir más atributos, también está abierta esa posibilidad de

extensión para la clase CommonAST. Por ejemplo, la siguiente clase incluye como

atributo de los árboles la línea en la que se encuentra el token correspondiente:

import antlr.*;

import antlr.collections.*;

public class MiArbol extends CommonAST {

// Nuevo atributo

int linea;

// Constructor vacío

public MiArbol() {

}

// Constructor a partir de un token

public MiArbol(Token t){

initialize(t);

}

// Constructor a partir de un árbol

public MiArbol(AST a) {

initialize(a);

}

// Inicialización a partir de un token

public void initialize(Token t) {

super.initialize(t);

setLinea(t.getLine());

}

// Inicialización a partir de un árbol

public void initialize(AST a)

{

super.initialize(a);

if(a instanceof MiArbol) {

setLinea(((MiArbol)a).getLinea());

}

}

// Métodos de actualización y acceso

// del atributo linea

public void setLinea(int l) {

linea=l;

}

public int getLinea() {

return linea;

}

// Redefinición del método toString

public String toString() {

StringBuffer sb = new StringBuffer("");

String textoCommonAST = super.toString();

sb.append(textoCommonAST);

sb.append( " (línea: ");

sb.append( linea );

sb.append( ")" );

return sb.toString();

}

}

La definición de la clase anterior incluye:

􀂃 La definición del atributo linea y de los correspondientes métodos

de actualización y consulta.

􀂃 Tres métodos constructores de la clase MiArbol: sin argumentos, a

partir de un token y a partir de otro árbol.

􀂃 La redefinición del método initialize que es el encargado de

actualizar los atributos de los árboles en el momento de la creación.

La actualización se puede hacer desde los datos proporcionados por

un token o por un árbol.

􀂃 La redefinición del método toString para que se incluya el

atributo linea al convertir el árbol a texto.

Una vez hecho esto tan sólo falta indicarle al analizador sintáctico que los

árboles que tiene que crear deben ser de la nueva clase. Esto se consigue con el método

setASTNodeType de la clase Anasint:

/////////////////////////////////////

// Procesador.java (clase principal)

/////////////////////////////////////

...

public class Procesador {

public static void main(String args[]) {

...

anasint = new Anasint(analex);

anasint.setASTNodeClass("MiArbol");

anasint.instrucciones();

...

}

}

Gracias a la redefinición del método toString, la visualización del árbol

también presentará el valor del nuevo atributo:


Cuando la sintaxis abstracta difiere de la concreta

Los operadores ^ y ! son muy potentes y dan lugar a especificaciones muy

compactas, pero no siempre tendremos suficiente con ellos para construir los árboles

que necesitaremos. El siguiente lenguaje nos plantea un problema de construcción de

árboles que no podrá ser resuelto de forma automática con estos operadores y para el

que necesitaremos especificar la manera en que deben tratarse ciertas reglas. Se trata del

lenguaje de las declaraciones de C:

int a,b,c;

char d,e,f;

Una primera solución a la construcción del árbol sería la siguiente:

declaraciones : (declaracion)*

{##=#(#[LISTA_DEC,"LISTA_DEC"],##);};

declaracion : t:tipo i:idents ";"!

{##=#(#t,#i);};

tipo : CHAR
INT ;

idents : IDENT (","! IDENT)*;

La regla asociada a declaraciones introduce el token ficticio LISTA_DEC para

enraizar la lista de la misma forma que se hizo con el token LISTA_INST en el primer

ejemplo del enunciado. La otra regla que no se resuelve con la construcción automática

es la de declaracion, en esta ocasión esto es debido a que el operador ^ sólo puede

aplicarse a símbolos terminales, de manera que si queremos que el árbol devuelto por

tipo sea la raíz debemos especificarlo directamente con el constructor #(...). Para

ello se utilizan las etiquetas t e i que, precedidas del operador #, dan acceso a los

árboles de tipo e idents. Con esta especificación el árbol para el ejemplo anterior

sería:


Puede que nos interese asociar a cada identificador el tipo, de manera que nos

quedase el mismo árbol que si la entrada hubiese sido:

int a;

int b;

int c;

char d;

char e;

char f;

De hecho es esto último lo que queremos expresar, pero el lenguaje nos permite

compartir una única aparición del tipo con varios identificadores para ahorrarnos

escritura. Si quisiésemos generar este tipo de árboles la especificación sería la siguiente:

declaracion : t:tipo! idents[#t] ";"!

;

tipo : CHAR


INT

;

idents[AST t] : ident[t] (","! ident[t])*

;

ident[AST t]: i:IDENT

{AST ct= astFactory.dupTree(t);

##=#(#[DECLAR,"DECLAR"],ct,#i);}

;

Lo más destacable de la especificación anterior es:

􀂃 El árbol asociado al símbolo tipo se desactiva ya que vendrá integrado en el

devuelto por el símbolo idents.

􀂃 El símbolo idents recibe como atributo heredado el tipo a través de la

etiqueta #t, este árbol es a su vez propagado al símbolo ident.

􀂃 En la regla ident se hace una copia al árbol del tipo (con el método

dupTree del objeto astFactory declarado en la clase Anasint) y se

utiliza como raíz el token ficticio DECLAR.

El árbol resultante quedaría ahora así: