语义分析与符号表¶
为什么语法正确还不够¶
一段代码即使语法上能被解析成 AST,也不代表它真的合法。
例如:
这段代码从语法角度看得过去,但如果 x 没定义,它在语义上就是错的。
这就是语义分析要接手的地方:程序长得像程序了,但还不代表它说的话真的通。
语义分析在检查什么¶
语义分析主要检查“结构是否合理、名字是否有效、规则是否被满足”。
在当前项目里,重点包括:
- 顶层名字不能重复
- 参数名不能重复
- 局部变量名不能重复
- 变量必须先定义再使用
- 调用的函数必须存在
- 参数数量必须匹配
- 全局初始化必须是常量表达式
- 赋值目标必须是合法左值
- 函数必须显式出现
return
这些问题如果一直拖到后端甚至拖到最终程序运行时才暴露,排查会痛苦很多,因为那时候你离问题的原始位置已经很远了。
什么是符号表¶
符号表可以理解成一个“名字登记本”。
它回答的问题是:
- 这个名字是否存在
- 它是变量还是函数
- 它在哪个作用域里有效
- 如果是函数,它有几个参数
当前项目用 SymbolTable 来管理作用域栈,语义分析时不断:
- 进入新作用域
- 注册新名字
- 查找已有名字
- 离开作用域
作用域为什么必须显式管理¶
看下面这个例子:
这里:
x在外层块中定义y只在内层块中有效
如果没有作用域栈,编译器就很容易糊涂:到底现在这个 x 能不能看见,那个 y 是不是已经出了作用域,都会变得说不清。
所以语义分析走到一个代码块时,就像进了一个新房间:
- 进去时先记一层作用域
- 出来时把这一层名字收掉
不然里外两个房间的变量就会混在一起。
当前项目为什么先收集顶层名字¶
语义分析在真正检查函数体前,会先把顶层声明扫描一遍。
这样做有两个很实际的好处:
- 能尽早发现顶层重名
- 能支持函数之间互相调用
不然的话,前面函数一旦调用后面才定义的函数,语义层就得一边走一边猜,代码会拧巴很多。
什么是“赋值目标必须合法”¶
不是所有表达式都能出现在赋值号左边。
例如:
x = 1可以arr[i] = 1可以*p = 1可以(a + b) = 1不可以
当前项目明确把可赋值目标限制为三类:
- 名字
- 数组下标
- 解引用表达式
这就是左值检查。名字听着有点书本味,但意思其实很朴素:赋值号左边必须真的是一个“能被写进去”的地方。
为什么全局初始化被限制为常量表达式¶
当前实现要求全局变量初始化必须能在编译时算出来。
例如:
int x = 3 + 4;可以int x = foo();不可以
这类限制很常见,说白了就是先不给后端添太多麻烦。全局初始化如果什么都能写,后面要处理的情况会一下子变多。
内建函数为什么要在语义层注册¶
当前项目有一个最小内建函数 print_int。
它的作用很实际:先让示例程序能把结果打出来,而不用立刻实现完整标准库支持。
既然它要像普通函数一样被调用,那么最自然的做法就是在语义层把它注册进符号系统里。
这样后面遇到 print_int(...) 时,语义层就不用额外开后门,直接按普通函数调用去检查就行。
为什么这一层特别重要¶
语义层就是很多“看起来挺像回事、其实根本不合法”的代码第一次被拦下来的地方。
它的价值在于:
- 错误暴露更早
- 报错位置更贴近人类理解
- 后续 IR 和后端可以假设输入已经过基本清洗
后面几层因此会轻松很多,因为它们收到的已经不是一堆乱七八糟的半成品了。
如何理解这一层在工程上的作用¶
如果没有语义分析,那么:
- IR Builder 要自己额外防很多非法情况
- 后端也要兜底更多异常输入
- 错误往往会在更远的地方炸开
所以语义层做的事可以很土味地理解成一句话:它先把前面的脏活拦住,别让后面几层继续替它擦屁股。