8.2 代码生成
图灵完备的一个重要特性是计算机程序可以生成另一个程序1,很多人可能认为生成代码在软件中并不常见,但是实际上它在很多场景中都扮演了重要的角色。Go 语言中的测试就使用了代码生成机制,go test
命令会扫描包中的测试用例并生成程序、编译并执行它们,我们在这一节中就会介绍 Go 语言中的代码生成机制。
8.2.1 设计原理
元编程(Metaprogramming)是计算机编程中一个非常重要、也很有趣的概念,维基百科上将元编程描述成一种计算机程序可以将代码看待成数据的能力2。
Metaprogramming is a programming technique in which computer programs have the ability to treat programs as their data.
如果能够将代码看做数据,那么代码就可以像数据一样在运行时被修改、更新和替换;元编程赋予了编程语言更加强大的表达能力,能够让我们将一些计算过程从运行时挪到编译时、通过编译期间的展开生成代码或者允许程序在运行时改变自身的行为。总而言之,元编程其实是一种使用代码生成代码的方式,无论是编译期间生成代码,还是在运行时改变代码的行为都是『生成代码』的一种3。
图 8-3 元编程的使用
现代的编程语言大都会为我们提供不同的元编程能力,从总体来看,根据『生成代码』的时机不同,我们将元编程能力分为两种类型,其中一种是编译期间的元编程,例如:宏和模板;另一种是运行期间的元编程,也就是运行时,它赋予了编程语言在运行期间修改行为的能力,当然也有一些特性既可以在编译期实现,也可以在运行期间实现。
Go 语言作为编译型的编程语言,它提供了比较有限的运行时元编程能力,例如:反射特性,然而由于性能的问题,反射在很多场景下都不被推荐使用。当然除了反射之外,Go 语言还提供了另一种编译期间的代码生成机制 — go generate
,它可以在代码编译之前根据源代码生成代码。
8.2.2 代码生成
Go 语言的代码生成机制会读取包含预编译指令的注释,然后执行注释中的命令读取包中的文件,它们将文件解析成抽象语法树并根据语法树生成新的 Go 语言代码和文件,生成的代码会在项目的编译期间与其他代码一起编译和运行。
//go:generate command argument...
go generate
不会被 go build
等命令自动执行,该命令需要显式的触发,手动执行该命令时会在文件中扫描上述形式的注释并执行后面的执行命令,需要注意的是 go:generate
和前面的 //
之间没有空格,这种不包含空格的注释一般是 Go 语言的编译器指令,而我们在代码中的正常注释都应该保留这个空格4。
代码生成最常见的例子就是官方提供的 stringer
5,这个工具可以扫描如下所示的常量定义,然后为当前常量类型 Piller
生成对应的 String()
方法:
// pill.go
package painkiller
//go:generate stringer -type=Pill
type Pill int
const (
Placebo Pill = iota
Aspirin
Ibuprofen
Paracetamol
Acetaminophen = Paracetamol
)
当我们在上述文件中加入 //go:generate stringer -type=Pill
注释并调用 go generate
命令时,在同一目录下会出现如下所示的 pill_string.go
文件,该文件中包含两个函数,分别是 _
和 String
:
// Code generated by "stringer -type=Pill"; DO NOT EDIT.
package painkiller
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[Placebo-0]
_ = x[Aspirin-1]
_ = x[Ibuprofen-2]
_ = x[Paracetamol-3]
}
const _Pill_name = "PlaceboAspirinIbuprofenParacetamol"
var _Pill_index = [...]uint8{0, 7, 14, 23, 34}
func (i Pill) String() string {
if i < 0 || i >= Pill(len(_Pill_index)-1) {
return "Pill(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
}
这段生成的代码很值得我们学习,它通过编译器的检查提供了非常健壮的 String
方法。我们在这里不展示具体的使用过程,本节将重点分析从执行 go generate
到生成对应 String
方法的整个过程,帮助各位理解代码生成机制的工作原理,代码生成的过程可以分成以下两个部分:
- 扫描 Go 语言源文件,查找待执行的
//go:generate
预编译指令; - 执行预编译指令,再次扫描源文件并根据源文件中的代码生成代码;
预编译指令
当我们在命令行中执行 go generate
命令时,它会调用源代码中的 cmd/go/internal/generate.runGenerate
函数扫描包中的预编译指令,该函数会遍历命令行传入包中的全部文件并依次调用 cmd/go/internal/generate.generate
:
func runGenerate(cmd *base.Command, args []string) {
...
for _, pkg := range load.Packages(args) {
...
pkgName := pkg.Name
for _, file := range pkg.InternalGoFiles() {
if !generate(pkgName, file) {
break
}
}
pkgName += "_test"
for _, file := range pkg.InternalXGoFiles() {
if !generate(pkgName, file) {
break
}
}
}
}
cmd/go/internal/generate.generate
函数会打开传入的文件并初始化一个用于扫描 cmd/go/internal/generate.Generator
的结构体:
func generate(pkg, absFile string) bool {
fd, err := os.Open(absFile)
if err != nil {
log.Fatalf("generate: %s", err)
}
defer fd.Close()
g := &Generator{
r: fd,
path: absFile,
pkg: pkg,
commands: make(map[string][]string),
}
return g.run()
}
结构体 cmd/go/internal/generate.Generator
的私有方法 cmd/go/internal/generate.Generator.run
会在对应的文件中扫描指令并执行,该方法的实现原理很简单,我们在这里简单展示一下该方法的简化实现:
func (g *Generator) run() (ok bool) {
input := bufio.NewReader(g.r)
for {
var buf []byte
buf, err = input.ReadSlice('\n')
if err != nil {
if err == io.EOF && isGoGenerate(buf) {
err = io.ErrUnexpectedEOF
}
break
}
if !isGoGenerate(buf) {
continue
}
g.setEnv()
words := g.split(string(buf))
g.exec(words)
}
return true
}
上述代码片段会按行读取被扫描的文件并调用 cmd/go/internal/generate.isGoGenerate
判断当前行是否以 //go:generate
注释开头,如果该行确定以 //go:generate
开头,那么就会解析注释中的命令和参数并调用 cmd/go/internal/generate.Generator.exec
运行当前命令。
抽象语法树
stringer
充分利用了 Go 语言标准库对编译器各种能力的支持,其中包括用于解析抽象语法树的 go/ast
、用于格式化代码的 go/fmt
等,Go 通过标准库中的这些包对外直接提供了编译器的相关能力,让使用者可以直接在它们上面构建复杂的代码生成机制并实施元编程技术。
作为二进制文件,stringer
命令的入口就是如下所示的 main
函数,在下面的代码中,我们初始化了一个用于解析源文件和生成代码的 Generator
,然后开始拼接生成的文件:
func main() {
types := strings.Split(*typeNames, ",")
...
g := Generator{
trimPrefix: *trimprefix,
lineComment: *linecomment,
}
...
g.Printf("// Code generated by \"stringer %s\"; DO NOT EDIT.\n", strings.Join(os.Args[1:], " "))
g.Printf("\n")
g.Printf("package %s", g.pkg.name)
g.Printf("\n")
g.Printf("import \"strconv\"\n")
for _, typeName := range types {
g.generate(typeName)
}
src := g.format()
baseName := fmt.Sprintf("%s_string.go", types[0])
outputName = filepath.Join(dir, strings.ToLower(baseName))
if err := ioutil.WriteFile(outputName, src, 0644); err != nil {
log.Fatalf("writing output: %s", err)
}
}
从这段代码中我们能看到最终生成文件的轮廓,最上面的调用的几次 Generator.Printf
会在内存中写入文件头的注释、当前包名以及引入的包等,随后会为待处理的类型依次调用 Generator.generate
,这里会生成一个签名为 _
的函数,通过编译器保证枚举类型的值不会改变:
func (g *Generator) generate(typeName string) {
values := make([]Value, 0, 100)
for _, file := range g.pkg.files {
file.typeName = typeName
file.values = nil
if file.file != nil {
ast.Inspect(file.file, file.genDecl)
values = append(values, file.values...)
}
}
g.Printf("func _() {\n")
g.Printf("\t// An \"invalid array index\" compiler error signifies that the constant values have changed.\n")
g.Printf("\t// Re-run the stringer command to generate them again.\n")
g.Printf("\tvar x [1]struct{}\n")
for _, v := range values {
g.Printf("\t_ = x[%s - %s]\n", v.originalName, v.str)
}
g.Printf("}\n")
runs := splitIntoRuns(values)
switch {
case len(runs) == 1:
g.buildOneRun(runs, typeName)
...
}
}
随后调用的 Generator.buildOneRun
会生成两个常量的声明语句并为类型定义 String
方法,其中引用的 stringOneRun
常量是方法的模板,与 Web 服务的前端 HTML 模板比较相似:
func (g *Generator) buildOneRun(runs [][]Value, typeName string) {
values := runs[0]
g.Printf("\n")
g.declareIndexAndNameVar(values, typeName)
g.Printf(stringOneRun, typeName, usize(len(values)), "")
}
const stringOneRun = `func (i %[1]s) String() string {
if %[3]si >= %[1]s(len(_%[1]s_index)-1) {
return "%[1]s(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _%[1]s_name[_%[1]s_index[i]:_%[1]s_index[i+1]]
}
整个生成代码的过程就是使用编译器提供的库解析源文件并按照已有的模板生成新的代码,这与 Web 服务中利用模板生成 HTML 文件没有太多的区别,只是最终生成的文件的用途稍微有一些不同,
8.2.3 小结
Go 语言的标准库中暴露了编译器的很多能力,其中包含词法分析和语法分析,我们可以直接利用这些现成的解析器编译 Go 语言的源文件并获得抽象语法树,有了识别源文件结构的能力,我们就可以根据源文件对应的抽象语法树自由地生成更多的代码,使用元编程技术来减少代码重复、提高工作效率。
Generating code https://blog.golang.org/generate ↩︎
Wikipedia: Metaprogramming https://en.wikipedia.org/wiki/Metaprogramming ↩︎
谈元编程与表达能力 https://draveness.me/metaprogramming/ ↩︎
如何写出优雅的 Go 语言代码 https://draveness.me/golang-101/ ↩︎
command stringer https://pkg.go.dev/golang.org/x/tools/cmd/stringer?tab=doc ↩︎
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。