6.1 递归下降与 AST:把优先级写进调用结构
词法分析把 1 + 2 * 3 交成五个 token。语法分析要恢复它们的层次:乘法成为加法的右子树,才能表达先乘后加。token 的队伍排好了,运算可不能只按先来后到。
本课目标
- 把适合预测分析的文法映射为递归函数;
- 在解析过程中构造 AST;
- 正确实现优先级和左、右结合;
- 保留 span 与错误节点,而不是只返回成功或失败。
1. 从分层文法开始
expression → additive
additive → multiplicative (("+" | "-") multiplicative)*
multiplicative → unary (("*" | "/") unary)*
unary → ("-" | "!") unary | primary
primary → NUMBER | IDENT | "(" expression ")"函数调用方向从低优先级走向高优先级:parse_additive 先调用 parse_multiplicative,因此乘法子树在加法组合前完成。
循环表达左结合。a - b - c 会逐次把已有左树与新右项组合成 (a-b)-c。右结合运算如赋值或幂,可以递归处理右侧,也可以用显式栈实现;不能照搬这个立即合并左树的循环。
2. AST 节点表达语义结构
以下 C 代码是可集成的解析器骨架,不是独立程序。宿主需要提供 <stddef.h>、<stdbool.h>、<assert.h>,以及 token 枚举、arena、诊断容器和 make_* 构造函数。这里的 Token 含 kind、span、integer_value;上一章的 start/end 必须先转成 Span,整数转换需另行检查 long long 范围。构造函数要么返回有效节点,要么让整个解析安全终止,不能返回未处理的空指针。
typedef struct { size_t start, end; } Span;
typedef enum {
EXPR_NUMBER,
EXPR_NAME,
EXPR_UNARY,
EXPR_BINARY,
EXPR_ERROR
} ExprKind;
typedef struct Expr Expr;
struct Expr {
ExprKind kind;
Span span;
union {
long long number;
struct { Span name; } name;
struct { TokenKind op; Expr *operand; } unary;
struct { Expr *left; TokenKind op; Expr *right; } binary;
} as;
};生产实现还要确定节点内存所有权。常见做法是 arena:整棵 AST 生命周期一致,可批量释放,避免每个节点单独 malloc/free。若需要增量更新,则可能使用不可变绿树或引用计数结构。
3. Parser 游标与消费原语
typedef struct {
const Token *tokens;
size_t count;
size_t current;
Arena *arena;
DiagnosticList *diagnostics;
} Parser;
static const Token *peek(const Parser *p) {
assert(p->count > 0 && p->current < p->count);
return &p->tokens[p->current];
}
static bool match(Parser *p, TokenKind kind) {
if (peek(p)->kind != kind) return false;
if (kind != TOK_EOF) p->current++;
return true;
}
static const Token *expect(Parser *p, TokenKind kind, const char *message) {
if (peek(p)->kind == kind) {
const Token *found = peek(p);
if (kind != TOK_EOF) p->current++;
return found;
}
report(p->diagnostics, peek(p)->span, message);
return NULL;
}宿主在入口验证:缓冲区非空,只有最后一项是 EOF,current 初值为零,token 与 arena 在解析期间有效。下面的消费原语在 EOF 上保持游标不动;断言只检查内部不变量,不能替代对外部输入的验证。expect 报错后是否消费 token 不能随意决定:消费错了可能跳过有效结构,不消费又可能让上层循环停滞。恢复策略放到下一课集中处理。
4. primary 构造叶子与括号结构
static Expr *parse_expression(Parser *p);
static Expr *parse_unary(Parser *p);
static Expr *parse_primary(Parser *p) {
const Token *current = peek(p);
if (match(p, TOK_NUMBER)) {
return make_number(p->arena, current->integer_value, current->span);
}
if (match(p, TOK_IDENTIFIER)) {
return make_name(p->arena, current->span);
}
if (match(p, TOK_LEFT_PAREN)) {
const Token *left = current;
Expr *inside = parse_expression(p);
const Token *right = expect(p, TOK_RIGHT_PAREN, "缺少右括号");
inside->span = (Span){
left->span.start, right != NULL ? right->span.end : inside->span.end
};
return inside;
}
report(p->diagnostics, current->span, "需要表达式");
if (current->kind != TOK_EOF && current->kind != TOK_RIGHT_PAREN) p->current++;
return make_error(p->arena, current->span);
}AST 可以不保留独立括号节点,但仍应保存覆盖括号的 span,或另存 parenthesized 标记。格式化器和精确源码变换可能需要完整 CST;“AST 有括号节点就一定错”是过度结论。
5. 用循环构造左结合二元树
static Expr *parse_multiplicative(Parser *p) {
Expr *left = parse_unary(p);
while (peek(p)->kind == TOK_STAR || peek(p)->kind == TOK_SLASH) {
TokenKind op = p->tokens[p->current++].kind;
Expr *right = parse_unary(p);
left = make_binary(p->arena, left, op, right);
}
return left;
}
static Expr *parse_additive(Parser *p) {
Expr *left = parse_multiplicative(p);
while (peek(p)->kind == TOK_PLUS || peek(p)->kind == TOK_MINUS) {
TokenKind op = p->tokens[p->current++].kind;
Expr *right = parse_multiplicative(p);
left = make_binary(p->arena, left, op, right);
}
return left;
}对 1 + 2 * 3,乘法函数先返回 *(2,3),加法函数再构造 +(1,*(2,3))。
6. 一元运算与右递归
static Expr *parse_unary(Parser *p) {
if (peek(p)->kind == TOK_MINUS || peek(p)->kind == TOK_BANG) {
const Token *op = &p->tokens[p->current++];
Expr *operand = parse_unary(p);
return make_unary(p->arena, op->kind, operand, op->span.start);
}
return parse_primary(p);
}递归允许 !!-x。骨架还没有深度计数器;接入不受信任输入前,应同时限制前缀链与括号嵌套,或使用显式栈。仅限制 token 总数不能保证系统调用栈足够大,数万个前缀运算符就可能将它耗尽。
7. 完整消费是入口职责
static Expr *parse_expression(Parser *p) {
return parse_additive(p);
}
Expr *parse(Parser *p) {
Expr *result = parse_expression(p);
if (peek(p)->kind != TOK_EOF) {
report(p->diagnostics, peek(p)->span, "表达式后存在多余 token");
}
return result;
}parse_expression 在这里委托给最低优先级层。返回一个节点可能伴随诊断;调用方必须检查诊断容器,不能直接把带错误节点的树拿去执行。
只调用 parse_expression 而不检查 EOF,会把 1 + 2 garbage 的合法前缀误当作完整成功。嵌入语句解析时则应检查该上下文的终止 token,如分号或右括号。
常见误区
- 每个文法都能直接变成递归下降:左递归和预测冲突需要处理。
- 括号从 AST 删除后位置也可以丢:诊断和源码工具仍需完整跨度。
- 成功返回节点代表消费了全部输入:入口必须检查上下文终止符。
- 递归深度理论无界所以实现也无需限制:实际调用栈和内存有限。
练习
- 增加比较运算层,保证比较优先级低于加法。
- 实现右结合赋值
a = b = 1,画出 AST。 - 为每个节点计算覆盖左右子节点和运算符的 span。
- 对一万个前缀
!和深层括号分别设计深度限制与诊断。
小结
递归下降把文法层级变成函数调用层级,把结合性变成循环或递归方向。AST 构造与语法识别同步进行,但源跨度、错误节点和内存所有权必须从第一版接口就考虑。
下一课用 FIRST/FOLLOW 集解释“为什么一个 token 足以选择产生式”,并设计遇错后仍能继续解析的同步策略。