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 之前顯式執行。它intended to be used by the author of the Go package, not its clients.(它intended to be used by the author of the Go package, not its clients.)。

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 生態系統。只是要記住,它適用於包作者,而不是客戶端,即使只是因為它呼叫的程式可能在目標機器上不可用。此外,如果包含的包 intended to be imported by 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 索引的字串切片,或者一個對映,或者其他技術。無論我們做什麼,如果我們改變了藥丸的集合,我們就需要維護它,並且我們需要確保它是正確的。(撲熱息痛的兩個名稱使這比其他情況更棘手。)此外,選擇哪種方法取決於型別和值:有符號或無符號,密集或稀疏,零基或非零基,等等。

stringer 程式負責所有這些細節。雖然它可以獨立執行,但它intended to be driven by 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 方法。

毫無疑問,生成的程式碼方法很難看。不過沒關係,因為人類不需要處理它;機器生成的程式碼通常很難看。它正在努力變得高效。所有名稱都被壓縮成一個字串,這可以節省記憶體(即使有無數個名稱,也只有一個字串頭)。然後一個名為 _Pill_index 的陣列透過一種簡單高效的技術將值對映到名稱。請注意,_Pill_index 是一個數組(而不是切片;又少了一個頭部)型別為 uint8,這是跨越值空間所需的最小整數。如果值更多,或者有負數,_Pill_index 的生成型別可能會更改為 uint16int8:任何最有效的方法。

stringer 列印的方法所使用的方法會根據常數集的大小而變化。例如,如果常量是稀疏的,它可能會使用一個對映。下面是一個基於表示二的冪的常量集的小示例:

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 釋出
部落格索引