4. 语义分析与符号表

好,咱们进入第四章。说实话,语义分析这块,是很多编译器初学者觉得「玄乎」的地方。词法分析和语法分析好歹能看到 token、看到 AST 树,但语义分析呢?它看不见摸不着,却决定了你的代码到底「对不对」。

我个人习惯把语义分析比作「警察查户口」。语法分析只检查你有没有按规矩排队,而语义分析要查你的身份证、查你的户籍、查你有没有乱认亲戚。说白了,就是检查代码的「合理性」。

4.1 作用域管理:你的变量住在哪条街?

先聊作用域。你想想看,一个变量名 x,在不同的代码块里可能代表完全不同的东西。编译器怎么知道你说的是哪个 x?

这就是作用域管理要解决的问题。

4.1.1 静态作用域 vs 动态作用域

绝大多数现代语言(C、Java、Python)都采用静态作用域(也叫词法作用域)。什么意思呢?就是变量的绑定关系在编译期就能确定,看代码结构就行。

我遇到过一些同学,把作用域和生命周期搞混。作用域是「哪里能看见你」,生命周期是「你什么时候出生、什么时候死」。两码事。

核心要点: 静态作用域在编译期确定,动态作用域在运行时确定。C/C++/Java/Rust 都是静态作用域。Lisp 的某些方言支持动态作用域。

举个例子:

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) 的平均查找时间,比线性查找快太多了。

小技巧: 符号表的哈希函数,可以用符号名的字符串哈希。我常用的是 DJB2 算法,简单且分布均匀。

4.2.2 作用域栈的设计

单张符号表不够用,因为作用域是嵌套的。我们需要一个作用域栈。

每进入一个作用域,就压入一张新的符号表(子表)。每退出一个作用域,就弹出这张表。查找变量时,从栈顶往下找,找到为止。

我画了一张图,帮你理解这个结构:

作用域栈结构示意图 作用域 3(内层函数) 作用域 2(外层函数) 作用域 1(全局) 查找变量 x: ① 先查作用域 3 ② 没找到,查作用域 2 ③ 还没找到,查作用域 1 ④ 找到则返回,否则报错 查找顺序:从栈顶到栈底 进入作用域 → 压栈 退出作用域 → 出栈

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;  // 未找到
    }
}
注意: 上面的实现是简化版。实际项目中,符号表还要处理重载函数、模板、命名空间等复杂情况。比如 C++ 的重载解析,就不是简单的名字查找,还要考虑参数类型匹配。

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;
            }
        // ... 其他情况
    }
}
经验之谈: 类型检查的错误信息要写得清晰。不要只报「类型错误」,要告诉用户「期望 int,但得到了 float」。好的错误信息能节省大量调试时间。

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);
        }
    }
};

这个分析器虽然简单,但包含了语义分析的核心逻辑:作用域管理、符号表操作、类型检查。实际项目中,还要处理函数调用、数组访问、结构体成员访问等更复杂的情况。

总结一下: 语义分析是编译器的「质检员」。它确保你的代码在逻辑上是正确的。作用域管理让变量各归其位,符号表让编译器知道每个名字的含义,类型检查防止你犯低级错误。这三者配合,构成了编译器的「语义骨架」。

嗯,这一章的内容就到这里。语义分析这块,说白了就是「较真」。编译器较真,是为了让你少犯错。下次写代码遇到类型错误,别烦,那是编译器在帮你。