vlambda博客
学习文章列表

Go 编译器内核:给 Go 新增一个语句 —— 第一部分

点击上方蓝色“ Go语言中文网 ”关注我们, 领全套Go资料 ,每天学习 Go 语言

这是两部分系列文章中的第一部分,该文章采用教程的方式来探讨 Go 编译器。Go 编译器复杂而庞大,需要一本书才可能描述清楚,所以这个系列文章旨在提供一个快速而深度优先的方式进入学习。我计划在以后会写更多关于编译器领域的描述文章。

我们会修改 Go 编译器来增加一个新的(玩具性质)语言特性,并构建一个经过修改的编译器进行使用。

任务 —— 增加新的语句

很多语言都有 while 语句,在 Go 中对应的是 for

for <some-condition> {
  <loop body>
}

增加 while 语句是比较简单的,因此 —— 我们只需简单将其转换为 for 语句。所以我选择了一个稍微有点挑战性的任务,增加 untiluntil 语句和 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 包中。我们需要做的就是理解关键字 —— untilsyntax/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 的整体结构。类似于 foruntil 语句有几个可选的子语句:

until <init>; <cond> {
<body>
}

<init><cond> 是可选的,不过省略 <cond> 也不是很常见。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.Cond, _ = p.header(_Until)
  s.Body = p.blockStmt("until clause")

  return s
}

我们复用现有的 parser.header 方法,因为它解析了 iffor 语句对应的 header。在常用的形式中,它支持三个部分(分号分隔)。在 for 语句中,第三部分常被用于 ["post" 语句](https://golang.org/ref/spec#PostStmt ""post" 语句"),但我们不打算为 until 实现这种形式,而只需实现前两部分。注意 header 接收源 token,以便能够区分 header 所处的具体场景;例如,编译器会拒绝 if 的“post”语句。虽然现在还没有费力气实现“post”语句,但我们在 until 的场景中应该明确地拒绝“post”语句。

这些都是我们需要对解析器进行的修改。因为 until 语句在结构上跟现有的一些语句非常相似,所以我们可以复用已有的大部分功能。

假如在编译器解析后输出语法树(使用 syntax.Fdump)然后使用语法树:

i = 4
until i == 0 {
  i--
  fmt.Println("Hello, until!")
}

我们会得到 until 语句的相关片段:

84  .  .  .  .  .  3: *syntax.UntilStmt {
85 . . . . . . Init: nil
86 . . . . . . Cond: *syntax.Operation {
87 . . . . . . . Op: ==
88 . . . . . . . X: i @ ./useuntil.go:13:8
89 . . . . . . . Y: *syntax.BasicLit {
90 . . . . . . . . Value: "0"
91 . . . . . . . . Kind: 0
92 . . . . . . . }
93 . . . . . . }
94 . . . . . . Body: *syntax.BlockStmt {
95 . . . . . . . List: []syntax.Stmt (2 entries) {
96 . . . . . . . . 0: *syntax.AssignStmt {
97 . . . . . . . . . Op: -
98 . . . . . . . . . Lhs: i @ ./useuntil.go:14:3
99 . . . . . . . . . Rhs: *(Node @ 52)
100 . . . . . . . . }
101 . . . . . . . . 1: *syntax.ExprStmt {
102 . . . . . . . . . X: *syntax.CallExpr {
103 . . . . . . . . . . Fun: *syntax.SelectorExpr {
104 . . . . . . . . . . . X: fmt @ ./useuntil.go:15:3
105 . . . . . . . . . . . Sel: Println @ ./useuntil.go:15:7
106 . . . . . . . . . . }
107 . . . . . . . . . . ArgList: []syntax.Expr (1 entries) {
108 . . . . . . . . . . . 0: *syntax.BasicLit {
109 . . . . . . . . . . . . Value: "\"Hello, until!\""
110 . . . . . . . . . . . . Kind: 4
111 . . . . . . . . . . . }
112 . . . . . . . . . . }
113 . . . . . . . . . . HasDots: false
114 . . . . . . . . . }
115 . . . . . . . . }
116 . . . . . . . }
117 . . . . . . . Rbrace: syntax.Pos {}
118 . . . . . . }
119 . . . . . }

创建 AST

由于有了源代码的语法树表示,编译器才能构建一个抽象语法树。我曾写过关于抽象 vs 具体语法树[2]的文章 —— 如果你不熟悉他们之间的区别,可以好好看看这个文章。在 Go 中,未来可能会有所变动。Go 编译器最初是用 C 语言编写的,后来自动翻译成 Go;所以编译器的某些部分是 C 时期遗留下来的,有些部分是比较新的。未来的重构可能只会留下一种语法树,但是现在(Go 1.12)我们必须遵循这个流程。

AST 代码位于 gc 包中,节点类型在 gc/syntax.go 中定义。(不要跟 syntax 包中的 CST 混淆)

Go AST 的结构与 CST 不同。所有的 AST 节点都是 syntax.Node 类型而非有各自的类型。syntax.Node 类型是一种 可区分的联合体,其中的字段有很多不同的类型。然而,这些字段是通用的,并且可用于大多数节点类型:

// 一个 Node 代表语法树中的单个节点
// 实际上,因为只有一个,所以语法树就是一个语法 DAG
// 对于一个给定的变量,使用 Op=ONAME 作为节点
// Op=OTYPE、Op=OLITERAL 也是这样,参考 Node.mayBeShared
type Node struct {
  // 树结构
  // 普通的递归遍历应该包含以下字段
  Left  *Node
  Right *Node
  Ninit Nodes
  Nbody Nodes
  List  Nodes
  Rlist Nodes

  // ...

我们以增加一个新的常量标识 until 节点作为开始:

// 语句
// ...
OFALL     // fallthrough
OFOR      // for Ninit; Left; Right { Nbody }
OUNTIL    // until Ninit; Left { Nbody }

我们再运行一下 go generate,这次在 gc/syntax.go 文件中,生成了一个代表新节点类型的字符串:

// 在 gc 的目录中
GOROOT=<src checkout> Go generate syntax.go

应该更新 gc/op_string.go 文件使其包含 OUNTIL。现在是时候为新节点类型编写 CST->AST 的转换代码了。

转换是在 gc/noder.go 中实现的。我们基于现有的 for 语句支持,对 until 修改建模,从包含一个分支语句类型的 stmtFall 开始:

case *syntax.ForStmt:
  return p.forStmt(stmt)
case *syntax.UntilStmt:
  return p.untilStmt(stmt)

然后是新的 untilStmt 方法,我们将其添加到 noder 类型上:

// untilStmt 把具体语法树节点 UntilStmt 转换为对应的 AST 节点
func (p *noder) untilStmt(stmt *syntax.UntilStmt) *Node {
  p.openScope(stmt.Pos())
  var n *Node
  n = p.nod(stmt, OUNTIL, nilnil)
  if stmt.Init != nil {
    n.Ninit.Set1(p.stmt(stmt.Init))
  }
  if stmt.Cond != nil {
    n.Left = p.expr(stmt.Cond)
  }
  n.Nbody.Set(p.blockStmt(stmt.Body))
  p.closeAnotherScope()
  return n
}

回想一下上面解释过的 Node 字段。这里我们使用 Init 作为可选的初始化操作,Left 字段作用于条件,Nbody 字段作用于循环体。

这就是新增 until 语句 AST 节点所需的全部内容。如果在构建后输出 AST,将得到以下这些:

.   .   UNTIL l(13)
. . . EQ l(13)
. . . . NAME-main.i a(true) g(1) l(6) x(0) class(PAUTO)
. . . . LITERAL-0 l(13) untyped number
. . UNTIL-body
. . . ASOP-SUB l(14) implicit(true)
. . . . NAME-main.i a(true) g(1) l(6) x(0) class(PAUTO)
. . . . LITERAL-1 l(14) untyped number

. . . CALL l(15)
. . . . NONAME-fmt.Println a(true) x(0) fmt.Println
. . . CALL-list
. . . . LITERAL-"Hello, until!" l(15) untyped string

类型检查

编译的下一步是类型检查,这是在 AST 的基础上完成的。除了检查类型错误外,Go 中的类型检查还包括 类型推导,类型推导可以让我们编写如下语句:

res, err := func(args)

无需显示的声明 reserr 的类型。Go 类型检查器还会做一些其它事情,比如链接标识符到对应的声明上,和计算“编译时”常量。代码在 gc/typecheck.go 文件中。同样,在 for 语句的引导下,我们把这个子句添加到 typecheck 的分支中:

case OUNTIL:
  ok |= ctxStmt
  typecheckslice(n.Ninit.Slice(), ctxStmt)
  decldepth++
  n.Left = typecheck(n.Left, ctxExpr)
  n.Left = defaultlit(n.Left, nil)
  if n.Left != nil {
    t := n.Left.Type
    if t != nil && !t.IsBoolean() {
      yyerror("non-bool %L used as for condition", n.Left)
    }
  }
  typecheckslice(n.Nbody.Slice(), ctxStmt)
  decldepth--

只有一部分的语句分配了类型,并且在布尔上下文中检查条件是否合法。

分析并重写 AST

在类型检查后,编译器会经历 AST 分析和重写等几个阶段。具体的序列在 gc/main.go 文件的 gc.Main 函数中列出。在编译器术语中,这个阶段通常称为 passes

很多 passes 中无需修改就能支持 until,因为这些 passes 对所有类型语句都是通用的(在这里通用的 gc.Node 结构也有效)。然而,只是有些场景是这样。比如逃逸分析中,它试图找到那些变量“逃离”函数作用域,并分配在堆上而非栈空间。

“逃逸分析”适用于每个语句类型,所以我们必须把它加在 Escape.stmt 对应的分支中:

case OUNTIL:
  e.loopDepth++
  e.discard(n.Left)
  e.stmts(n.Nbody)
  e.loopDepth--

最后,gc.Main 可以调用可移植的代码生成器(gc/pgen.go)来编译分析代码。代码生成器首先进行一系列 AST 转换,将 AST 维度降低便于编译。这是在先调用 ordercompile 函数中完成的。

这个转换(在 gc/order.go 中)对语句和表达式重新排序,以强制执行计算标识符顺序。比如,把 foo /= 10 重写为 foo = foo / 10,用多个单赋值语句替换多赋值语句等等。

为了支持 until 语句,我们需要向 Order.stmt 增加以下内容:

case OUNTIL:
  t := o.markTemp()
  n.Left = o.exprInPlace(n.Left)
  n.Nbody.Prepend(o.cleanTempNoPop(t)...)
  orderBlock(&n.Nbody, o.free)
  o.out = append(o.out, n)
  o.cleanTemp(t)

ordercompile 调用位于 gc/walk.go 中的 walk 后。这个传递过程收集了一系列 AST 转换,这些语句在后面有助于降低 AST 的维度成为 SSA。比如,在 for 循环中重写 range 语句,转变成更为简单的、有具体变量的 for 循环的形式 \[1\][3]运行时重写调用 map 的访问方式[4]等等。

为了支持 walk 中的新语句,我们必须在 walkstmt 函数中添加一个 switch 子句。顺便说一下,这也是我们实现 until 语句要修改的地方,主要是将它重写为编译器能识别的 AST 节点。在 until 的例子中,这比较简单 —— 如文章开头所示,我们只是用倒装条件将它重写为一个 for 循环。具体转换代码如下:

case OUNTIL:
  if n.Left != nil {
    walkstmtlist(n.Left.Ninit.Slice())
    init := n.Left.Ninit
    n.Left.Ninit.Set(nil)
    n.Left = nod(ONOT, walkexpr(n.Left, &init), nil)
    n.Left = addinit(n.Left, init.Slice())
    n.Op = OFOR
  }
  walkstmtlist(n.Nbody.Slice())

注意我们替换了 n.Left(条件),它带有类型为 ONOT 的新节点(它代表一元操作符 !),新节点包装了之前的 n.Left,然后我们用 OFOR 替换 n.Op。就是这样!

如果在遍历之后输出 AST,我们会看到 OUNTIL 节点不见了,取而代之的是新的 OFOR 节点。

尝试

我们现在可以尝试修改编译器,然后运行一个使用了 until 语句的示例程序,

$ cat useuntil.go
package main

import "fmt"

func main() {
  i := 4
  until i == 0 {
    i--
    fmt.Println("Hello, until!")
  }
}

$ <src checkout>/bin/go run useuntil.go
Hello, until!
Hello, until!
Hello, until!
Hello, until!

成功了!

提醒:<src checkout> 是我们检出的 Go 代码仓库,我们需要修改它、编译它(更多细节参见附录)

结论部分 1

这是结论第一部分。我们成功地在 Go 编译器中实现了新增一个语句。这个过程没有涵盖编译器的所有方面,因为这种通过使用 for 节点的方式重写 until 节点的 AST 方式像是一条捷径。这是一种有效的编译策略,Go 编译器已经有了很多类似的优化手段来 转换 AST(这将减少构成的形式,便于编译的最后阶段做更少的工作)。即便如此,我们仍然有兴趣探索最后两个编译阶段 —— 转换为 SSA生成机器码。这些将在[第 2 部分]((http://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-2/ "第 2 部分")) 讨论。

Appendix - 构建 Go 的工具链

请先浏览 Go 贡献指南[5]。下面是类似于本文关于重述修改 Go 编译器的简要说明。

有两种方法:

  1. 克隆官方的 Go 仓库 [6]并实践本文所描述的内容。
  2. 克隆官方的 Go 仓库 [7],并检出 adduntil 分支,这个分支中已经有很多基于调试工具的修改。

克隆的路径是本文中 <src checkout> 所表示的路径。

要编译工具链,进入 src/ 目录并运行 ./make.bash。也能通过 ./all.bash 来运行所有的测试用例并构建。运行 make.bash 将调用构建 Go 完整的 3 个引导步骤,这在我的旧机器上大概需要 50 秒时间。

一旦构建完成,工具链安装在 src 同级的 bin 目录中。然后,我们可以通过运行 bin/go 安装 cmd/compile 来快速重新构建编译器。

\[1\][8] Go 有一些特殊的“魔法”、range 子句,比如在字符串上使用 range 子句,把字符串分隔成字符。这个地方就用了“转换”来实现。

如果要评论,请给我发 邮件[9],或者在推特[10]上联系我。


via: https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-1/

本文由 GCTT[14] 原创编译,Go 中文网[15] 荣誉推出

参考资料

[1]

从 Go 1.12 开始必须设置: https://github.com/golang/go/issues/32724

[2]

抽象 vs 具体语法树: http://eli.thegreenplace.net/2009/02/16/abstract-vs-concrete-syntax-trees

[3]

[1]: https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-1/#id2

[4]

运行时重写调用 map 的访问方式: https://dave.cheney.net/2018/05/29/how-the-go-runtime-implements-maps-efficiently-without-generics

[5]

Go 贡献指南: https://golang.org/doc/contribute.html

[6]

Go 仓库: https://github.com/golang/go

[7]

Go 仓库: https://github.com/golang/go

[8]

[1]: https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-1/#id1

[9]

邮件: [email protected]

[10]

推特: https://twitter.com/elibendersky

[11]

Eli Bendersky: https://eli.thegreenplace.net

[12]

suhanyujie: https://github.com/suhanyujie

[13]

JYSDeveloper: https://github.com/JYSDeveloper

[14]

GCTT: https://github.com/studygolang/GCTT

[15]

Go 中文网: https://studygolang.com/


推荐阅读



喜欢本文的朋友,欢迎关注“Go语言中文网