任务 —— 增加新的语句
很多语言都有 while 语句,在 Go 中对应的是 for:
for <some-condition> {
<loop body>
}
增加 while 语句是比较简单的,因此 —— 我们只需简单将其转换为 for 语句。所以我选择了一个稍微有点挑战性的任务,增加 until。until 语句和 while 语句是一样的,只是有了条件判断。例如下面的代码:
i := 4
until i == 0 {
i--
fmt.Println("Hello, until!")
}
等价于:
i := 4
for i != 0 {
i--
fmt.Println("Hello, until!")
}
事实上,我们甚至可以像下面代码一样,在循环声明中使用一个初始化语句:
until i := 4; i == 0 {
i--
fmt.Println("Hello, until!")
}
我们的目标是支持这个特性。
特别声明 —— 这只是一个玩具性的探索。我觉得在 Go 中添加 until 并不好,因为 Go 的极简主义设计思想本身就是非常正确的理念。
Go 编译器的高级结构
默认情况下,Go 编译器(gc)是以相当传统的结构来设计的,如果你使用过其他编译器,你应该很快就能熟悉它:
Go 仓库中相对路径的根目录下,编译器实现位于 src/cmd/compile/internal;本文后续提到的所有代码路径都是相对于这个目录。编译器是用 Go 编写的,代码可读性很强。在这篇文章中,我们将一点一点的研究这些代码,同时添加支持 until 语句的实现代码.
查看 src/cmd/compile 中的 README 文件,了解编译步骤的详细说明。它将与本文息息相关。
扫描
扫描器(也称为 词法分析器)将源码文本分解为编译器所需的离散实体。例如 for 关键字会转变成常量 _For;符号 … 转变成 _DotDotDot,. 将转变成 _Dot 等等。
扫描器的实现位于 syntax 包中。我们需要做的就是理解关键字 —— until。syntax/tokens.go 文件中列出了所有 token,我们要添加一个新的:
_Fallthrough // fallthrough
_For // for
_Until // until
_Func // func
token 常量右侧的注释非常重要,它们用来标识 token。这是通过 syntax/tokens.go 生成代码来实现的,文件上面的 token 列表有如下这一行:
//go:generate stringer -type token -linecomment
go generate 必须手动执行,输出文件(syntax/token_string.go)被保存在 Go 源码仓库中。为了重新生成它,我在 syntax 目录中执行如下命令:
GOROOT=<src checkout> Go generate tokens.go
环境变量 GOROOT 是从 Go 1.12 开始必须设置[1],并且必须指向检出的源码根目录,我们要修改这个编译器。
运行代码生成器并验证包含新的 token 的 syntax/token_string.go 文件,我试着重新编译编译器,却出现了 panic 提示:
panic: imperfect hash
这个 panic 是 syntax/scanner.go 中代码引起的:
// hash 是针对关键词的完美哈希函数
// 它假定参数 s 的长度至少为 2
func hash(s []byte) uint {
return (uint(s[0])<<4 ^ uint(s[1]) + uint(len(s))) & uint(len(keywordMap)-1)
}
var keywordMap [1 << 6]token // 大小必须是 2 的整数倍(2 的整数次幂)
func init() {
// 填充 keywordMap
for tok := _Break; tok <= _Var; tok++ {
h := hash([]byte(tok.String()))
if keywordMap[h] != 0 {
panic("imperfect hash")
}
keywordMap[h] = tok
}
}
编译器试图构建一个“完美”哈希表来执行关键字字符串到 token 的查询。“完美”意味着它不太可能发生冲突,是一个线性的数组,其中每个关键字都映射为一个单独的索引。哈希函数相当特殊(例如,它查看字符串 token 的第一个字符),并且不容易调试新 token 为何出现冲突等问题。为了解决这个问题,我将查找表的大小更改为 [1 << 7]token,从而将查找数组的大小从 64 改成 128。这给予哈希函数更多的空间来分配对应的键,冲突也就消失了。
解析
Go 有一个相当标准的递归下降算法的解析器,它把扫描生成的 token 流转换为 具体语法树。我们开始为 syntax/nodes.go 中的 until 添加新的节点类型:
UntilStmt struct {
Init SimpleStmt
Cond Expr
Body *BlockStmt
stmt
}
我借鉴了用于 for 循环的 ForStmt 的整体结构。类似于 for,until 语句有几个可选的子语句:
until <init>; <cond> {
<body>
}
和 是可选的,不过省略 也不是很常见。UntilStmt.stmt 中嵌入的字段用于表示整个语法树语句,并包含对应的位置信息。
解析过程在 syntax/parser.go 中实现。parser.stmtOrNil 方法解析当前位置的语句。它查看当前 token 并决定解析哪个语句。下方是添加的代码片段:
switch p.tok {
case _Lbrace:
return p.blockStmt("")
// ...
case _For:
return p.forStmt()
case _Until:
return p.untilStmt()
And this is untilStmt:
func (p *parser) untilStmt() Stmt {
if trace {
defer p.trace("untilStmt")()
}
s := new(UntilStmt)
s.pos = p.pos()
s.Init, s