4. 语义分析与符号表
好,咱们进入第四章。说实话,语义分析这块,是很多编译器初学者觉得「玄乎」的地方。词法分析和语法分析好歹能看到 token、看到 AST 树,但语义分析呢?它看不见摸不着,却决定了你的代码到底「对不对」。
我个人习惯把语义分析比作「警察查户口」。语法分析只检查你有没有按规矩排队,而语义分析要查你的身份证、查你的户籍、查你有没有乱认亲戚。说白了,就是检查代码的「合理性」。
4.1 作用域管理:你的变量住在哪条街?
先聊作用域。你想想看,一个变量名 x,在不同的代码块里可能代表完全不同的东西。编译器怎么知道你说的是哪个 x?
这就是作用域管理要解决的问题。
4.1.1 静态作用域 vs 动态作用域
绝大多数现代语言(C、Java、Python)都采用静态作用域(也叫词法作用域)。什么意思呢?就是变量的绑定关系在编译期就能确定,看代码结构就行。
我遇到过一些同学,把作用域和生命周期搞混。作用域是「哪里能看见你」,生命周期是「你什么时候出生、什么时候死」。两码事。
举个例子:
int x = 10;
void foo() {
int x = 20; // 这个 x 遮蔽了外层的 x
printf("%d", x); // 输出 20
}
printf("%d", x); // 输出 10
这段代码,编译器在编译 foo 函数时,就知道里面的 x 是局部变量,和外层的 x 不是同一个东西。这就是静态作用域的魅力。
4.1.2 作用域嵌套与遮蔽规则
作用域是可以嵌套的。C 语言里,一对花括号就是一个作用域。嵌套规则很简单:内层可以访问外层,外层不能访问内层。
遮蔽规则更直接:内层定义了同名变量,外层的就被「遮住」了。嗯,这里要注意,有些语言(比如 Rust)会警告你变量遮蔽,但 C 语言直接静默处理。
{
int a = 1;
{
int a = 2; // 遮蔽外层的 a
int b = 3;
}
// b 在这里不可见
// a 在这里是 1
}
我曾经在项目里遇到过一个 bug,就是因为内层作用域不小心遮蔽了外层变量,导致外层变量的值被「遗忘」了。排查了半天,最后发现是作用域嵌套太深,变量名重复了。从那以后,我建议团队:嵌套不要超过三层,变量名不要重复使用。
4.2 符号表:编译器的「户籍系统」
符号表,说白了就是一本「户口本」。每个变量、函数、类型,都在符号表里有一条记录。编译器查符号表,就像警察查户籍一样。
4.2.1 符号表的基本结构
一个符号表条目通常包含:
- 符号名:比如
foo、x - 符号类型:int、float、函数指针等
- 作用域信息:这个符号属于哪个作用域
- 存储位置:栈偏移、全局地址等
- 其他属性:const、static、extern 等修饰符
我习惯用哈希表来实现符号表。为什么?因为查找快。编译期要频繁查符号表,哈希表 O(1) 的平均查找时间,比线性查找快太多了。
4.2.2 作用域栈的设计
单张符号表不够用,因为作用域是嵌套的。我们需要一个作用域栈。
每进入一个作用域,就压入一张新的符号表(子表)。每退出一个作用域,就弹出这张表。查找变量时,从栈顶往下找,找到为止。
我画了一张图,帮你理解这个结构:
4.2.3 符号表的实现细节
来看一段简单的符号表实现(伪代码):
class SymbolTable {
// 作用域栈,每个元素是一张哈希表
vector<unordered_map<string, Symbol>> scopes;
void enterScope() {
scopes.push_back({}); // 压入新作用域
}
void exitScope() {
scopes.pop_back(); // 弹出当前作用域
}
void insert(string name, Symbol sym) {
scopes.back()[name] = sym; // 插入到当前作用域
}
Symbol* lookup(string name) {
// 从栈顶往下找
for (int i = scopes.size() - 1; i >= 0; i--) {
if (scopes[i].count(name)) {
return &scopes[i][name];
}
}
return nullptr; // 未找到
}
}
4.3 类型检查:你的代码「类型对」吗?
类型检查,说白了就是检查「类型是否匹配」。你不能把字符串赋值给整数,也不能把函数指针当数组用。
4.3.1 静态类型 vs 动态类型
静态类型检查在编译期进行。C、Java、Rust 都是静态类型。动态类型检查在运行时进行。Python、JavaScript 是动态类型。
我个人的观点:静态类型虽然写代码时麻烦点,但能提前发现大量 bug。我在项目中见过太多因为类型不匹配导致的运行时崩溃了。静态类型检查,相当于给代码上了一道保险。
4.3.2 类型等价与类型兼容
类型检查的核心是两个概念:类型等价和类型兼容。
- 类型等价:两个类型完全一样。比如
int和int等价。 - 类型兼容:一个类型可以隐式转换为另一个类型。比如
int可以赋值给float。
举个例子:
int a = 5;
float b = a; // 类型兼容:int 可以隐式转换为 float
float c = 3.14;
int d = c; // 有些语言允许(截断),有些语言报错
我曾经在项目中遇到过一个问题:一个 int 类型的变量,被隐式转换成了 unsigned int,结果导致负数变成了很大的正数。从那以后,我特别关注隐式类型转换的规则。
4.3.3 类型检查的实现
类型检查通常和 AST 遍历一起做。每遇到一个表达式或语句,就检查其类型是否正确。
来看一个简单的类型检查伪代码:
Type checkExpression(ASTNode* node, SymbolTable* symtab) {
switch (node->kind) {
case INTEGER_LITERAL:
return Type::INT;
case FLOAT_LITERAL:
return Type::FLOAT;
case IDENTIFIER:
return symtab->lookup(node->name)->type;
case BINARY_OP:
Type left = checkExpression(node->left, symtab);
Type right = checkExpression(node->right, symtab);
if (left == Type::INT && right == Type::INT) {
return Type::INT;
} else if (left == Type::FLOAT || right == Type::FLOAT) {
return Type::FLOAT;
} else {
error("类型不匹配");
return Type::ERROR;
}
// ... 其他情况
}
}
4.4 综合案例:一个简单的语义分析器
把作用域管理和类型检查结合起来,就是一个完整的语义分析器。来看一个综合示例:
class SemanticAnalyzer {
SymbolTable symtab;
void visitProgram(ProgramNode* node) {
symtab.enterScope(); // 全局作用域
for (auto stmt : node->statements) {
visitStatement(stmt);
}
symtab.exitScope();
}
void visitBlock(BlockNode* node) {
symtab.enterScope(); // 块作用域
for (auto stmt : node->statements) {
visitStatement(stmt);
}
symtab.exitScope();
}
void visitVariableDecl(VarDeclNode* node) {
// 检查变量是否已声明
if (symtab.lookupInCurrentScope(node->name)) {
error("变量 " + node->name + " 重复声明");
return;
}
// 插入符号表
symtab.insert(node->name, Symbol(node->type));
}
void visitAssignment(AssignNode* node) {
Symbol* sym = symtab.lookup(node->name);
if (!sym) {
error("变量 " + node->name + " 未声明");
return;
}
Type exprType = checkExpression(node->expr);
if (!isCompatible(sym->type, exprType)) {
error("类型不匹配:不能将 " + exprType + " 赋值给 " + sym->type);
}
}
};
这个分析器虽然简单,但包含了语义分析的核心逻辑:作用域管理、符号表操作、类型检查。实际项目中,还要处理函数调用、数组访问、结构体成员访问等更复杂的情况。
嗯,这一章的内容就到这里。语义分析这块,说白了就是「较真」。编译器较真,是为了让你少犯错。下次写代码遇到类型错误,别烦,那是编译器在帮你。