Come si crea esattamente un albero di sintassi astratto?


47

Penso di aver capito l'obiettivo di un AST, e prima ho costruito un paio di strutture ad albero, ma mai un AST. Sono per lo più confuso perché i nodi sono testo e non numeri, quindi non riesco a pensare a un modo carino per inserire un token / stringa mentre analizzo del codice.

Ad esempio, quando ho esaminato i diagrammi di AST, la variabile e il suo valore erano nodi foglia con un segno di uguale. Questo ha perfettamente senso per me, ma come potrei fare per implementarlo? Immagino di poterlo fare caso per caso, così quando inciampo in un "=" lo uso come nodo e aggiungo il valore analizzato prima di "=" come foglia. Sembra semplicemente sbagliato, perché probabilmente dovrei creare casi per tonnellate e tonnellate di cose, a seconda della sintassi.

E poi ho riscontrato un altro problema, come viene attraversato l'albero? Vado fino in fondo all'altezza e torno su un nodo quando colpisco il fondo e faccio lo stesso per il suo vicino?

Ho visto tonnellate di diagrammi su AST, ma non sono riuscito a trovare un esempio abbastanza semplice di uno nel codice, che probabilmente aiuterebbe.


Il concetto chiave che ti manca è la ricorsione . La ricorsione è in qualche modo controintuitiva, ed è diversa per ogni studente quando alla fine "farà clic" con loro, ma senza ricorsione, semplicemente non c'è modo di comprendere l'analisi (e anche molti altri argomenti di calcolo).
Kilian Foth,

Ricevo la ricorsione, pensavo solo che sarebbe stato difficile implementarla in questo caso. In realtà volevo usare la ricorsione e ho finito con molti casi che non avrebbero funzionato per una soluzione generale. La risposta di Gdhoward mi sta aiutando molto in questo momento.
Howcan,

Potrebbe essere esercizio costruire un calcolatore RPN come esercizio. Non risponderà alla tua domanda ma potrebbe insegnare alcune abilità necessarie.

In realtà ho già costruito un calcolatore RPN prima. Le risposte mi hanno aiutato molto e penso di poter fare un AST di base ora. Grazie!
Howcan,

Risposte:


47

La risposta breve è che usi stack. Questo è un buon esempio, ma lo applicherò a un AST.

Cordiali saluti, questo è l' algoritmo di Shunting-Yard di Edsger Dijkstra .

In questo caso, userò una pila di operatori e una pila di espressioni. Poiché i numeri sono considerati espressioni nella maggior parte delle lingue, userò lo stack di espressioni per memorizzarli.

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()

(Per favore sii gentile con il mio codice. So che non è robusto; dovrebbe solo essere uno pseudocodice.)

Ad ogni modo, come puoi vedere dal codice, espressioni arbitrarie possono essere operandi di altre espressioni. Se hai il seguente input:

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

il codice che ho scritto produrrebbe questo AST:

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

Quindi, quando si desidera produrre il codice per tale AST, si esegue un Traversal dell'albero degli ordini postali . Quando si visita un nodo foglia (con un numero), si genera una costante perché il compilatore deve conoscere i valori dell'operando. Quando si visita un nodo con un operatore, si generano le istruzioni appropriate dall'operatore. Ad esempio, l'operatore '+' fornisce un'istruzione "add".


Questo funziona per gli operatori che hanno associatività da sinistra a destra, non da destra a sinistra.
Simon,

@Simon, sarebbe estremamente semplice aggiungere la possibilità per gli operatori da destra a sinistra. Il più semplice sarebbe aggiungere una tabella di ricerca e, se un operatore da destra a sinistra, inverte semplicemente l'ordine degli operandi.
Gavin Howard,

4
@Simon Se si desidera supportare entrambi, è meglio cercare l' algoritmo di shunting yard nella sua piena gloria. Come vanno gli algoritmi, è un vero cracker.
biziclop,

19

Esiste una differenza significativa tra il modo in cui un AST viene generalmente rappresentato nel test (un albero con numeri / variabili nei nodi foglia e simboli nei nodi interni) e come viene effettivamente implementato.

L'implementazione tipica di un AST (in un linguaggio OO) fa un uso pesante del polimorfismo. I nodi nell'AST vengono in genere implementati con una varietà di classi, tutte derivate da una ASTNodeclasse comune . Per ogni costrutto sintattico nella lingua che stai elaborando, ci sarà una classe per rappresentare quel costrutto nell'AST, come ConstantNode(per le costanti, come 0x10o 42), VariableNode(per i nomi delle variabili), AssignmentNode(per le operazioni di assegnazione), ExpressionNode(per i generici espressioni), ecc.
Ogni tipo di nodo specifico specifica se quel nodo ha figli, quanti e possibilmente di quale tipo. Una ConstantNodevolontà in genere non ha figli, una AssignmentNodeavrà due e una ExpressionBlockNodepuò avere un numero qualsiasi di figli.

L'AST viene creato dal parser, che sa quale costrutto ha appena analizzato, quindi può costruire il giusto tipo di nodo AST.

Quando si attraversa l'AST, entra in gioco il polimorfismo dei nodi. La base ASTNodedefinisce le operazioni che possono essere eseguite sui nodi e ciascun tipo di nodo specifico implementa tali operazioni nel modo specifico per quel particolare costrutto di linguaggio.


9

Costruire l'AST dal testo sorgente significa "semplicemente" analizzare . Il modo esatto dipende dal linguaggio formale analizzato e dall'implementazione. È possibile utilizzare generatori di parser come menhir (per Ocaml) , GNU bisoncon flex, o ANTLR ecc. Ecc. Viene spesso eseguito "manualmente" codificando un parser di discesa ricorsivo (vedere questa risposta che spiega il perché). L'aspetto contestuale dell'analisi viene spesso eseguito altrove (tabelle dei simboli, attributi, ....).

Tuttavia, in pratica le AST sono molto più complesse di quanto si pensi. Ad esempio, in un compilatore come GCC l'AST mantiene le informazioni sulla posizione di origine e alcune informazioni di battitura. Leggi gli alberi generici in GCC e guarda all'interno di gcc / tree.def . A proposito, guarda anche all'interno di GCC MELT (che ho progettato e implementato), è rilevante per la tua domanda.


Sto creando un interprete Lua per analizzare il testo sorgente e trasformarlo in un array in JS. Posso considerarlo un AST? Dovrei fare qualcosa del genere: si --My comment #1 print("Hello, ".."world.") trasforma in `[{" type ":" - "," content ":" Il mio commento # 1 "}, {" type ":" call "," name ":" print "," argomenti ": [[{" type ":" str "," action ":" .. "," content ":" Hello, "}, {" type ":" str "," content ": "mondo." }]]}] `Penso che sia molto più semplice in JS rispetto a qualsiasi altra lingua!
Idroper

@TheProHands Questo sarebbe considerato token, non un AST.
YoYoYonnY,

2

So che questa domanda ha più di 4 anni, ma sento che dovrei aggiungere una risposta più dettagliata.

Gli alberi di sintassi astratti non sono creati diversamente dagli altri alberi; l'affermazione più vera in questo caso è che i nodi dell'albero di sintassi hanno una quantità variabile di nodi COME NECESSARIO.

Un esempio sono le espressioni binarie come 1 + 2 Una semplice espressione del genere creerebbe un singolo nodo radice contenente un nodo destro e sinistro che contiene i dati sui numeri. In linguaggio C, sarebbe simile

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;
};

La tua domanda era anche come attraversare? La traversata in questo caso si chiama Visiting Nodes . Per visitare ciascun nodo è necessario utilizzare ciascun tipo di nodo per determinare come valutare i dati di ciascun nodo di sintassi.

Ecco un altro esempio di quello in C in cui stampo semplicemente il contenuto di ciascun 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;
    }
}

Nota come la funzione visita ricorsivamente ogni nodo in base al tipo di nodo con cui abbiamo a che fare.

Aggiungiamo un esempio più complesso, un ifcostrutto di istruzione! Ricordiamo che se le istruzioni possono anche avere una clausola else facoltativa. Aggiungiamo l'istruzione if-else alla nostra struttura di nodi originale. Ricorda che se le istruzioni stesse possono avere anche istruzioni if, può verificarsi una sorta di ricorsione all'interno del nostro sistema di nodi. Le altre dichiarazioni sono facoltative, quindi il elsestmtcampo può essere NULL che la funzione visitatore ricorsivo può ignorare.

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;
};

tornando alla nostra funzione di stampa del visitatore del nodo chiamata AST_PrintNode, possiamo accogliere l' ifistruzione AST build aggiungendo questo codice 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;

Così semplice! In conclusione, l'albero della sintassi non è molto più di un albero di un'unione etichettata dell'albero e dei suoi stessi dati!

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.