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

metaprogramming-usage

图 8-3 元编程的使用

现代的编程语言大都会为我们提供不同的元编程能力,从总体来看,根据『生成代码』的时机不同,我们将元编程能力分为两种类型,其中一种是编译期间的元编程,例如:宏和模板;另一种是运行期间的元编程,也就是运行时,它赋予了编程语言在运行期间修改行为的能力,当然也有一些特性既可以在编译期实现,也可以在运行期间实现。

Go 语言作为编译型的编程语言,它提供了比较有限的运行时元编程能力,例如:反射特性,然而由于性能的问题,反射在很多场景下都不被推荐使用。当然除了反射之外,Go 语言还提供了另一种编译期间的代码生成机制 — go generate,它可以在代码编译之前根据源代码生成代码。

8.2.2 代码生成

Go 语言的代码生成机制会读取包含预编译指令的注释,然后执行注释中的命令读取包中的文件,它们将文件解析成抽象语法树并根据语法树生成新的 Go 语言代码和文件,生成的代码会在项目的编译期间与其他代码一起编译和运行。

  1. //go:generate command argument...

go generate 不会被 go build 等命令自动执行,该命令需要显式的触发,手动执行该命令时会在文件中扫描上述形式的注释并执行后面的执行命令,需要注意的是 go:generate 和前面的 // 之间没有空格,这种不包含空格的注释一般是 Go 语言的编译器指令,而我们在代码中的正常注释都应该保留这个空格4

代码生成最常见的例子就是官方提供的 stringer5,这个工具可以扫描如下所示的常量定义,然后为当前常量类型 Piller 生成对应的 String() 方法:

  1. // pill.go
  2. package painkiller
  3. //go:generate stringer -type=Pill
  4. type Pill int
  5. const (
  6. Placebo Pill = iota
  7. Aspirin
  8. Ibuprofen
  9. Paracetamol
  10. Acetaminophen = Paracetamol
  11. )

当我们在上述文件中加入 //go:generate stringer -type=Pill 注释并调用 go generate 命令时,在同一目录下会出现如下所示的 pill_string.go 文件,该文件中包含两个函数,分别是 _String

  1. // Code generated by "stringer -type=Pill"; DO NOT EDIT.
  2. package painkiller
  3. import "strconv"
  4. func _() {
  5. // An "invalid array index" compiler error signifies that the constant values have changed.
  6. // Re-run the stringer command to generate them again.
  7. var x [1]struct{}
  8. _ = x[Placebo-0]
  9. _ = x[Aspirin-1]
  10. _ = x[Ibuprofen-2]
  11. _ = x[Paracetamol-3]
  12. }
  13. const _Pill_name = "PlaceboAspirinIbuprofenParacetamol"
  14. var _Pill_index = [...]uint8{0, 7, 14, 23, 34}
  15. func (i Pill) String() string {
  16. if i < 0 || i >= Pill(len(_Pill_index)-1) {
  17. return "Pill(" + strconv.FormatInt(int64(i), 10) + ")"
  18. }
  19. return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
  20. }

这段生成的代码很值得我们学习,它通过编译器的检查提供了非常健壮的 String 方法。我们在这里不展示具体的使用过程,本节将重点分析从执行 go generate 到生成对应 String 方法的整个过程,帮助各位理解代码生成机制的工作原理,代码生成的过程可以分成以下两个部分:

  1. 扫描 Go 语言源文件,查找待执行的 //go:generate 预编译指令;
  2. 执行预编译指令,再次扫描源文件并根据源文件中的代码生成代码;

预编译指令

当我们在命令行中执行 go generate 命令时,它会调用源代码中的 cmd/go/internal/generate.runGenerate 函数扫描包中的预编译指令,该函数会遍历命令行传入包中的全部文件并依次调用 cmd/go/internal/generate.generate

  1. func runGenerate(cmd *base.Command, args []string) {
  2. ...
  3. for _, pkg := range load.Packages(args) {
  4. ...
  5. pkgName := pkg.Name
  6. for _, file := range pkg.InternalGoFiles() {
  7. if !generate(pkgName, file) {
  8. break
  9. }
  10. }
  11. pkgName += "_test"
  12. for _, file := range pkg.InternalXGoFiles() {
  13. if !generate(pkgName, file) {
  14. break
  15. }
  16. }
  17. }
  18. }

cmd/go/internal/generate.generate 函数会打开传入的文件并初始化一个用于扫描 cmd/go/internal/generate.Generator 的结构体:

  1. func generate(pkg, absFile string) bool {
  2. fd, err := os.Open(absFile)
  3. if err != nil {
  4. log.Fatalf("generate: %s", err)
  5. }
  6. defer fd.Close()
  7. g := &Generator{
  8. r: fd,
  9. path: absFile,
  10. pkg: pkg,
  11. commands: make(map[string][]string),
  12. }
  13. return g.run()
  14. }

结构体 cmd/go/internal/generate.Generator 的私有方法 cmd/go/internal/generate.Generator.run 会在对应的文件中扫描指令并执行,该方法的实现原理很简单,我们在这里简单展示一下该方法的简化实现:

  1. func (g *Generator) run() (ok bool) {
  2. input := bufio.NewReader(g.r)
  3. for {
  4. var buf []byte
  5. buf, err = input.ReadSlice('\n')
  6. if err != nil {
  7. if err == io.EOF && isGoGenerate(buf) {
  8. err = io.ErrUnexpectedEOF
  9. }
  10. break
  11. }
  12. if !isGoGenerate(buf) {
  13. continue
  14. }
  15. g.setEnv()
  16. words := g.split(string(buf))
  17. g.exec(words)
  18. }
  19. return true
  20. }

上述代码片段会按行读取被扫描的文件并调用 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,然后开始拼接生成的文件:

  1. func main() {
  2. types := strings.Split(*typeNames, ",")
  3. ...
  4. g := Generator{
  5. trimPrefix: *trimprefix,
  6. lineComment: *linecomment,
  7. }
  8. ...
  9. g.Printf("// Code generated by \"stringer %s\"; DO NOT EDIT.\n", strings.Join(os.Args[1:], " "))
  10. g.Printf("\n")
  11. g.Printf("package %s", g.pkg.name)
  12. g.Printf("\n")
  13. g.Printf("import \"strconv\"\n")
  14. for _, typeName := range types {
  15. g.generate(typeName)
  16. }
  17. src := g.format()
  18. baseName := fmt.Sprintf("%s_string.go", types[0])
  19. outputName = filepath.Join(dir, strings.ToLower(baseName))
  20. if err := ioutil.WriteFile(outputName, src, 0644); err != nil {
  21. log.Fatalf("writing output: %s", err)
  22. }
  23. }

从这段代码中我们能看到最终生成文件的轮廓,最上面的调用的几次 Generator.Printf 会在内存中写入文件头的注释、当前包名以及引入的包等,随后会为待处理的类型依次调用 Generator.generate,这里会生成一个签名为 _ 的函数,通过编译器保证枚举类型的值不会改变:

  1. func (g *Generator) generate(typeName string) {
  2. values := make([]Value, 0, 100)
  3. for _, file := range g.pkg.files {
  4. file.typeName = typeName
  5. file.values = nil
  6. if file.file != nil {
  7. ast.Inspect(file.file, file.genDecl)
  8. values = append(values, file.values...)
  9. }
  10. }
  11. g.Printf("func _() {\n")
  12. g.Printf("\t// An \"invalid array index\" compiler error signifies that the constant values have changed.\n")
  13. g.Printf("\t// Re-run the stringer command to generate them again.\n")
  14. g.Printf("\tvar x [1]struct{}\n")
  15. for _, v := range values {
  16. g.Printf("\t_ = x[%s - %s]\n", v.originalName, v.str)
  17. }
  18. g.Printf("}\n")
  19. runs := splitIntoRuns(values)
  20. switch {
  21. case len(runs) == 1:
  22. g.buildOneRun(runs, typeName)
  23. ...
  24. }
  25. }

随后调用的 Generator.buildOneRun 会生成两个常量的声明语句并为类型定义 String 方法,其中引用的 stringOneRun 常量是方法的模板,与 Web 服务的前端 HTML 模板比较相似:

  1. func (g *Generator) buildOneRun(runs [][]Value, typeName string) {
  2. values := runs[0]
  3. g.Printf("\n")
  4. g.declareIndexAndNameVar(values, typeName)
  5. g.Printf(stringOneRun, typeName, usize(len(values)), "")
  6. }
  7. const stringOneRun = `func (i %[1]s) String() string {
  8. if %[3]si >= %[1]s(len(_%[1]s_index)-1) {
  9. return "%[1]s(" + strconv.FormatInt(int64(i), 10) + ")"
  10. }
  11. return _%[1]s_name[_%[1]s_index[i]:_%[1]s_index[i+1]]
  12. }

整个生成代码的过程就是使用编译器提供的库解析源文件并按照已有的模板生成新的代码,这与 Web 服务中利用模板生成 HTML 文件没有太多的区别,只是最终生成的文件的用途稍微有一些不同,

8.2.3 小结

Go 语言的标准库中暴露了编译器的很多能力,其中包含词法分析和语法分析,我们可以直接利用这些现成的解析器编译 Go 语言的源文件并获得抽象语法树,有了识别源文件结构的能力,我们就可以根据源文件对应的抽象语法树自由地生成更多的代码,使用元编程技术来减少代码重复、提高工作效率。


  1. Generating code https://blog.golang.org/generate ↩︎

  2. Wikipedia: Metaprogramming https://en.wikipedia.org/wiki/Metaprogramming ↩︎

  3. 谈元编程与表达能力 https://draveness.me/metaprogramming/ ↩︎

  4. 如何写出优雅的 Go 语言代码 https://draveness.me/golang-101/ ↩︎

  5. command stringer https://pkg.go.dev/golang.org/x/tools/cmd/stringer?tab=doc ↩︎

wechat-account-qrcode

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。