TT Lab
开始
学习 学习路径 课程

编译器 — 从头到尾亲手打造一门小语言

语法没错,意思却错了 — 语义分析

在 TT Lab 中继续学习

一句话总结

语法分析器搭起来的树只保证语法正确。语义分析会把这棵树再走一遍,在运行之前确认每个名字指向哪个声明(名称解析),以及能确定的地方类型是否正确,并把找到的问题按位置顺序全部汇总后报告。

为什么需要它

print cnt; 在语法上毫无瑕疵。但如果声明的名字是 count,解释器要等到执行到这一行的那一刻才会停下——如果这一行位于一个月只跑一次的结算分支里,就要等一个月之后。不运行就能知道的错误,应该在运行之前告知。这条边界划在哪里,就是语义分析要决定的事。

名称解析的结果不只是错误。“这个 x 就是第 3 行的那个 x”这张表,是后面阶段的材料。解释器靠这张表不会搞混被遮蔽的名字,编译器靠这张表确定局部变量的槽位编号。

工作原理

作用域是一个栈。最底层是全局,每进入一个块或函数就压入一个空字典,离开时丢弃。查找名字时从内层开始看。因此内层的同名名字会遮住外层的(遮蔽,shadowing)。如果离开块时不丢弃字典,内层的名字就会泄漏到外面——这就是作用域泄漏。

let x = 1;            스택 [ {x:1:5} ]
{                     스택 [ {x:1:5}, {} ]
  let x = true;       스택 [ {x:1:5}, {x:3:7} ]   ← 여기서 x 는 3:7 (bool)
  print !x;
}                     스택 [ {x:1:5} ]            ← 안쪽 사전을 버린다
print x + 1;          x 는 다시 1:5 (int)

声明分两次放入。let 先把名字以“尚未定义”放进去,检查完初始值之后,再改成“已定义”。在此期间,如果在同一个作用域里读取自己的名字,就是“从自己的初始值中读取”错误。相反,函数则先把名字完整放入,再检查函数体。这样函数体里才能调用自己(递归)。形参和函数体最外层的声明共用一个作用域——fn f(a) { let a = 1; } 是重复声明。

类型只看能确定的地方。mini 不给形参标注类型。所以 fn f(p) { return p + 1; } 里的 p 类型未知。如果把未知的地方也当作错误,完好的程序就会被拒绝(误报);如果把未知的地方当作任意类型,后面就会撒谎。所以规则只有一条——只有当两边的类型都已知时才检查,不知道就跳过。跳过的地方,由解释器在运行时再检查一遍。这种方式叫渐进式类型检查。

表达式 要求 结果
+ - * / % ^、前缀的 - 两边都是 int int
< <= > >= 两边都是 int bool
== != 两边类型相同 bool
! && || bool bool
if、while 的条件 bool —
调用 如果是通过名字直接调用的函数,实参个数必须一致 未知

错误按位置顺序排列,而不是按发现的顺序。遍历树的顺序可能因实现而异(先看运算符节点,还是先看操作数),但用户阅读的顺序是从文件的上到下。

在现场相遇的样子

下一项实验要做什么

在 checker.py 中依次做出作用域栈、名称解析(块、遮蔽、读取自己的初始值)、函数、return 和调用检查、只看能确定之处的类型检查,以及条件类型和结果整理(analyze)。评分器会把错误列表和名称解析表与参考整体对照,并检查在随机生成的正确程序上是否没有误报,以及在逐处破坏的程序上,是否在同样的位置给出同样的错误。