第 3、4 章的解析器边解析边求值(算了一下表达式),那其实是「解释器」的做法。编译器要做的是把树保留下来:解析的产物是抽象语法树(AST),后续所有阶段(语义分析、优化、代码生成)都围绕这棵树工作。本章讲 AST 的设计取舍、语法制导翻译(边解析边构造树/生成代码的框架)、以及遍历 AST 的基本姿势。
解析树(parse tree,含文法每一步推导)很啰嗦,AST 是它的瘦身版:去掉纯语法的括号、去掉单链的传递节点,保留语义结构。2 + 3 * 4 的 AST:
+
/ \
2 *
/ \
3 4
注意乘法是加法的孩子:树的形状直接编码了优先级和结合性,这就是为什么解析阶段要按优先级嵌套规则。AST 的 C 定义:
typedef enum { ND_NUM, ND_ADD, ND_MUL, ND_NEG } NodeKind;
typedef struct Node {
NodeKind kind;
int value; // ND_NUM 专用
struct Node *lhs, *rhs; // 二元运算的两个孩子
} Node;
Node *new_add(Node *l, Node *r) {
Node *n = calloc(1, sizeof(Node));
n->kind = ND_ADD; n->lhs = l; n->rhs = r;
return n;
}
设计取舍两问:一元运算(负号)用专节点还是特殊标记;if 这类变长结构用孩子数组还是固定 lhs/mid/rhs。没有标准答案,clang 的 AST 类有几百个节点类(继承体系),迷你编译器用一个 enum + 通用结构就够,「够用且可扩展」是分界线。
语法制导定义(SDD) = 文法 + 每条产生式配的语义动作(继承文法符号的「属性」)。bison 的 $$ = $1 + $3 就是它:产生式 expr → expr + expr 的动作取两个孩子的属性算出父属性。三类经典应用:
new_add($1, $3),属性是 AST 指针;「继承属性 vs 综合属性」是理论重点:综合属性自底向上算(孩子的值算父亲的),继承属性自顶向下传(父亲给孩子环境信息,比如类型声明作用域)。L 属性文法(一次深度优先遍历能算完所有属性)是实践的可计算边界,记住名字和直觉即可。
把第 3 章的解析器改造成建 AST(不求值):
Node *parse_expr(void) { // expr → term (+ term)*
Node *n = parse_term();
while (lookahead == T_PLUS) {
advance();
n = new_add(n, parse_term()); // 左结合:树向左长
}
return n;
}
左结合的验证:1+2+3 的树必须是 +(+(1,2),3) 而不是 +(1,+(2,3))(结合性不同,求值顺序和减除法的结果都不同)。遍历求值验证树的正确性:
int eval(const Node *n) {
switch (n->kind) {
case ND_NUM: return n->value;
case ND_ADD: return eval(n->lhs) + eval(n->rhs);
case ND_MUL: return eval(n->lhs) * eval(n->rhs);
}
}
注意 eval 本身就是解释器的雏形:对 AST 的后序遍历求值。编译器和解释器共享 AST,分家在后半程(第 10 章)。
编译器各阶段对 AST 的操作高度模式化:递归下降遍历,后序处理。因为「先处理孩子、再处理自己」恰好对应「先算操作数、再算运算」。第 7 章生成三地址码、第 10 章生成汇编,都是同一个遍历骨架:
void gen(const Node *n) { // 伪码:每章会具体化
switch (n->kind) {
case ND_NUM: emit_load(n->value); break;
case ND_ADD: gen(n->lhs); gen(n->rhs); emit_add(); break;
}
}
这个骨架是本课实验的心脏,值得背下来。顺带把「树遍历的栈深度」和第 8 章(运行时栈帧)连上:递归遍历深 AST 会爆栈,真实编译器用显式栈迭代或限制 AST 深度(表达式嵌套限制)。
10-4-3 跑。-、/、一元负号;打印树(缩进缩出层次),用 10-4-3 和 -2*3 验证结合性。print_ast 的 DOT 输出(Graphviz),把 AST 画成图,后面的章节都用它调试。a = b * (c + d) 画出 AST(带赋值节点),标出哪部分是「语句节点」哪部分是「表达式节点」,想想 AST 里语句和表达式要不要分家。