第 3 章说过文法是「上下文无关」的,但语言显然有上下文:用没声明的变量、类型不匹配、重复定义,这些错误语法层完全合法。本章的语义分析就是补上这块:「带上下文的检查」。主角是符号表(symbol table)和类型系统,它们也是这门课和「程序语言设计」交汇最深的地方。C 指针课第 2 章(作用域与栈帧)、Java 课第 8 章(类型与访问控制)的知识在此全部兑现为「编译器视角」。
一份典型清单(以类 C 语言为例):
x = 1 之前,x 声明过吗?int + char*?if (数组名)?类型系统逐节点裁决;这些检查的共同形状:遍历 AST,每个节点带着「当前上下文」去查。上下文就是符号表。
符号表 = 「名字 → 声明信息(类型、位置等)」的映射,作用域规则用栈式哈希表实现:进入作用域压一层,离开弹一层,查找从栈顶往下:
#define SCOPE_MAX 32
typedef struct Sym { char name[64]; Type *type; struct Sym *next; } Sym;
Sym *scopes[SCOPE_MAX]; // 每层一个链表(或哈希表)
int depth = -1;
void enter(void) { scopes[++depth] = NULL; }
void leave(void) { depth--; }
Sym *declare(const char *name, Type *t) { // 当前层登记(重名报错)
Sym *s = malloc(sizeof(Sym));
strcpy(s->name, name); s->type = t; s->next = scopes[depth];
scopes[depth] = s;
return s;
}
Sym *lookup(const char *name) { // 从内向外找(遮蔽语义)
for (int d = depth; d >= 0; d--)
for (Sym *s = scopes[d]; s; s = s->next)
if (!strcmp(s->name, name)) return s;
return NULL;
}
遍历 AST 时的动作模式:{ 进 enter,} 走 leave;声明节点 declare;引用节点 lookup,查不到报 undefined variable。验证一下:
{ int x = 1; { int x = 2; use(x); } use(x); } // 内层 x 遮蔽外层,各用各的
数据结构课第 7 章(哈希表)的直接应用:符号表查找是编译器最热的小操作之一,真实编译器用精心设计的哈希(string interning,字符串驻留)。
类型检查的基本形状是递归的谓词:每个 AST 节点带类型属性,自底向上合成:
Type *check(Node *n) {
switch (n->kind) {
case ND_NUM: return ty_int;
case ND_VAR: return lookup(n->name)->type; // 查不到已在别处报
case ND_ADD:
Type *l = check(n->lhs), *r = check(n->rhs);
if (l == ty_int && r == ty_int) return ty_int;
if (l == ty_ptr && r == ty_int) return ty_ptr; // 指针+整数(C 课第 3 章)
type_error(n, "type mismatch");
}
}
两条路线:类型检查(声明里写了类型,查一致性,如 C/Java)与类型推导(不写类型,编译器推断,如 Rust 的 let x = 5、Hindley-Milner)。推导是检查的超集(它先「算出」类型再查),实现上就是双向传播:表达式向上合成类型、约束向下传递。子类型(Student 是 Person 的子类,Java 课第 8 章)引入「可赋值性」和「协变逆变」的深水区,本课止步于知道门槛在哪。
合法但不完全匹配的类型对,语言规定隐式转换(int → double 提升,C 课第 2 章「窄转必须显式」的反面),编译器在 AST 上插入转换节点(implicit cast),让后续阶段看到的是「类型完全一致」的树。这个「检查时顺手改树」的手法到处都是:插转换、插默认初始化、把语法糖展开(syntactic sugar desugaring,Python 的推导式、Java 的增强 for 都在语义分析或更早阶段被翻译成朴素结构)。编译器前端的后半程,大量工作是「把糖剥掉」。
语义检查的错误处理,是编译器工程质量的试金石。好报错的三要素:位置(行:列,第 5 章建树时记的)、因果(「expected int, got char*」而不只是「error」)、修复建议(「did you mean ...?」「add a semicolon here」)。rustc 把这条做到了艺术品级。错误恢复延续第 3 章 panic mode 的思路:语义错误也不中断,记下来继续检查(常见技巧:出错的地方给一个「万能错误类型」ty_error,它能和任何类型兼容,避免一错引发连环误报)。
stmt → ident = expr | decl ident = expr,实现栈式符号表 + 「先声明后使用」检查,报错带行号。{int x; {char x; {double x;}}} 画出三个作用域的符号表栈的push/pop 过程,标每个 x 的类型。