Go 博客

生成代码

Rob Pike
2014 年 12 月 22 日

通用计算的一个属性——图灵完备性——是计算机程序可以编写计算机程序。这是一个强大的概念,尽管它经常发生,但人们并没有像它应该得到的那样重视它。例如,它是编译器定义的重要组成部分。这也是 go test 命令的工作原理:它扫描要测试的包,编写一个包含针对该包定制的测试框架的 Go 程序,然后编译并运行它。现代计算机非常快,因此这个听起来很昂贵的序列可以在几分之一秒内完成。

还有很多其他编写程序的程序的例子。例如,Yacc 读取语法描述并编写一个程序来解析该语法。协议缓冲区“编译器”读取接口描述并生成结构定义、方法和其他支持代码。各种配置工具也以这种方式工作,检查元数据或环境并生成根据本地状态定制的脚手架。

因此,编写程序的程序是软件工程中的重要元素,但是像 Yacc 这样的生成源代码的程序需要集成到构建过程中,以便它们的输出可以被编译。当使用像 Make 这样的外部构建工具时,这通常很容易做到。但在 Go 中,其 go 工具从 Go 源代码中获取所有必要的构建信息,因此存在一个问题。简单来说,没有机制从 go 工具单独运行 Yacc。

直到现在。

最新 Go 版本 1.4 包含一个新命令,使运行此类工具变得更容易。它被称为 go generate,它通过扫描 Go 源代码中的特殊注释来工作,这些注释标识要运行的通用命令。重要的是要理解 go generate 不是 go build 的一部分。它不包含任何依赖项分析,必须在运行 go build 之前显式运行。它旨在由 Go 包的作者使用,而不是其客户端。

go generate 命令易于使用。作为热身,以下是如何使用它来生成 Yacc 语法。

首先,安装 Go 的 Yacc 工具

go get golang.org/x/tools/cmd/goyacc

假设您有一个名为 gopher.y 的 Yacc 输入文件,它定义了您新语言的语法。要生成实现语法的 Go 源文件,您通常会像这样调用命令

goyacc -o gopher.go -p parser gopher.y

-o 选项命名输出文件,而 -p 指定包名称。

要让 go generate 驱动这个过程,在同一目录中的任何一个常规(非生成的).go 文件中,在文件中任何位置添加此注释

//go:generate goyacc -o gopher.go -p parser gopher.y

此文本只是上面命令在 go generate 识别的特殊注释前缀。注释必须从行首开始,//go:generate 之间没有空格。在该标记之后,行中的其余部分指定 go generate 要运行的命令。

现在运行它。切换到源目录并运行 go generate,然后运行 go build 等等

$ cd $GOPATH/myrepo/gopher
$ go generate
$ go build
$ go test

就是这样。假设没有错误,go generate 命令将调用 yacc 创建 gopher.go,此时目录将包含完整的 Go 源文件集,因此我们可以正常构建、测试和工作。每次修改 gopher.y 时,只需重新运行 go generate 即可重新生成解析器。

有关 go generate 工作原理的更多详细信息,包括选项、环境变量等,请参阅设计文档

Go generate 并没有做任何 Make 或其他构建机制无法做的事情,但它与 go 工具一起提供——无需额外安装——并且完美地融入 Go 生态系统。请记住,它用于包作者,而不是客户端,原因仅仅是它调用的程序可能在目标机器上不可用。此外,如果包含的包旨在通过 go get 导入,那么在文件生成(并测试!)后,必须将其检入源代码存储库,以便客户端可以使用它。

现在我们有了它,让我们用它来做一些新东西。作为 go generate 如何提供帮助的不同例子的例子,golang.org/x/tools 存储库中提供了一个名为 stringer 的新程序。它会自动为一组整型常量编写字符串方法。它不是发布发行版的一部分,但很容易安装

$ go get golang.org/x/tools/cmd/stringer

以下是从 stringer 文档中摘录的一个例子。假设我们有一些包含一组定义不同类型药丸的整型常量的代码

package painkiller

type Pill int

const (
    Placebo Pill = iota
    Aspirin
    Ibuprofen
    Paracetamol
    Acetaminophen = Paracetamol
)

为了调试,我们希望这些常量能够很好地打印自己,这意味着我们需要一个具有以下签名的方法:

func (p Pill) String() string

用手写一个很容易,也许是这样的

func (p Pill) String() string {
    switch p {
    case Placebo:
        return "Placebo"
    case Aspirin:
        return "Aspirin"
    case Ibuprofen:
        return "Ibuprofen"
    case Paracetamol: // == Acetaminophen
        return "Paracetamol"
    }
    return fmt.Sprintf("Pill(%d)", p)
}

当然,还有其他方法可以编写此函数。我们可以使用一个以 Pill 为索引的字符串切片,或者使用一个 map,或者使用其他技术。无论我们做什么,如果我们更改了药丸集,我们都需要维护它,并且我们需要确保它是正确的。(两种对乙酰氨基酚的名称使这比它看起来更复杂。)此外,采用哪种方法的选择取决于类型和值:有符号或无符号、密集或稀疏、从零开始还是不从零开始等等。

stringer 程序负责所有这些细节。虽然它可以单独运行,但它旨在由 go generate 驱动。要使用它,请在源代码中添加一个生成注释,也许在类型定义附近

//go:generate stringer -type=Pill

此规则指定 go generate 应该运行 stringer 工具为 Pill 类型生成 String 方法。输出会自动写入 pill_string.go(一个我们可以使用 -output 标志覆盖的默认值)。

让我们运行它

$ go generate
$ cat pill_string.go
// Code generated by stringer -type Pill pill.go; DO NOT EDIT.

package painkiller

import "fmt"

const _Pill_name = "PlaceboAspirinIbuprofenParacetamol"

var _Pill_index = [...]uint8{0, 7, 14, 23, 34}

func (i Pill) String() string {
    if i < 0 || i+1 >= Pill(len(_Pill_index)) {
        return fmt.Sprintf("Pill(%d)", i)
    }
    return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
}
$

每次更改 Pill 的定义或常量时,我们只需要运行

$ go generate

来更新 String 方法。当然,如果我们在同一个包中设置了多种类型,那么单个命令将使用单个命令更新所有 String 方法。

毫无疑问,生成的 method 很丑。不过这没关系,因为人类不需要处理它;机器生成的代码通常很丑。它努力高效。所有名称都被合并到一个字符串中,这节省了内存(所有名称只有一个字符串头,即使有数百万个名称)。然后,一个数组 _Pill_index 通过一个简单而高效的技术将值映射到名称。还要注意,_Pill_index 是一个 uint8 类型的数组(而不是切片;又减少了一个头),它是跨越值空间的最小整数。如果还有更多值,或者存在负值,则 _Pill_index 的生成类型可能会更改为 uint16int8:无论哪种最有效。

stringer 打印的方法使用的方法会根据常量集的属性而异。例如,如果常量是稀疏的,它可能会使用一个 map。以下是一个基于表示 2 的幂的常量集的简单示例

const _Power_name = "p0p1p2p3p4p5..."

var _Power_map = map[Power]string{
    1:    _Power_name[0:2],
    2:    _Power_name[2:4],
    4:    _Power_name[4:6],
    8:    _Power_name[6:8],
    16:   _Power_name[8:10],
    32:   _Power_name[10:12],
    ...,
}

func (i Power) String() string {
    if str, ok := _Power_map[i]; ok {
        return str
    }
    return fmt.Sprintf("Power(%d)", i)
}

简而言之,自动生成方法使我们能够比我们预期人类能够做到的做得更好。

Go 树中已经安装了很多其他使用 go generate 的例子。例如,在 unicode 包中生成 Unicode 表格,在 encoding/gob 中为编码和解码数组创建高效方法,在 time 包中生成时区数据等等。

请创造性地使用 go generate。它旨在鼓励实验。

即使你不这样做,也要使用新的 stringer 工具为你的整型常量编写 String 方法。让机器来做这项工作。

下一篇文章: Gopher Gala 是全球首个 Go 黑客马拉松
上一篇文章: Go 1.4 发布
博客索引