Go 風格:可讀且快速
目標是寫出維護者能從頭到尾讀懂的程式碼,其效能來自於 API 與資料的形狀,而非散落在程式碼中的取巧技巧。一旦設計正確,可讀性與速度很少會衝突;若兩者真的衝突,只有在經過量測的熱路徑上才放棄可讀性,並在註解中說明。
此風格遵循 Dave Cheney 的「把 API 設計當成效能基本單元」:超過某個臨界點後,效能無法事後補上,因此應預先設計,其餘部分保持簡潔。
來源:Cheney 的演講 (https://dave.cheney.net/paste/gophercon-uk-2026.html),程式碼在 github.com/pkg/json 與 github.com/davecheney/bytereader;John Ousterhout,「將錯誤定義在存在之外」。
優先順序
- 正確性。 永遠不要為了速度或簡潔而犧牲它。
- API 形狀與 big-O。 誰擁有記憶體、回傳什麼、每次呼叫做多少工作。這些比任何後續調校更能決定效能。
- 熱路徑上的分配。 每次分配耗費兩次成本:一次分配,一次供垃圾收集器處理。收集器或許很快,「但永遠不會比不分配快。」
- 每單位輸入的工作量。 每個位元組只處理一次,並以每個 token 或記錄為單位進行函式呼叫,而非每個位元組。
- 微優化(手動內聯、位元技巧、避免邊界檢查),僅在剖析顯示為熱路徑上進行,且必須有基準測試證明其效益。
如果一項變更在上層級別中沒有被證明是合理的,就不要做。
區隔熱路徑與冷路徑
判斷哪些程式碼在每次事件或請求時執行(熱路徑),哪些在設定、建置、配置或更新時執行(冷路徑)。
- 冷路徑: 寫最簡單的程式碼。使用複製而非共用、使用 map 與
fmt、自由地分配。不要在此引入熱路徑的慣用語法,它們會犧牲可讀性卻毫無助益。 - 熱路徑: 可以使用可重複使用的緩衝區、世代計數器、平面陣列及類似技術。為每種技術命名(一個小型別或輔助函式)並加上註解解釋其存在原因,讓讀者不必逆向工程。
以效能為考量設計 API
API 決定了呼叫端最多能節省多少。「你是在對垃圾收集器做基準測試」當 API 強迫每個結果都放到堆上時。將 API 塑造得讓快速路徑是自然的路徑;「如果你準備好接受不同的 API」,通常可獲得 2-10 倍的效能提升。
- 不要回傳必須逸出的值。 透過
interface{}/any、全新的字串、map 或指向每個項目新結構的指標來回傳原始型別,會迫使每次呼叫都分配一次記憶體(encoding/json.Decoder.Token是經典的「垃圾工廠」)。回傳具體型別,或類似[]byte的視圖,讓呼叫端只轉換它需要的部分。 - 回傳視圖而非副本, 當呼叫端只需要讀取時。記錄視圖的有效時間(「在下一次呼叫前有效」,類似
bufio.Scanner.Bytes)。原始輸入的視圖(token 的位元組,其第一個位元組指示其種類)比一個呼叫端可能忽略的解碼值更節省。 - 讓呼叫者擁有可重複使用的記憶體。 接受一個暫存區或緩衝區參數,或將其保存在由呼叫者維護的結構中,而不是在每次呼叫時分配。讓呼叫者提供初始緩衝區,使其可存在於堆疊或來自物件池:
var buf [8 << 10]byte; d := NewDecoderBuffer(r, buf[:])。 - 透過少數方法隱藏緩衝區管理。
io.Reader會複製到呼叫者管理的緩衝區,並將重新填滿的責任留給每個呼叫者。一個小型視窗 API 將資料保留在原位:使用Window() []byte查看,Release(n)消耗,Extend() int讀取更多,並在文件註解中說明「視窗在 Release 或 Extend 後失效」。 - 將錯誤定義在熱迴圈之外。 使用回傳 bool 或空結果並在之後報告錯誤的迭代方式:
for s.Next() { ... }然後s.Err()。在慢路徑上使用哨兵結果(Extend回傳 0,然後呼叫者詢問錯誤),使內部迴圈沒有錯誤分支。將io.EOF視為任何其他的終止條件。 - 計算每單位輸入的工作量。 每個位元組只處理一次,在每個 token 或記錄層級進行函式呼叫,而非每個位元組,並移除中間複本。從逐位元組呼叫改為逐 token 呼叫,往往是僅次於移除分配的第二大效能提升。
- 將 API 分層。 提供一個低階、無分配的建構塊(原始 token、視圖、擁有暫存區的迭代器),並在其上建構便利的 API。不要只提供便利但會分配的 API。
- 在文件註解中寫下所有權與生命週期: 哪些是借用的、哪些會保留、哪些可安全用於並發、零值的意義。
- 讓零值有用, 在自然的情況下。
- 偏好不可變的共用值 並發布新的快照,勝過在共用可變狀態上使用鎖,當讀取操作佔多數時。
為 API 做基準測試,而不僅僅是實作:比較 allocs/op 和吞吐量,與您正在取代的 API 相比。references/examples.md 並排展示了這些形狀。
調校熱迴圈,最後才做
只有在 API 和 big-O 都正確,且剖析顯示該迴圈是熱的之後:
- 在狀態上的
switch會編譯成一串比較。每個狀態使用一個小型方法,透過func欄位選擇(d.state = (*Decoder).stateValue),使每個狀態保持可讀,並可消除那個分派。 - 將檢查移到使用該值的函式中,讓編譯器能證明邊界檢查是不必要的,並將入口點維持在內聯預算內(80;一次呼叫成本約為 52)。使用
go build -gcflags=-m=2檢查。 - 合併兩個函式以避免一次呼叫,可以在每個 token 路徑上獲得 5-15% 的效益,但代價是更深的巢狀結構。只在有基準測試的情況下才這麼做,並在註解中說明原因。
正如 Cheney 所言:不要「在未解決 API 中的 big O 效應之前」就使用這些技巧。「它不會讓指針移動。」
保持函式短小且扁平
目標是每個函式只做一件事,最多一到兩個分支。作為指引,將循環複雜度維持在 10 或以下,認知複雜度維持在 10 或以下(使用 gocyclo、gocognit)。一個將每個 case 分配給一個呼叫的扁平型別 switch 是可以接受的,但巢狀就不行。
- 為多項條件命名。 將
if len(a)*len(b) > maxN || size(a)*len(b)+size(b)*len(a) > maxM替換為if !fitsProduct(a, b)。名稱記錄了意圖,而函式可以承載解釋。 - 分派,然後委派。 針對節點或訊息種類的 switch 應該為每個 case 呼叫一個方法,而不是包含每個 case 的邏輯。
- 及早回傳。 先處理警衛條件,主路徑不縮排。
- 不要重複 case。 如果兩個分支僅在運算元順序上不同,寫一個輔助函式並呼叫兩次。
- 拆分混合的關注點。「找到它」、「建構新值」和「整理」是三個不同的函式,不是一個。
- 避免布林旗標參數。 如果布林旗標是最清楚的選項,給它一個有意義的名稱,並在呼叫點處加以說明。
- 使用查找表(
map[string]Kind、[256]bool)取代字串或位元組比較鏈。
為讀者與 CPU 塑造資料
- 將平行切片分組為結構體切片。 四個由相同 ID 索引的切片是一個結構體。這讀起來更好,且通常能改善位置 locality。
- 為重複的機制賦予其小型型別。 如果相同的計數器環繞、點陣圖或緩衝區邏輯出現兩次,它就是一個有兩到三個方法的型別。
- 偏好顯式欄位勝過位元打包, 除非量測顯示打包會影響效能。
[]segment{name, each}勝過一個帶有位移運算的uint64旗標。 - 保持少的表示方式。 更少的狀態或種類意味著更少重複的 switch。在沒有顯著效能成本的情況下,將特殊情況合併到一般情況中。
證明效能宣稱
永遠不要沒有量測就宣稱一項變更更快、更慢或無分配。在修改效能敏感的程式碼之前:
- 儲存相關基準測試的
benchstat基準線。 - 進行變更,然後進行比較。用數字報告顯著的差異。
- 將
go build -gcflags=-m的輸出與基準線進行差異比較,查看新的堆逸出。 - 對於承諾零分配的路徑,保留或新增
testing.AllocsPerRun測試。 - 在猜測回歸原因之前先進行剖析。
如果簡化會花費可量測的熱路徑時間,且你無法乾淨地回復,就停下來報告這個取捨,而不是悄悄地增加複雜性或默默地接受回歸。
閱讀 references/verify.md 了解確切的命令,包括如何量測複雜度以及通常會導致回歸的陷阱。閱讀 references/examples.md 了解展示此風格的重構前後範例。
遵循 Go 慣例
gofmt、go vet及專案的 linter 必須通過。修正程式碼,不要削弱或排除 linter 規則;僅在特定行使用具名規則和原因來抑制。- 使用簡短的接收者名稱,在一個型別的所有方法間保持一致;若無理由,不要在一個型別上混合使用指標與值接收者。
- 文件註解以識別碼名稱開頭,並陳述合約。
- 註解要寫原因(why),而非內容(what)。與周圍的註解密度匹配。
- 優先使用標準函式庫(
slices、maps、strings、math/bits),然後才是手寫輔助函式。 - 絕對不要手動編輯產生的程式碼。
審查或重構時
- 在觸碰任何東西之前,先識別熱路徑及其基準測試。
- 量測複雜度以找出最差的函式;優先修正它們。
- 以小步驟重構,每一步之後都執行有針對性的測試。
- 保持行為相同:相同的結果、相同的預算與後備方案、相同的分配保證。只有在獨立、明確聲明的決策下才改變行為。
- 報告什麼改變了、前後的複雜度與基準測試數字,以及任何未經驗證的事項。