mini-dog-c编译器开发 - 03 抽象语法树(AST)

本篇为 mini-dog-c 编译器开发系列第三篇,介绍 AST 的设计思路与节点实现。

1. 为什么需要 AST?

Token 序列是扁平的、线性的信息,而代码本身是有层次结构的。比如 let x = a + b * 2; 这行代码,Token 流只告诉我们"这里有个加号,那里有个乘号",但没有告诉我们谁先算、谁后算

抽象语法树(Abstract Syntax Tree,AST) 就是用来表达这种层次结构的数据结构。每个 AST 节点代表一个语法构造,节点之间的父子关系表达了优先级和求值顺序。

复制代码
let x = a + b * 2;

对应的 AST:

复制代码
VarDecl: x
└── AssignExpr: =
    ├── Identifier: a
    └── BinaryExpr: +
        ├── Identifier: b
        └── BinaryExpr: *
            ├── Identifier: b
            └── IntLiteral: 2

这样一眼就能看出:b * 2 先算,然后 a + (b * 2)

2. AST 节点设计

mini-dog-c 的 AST 节点定义在 src/ast.h 中,采用枚举 + 联合体的设计:

复制代码
typedef enum {
    AST_PROGRAM,          // 程序(根节点)
    AST_VAR_DECL,         // 变量声明: let x = expr;
    AST_FN_DECL,          // 函数声明: fn add(a, b) { ... }
    AST_RETURN_STMT,      // 返回语句: return expr;
    AST_IF_STMT,          // 条件语句: if (cond) { ... } else { ... }
    AST_BLOCK_STMT,       // 代码块: { stmt* }
    AST_EXPR_STMT,        // 表达式语句: expr;
    AST_BINARY_EXPR,      // 二元表达式: a + b
    AST_UNARY_EXPR,       // 一元表达式: -a, !b
    AST_ASSIGN_EXPR,      // 赋值表达式: x = value
    AST_CALL_EXPR,        // 函数调用: foo(a, b)
    AST_IDENTIFIER,       // 标识符: x
    AST_LITERAL_INT,      // 整数字面量
    AST_LITERAL_DOUBLE,   // 浮点字面量
    AST_LITERAL_CHAR,     // 字符字面量
    AST_LITERAL_BOOL,     // 布尔字面量
    AST_LITERAL_STRING,   // 字符串字面量
} ASTNodeType;

每个节点是一个联合体,不同类型的节点使用联合体的不同字段:

复制代码
typedef struct ASTNode {
    ASTNodeType type;
    Token *token;             // 对应的源 Token(用于行号等元信息)
    struct ASTNode *parent;   // 父节点
    union {
        // 程序
        struct {
            struct ASTNode **statements;
            int statement_count;
        } program;
​
        // 变量声明: let x = expr;
        struct {
            char *name;
            struct ASTNode *initializer;
        } var_decl;
​
        // 函数声明: fn add(a, b) { body }
        struct {
            char *name;
            char **params;
            int param_count;
            struct ASTNode *body;
        } fn_decl;
​
        // 返回语句
        struct {
            struct ASTNode *value;
        } return_stmt;
​
        // If 语句
        struct {
            struct ASTNode *condition;
            struct ASTNode *then_branch;
            struct ASTNode *else_branch;
        } if_stmt;
​
        // 代码块
        struct {
            struct ASTNode **statements;
            int statement_count;
        } block;
​
        // 二元表达式
        struct {
            TokenType op;
            struct ASTNode *left;
            struct ASTNode *right;
        } binary_expr;
​
        // 一元表达式
        struct {
            TokenType op;
            struct ASTNode *operand;
        } unary_expr;
​
        // 赋值表达式
        struct {
            char *name;
            struct ASTNode *value;
        } assign_expr;
​
        // 函数调用
        struct {
            char *name;
            struct ASTNode **args;
            int arg_count;
        } call_expr;
​
        // 标识符
        struct {
            char *name;
        } identifier;
​
        // 字面量(直接存储在联合体中,无需额外字段)
        int int_value;
        double double_value;
        char char_value;
        bool bool_value;
        char *string_value;
    } data;
} ASTNode;

为什么用联合体? 因为每个节点类型只需要存储自己需要的数据,用联合体可以节省内存------所有字段共享同一块内存,节点大小是最大字段的大小,而不是所有字段之和。

3. 节点创建函数

每个 AST 节点类型都有对应的创建函数,封装了内存分配和字段初始化的细节:

复制代码
ASTNode *ast_create_binary(TokenType op, ASTNode *left, ASTNode *right) {
    ASTNode *node = (ASTNode *)malloc(sizeof(ASTNode));
    node->type = AST_BINARY_EXPR;
    node->token = NULL;
    node->parent = NULL;
    node->data.binary_expr.op = op;
    node->data.binary_expr.left = left;
    node->data.binary_expr.right = right;
    if (left)  left->parent  = node;
    if (right) right->parent = node;
    return node;
}

创建二元表达式节点时,同时维护了 parent 指针,这样可以方便地从任意节点向上遍历到根节点。

4. 表达式优先级的体现

AST 的树形结构天然地表达了表达式的优先级。看一个稍微复杂的例子:

复制代码
a + b * c == d - e / f

对应的 AST:

复制代码
BinaryExpr: ==
├── BinaryExpr: +
│   ├── Identifier: a
│   └── BinaryExpr: *
│       ├── Identifier: b
│       └── Identifier: c
└── BinaryExpr: -
    ├── Identifier: d
    └── BinaryExpr: /
        ├── Identifier: e
        └── Identifier: f

*/ 的节点深度最浅(优先级最高),所以最先计算;== 深度最深(优先级最低),最后计算。这正是我们想要的语义。

5. 语句与表达式的统一

在很多语言中,语句和表达式是有区别的------语句不产生值,表达式产生值。但 mini-dog-c 的设计比较简洁:

  • 声明类AST_VAR_DECLAST_FN_DECL --- 声明本身不产生值

  • 语句类AST_RETURN_STMTAST_IF_STMTAST_BLOCK_STMTAST_EXPR_STMT --- 执行控制流

  • 表达式类AST_BINARY_EXPRAST_CALL_EXPR 等 --- 产生值

AST_EXPR_STMT 是一个桥接:它把一个表达式包装成语句,比如 foo();foo() 是表达式,外面包一层 EXPR_STMT 表示"这是一个语句"。

6. AST 打印(调试用)

开发过程中,经常需要验证 AST 是否正确构建。ast_print 函数实现了树的递归打印:

复制代码
void ast_print(const ASTNode *node, int indent) {
    if (!node) return;
​
    for (int i = 0; i < indent; i++) printf("  ");
​
    switch (node->type) {
        case AST_PROGRAM:
            printf("Program (%d statements):\n",
                   node->data.program.statement_count);
            for (int i = 0; i < node->data.program.statement_count; i++)
                ast_print(node->data.program.statements[i], indent + 1);
            break;
​
        case AST_VAR_DECL:
            printf("VarDecl: %s\n", node->data.var_decl.name);
            ast_print(node->data.var_decl.initializer, indent + 1);
            break;
​
        case AST_BINARY_EXPR:
            printf("BinaryExpr: %s\n", op_to_string(node->data.binary_expr.op));
            ast_print(node->data.binary_expr.left, indent + 1);
            ast_print(node->data.binary_expr.right, indent + 1);
            break;
​
        case AST_LITERAL_INT:
            printf("IntLiteral: %d\n", node->data.int_value);
            break;
        // ...
    }
}

输入:

复制代码
fn add(a, b) {
    let result = a + b;
    return result;
}

输出:

复制代码
Program (1 statements):
  FnDecl: add (params: 2)
    param: a
    param: b
    Block (2 statements):
      VarDecl: result
        BinaryExpr: +
          Ident: a
          Ident: b
      ReturnStmt:
        Ident: result

7. AST 内存管理

AST 节点的内存分配使用 malloc,释放时需要递归释放所有子节点:

复制代码
void ast_free(ASTNode *node) {
    if (!node) return;

    switch (node->type) {
        case AST_PROGRAM:
            for (int i = 0; i < node->data.program.statement_count; i++)
                ast_free(node->data.program.statements[i]);
            free(node->data.program.statements);
            break;

        case AST_VAR_DECL:
            free(node->data.var_decl.name);
            ast_free(node->data.var_decl.initializer);
            break;

        case AST_BINARY_EXPR:
            ast_free(node->data.binary_expr.left);
            ast_free(node->data.binary_expr.right);
            break;

        case AST_LITERAL_STRING:
            free(node->data.string_value);  // 字符串需要释放
            break;
        // ...
    }
    free(node);
}

注意:

  • AST_IDENTIFIER 的 name 用 strdup 分配,需要 free

  • AST_LITERAL_STRING 的 string_value 同理

  • 字面量节点(int/double/char/bool)直接存储在联合体中,不需要额外释放

8. 语法规则与 AST 的对应关系

回顾第一篇中的语法规则(BNF),每条规则都对应一种 AST 节点:

BNF 规则 AST 节点类型
let_decl AST_VAR_DECL
fn_decl AST_FN_DECL
return_stmt AST_RETURN_STMT
if_stmt AST_IF_STMT
block AST_BLOCK_STMT
expr 各类表达式节点

这就是 AST 设计与语法规则之间的对应关系:每个语法构造都可以映射到一种 AST 节点

9. 小结

本篇介绍了 mini-dog-c 的 AST 设计:

  • 使用枚举 + 联合体的方式组织不同类型的节点

  • AST 的树形结构天然表达了优先级和求值顺序

  • 每个节点都维护 parent 指针,方便遍历

  • 节点创建函数封装了内存分配和父子关系的维护

  • ast_print 辅助调试,ast_free 负责内存回收

下一篇我们将实现递归下降语法分析器,把 Token 流变成 AST。

相关推荐
众少成多积小致巨11 天前
Soong构建入门
android·go·编译器
杨艺韬19 天前
Rust编译器原理-第11章 闭包:匿名函数的编译器实现
rust·编译器
杨艺韬19 天前
Rust编译器原理-第15章 MIR 优化:编译器的中间表示与优化管线
rust·编译器
杨艺韬19 天前
Rust编译器原理-第6章 单态化:泛型的编译期展开
rust·编译器
杨艺韬19 天前
Rust编译器原理-第14章 宏系统:编译期的元编程引擎
rust·编译器
杨艺韬19 天前
Rust编译器原理-第16章 LLVM 代码生成:从 MIR 到机器码
rust·编译器
杨艺韬19 天前
Rust编译器原理-第5章 内存布局:编译器如何排列数据
rust·编译器
杨艺韬19 天前
Rust编译器原理-第3章 借用检查器:编译器如何证明内存安全
rust·编译器
杨艺韬19 天前
Rust编译器原理-第9章 async/await:状态机的编译器变换
rust·编译器