¿Cómo se crea exactamente un árbol de sintaxis abstracta?

47

Creo que entiendo el objetivo de un AST, y he construido un par de estructuras de árbol antes, pero nunca un AST. Estoy bastante confundido porque los nodos son texto y no número, por lo que no puedo pensar en una buena manera de ingresar un token / cadena mientras estoy analizando algún código.

Por ejemplo, cuando miré los diagramas de AST, la variable y su valor eran nodos hoja a un signo igual. Esto tiene mucho sentido para mí, pero ¿cómo haría para implementar esto? Supongo que puedo hacerlo caso por caso, de modo que cuando tropiezo con un "=" lo uso como un nodo y agrego el valor analizado antes del "=" como la hoja. Simplemente parece incorrecto, porque probablemente tendría que hacer casos para toneladas y toneladas de cosas, dependiendo de la sintaxis.

Y luego me encontré con otro problema, ¿cómo se atraviesa el árbol? ¿Voy todo el camino por la altura, y regreso a un nodo cuando toco el fondo, y hago lo mismo para su vecino?

He visto toneladas de diagramas en AST, pero no pude encontrar un ejemplo bastante simple de uno en el código, lo que probablemente ayudaría.

Howcan
fuente
El concepto clave que te estás perdiendo es la recursividad . La recursión es un poco contraintuitiva, y es diferente para cada alumno cuando finalmente hará 'clic' con ellos, pero sin recurrencia, simplemente no hay forma de entender el análisis (y también muchos otros temas computacionales).
Kilian Foth
Tengo recurrencia, solo pensé que sería difícil implementarlo en este caso. En realidad, quería usar la recursión y terminé con muchos casos que no funcionarían para una solución general. La respuesta de Gdhoward me está ayudando mucho en este momento.
Howcan
Puede ser un ejercicio construir una calculadora RPN como ejercicio. No responderá a su pregunta, pero podría enseñar algunas habilidades necesarias.
De hecho, he construido una calculadora RPN antes. Las respuestas me ayudaron mucho y creo que puedo hacer un AST básico ahora. ¡Gracias!
Howcan

Respuestas:

47

La respuesta corta es que usas pilas. Este es un buen ejemplo, pero lo aplicaré a un AST.

Para su información, este es el algoritmo de Edsger Dijkstra Shunting-Yard .

En este caso, usaré una pila de operadores y una pila de expresiones. Como los números se consideran expresiones en la mayoría de los idiomas, usaré la pila de expresiones para almacenarlos.

class ExprNode:
    char c
    ExprNode operand1
    ExprNode operand2

    ExprNode(char num):
        c = num
        operand1 = operand2 = nil

    Expr(char op, ExprNode e1, ExprNode e2):
        c = op
        operand1 = e1
        operand2 = e2

# Parser
ExprNode parse(string input):
    char c
    while (c = input.getNextChar()):
        if (c == '('):
            operatorStack.push(c)

        else if (c.isDigit()):
            exprStack.push(ExprNode(c))

        else if (c.isOperator()):
            while(operatorStack.top().precedence >= c.precedence):
                operator = operatorStack.pop()
                # Careful! The second operand was pushed last.
                e2 = exprStack.pop()
                e1 = exprStack.pop()
                exprStack.push(ExprNode(operator, e1, e2))

            operatorStack.push(c)

        else if (c == ')'):
            while (operatorStack.top() != '('):
                operator = operatorStack.pop()
                # Careful! The second operand was pushed last.
                e2 = exprStack.pop()
                e1 = exprStack.pop()
                exprStack.push(ExprNode(operator, e1, e2))

            # Pop the '(' off the operator stack.
            operatorStack.pop()

        else:
            error()
            return nil

    # There should only be one item on exprStack.
    # It's the root node, so we return it.
    return exprStack.pop()

(Por favor, sé amable con mi código. Sé que no es robusto; se supone que es un pseudocódigo).

De todos modos, como puede ver en el código, las expresiones arbitrarias pueden ser operandos para otras expresiones. Si tiene la siguiente entrada:

5 * 3 + (4 + 2 % 2 * 8)

el código que escribí produciría este AST:

     +
    / \
   /   \
  *     +
 / \   / \
5   3 4   *
         / \
        %   8
       / \
      2   2

Y luego, cuando desea producir el código para ese AST, realiza un recorrido del árbol de pedidos posteriores . Cuando visita un nodo hoja (con un número), genera una constante porque el compilador necesita conocer los valores de los operandos. Cuando visita un nodo con un operador, genera la instrucción adecuada del operador. Por ejemplo, el operador '+' le da una instrucción "agregar".

Gavin Howard
fuente
Esto funciona para operadores que tienen asociatividad de izquierda a derecha, no de derecha a izquierda.
Simon
@ Simon, sería extremadamente simple agregar la capacidad para operadores de derecha a izquierda. Lo más sencillo sería agregar una tabla de búsqueda y, si un operador es de derecha a izquierda, simplemente invierta el orden de los operandos.
Gavin Howard
44
@Simon Si quieres apoyar a ambos, es mejor que busques el algoritmo del patio de maniobras en toda su gloria. A medida que avanzan los algoritmos, eso es una galleta absoluta.
biziclop
19

Hay una diferencia significativa entre cómo se representa típicamente un AST en la prueba (un árbol con números / variables en los nodos de hoja y símbolos en los nodos interiores) y cómo se implementa realmente.

La implementación típica de un AST (en un lenguaje OO) hace un uso intensivo del polimorfismo. Los nodos en el AST generalmente se implementan con una variedad de clases, todas derivadas de una ASTNodeclase común . Para cada construcción sintáctica en el lenguaje que está procesando, habrá una clase para representar esa construcción en el AST, como ConstantNode(para constantes, como 0x10o 42), VariableNode(para nombres de variables), AssignmentNode(para operaciones de asignación), ExpressionNode(para genéricos expresiones), etc.
Cada tipo de nodo específico especifica si ese nodo tiene hijos, cuántos y posiblemente de qué tipo. A ConstantNodenormalmente no tendrá hijos, AssignmentNodetendrá dos y una ExpressionBlockNodepuede tener cualquier número de hijos.

El analizador crea el AST, que sabe qué construcción acaba de analizar, por lo que puede construir el tipo de nodo AST correcto.

Al atravesar el AST, el polimorfismo de los nodos entra realmente en juego. La base ASTNodedefine las operaciones que se pueden realizar en los nodos, y cada tipo de nodo específico implementa esas operaciones de la manera específica para esa construcción de lenguaje en particular.

Bart van Ingen Schenau
fuente
9

Construir el AST a partir del texto fuente es un análisis "simplemente" . Cómo exactamente se hace depende del lenguaje formal analizado y la implementación. Puede usar generadores de analizadores sintéticos como menhir (para Ocaml) , GNU bisoncon flexo ANTLR, etc. etc. A menudo se hace "manualmente" codificando un analizador de descenso recursivo (vea esta respuesta explicando por qué). El aspecto contextual del análisis a menudo se realiza en otros lugares (tablas de símbolos, atributos, ...).

Sin embargo, en la práctica, los AST son mucho más complejos de lo que crees. Por ejemplo, en un compilador como GCC, el AST mantiene la información de ubicación de origen y alguna información de tipeo. Lea sobre Generic Trees en GCC y mire dentro de su gcc / tree.def . Por cierto, mire también dentro de GCC MELT (que he diseñado e implementado), es relevante para su pregunta.

Basile Starynkevitch
fuente
Estoy haciendo un intérprete de Lua para analizar el texto fuente y transformarlo en una matriz en JS. ¿Puedo considerarlo un AST? Se supone que debo hacer algo como esto: se --My comment #1 print("Hello, ".."world.") transforma en `[{" type ":" - "," content ":" My comment # 1 "}, {" type ":" call "," name ":" print "," argumentos ": [[{" type ":" str "," action ":" .. "," content ":" Hola ",}, {" type ":" str "," content ": "mundo." }]]}] `¡Creo que es mucho más simple en JS que en cualquier otro idioma!
Hydroper
@TheProHands Esto se consideraría tokens, no un AST.
YoYoYonnY
2

Sé que esta pregunta tiene más de 4 años, pero creo que debería agregar una respuesta más detallada.

Los árboles de sintaxis abstracta se crean de manera diferente a otros árboles; la afirmación más verdadera en este caso es que los nodos del árbol de sintaxis tienen una cantidad variada de nodos según sea necesario.

Un ejemplo son las expresiones binarias como 1 + 2 Una expresión simple como esa crearía un solo nodo raíz que contiene un nodo derecho e izquierdo que contiene los datos sobre los números. En lenguaje C, se vería algo así como

struct ASTNode;
union SyntaxNode {
    int64_t         llVal;
    uint64_t        ullVal;
    struct {
        struct ASTNode *left, *right;
    } BinaryExpr;
};

enum SyntaxNodeType {
    AST_IntVal, AST_Add, AST_Sub, AST_Mul, AST_Div, AST_Mod,
};

struct ASTNode {
    union SyntaxNode *Data;
    enum SyntaxNodeType Type;
};

¿Tu pregunta también fue cómo atravesar? Recorrer en este caso se llama Nodos de visita . Visitar cada nodo requiere que use cada tipo de nodo para determinar cómo evaluar los datos de cada nodo de sintaxis.

Aquí hay otro ejemplo de eso en C donde simplemente imprimo el contenido de cada nodo:

void AST_PrintNode(const ASTNode *node)
{
    if( !node )
        return;

    char *opername = NULL;
    switch( node->Type ) {
        case AST_IntVal:
            printf("AST Integer Literal - %lli\n", node->Data->llVal);
            break;
        case AST_Add:
            if( !opername )
                opername = "+";
        case AST_Sub:
            if( !opername )
                opername = "-";
        case AST_Mul:
            if( !opername )
                opername = "*";
        case AST_Div:
            if( !opername )
                opername = "/";
        case AST_Mod:
            if( !opername )
                opername = "%";
            printf("AST Binary Expr - Oper: \'%s\' Left:\'%p\' | Right:\'%p\'\n", opername, node->Data->BinaryExpr.left, node->Data->BinaryExpr.right);
            AST_PrintNode(node->Data->BinaryExpr.left); // NOTE: Recursively Visit each node.
            AST_PrintNode(node->Data->BinaryExpr.right);
            break;
    }
}

Observe cómo la función visita recursivamente cada nodo de acuerdo con el tipo de nodo con el que estamos tratando.

¡Agreguemos un ejemplo más complejo, una ifconstrucción de declaración! Recuerde que las declaraciones if también pueden tener una cláusula else opcional. Agreguemos la instrucción if-else a nuestra estructura de nodo original. Recuerde que las declaraciones if también pueden tener declaraciones if, por lo que puede ocurrir una especie de recursión dentro de nuestro sistema de nodos. Las otras declaraciones son opcionales, por lo que el elsestmtcampo puede ser NULL, lo que la función recursiva del visitante puede ignorar.

struct ASTNode;
union SyntaxNode {
    int64_t         llVal;
    uint64_t        ullVal;
    struct {
        struct ASTNode *left, *right;
    } BinaryExpr;
    struct {
        struct ASTNode *expr, *stmt, *elsestmt;
    } IfStmt;
};

enum SyntaxNodeType {
    AST_IntVal, AST_Add, AST_Sub, AST_Mul, AST_Div, AST_Mod, AST_IfStmt, AST_ElseStmt, AST_Stmt
};

struct ASTNode {
    union SyntaxNode *Data;
    enum SyntaxNodeType Type;
};

de nuevo en nuestra función de impresión de visitante de nodo llamada AST_PrintNode, podemos acomodar la ifconstrucción AST de la declaración agregando este código C:

case AST_IfStmt:
    puts("AST If Statement\n");
    AST_PrintNode(node->Data->IfStmt.expr);
    AST_PrintNode(node->Data->IfStmt.stmt);
    AST_PrintNode(node->Data->IfStmt.elsestmt);
    break;

¡Tan sencillo como eso! En conclusión, el árbol de sintaxis no es mucho más que un árbol de una unión etiquetada del árbol y sus datos.

Nergal
fuente