本篇为 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_DECL、AST_FN_DECL--- 声明本身不产生值 -
语句类 :
AST_RETURN_STMT、AST_IF_STMT、AST_BLOCK_STMT、AST_EXPR_STMT--- 执行控制流 -
表达式类 :
AST_BINARY_EXPR、AST_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。