153 lines
23 KiB
Markdown
153 lines
23 KiB
Markdown
# CS143 体验报告
|
||
|
||
## 理论课程部分
|
||
|
||
> 待施工
|
||
|
||
## Written Assignment 部分
|
||
|
||
> 既然它网站上都放了,那我就姑且做一下?
|
||
>
|
||
> 还没咋做捏
|
||
|
||
## Programming Assignment 部分
|
||
|
||
### 开始之前
|
||
|
||
这门课的官方材料好像每隔几年就会换一个地方,导致找起来十分的困难(之前做的 xv6 就很棒,多少年的东西都放在同一个网站上),现在这个时间点(2023年3月)的官网应该是在 edx 上面,需要注册收费之类的才能获取相关材料。不过这门课历史悠久,基本上从2000年左右就有了,自然是被大家复制传播到了各种地方。这个 repo 里面的 starter code 是从 github 上别人的仓库里面的分发包(`student-dist.tar.gz`)解压出来的,我对比了几个仓库的代码内容都是一样的,那么做这个版本的应该问题不大。不过和课程网站上的内容还是有一些不同的,这就没有办法了,大家也拿不到 Stanford 的内部版本。
|
||
|
||
### PA1
|
||
|
||
第一个实验是用 COOL 写一个简单的程序,程序的功能是解释一个简单的 Stack Machine 语言,会输入一些序列然后按照文档的说明进行操作。
|
||
|
||
推荐的实现方式是,定义一个 `StackCommand` 类,然后为每一条命令实现一个子类用来执行具体的操作。这里还给了一个工具类 `atoi.cl`,里面实现了 `String` 到 `int` 的转换。不需要实现错误处理,假设给出的序列都是合法的。不过本人才疏学浅,就写了几个 if 就写完了,压根没写这么复杂。
|
||
|
||
首先我们需要一个 List 作为栈,这个可以从 `examples/list.cl` 里面抄过来,然后把数据类型改成 String 就可以了。然后就是处理输入输出,定义一个输入变量,一个大循环判断是不是 `input = "x"`,里面一个大 `if-else-then` 判断输入的命令,如果是 `d` 就调用前面抄过来的 `print_list`(需要修改一下格式和类型);如果是 `e` 需要再来一个 `if-else-then` 来处理求值操作;其他的就直接塞进栈里面,这里不做错误处理,假设都是合法的。具体到 `e` 命令里面,`pop` 并判断栈顶,如果是 `+`,`pop` 两次,调用 `a2i` 做运算然后再 `i2a` 变回字符串 `push` 到栈上;如果是 `s`,依然需要 `pop` 两次并保存 `pop` 出来的东西,然后逆序 `push` 进去就完成了交换操作。
|
||
|
||
主要的困难在于 COOL 的智障语法,其他的倒是没啥难度。
|
||
- 最智障的是它的 `expr`。绝大部分的东西都是个表达式,两个大括号也是表达式 `{ [[expr; ]]+}`,两个大括号中间的表达式后面必须跟分号(其他的不需要),`if - fi` 后面都必须跟分号我也是绷不住了。然后它的函数定义后面跟着那两个大括号是个语法符号(摆设)而不是 `expr`,这种就很不直觉,让我困惑了半天然后翻它的文法声明才发现这个事情。`feature::=ID( [ formal [[, formal]]∗ ] ) : TYPE { expr }`,也就是说,只能写一个表达式,不然就得再加一个单独的大括号然后里面写多个分号隔开的表达式。
|
||
- 其次是它的 `if-then-else` 居然不能没有 `else`!这导致我不得不像个傻子一样定义一个 `dummy(): Object {0};`,然后放在根本不需要的 `else` 子句里面。
|
||
- 还有它的 `while` 循环居然没有 `break` ?!
|
||
- `let` 语法也是究极奇葩,我是不能理解为什么要设计成这个样子。想定义一个局部变量都得加一层嵌套,这个嵌套多得让我觉得我 tm 是在写 `scheme`。
|
||
- 它的 `case` 也是奇葩,居然是用来做动态类型匹配的,匹配的是类型而不是值,和正常的 `switch-case` 完全不一样,倒是有点像 `rust` 里面的那种感觉。
|
||
|
||
测试的话,虽然 handout 里面说是直接 `make test` 然后对比输出,不过我也没看到它哪里有所谓的 reference implementation,自己看了看测例觉着没啥问题就行了。
|
||
|
||
### PA2
|
||
|
||
PA 2-5 正式写编译器。PA2 写词法分析器,首先读一遍 README 和 handout。
|
||
|
||
> 环境配置
|
||
>
|
||
> 因为这个项目的结构非常的智障,导致需要进行一些配置才能让 `clangd` 正常工作。因为是 `Makefile` 项目,所以不能直接生成 `compile_commands.json`。
|
||
> 1. 安装 `apt install bear`,这个工具可以拦截 `make` 命令来生成上述的文件。
|
||
> 2. 在 PA2 目录下,运行 `make clean && bear -- make lexer`,然后 `clangd` 应该就不会找不到头文件之类的了
|
||
|
||
> 获取评测脚本
|
||
>
|
||
> 现在已经没有办法在线提交测试了(除非花钱?),因此需要从一些奇怪的地方获取测试工具,这里我从 https://github.com/shootfirst/CS143 这个 repo 里面扒了评测脚本(测试数据已经包含在了脚本里了) `pa[2-5]-grading.pl`,测试的话直接用 `perl ./pa2-grading.pl` 就行了。
|
||
> 这个东西确实测出来一些自己没考虑到的边角问题,虽然我已经很努力的在编测例了。
|
||
|
||
写完了发现还是很折磨的,写了快两天的样子,一方面是对于 `flex` 工具和配套的基础设施不是很了解,另一方面是各种细节问题需要处理。
|
||
|
||
#### 实现说明
|
||
|
||
比较简单的部分是那些只需要返回一个 token 类型的东西(关键字和符号这种),pattern 就是他们自己,然后 action 就一个 `{return (TOKEN)}`。
|
||
|
||
然后稍微想了一下的,标识符和 Int、Bool 常量,需要写一个简单的正则外加操作 String Table 和 `cool_yylval`。
|
||
|
||
需要折腾一会的是注释,需要用到 flex 的状态。
|
||
- 比较简单的是单行注释,检测到 `--` 就进入 `<SCOMMENT>`(Single-line Comment),然后检测到换行就回到 `INITIAL` 状态。这里是 `.` 和 `\n` 这一组互斥状态,EOF 不需要单独处理,可知已经覆盖了所有的情况。
|
||
- 然后是多行注释,不过这里需要支持 nested comment。这玩意的意思是说,需要在注释里面也要成对匹配 `(*` `*)`,不能简单的只考虑最外层或者最内层的符号对。这里需要多加一个变量 `comment_nest_level` 来维护嵌套的层数,流程也更复杂一些。
|
||
首先遇到 `(*` 进入 `<NCOMMENT>`(Nested Comment),同时初始化嵌套层数;然后在该状态下过滤 `(*`,发现一个就叠一层,过滤 `*)` 发现一个减一层或者到底了就退出状态就行了;这里需要特殊处理 EOF,如果在该状态下匹配到 EOF,需要报错,但是因为反正输入流已经结束了,就不需要做 resume 了;遇到 `\n` 维护一下行号;剩下的就交给 `.`。前面其实不需要写 exclusion,因为最长匹配(`(* *)` 都是2个字符,`.` 和 `\n` 都是1个, EOF 不可能匹配到其他的东西上),所以可以简单的囊括所有的情况并维护优先级。
|
||
- 除了注释里面的部分,还需要考虑一个单独的 `*)`,需要匹配并报错(而不是`*` `)` 两个 token)。这个是4.1节第5条的要求。这样一来,我们就解决了嵌套注释不匹配的两种错误情况,即:左符号比右符号多 => `EOF in comment` 报错;右符号比左符号多(等价于单独的 `*)`) => `Unmatched *)` 报错。
|
||
|
||
最折腾的是字符串,因为它要考虑多行的情况,而且需要做特殊的 resume 处理。好消息是这些细节文档都有描述,坏消息是需要看好几遍才能完全理解。
|
||
- 为了方便处理出错恢复处理,用了两个状态 `<STRING>` 和 `<STRINGREC>`(String Recovery)(不然需要在 action 里面写很多的特判)。
|
||
- 正常情况下,遇到 `"` 进入 `<STRING>`;然后对需要特殊处理的部分编写规则:
|
||
- `\` 转义:匹配两个字符,根据 `\` 后面的字符决定把什么东西写进最终的字符串常量里面,这个东西在 handout 的第4.3节和 manual 的10.2节有描述;需要注意的是,合法多行转义可以(在最后加一个 `\` 然后换行,后面没有其他空白符)在这里一并处理掉,只不过需要写成`(.|\n)`。
|
||
- EOF:根据 manual,除了 `\0` 和 EOF,其他字符均可出现在字符串中。因此,需要单独处理这两玩意。EOF 和前面的注释类似,比较简单。
|
||
- 0字符:0字符需要恢复。除了返回 ERROR Token,还要把状态切换到 `<STRINGREC>`,剩下的部分交给恢复规则。
|
||
- 未转义换行:根据最长匹配,可以直接写 `<STRING>\n`,因为转义过的是2个字符。这个不需要进恢复状态,因为它直接返回 `<INITIAL>` 然后继续下一个字符就相当于处理了恢复的过程。
|
||
- 对于除了以上情况的所有字符(可以 exclude 或者把 `.` 写到最后),用 boilerplate 给我们定义的 `string_buf` 存放。这里需要处理一个字符串过长的问题,判断指针超限之后,进 `<STRINGREC` 并返回 ERROR Token,具体的边界情况可以对拍(对比标准实现)来获知。最后就是遇到了未转义的 `"`(根据最长匹配可以直接写 `<STRING>\"`),把 `string_buf` 塞进 `stringtable` 里面,回到 `<INITIAL>`。
|
||
- 对于恢复状态 `<STRINGREC>`,根据手册,从下一个未转义的换行符或者未转义的`"`(因为要求是 `closing "`)开始继续正常的词法分析。所以在前面需要把 `\\\n` 和 `\\\"` 给处理掉,然后遇到 `\"|\n` 这两个之后就直接返回 `<INITIAL>` 就行,也不需要 return 任何东西。期间也要记得维护行号。不过事实证明,不需要单独处理 EOF,因为恢复了也没东西了。
|
||
- 这里有一个细节问题,就是报错的行号必须等于出错的地方的行号,因此不能留到后面统一返回报错,而是一旦出错就立刻返回。
|
||
|
||
容易忘掉的点,一个是空白符匹配,需要一个 pattern(action 留空),不然遇到空白符 lexer 会行为异常,同时遇到换行还要维护行号;第二个是不合法字符,有些 ASCII 我们并没有用到,因此需要最后一个 `.` 来匹配并报错、跳过。同样的,`.` 和 `\n`(包含在空白符规则里面)的组合至少保证了所有的字符都会被匹配、处理。
|
||
|
||
不过其实,最离谱的是自己设计测试样例,这很考验对于手册的理解,不然就会漏掉点什么(虽然漏掉一些 edge case 对于后面也没啥影响就是了)。
|
||
|
||
在用了官方的评测脚本之后,又发现了一个问题:`\\0`(`0x5C 0x00`) 是不允许的。这个其实判定起来不难,但是会想不到,因为 PA2 的 handout 没写这一条,虽然 manual 里面 `A string may not contain the null` 的确是不允许这种情况(因为它转义完了还是 `\0`),但是谁会想到测这个呢?
|
||
|
||
#### 基础设施说明
|
||
|
||
个人感觉,一开始做的比较迷惑的主要原因在于它的代码框架比较凌乱,文档也有点谜语人,读了好几遍文档才知道该写点啥。
|
||
|
||
写之前多读几遍文档,首先看一遍 PA2 的实验说明,了解一下需要干什么,里面也有一些读其他文档和代码的指导。然后是 `cool-manual.pdf` 看第10节(主要是写 pattern 的时候看)和第12节里面那个大的 BNF 文法(里面描述了所有用到的符号),以及 `cool-tour.pdf` 看第3节,在写 action 的时候会用到它来存字符串。最后看一下代码,主要是 `/include/cool-parse.h`,这里面定义了非 ASCII 符号的 TOKEN,还有 `YYSTYPE` 枚举,这个枚举是用来存 lexeme 的,`;lextest.cc` 也要看一下,至少知道输出的都是什么。
|
||
|
||
这个里面用到的基础设施主要是一个 String Table,用来存放所有遇到的字符串。里面有个 `add_string` 方法,会先查再加还带内存分配,所以直接调用就行。碰到需要存字符串值的东西,各种 identifier、String 常量和 Int 常量(不检查不转换,直接存),这三个每个都有一个单独的实例对象(`idtable` `stringtable` `inttable`),需要根据不同的 token 用。
|
||
|
||
另外一个就是 `YYSTYPE cool_yylval` 这个枚举。如果有需要存的信息(lexeme 或者错误信息,参考第5节 `Notes for the C++ Version of the Assignment`),每个 action 往里面写一个值,这个对应 PA2 文档第4节开头的部分,当时看了半天才理解到是要存这东西里面。用到里面的3个枚举值:`boolean`、`symbol`、`error_msg`。如果是解析出来 `Bool` 常量,那么直接写进去 `true/false`;如果出错了,给字符指针 `error_msg` 赋值,我猜需要用到 `strdup`,但是单就这个实验看不出来,总感觉要出内存 bug(也不知道后面会不会 free 掉);剩下来那些需要存 lexeme 的就先 `add_string` 然后它会返回一个 Entry,这东西就是所谓的 Symbol 啦。
|
||
|
||
#### flex 工具踩坑
|
||
|
||
最后记录一下 `flex` 这个工具的一些坑。
|
||
- **版本**:因为不知道在网上的什么地方看到了一个 blog 说是新版本的 `flex` 在处理 c++ 时的行为和旧版不一样,所以选择了旧版本,也就是 `apt install flex-old` 安装的版本(2.5.4)。问题在于我看的文档版本是最新的,遇到了一个不支持的语法,不过其他的倒是没有特别需要注意的地方。旧版本的 `flex` 似乎不支持 `(?i:xxx)` 这种写法,这个在关键字忽略大小写的时候会比较省事(因为只有关键字需要忽略大小写,所以不能开全局忽略),可惜的是旧版本只能像个傻子一样写成 `CLASS [Cc][Ll][Aa][Ss][Ss]`。
|
||
- **奇怪的缩进规则**:它居然和 python 一样,依靠缩进来分辨一些东西,每个 Section 的规则还稍有不同。比如在 Rule Section(就是实际写 action 的那一节),pattern 必须无缩进,然后其他的东西(比如注释)必须前面有空白符,不然就会被认为是一个 pattern。在看文档的时候要注意一下 `(un)indented` 这个词,可以避免很多奇怪的事情。
|
||
一开始没看到 `the pattern must be unindented and the action must begin on the same line` 这一句,没给注释加前面的空格,然后就调了半天的 `unrecognized rule`。
|
||
还有就是 action 可以没有大括号但是不能没有分号,以及它其实是可以写多行的,只要前面有 indent 就行(不然会写出很长一串)。
|
||
- **EOF 符号**:`flex` 里面的 EOF 专门有一个 `<<EOF>>` 来表示,但是它不能和其他的 pattern 写在一起,也就是需要单独一行写一个这东西,不然报错。
|
||
- **0 字符(null character)**:这个本身没什么特别的,直接用就行,不过需要注意的是,`.` 通配符是包含 `\0` 的,也就是一不小心就用这玩意把 `\0` 给匹配进去了,单独写的那个 `\0` 规则就不生效,会导致一些费解的事情发生。
|
||
- **VSCode 插件**:搜到两个还算下载量比较多的,但是都不行,有一个着色有 bug(可能是因为它太老了,接口不兼容了),还有一个加了莫名其妙的、极具误导性的语法检查还关不掉(一点问题都没有的代码给我每行一个红色波浪线)。建议用第二个,然后大脑忽略他的错误提示。
|
||
|
||
剩下的就看两眼文档就会了,跟课上教的 Regular Expression 基本上大差不差。
|
||
|
||
### PA3
|
||
|
||
这个作业写 Parser。主要问题依然来自于 bison 工具不会用以及各种奇形怪状的 edge case,折磨程度和前一个差不多,写了3天的样子。写的时候一直在想如果有 `ANTLR` 就好了,之前玩过,感觉比 `Flex` + `Bison` 这种老古董要舒服多了。
|
||
|
||
#### Bison 踩坑
|
||
|
||
前期主要是 Bison 不会用,这里记录一些坑。基本上的工作就是在翻译手册上的 BNF 然后写 semantic action。写着写着突然发现它的课上好像根本没讲这玩意,就挺抽象的。
|
||
|
||
一开始需要了解的东西(不至于迷惑):`$$` `$[1-n]` 分别表示 `:` 左边的 non-terminal 和右边的第n个符号所关联的值,这些值可以理解为树的结点。这些东西是有类型的,所有可用类型定义在 `%union` 里面,对应到产生式上需要单独指定(貌似新版的 bison 提供了推导功能,但是这个实验在设计的时候显然没有)。对于终结符(这里就是那些 `TOKEN`),默认 `int`,如果带 `lexeme` 值的需要特别指定 `%token<typename> token-name` 这种;对于非终结符,需要指定 `%type<typename> non-terminal-name`,这里就类似于 non-terminal 的声明。bison 会检查这些所有的类型是不是匹配,当然这里的检查只是检查这些语法符号之间的类型匹配,具体到 C++ 的函数调用还是交给编译器。`@$` `@[1-n]` 是行号相关的东西。详细的信息似乎在 bison 文档里面有一个单独的小节列出了符号的含义来着,忘了。
|
||
|
||
1. 关于 Bison 手册的阅读:这里我直接用的最新的3.8.2,直接看最新的文档就行了。
|
||
1. 1.1-1.4,简单了解一下;第2章可以看,也可以去看看《Flex与Bison》,都是用例子讲解;然后就是具体的东西,3.2-3.4,3.7,5.1-5.7这些都会用到;看看这些基本上就能把框架搭起来了。
|
||
2. 然后到了第一次编译,第8章关于 debug 的说明还是可以看看的
|
||
3. 最后是看第6章关于错误处理的说明,虽然这个说明也没啥大用。
|
||
2. 框架代码会导致 Shift-Reduce Conflict:如果是框架提供的那种、用空规则定义各种 `xx_list` 的写法,会出现 SR Conflict。虽然实际上应该是可以用的(大雾)(因为有一些 bison 的默认行为会使得这么做能够得到正常的结果),但是我还是把它拆掉了(不像看到 warning),也就是把用到 `xx_list` 的地方写成有 `xx_list` 和无 `xx_list` 两种,而不是交给 `xx_list` 这个 `nterm` 来处理空的情况。这样就没有 warning 了。
|
||
3. 一些东西是 bison 的扩展,因为开了兼容性警告 `-Wyacc`,所以用了会 warning,比如 `%empty` `%precedence` 这种。
|
||
|
||
剩下的倒是没啥,因为没有用旧版本的东西,所以没有出现 `flex` 那么多奇怪的东西。
|
||
|
||
#### 基础设施说明
|
||
|
||
这里的代码库主要是他们自己写的奇怪的 AST 的东西,虽然用起来也没啥大问题,不过总觉得有点抽象。这里主要参考 `cool-tour.pdf` 的第6节,描述还算是比较详细的。
|
||
|
||
具体的东西没必要了解,也不需要修改它的结构,说一下基本的逻辑就能上手写 semantic action 了。
|
||
- 首先它的功能是提供一种 AST 的描述方法,定义了一堆不同类型的 node,写语义动作的过程就是用这些 node 来组装一个 AST。
|
||
- 用到的接口基本上就是类似于构造函数一类的东西(这么理解其实对于写这个 lab 而言没有任何问题),每种 node 都有一个自己的类型,能够接收一组特定类型的参数进行构造。不同的类型通过 bison 文件前面的 `%union` 进行定义,构造器就是在填当前 node(`$$`)的信息和子节点关系。写文法就类似于递归下降(虽然实际上 bison 用的是 LALR),一种自然的构造语法树的过程。
|
||
- node 主要有两种,一种是 list,另一种是单独的 node。list 表达的是一堆同类型的子节点,构造的时候和链表类似,这个在 skeleton 里面有例子(class list)。然后我们在定义的时候,可以参考着写。
|
||
- 除了简单看一看那个 `cool-tour`,具体的定义去看那个 `aps` 文件,描述了详细的参数类型。
|
||
- 这个实验只需要用构造器函数就行了,生成 C++ 时候添加的方法用不到,是给后面计算 AST 用的。
|
||
|
||
|
||
#### 实现说明
|
||
|
||
要修改的文件主要是 `cool.y`,但是为了处理行号的问题,还需要修改 `/include/PA3/cool-parse.h:754`。
|
||
|
||
最简单的部分是翻译文法定义,照着 manual 12节抄就行了。在此过程中,可选的部分要稍微处理一下,描述的时候拆成有和没有可选部分两条规则,然后还要填上默认值,比如省略初始化的时候需要填个 `no_expr()`、函数调用省略对象的时候需要补上默认的 `object(idtable.add_string("self"))` 。这里面唯一要动一动脑子的就是前面提到的 list 的书写方法,一方面是 Shift-Reduce Conflict 最好不要写空规则,另一方面就是链表的构造,不过这个可以抄前面的 `class_list` 的定义,不需要自己折腾了。
|
||
|
||
然后的是优先级定义,虽然也没啥好说的,照抄 manual 的优先级表。值得注意的是,这里不需要理会 `dangling else` 问题,因为文法不允许省略 else 子句;这种设计虽然对于 PA1 写代码很不友好,但是实现起来还是很舒服的。
|
||
|
||
比较头疼的是 `let`,这东西没办法写在 `expression` 规则里面,需要自己再开一个 non-terminal 来写递归。之所以麻烦,是因为它的定义列表是没有长度限制的,所以要用文法描述就必须通过递归。还有就是它需要把有多个符号定义的 `let` 转换为多个只有一个符号定义的 `let` 的嵌套。比较好理解的写法是写右递归,类似于 lisp 里面的链表,看到逗号就继续递归加尾巴,把 `IN expression` 作为终止条件。不过这个最后的 `expression` 会导致冲突,因为 `let` 本身就是一个 `expr`,所以你也不知道这个后面的东西是接在里面这个 `expr` 的后面还是接在 `let` 这个大的 `expr` 的后面。一个具体的例子:`let id1:T1 in expression.f()`,有两种加括号的方法 `(let id1:T1 in expression).f()` 和 `let id1:T1 in (expression.f())`,也就是在这个 `.` 的地方产生了 Shift-Reduce。根据提示,我们可以用 `%prec` 来解决问题,在优先级列表最低级里面加一个 `LETEXPR`,然后在 `IN expr %prec LETEXPR`,这样就会优先匹配其他的可能属于 `expr` 的产生式,最后再匹配 `let` 后面的那个。
|
||
|
||
写完以上这些东西,这个文法实际上已经完成了,能够解析所有合法的情况。
|
||
|
||
最麻烦的事情是错误恢复。不过说难也还好,关键是没个例子不知道咋写。基本的写法就是有一个内置的 terminal `error`,可以匹配所有的出错情况,然后就可以一直跳过直到我们指定的某一个 token。基本上就是在每个 list 那边加一条 `| error ';' ` 类似的东西,这样就可以把错误跳过去直到一个合法的结束符(handout 里面的要求就是如果有合法的结束,那么从下一个同类型的结构继续)。不过 `let` 那边需要特别注意一下,因为除了中间的声明部分的情况,我们还希望继续检查最后的那个 `expr`,因此不能只写一个 `error ','`,如果只有一个变量声明的话,那么后面的 `expr` 就会无法匹配规则,因此还要写一个 `error IN`。
|
||
|
||
不过以上都是我的写法,我看 Github 上还有别的写法,有的有问题,有的写的不太好但是能过(就比如说用空规则),总之能用就行,我也不能保证我写的一定没问题,毕竟对于 bison 还不是很熟悉。
|
||
|
||
最后还有一个行号的问题,建议把所有的行号都设置成最后一个符号的行号,不过实际上可以不改。感觉需要改一下的是 `cool-parse.h` 那边,因为 `no_expr` 的行号设为0比较合理,我这里好像是会继承基类`tree_node` 的构造函数,把行号设置为当前的行号;但是其实应该写成0,我也不知道当年的编译器是咋做的。
|