概述
语义分析对上一步生成的 AST 进行进一步的检查,同时对每个 AST 结点生成相应的语义信息,以便后续的代码生成。通常来说语义分析的过程可以拆分为多遍的 AST 遍历。
虽然 CFG 能够表示许多语言,但编程语言中的某些结构仍然无法使用 CFG 表示,例如下面这个语言:
一个类似的例子是在许多编程语言中,变量需要先定义后使用。这些语法结构将由语义分析器检查,并筛出不正确的代码。
语义分析是编译器前端部分的最后一步,需要负责语言层面的剩余的所有检查,通常有作用域检查和类型检查。经过语义分析后的代码会被认为是一个合法的程序,由后端生成汇编代码。
作用域
一个标识符的作用域(scope)是指程序的一个部分,在该部分内标识符是可访问的(accessible)。
绝大多数编程语言中作用域是静态的(static),意思是标识符的作用域仅与代码有关,而与运行时的行为无关,这种作用域便可以通过语义分析来识别;然而有一部分于语言的作用域是动态的(dynamic),这类语言通常会用运行时的方法来检查,比如将未定义的变量默认为空。
同一个标识符在不同的代码部分中代表的含义可能不同。在大多数编程语言中,大多数标识符遵循最近嵌套原则(most-closely nested rule):代码由许多嵌套的代码块组成,对于一个标识符的引用,从该引用所处的代码块由里向外寻找该标识符的定义。
但是也有许多例外,例如在许多编程语言中,函数(或者方法)与类可以在声明之前使用;在大多数面向对象的语言中,类中的方法可以不在该类所在的块中定义而定义在其基类中,同时方法还可以由子类重定义。这些例外大多需要特殊处理。
由于涉及作用域的问题,所以符号表(symbol table)的结构不能像前两个步骤一样仅由一个哈希表完成。一般需要支持如下的操作:
- 进入新的作用域;
- 在当前符号表中寻找某个符号(标识符);
- 在当前作用域中寻找某个符号;
- 添加符号;
- 退出作用域;
其中第三个操作是因为一些语言不允许同一个作用域内以及函数的形参重定义同名的变量,也由于该操作所以通常符号表会设计成包含哈希表的栈。
对于例外的那些情况,例如全局定义的类或方法,常用的方法是先遍历一遍收集这些符号,再重新遍历一遍检查符号的引用。
类型
类型(type)的定义在各种编程语言中不尽相同。但通常来说,类型是一类能够进行相同操作的变量集合,以及一些在该集合上可以进行的操作的统称。对于面向对象的语言来说,类型(type)和类(class)基本上是同义词。
汇编语言没有类型的概念,所以对变量进行类型检查,保证对变量的操作合法是编译器的责任。
类型系统的实现有两种方式:静态类型(static typed)语言是指大多数或者绝大多数对类型的检查在编译期完成的语言,如 C、Java 等语言;动态类型(dynamic typed)语言是指绝大多数对类型的检查在运行时完成的语言,如 Scheme、Python、Javascript 等语言。
通常类型检查会写成命题逻辑的形式,例如对加法表达式进行类型检查可以写成如下形式:
冒号表示某个表达式是哪个类型。传统上也会写成如下形式:
前面的$O,M,C$称为类型环境(Type environments)。
类型环境
对于一部分表达式,仅靠命题逻辑形式本身是无法进行类型检测的,需要给出外部条件(例如对变量类型的检查),这类外部条件称为类型环境。
有了类型环境,便可以写出变量的类型检查逻辑:
其中$O$代表 Object,是一个从变量标识符到类型的映射,可以使用之前所述的符号表来维护。
变量定义的类型检查逻辑可以写为(COOL 语法):
其中中括号记号的含义如下:
至于 COOL 语言中的其它的类型环境,$M$代表 Method,接受方法名与类名为参数,返回各形参类型与返回值类型;$C$代表 Class,代表当前表达式所在的类,与 self 类型有关。
子类型
对于面向对象的语言来说有子类型(subtype)的概念。有子类型就需要类型系统满足里氏替换原则,即所有基类可以出现的地方都可以出现其子类型。从集合的角度来说子类型应该是基类的子集。
可以在类型上建立如下的偏序关系$\leq$来描述这个概念:
- 当类型(类)$X$继承自类型(类)$Y$时,有$X \leq Y$;
- 自反性:对于类型$X, X \leq X$;
- 传递性:$(X\leq Y \land Y\leq Z) \Rightarrow X\leq Z$。
有了该关系便可以对一些需要里氏替换原则的表达式进行类型检测。例如,对于赋值语句:
同时可以定义一个重要的概念:最小上限(Least Upper Bound, abbr. lub)。形式化地:
对于绝大多数只有单继承的语言(如 Java)来说,其继承关系是一棵树,则 lub 即为两个类在树继承树上的最近公共祖先(Least Common Ancestor, abbr. LCA);对于有多继承的语言(如 C++)则要复杂许多,其继承关系是有向无环图(Directed Acyclic Graph, abbr. DAG),lub 为两个点的最近公共支配点。
有了最小上限的概念则可以对分支结构进行类型检查:
因为运行前并不知道$e_0$求值的结果是真是假,所以在编译期这里只能做最保险的假设,也即$\mathrm{lub}(T_1, T_2)$。
self 类型
有的时候方法可能需要将自身作为返回值返回。对于没有继承特性的语言来说没有什么问题,但对于许多面向对象的语言来说,如果这个方法在基类当中,则对返回值类型的检查可能会造成问题。因此需要引入 self 类型。
对于一个方法,如果声明其返回值为 self 类型,则返回值的类型与调用对象的类型相同;而调用对象的类必须是方法定义所在的类或其子类,因此 self 类型所代表的是某个类及其子类的一个集合,对于没有多继承的语言来说即为继承树上的一个子树。
令$S_C$为出现在类$C$中的 self 类型,则有如下关系:
- $S_C \leq C, S_C \leq S_C$;
- $C \leq T \Rightarrow S_C \leq T$;
- $\neg(T \leq S_C)$;
- 对于两个不同的类,其 self 类型的关系未定义。
第三条规则是因为$S_C$代表了$C$的所有子类,无法保证某个类型$T$与$C$的所有子类的关系;第四条规则是因为两个不同类的 self 类型不会同时出现,因此这样的关系没有意义。
定义了偏序关系后同样可以定义 self 类型的 lub:
- $\mathrm{lub}(S_C, S_C) = S_C$;
- $\mathrm{lub}(S_C, T) = \mathrm{lub}(T, S_C) = \mathrm{lub}(C, T)$。
对于 self 类型声明可能出现的位置,唯一需要注意的是 self 类型声明不能出现在方法形参的类型声明中。因为对于方法调用有如下规则:
然而由于上面的规则,如果形参的类型声明中出现 self 类型,则最后一条$T_i\leq T’_i$对该形参为恒假,因此 self 类型声明不能出现在方法形参的类型声明中。
错误恢复
同样地,语义分析器也需要错误恢复来尽可能多的发现代码中的错误。不过由于已经建立起了 AST,因此相比前面的步骤,在语义分析中进行错误恢复要更简单,例如对于未定义或重定义的变量,直接报告错误即可。
语义分析中的错误恢复的重点是类型检查中的错误恢复。一个朴素的解决方法是将类型检查错误的表达式指定为 Object 类型,即所有类型的根类型。但这样会导致级联报错,因为作为根类型,绝大多数的操作是无法对 Object 类进行的,而这又会导致错误并被指定为 Object 类型。
另一个方案是引入一个新的类型 No_type,并定义对所有类型$C$,有$\mathrm{No\_type} \leq C$。这样所有操作对 No_type 均有定义,报错时只会报告发现错误的位置。
但引入 No_type 有一个问题:对于无多继承的语言来说,引入 No_type 后其继承关系将会由树变为一个 DAG,这会使得对类型的处理变得十分复杂。一个解决方法是将 No_type 视为一个特例,对其它的类型仍然使用基于树的算法。
复合类型
许多语言都有复合类型的概念,例如 C 中的数组、指针,C++ 的模板类等。这些复合类型的声明需要使用类型表达式(type expression),且需要语义分析器具有识别类型表达式的功能,并给出合适的类型签名。
对于数组、指针这类的类型,可能会导致别名(aliasing)问题:基类的指针可以指向子类指针所指向的空间并使用基类的初始化函数,此时子类指针想要调用基类中没有的方法便会产生错误。
一个方法是对这些类型的类型检查中禁用子类替换,这可能会损失一部分表达性;另一种方法是类似于 Java 使用运行时的机制来检测这类问题。
类型转换
许多语言都有不同类型的“数”,例如有符号和无符号整数、不同长度的整数、浮点数等等。这些类型之间在进行算术运算的时候往往并不是通过定义每个类型之间的运算来实现,而是通过类型转换(type conversion)来实现的。
某些类型转换必须要通过某种声明来触发,这些类型转换称为显式类型转换(explicit ~ / cast);还有一些类型转换可以由编译器来识别并进行,称为隐式类型转换(implicit ~ / coercion)。
通常能够进行隐式类型转换的关系可以表示为一棵树,若两个被运算数的类型不同则将两个数都转换到该树的 LCA 上。
Lab
作业四需要使用 C++ 或 Java 来实现 COOL 语言的语义分析器。
首先在头文件中定义继承树的结点类:
1 | typedef InheritsNode_class* InheritsNode; |
flag_errtype
为true
代表该结点为Err_type
(即为前文所述的 No_type,由于作业中要求将 no_expr 的类型标记为 No_type 因此改了个名字);flag_prim
为true
代表该节点为特殊类型prim_slot
(其实去掉好像也行);其它的属性为与树结构相关的信息,方便后续的树上操作。
然后定义判断类型关系与求最近公共祖先的函数。由于有 self 类型和 Err_type,为了方便起见先定义一个类InheritsOperand
,对于类型关系之间的操作都在该类上实现:
1 | class InheritsOprand; |
对类型关系的操作使用了重链剖分。首先需要初始化结点中的相关信息:
1 | void InheritsNode_class::calc(int dep=1) { |
calc()
用于计算各结点深度与子树大小,以及选择重儿子;link()
用于连接重链,标记重链链顶;init()
仅需根结点调用,用于对整棵树进行初始化。
接下来便是实现关系判定以及求 LCA 的函数:
1 | InheritsNode lca(InheritsNode lhs, InheritsNode rhs) { |
然后修改一下ClassTable
的定义使其支持手写的继承树:
1 | class ClassTable { |
其中的inherits_tree
即为继承树的结点,node_obj
为继承树的根,node_err
代表特殊的 Err_type 结点。
然后修改一下入口函数program_class::semant()
的定义来确定语义分析的步骤:
1 | void program_class::semant() |
语义分析的步骤首先是建立继承树,然后收集属性和方法名,接着根据继承树和属性与方法符号表进行检查。
然后是定义ClassTable
的构造函数,根据入口函数program_class::semant()
的注释,该构造函数作用是检测各个类的声明是否合法并构造继承树:
1 | ClassTable::ClassTable(Classes classes) : semant_errors(0) , error_stream(cerr) { |
在install_basic_classes()
中也需要添加语句将原始类型接到继承树上:
1 | void ClassTable::install_basic_classes() { |
然后是收集符号,将属性和方法分别收集到每个类的变量符号表和方法符号表中:
1 | void ClassTable::gather_features(InheritsNode node) { |
然后是类型检查,根据手册上给出的类型检查规则写即可:
1 | void ClassTable::check(InheritsNode node) { |