COOL Compiler-Part4

PA4

终于不用折腾老古董了,虽然但是这个 PA 的代码量有点大啊,而且要考虑的东西变多了,为啥越做越难了。

这个 PA 的代码量特别大,写了我三天半,而且是周末的三天半(每天写12小时代码),主要是实现语义检查,有很多细节需要考虑,包括生成的一些错误信息之类的。将近1/3的时间会花费在读文档、写测例、对着参考编译器复制错误信息(虽然不一定要求和参考程序一样,但是写成一样的会使得 diff 比较方便)以及考虑各种细节问题上。

写之前先读文档

需要读的 Manual: Typing rules,scoping rules。除了13节 operational semantics 之外,应该都要看完。

主要任务:

  • 遍历所有的类、构建继承关系图、检查继承关系
  • 对于每一个类,先遍历一遍构建符号表,然后类型检查并在 AST 上做类型注解

需要修改的文件:

  • cool-tree.h 扩展 AST 的定义;
  • semant.cc semant.h 实现主要的逻辑:semant() 方法会被主程序外部调用,ClassTable 写了一点 starter code,构建继承关系用这个东西。

如何遍历:

  • 看代码 dump_with_types

继承关系构建:建个图然后检查环路。从 basic class 继承有限制(Int、String和Bool这三个都是不可继承、不可重定义的,其他的basic class 不能重新定义),同时不能继承一个不存在的类。 推荐的分析过程:第一步先检查继承关系,第二步检查其他的语义条件。

作用域:self 自动引入,其他带需要考虑命名覆盖的问题。注意,class、method、attribute 这些的命名可以在定义前被使用,因此可能需要多趟处理。符号表在 support code 里面有定义,甚至有一个 symtab_example.cc 的样例程序。

类型检查:对于无法确定类型的表达式,赋一个 Object 然后尝试恢复。需要给出错误信息,不过正如课上讲的 cascading error 是可以接受的。

代码生成接口约束:所有表达式节点的 type 必须设置为 Symbol,具体的值由 type checker 决定。no_expr 赋值为预定义的 Symbol 变量 No_type

错误输出:对于不存在继承相关错误的程序,需要报告所有的语义错误(不要求和参考实现完全一致)。这个作业里面需要手动调用报错方法 ClassTable::semant_error(),(前两个PA是工具自动生成的)

调试:skeleton 提供了一个命令行开关 -s,对应全局变量 semant_debug,可以用来条件打开调试信息。

实现说明

需要修改的文件有四个,semant.cc semant.h cool-tree.h cool-tree.handcode.h。前两个主要是自己实现,主要的代码都在这里;后两个是为了定义 AST 节点上的接口。

接口方面,我增加了一个 semant(Class_, ClassTable*) 来做类型检查以及一些必要的 getter 供父节点访问子节点内部信息。不过也不是所有类型的都写了,为了省事,我只写了 FeatureExpression 两个类的 semant 方法。写的时候可以在 cool-tree.handcode.h 的宏里面定义这些接口,这样就不用手动把定义复制到每个子类里面去了。剩下的各种列表、BranchFormal 的处理逻辑就一起写到他们的父节点里面了,因为他们被用到的地方是唯一的,写在哪里都是写一遍。semant 方法接受两个参数,一个当前的类,对应规则里面的环境 C;还要一个是 ClassTable 这里面存放了所有需要用到的工具以及符号表(对应规则里的环境 O,M),然后递归向下处理整个AST。

整个过程分为两个部分实现,分别处理继承关系和其余部分(主要是类型检查)。

  • 继承关系检查又需要扫描三次类列表,第一遍去除重复的类定义并检查预定义类冲突;第二遍初步建立继承关系树,检查基类存在性;第三遍遍历继承关系树,检查是否存在环形继承。这些工作完成后,产生了一个继承树和一些其他的辅助记录结构,后面的检查会依赖这些信息。
    • 检查环路其实很简单,只需要从 Object 开始遍历一遍树,没访问到的肯定有环。因为语法分析保证了一个类只能有一个基类,如果存在环那么它向根肯定跳不到 Object,不过向上跳不好写,反过来就是从 Object 开始遍历树无法访问到的节点在环上,就很简单了。
    • 不过 skeleton 并没有提供相应的数据结构,需要自己实现。写一个 node 类型,然后写上父节点和子节点指针(列表),另外需要一个指针存对应的类以及一个辅助变量(用来后面写 LCA 和判定环路)。
    • 在实现的过程中比较遗憾的是,指针乱飞,而且它提供的符号表还内存泄漏,实在是一言难尽。不过累了,懒得改 RAII 了,
  • 其余部分需要两次遍历,一次线性扫描所有的类,遍历每个类的 Features 列表,收集每个类里面的属性和方法定义加入符号表并检查重复定义和方法覆盖的情况;同时还要顺便保证一下 Main 类和 main 方法的存在。第二次就是类型检查,从 AST 的 program 开始(这个 skeleton 已经写好了)向下递归调用子节点的 semant 方法来进行检查。 之所以要两遍,是因为属性和方法可以在定义前被使用,因此需要提前收集,不然没法做类型检查。
    • 这里涉及到符号表的设计。根据类型检查规则,需要两个表,一个是标识符,一个是方法。我的做法是给每个类分了一组符号表(标识符和方法),用 map 索引起来,在第一次扫描的时候把类的属性和方法就塞进去,同时还有 self:SELF_TYPE 这个保留定义。
    • 第一次检查的时候,需要处理重复定义和方法覆盖。对于属性,重复定义还需要考虑继承的属性;对于方法,特别注意方法覆盖,需要比较长度、返回类型(完全一致,不可用派生类)和每个形式参数的完全一致。
    • 第二次就比较的繁琐,需要把每一条类型检查规则写成 C++ 代码。为此,还需要定义运算符 joinconform(相当于课上的 lub<=)。前者是因为 ifcase 两个语句的返回类型要取它们的最小上界。这个过程中需要特别注意的是对 SELF_TYPE 的处理,因为 SELF_TYPE 不在类定义表里,而且很多规则都涉及到这个东西。 具体的实现因为太多了,就不写了。

对着手册写完了一堆规则之后,还有究极麻烦的错误处理。这里列几个基本的原则,实在难绷的会记录在下面的细节说明中。

  • 只有在无法确定类型的时候赋值 Object,而不是一有类型错误就返回 Object。比如使用了未定义的类型,只需要报错就行了,在后续的检查中不需要把他改成 Object。还有算术,无论两边的操作数类型是啥,这个算术表达式都是返回 Int 类型的。
  • SELF_TYPESELF_TYPE_c,这两个东西在 AST 里面都写成 SELF_TYPE 这个 Symbol,在实际检查的时候再特别判定。另外关于这两个玩意的错误很多都是特别判定的,再参考实现里面这东西的错误不和其他的一起判断,我也推荐这样的写法,比如一开始先不考虑这两玩意儿,写完之后再加上。
  • 尽量多检查一些东西,有些能继续的就继续,只有一阶段检查完之后可以 abort,其他的尽量不直接 return。

细节说明

特别注意一下的情况统计,会比较有用。

引入新变量绑定的四种情况

  • attribute definitions;
  • formal parameters of methods;
  • let expressions;
  • branches of case statements.

允许使用 SELF_TYPE 的四种情况

  • new SELF TYPE
  • return type of a method,
  • declared type of a let variable
  • declared type of an attribute

这个 PA 的坑细节很多,调了大半天bug。

  • 关于 selfSELF_TYPE 的特别判定
    • self 不能被赋值、不能重定义(属性名、 let 变量名、形式参数名、case 绑定名)
    • SELF_TYPE 不能重定义(类检查)、只能出现在四个地方(因此不能用于形参定义、静态函数调用)
    • 表达式的返回值可能是 SELF_TYPE,这是就需要结合当前类来执行 conformjoin 操作了,如果不考虑的话会段错误。
  • 记得在返回类型的时候同时改 AST 上的类型标注(主要是 Expression 类下面的,Feature 和 Formal 一开始就标好了 type_decl 这个反而不能改)
  • 方法、属性查询的时候,记得要查父类,不然继承的变量和方法就找不到了。
  • 还有各种未定义的处理,一不小心就段错误了
  • 返回 Object 的情况:objectid 未定义、new 未定义类、函数调用中无法确定被调用函数的情况(类未定义、函数未定义、函数参数个数不对)、loop 返回值。

剩下的基本上能从手册的12章中比较清晰的看出来。

摸摸猫猫
Built with Hugo
主题 StackJimmy 设计