簡單易懂的 Go 泛型使用和實現原理介紹
原文:A gentle introduction to generics in Go by Dominik Braun
萬俊峰Kevin:我看了覺得文章非常簡單易懂,就徵求了作者同意,翻譯出來給大家分享一下。
本文是對泛型的基本思想及其在 Go 中的實現的一個比較容易理解的介紹,同時也是對圍繞泛型的各種效能討論的簡單總結。首先,我們來看看泛型所解決的核心問題。
問題
假設我們想實現一個簡單的 tree
資料結構。每個節點持有一個值。在 Go 1.18 之前,實現這種結構的典型方法如下。
type Node struct {
value interface{}
}
這在大多數情況下都很好用,但它也有一些缺點。
首先,interface{}
可以是任何東西。如果我們想限制 value
可能持有的型別,例如整數和浮點數,我們只能在執行時檢查這個限制。
func (n Node) IsValid() bool {
switch n.value.(type) {
case int, float32, float64:
return true
default:
return false
}
}
這樣並不可能在編譯時限制類型,像上面這樣的型別判斷在許多 Go 庫中都是很常見的做法。這裡有 go-zero 專案中的例子。
第二,對 Node 中的值進行處理是非常繁瑣和容易出錯的。對值做任何事情都會涉及到某種型別的斷言,即使你可以安全地假設值持有一個 int
值。
number, ok := node.value.(int)
if !ok {
// ...
}
double := number * 2
這些只是使用 interface{}
的一些不便之處,它沒有提供型別安全,並有可能導致難以恢復的執行時錯誤。
解決方法
我們不打算接受任意資料型別或具體型別,而是定義一個叫做 T
的 佔位符型別
作為值的型別。請注意,這段程式碼還不會通過編譯。
type Node[T] struct {
value T
}
首先需要宣告泛型型別 T
,這是在結構或函式名稱後面方括號裡面使用的。
T
可以是任何型別,只有在例項化一個具有明確型別的 Node
時,T
才會被推導為該型別。
n := Node[int]{
value: 5,
}
泛型 Node
被例項化為 Node[int]
(整數節點),所以 T
是一個 int
。
型別約束
上面的實現裡,T
的宣告缺少一個必要的資訊:型別約束。
型別約束用於進一步限制可以作為 T
的可能型別。Go 本身提供了一些預定義的型別約束,但也可以使用自定義的型別約束。
type Node[T any] struct {
value T
}
任意型別(any)約束允許 T
實際上是任何型別。如果節點值需要進行比較,有一個 comparable
型別約束,滿足這個預定義約束的型別可以使用 ==
進行比較。
type Node[T comparable] struct {
value T
}
任何型別都可以作為一個型別約束。Go 1.18 引入了一種新的 interface
語法,可以嵌入其他資料型別。
type Numeric interface {
int | float32 | float64
}
這意味著一個介面不僅可以定義一組方法,還可以定義一組型別。使用 Numeric
介面作為型別約束,意味著值可以是整數或浮點數。
type Node[T Numeric] struct {
value T
}
重獲型別安全
相對於使用 interface{}
,泛型型別引數的巨大優勢在於,T
的最終型別在編譯時就會被推匯出來。為 T
定義一個型別約束,完全消除了執行時檢查。如果用作 T
的型別不滿足型別約束,程式碼就不會編譯通過。
在編寫泛型程式碼時,你可以像已經知道 T
的最終型別一樣寫程式碼。
func (n Node[T]) Value() T {
return n.value
}
上面的函式返回 n.Value
,它的型別是 T
。因此,返回值是 T
,如果 T
是一個整數,那麼返回型別就已知是 int
。因此,返回值可以直接作為一個整數使用,不需要任何型別斷言。
n := Node[int]{
value: 5,
}
double := n.Value() * 2
在編譯時恢復型別安全使 Go 程式碼更可靠,更不容易出錯。
泛型使用場景
在 Ian Lance Taylor
的 When To Use Generics 中列出了泛型的典型使用場景,歸結為三種主要情況:
- 使用內建的容器型別,如
slices
、maps
和channels
- 實現通用的資料結構,如
linked list
或tree
- 編寫一個函式,其實現對許多型別來說都是一樣的,比如一個排序函式
一般來說,當你不想對你所操作的值的內容做出假設時,可以考慮使用泛型。我們例子中的 Node
並不太關心它持有的值。
當不同的型別有不同的實現時,泛型就不是一個好的選擇。另外,不要把 Read(r io.Reader)
這樣的介面函式簽名改為 Read[T io.Reader](r T)
這樣的通用簽名。
效能
要了解泛型的效能及其在 Go 中的實現,首先需要了解一般情況下實現泛型的兩種最常見方式。
這是對各種效能的深入研究和圍繞它們進行的討論的簡要介紹。你大概率不太需要關心 Go 中泛型的效能。
虛擬方法表
在編譯器中實現泛型的一種方法是使用 Virtual Method Table
。泛型函式被修改成只接受指標作為引數的方式。然後,這些值被分配到堆上,這些值的指標被傳遞給泛型函式。這樣做是因為指標看起來總是一樣的,不管它指向的是什麼型別。
如果這些值是物件,而泛型函式需要呼叫這些物件的方法,它就不能再這樣做了。該函式只有一個指向物件的指標,不知道它們的方法在哪裡。因此,它需要一個可以查詢方法的記憶體地址的表格:Virtual Method Table
。這種所謂的動態排程已經被 Go 和 Java 等語言中的介面所使用。
Virtual Method Table
不僅可以用來實現泛型,還可以用來實現其他型別的多型性。然而,推導這些指標和呼叫虛擬函式要比直接呼叫函式慢,而且使用 Virtual Method Table
會阻止編譯器進行優化。
單態化
一個更簡單的方法是單態化(Monomorphization
),編譯器為每個被呼叫的資料型別生成一個泛型函式的副本。
func max[T Numeric](a, b T) T {
// ...
}
larger := max(3, 5)
由於上面顯示的max函式是用兩個整數呼叫的,編譯器在對程式碼進行單態化時將為 int
生成一個 max
的副本。
func maxInt(a, b int) int {
// ...
}
larger := maxInt(3, 5)
最大的優勢是,Monomorphization
帶來的執行時效能明顯好於使用 Virtual Method Table
。直接方法呼叫不僅更有效率,而且還能適用整個編譯器的優化鏈。不過,這樣做的代價是編譯時長,為所有相關型別生成泛型函式的副本是非常耗時的。
Go 的實現
這兩種方法中哪一種最適合 Go?快速編譯很重要,但執行時效能也很重要。為了滿足這些要求,Go 團隊決定在實現泛型時混合兩種方法。
Go 使用 Monomorphization
,但試圖減少需要生成的函式副本的數量。它不是為每個型別建立一個副本,而是為記憶體中的每個佈局生成一個副本:int
、float64
、Node
和其他所謂的 "值型別"
在記憶體中看起來都不一樣,因此泛型函式將為所有這些型別複製副本。
與值型別相反,指標和介面在記憶體中總是有相同的佈局。編譯器將為指標和介面的呼叫生成一個泛型函式的副本。就像 Virtual Method Table
一樣,泛型函式接收指標,因此需要一個表來動態地查詢方法地址。在 Go 實現中的字典與虛擬方法表的效能特點相同。
結論
這種混合方法的好處是,你在使用值型別的呼叫中獲得了 Monomorphization
的效能優勢,而只在使用指標或介面的呼叫中付出了 Virtual Method Table
的成本。
在效能討論中經常被忽略的是,所有這些好處和成本只涉及到函式的呼叫。通常情況下,大部分的執行時間是在函式內部使用的。呼叫方法的效能開銷可能不會成為效能瓶頸,即使是這樣,也要考慮先優化函式實現,再考慮呼叫開銷。
更多閱讀
Vicent Marti: Generics can make your Go code slower (PlanetScale)
Andy Arthur: Generics and Value Types in Golang (Dolthub)
對標準庫的影響
作為 Go 1.18 的一部分,不改變標準庫
是一個謹慎的決定。目前的計劃是收集泛型的經驗,學習如何適當地使用它們,並在標準庫中找出合理的用例。
Go 有一些關於通用包、函式和資料結構的提議:
constraints
, providing type constraints (#47319)maps
, providing generic map functions (#47330)slices
, providing generic slice functions (#47203)sort.SliceOf
, a generic sort implementation (#47619)sync.PoolOf
and other generic concurrent data structures (#47657)
關於 go-zero 泛型的計劃
對 go-zero 支援用泛型改寫,我們持謹慎態度,因為一旦使用泛型,那麼 Go 版本必須從 1.15 升級到 1.18,很多使用者的線上服務現在還未升級到最新版,所以 go-zero 的泛型改寫會延後 Go 兩三個版本,確保使用者線上服務大部分已經升級到 Go 1.18
go-zero
也在對泛型做充分的調研和嘗試。
其中的 mr
包已經新開倉庫支援了泛型:
http://github.com/kevwan/mapreduce
其中的 fx
包也已新開倉庫嘗試支援泛型,但是由於缺少 template method
,未能完成,期待後續 Go 泛型的完善
http://github.com/kevwan/stream
當後續 go-zero
支援泛型的時候,我們就會合入這些已經充分測試的泛型實現。
專案地址
http://github.com/zeromicro/go-zero
http://gitee.com/kevwan/go-zero
歡迎使用 go-zero
並 star 支援我們!
- go-zero微服務實戰系列(六、快取一致性保證)
- 詳解連線池引數設定(邊調邊看)
- go-zero微服務實戰系列(五、快取程式碼怎麼寫)
- go-zero微服務實戰系列(四、CRUD熱熱身)
- go-zero微服務實戰系列(三、API定義和表結構設計)
- go-zero 微服務實戰系列(二、服務拆分)
- go-zero 微服務實戰系列(一、開篇)
- 微服務效率工具 goctl 深度解析(上)
- 型別安全的 Go HTTP 請求
- 用 Go 快速開發一個 RESTful API 服務
- Go 專案配置檔案的定義和讀取
- 簡單易懂的 Go 泛型使用和實現原理介紹
- Go單體服務開發最佳實踐
- 通過 SingleFlight 模式學習 Go 併發程式設計
- 程序內優雅管理多個服務
- 構建 Go 應用 docker 映象的十八種姿勢
- 史上最強程式碼自測方法,沒有之一!
- 微服務從程式碼到k8s部署應有盡有大結局(k8s部署)
- 微服務從程式碼到k8s部署應有盡有系列(十四、部署環境搭建)
- 微服務從程式碼到k8s部署應有盡有系列(十三、服務監控)