Go 部落格
泛型簡介
簡介
這篇博文基於我們在 GopherCon 2021 上的演講
Go 1.18 版本增加了對泛型的支援。泛型是自 Go 第一個開源版本以來我們對 Go 所做的最大改變。在本文中,我們將介紹新的語言特性。我們不會試圖涵蓋所有細節,但會涉及所有重要的點。有關更詳細和更長的描述,包括許多示例,請參閱提案文件。有關語言變化的更精確描述,請參閱更新的語言規範。(請注意,實際的 1.18 實現對提案文件允許的內容施加了一些限制;規範應是準確的。未來的版本可能會取消部分限制。)
泛型是一種編寫獨立於所使用的特定型別的程式碼的方式。函式和型別現在可以編寫為使用任意一組型別。
泛型為語言增加了三大新特性
- 函式和型別的型別引數。
- 將介面型別定義為型別的集合,包括沒有方法的型別。
- 型別推斷,在許多情況下呼叫函式時允許省略型別引數。
型別引數
函式和型別現在允許擁有型別引數。型別引數列表看起來像普通引數列表,只是它使用方括號而不是圓括號。
為了說明這是如何工作的,讓我們從用於浮點值的基本非泛型 Min
函式開始
func Min(x, y float64) float64 {
if x < y {
return x
}
return y
}
我們可以透過新增一個型別引數列表來使此函式泛型化——使其適用於不同的型別。在此示例中,我們添加了一個包含單個型別引數 T
的型別引數列表,並將 float64
的用法替換為 T
。
import "golang.org/x/exp/constraints"
func GMin[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
現在可以透過編寫如下呼叫來使用型別引數呼叫此函式
x := GMin[int](2, 3)
向 GMin
提供型別引數,在此例中為 int
,稱為 例項化。例項化分兩步進行。首先,編譯器將所有型別引數替換到泛型函式或型別中各自的型別引數位置。其次,編譯器驗證每個型別引數是否滿足相應的約束。我們稍後會詳細說明這意味著什麼,但如果第二步失敗,例項化就失敗,並且程式無效。
例項化成功後,我們得到一個非泛型函式,可以像呼叫其他任何函式一樣呼叫。例如,在如下程式碼中
fmin := GMin[float64]
m := fmin(2.71, 3.14)
例項化 GMin[float64]
實際上生成了我們最初的浮點 Min
函式,我們可以在函式呼叫中使用它。
型別引數也可以用於型別。
type Tree[T interface{}] struct {
left, right *Tree[T]
value T
}
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }
var stringTree Tree[string]
在這裡,泛型型別 Tree
儲存型別引數 T
的值。泛型型別可以有方法,例如此示例中的 Lookup
。為了使用泛型型別,必須對其進行例項化;Tree[string]
是使用型別引數 string
例項化 Tree
的一個示例。
型別集
讓我們更深入地看看可用於例項化型別引數的型別引數。
普通函式為每個值引數都有一個型別;該型別定義了一組值。例如,如果我們在上面的非泛型函式 Min
中有一個 float64
型別,則允許的引數值集合是可由 float64
型別表示的浮點值集合。
類似地,型別引數列表為每個型別引數都有一個型別。由於型別引數本身就是一個型別,因此型別引數的型別定義了型別集合。這種元型別稱為 型別約束。
在泛型 GMin
中,型別約束是從 constraints 包匯入的。Ordered
約束描述了所有具有可排序值的型別集合,換句話說,可以使用 < 運算子(或 <=、> 等)進行比較的值。該約束確保只有具有可排序值的型別才能傳遞給 GMin
。這也意味著在 GMin
函式體中,該型別引數的值可以使用 < 運算子進行比較。
在 Go 中,型別約束必須是介面。也就是說,介面型別可以用作值型別,也可以用作元型別。介面定義方法,因此顯然我們可以表達需要存在某些方法的型別約束。但是 constraints.Ordered
也是一個介面型別,而 < 運算子不是一個方法。
為了使其工作,我們以一種新的方式看待介面。
直到最近,Go 規範稱介面定義了一個方法集,這大致是介面中列舉的方法集合。任何實現了所有這些方法的型別都實現了該介面。

但另一種看待這個問題的方式是說介面定義了一組型別,即實現這些方法的型別。從這個角度來看,任何是介面型別集元素的型別都實現了該介面。

這兩種觀點導致相同的結果:對於每組方法,我們可以想象實現這些方法的相應型別集合,這就是介面定義的型別集合。
然而,就我們的目的而言,型別集檢視相對於方法集檢視有一個優勢:我們可以顯式地向集合中新增型別,從而以新的方式控制型別集。
我們擴充套件了介面型別的語法以使其工作。例如,interface{ int|string|bool }
定義了包含 int
、string
和 bool
型別的型別集。

換句話說,只有 int
、string
或 bool
才能滿足此介面。
現在讓我們看看 constraints.Ordered
的實際定義
type Ordered interface {
Integer|Float|~string
}
此宣告表示 Ordered
介面是所有整數、浮點和字串型別的集合。豎線表示型別的並集(或在此例中為型別集合的並集)。Integer
和 Float
是在 constraints
包中類似定義的介面型別。請注意,Ordered
介面沒有定義任何方法。
對於型別約束,我們通常不關心特定型別,例如 string
;我們關注的是所有字串型別。這就是 ~
標記的作用。表示式 ~string
表示其底層型別是 string
的所有型別集合。這包括 string
型別本身以及透過如 type MyString string
這樣的定義宣告的所有型別。
當然,我們仍然希望在介面中指定方法,並且我們希望向後相容。在 Go 1.18 中,介面可以像以前一樣包含方法和嵌入式介面,但它也可以嵌入非介面型別、聯合以及底層型別集合。
當用作型別約束時,介面定義的型別集精確指定了相應型別引數允許作為型別引數的型別。在泛型函式體內部,如果運算元的型別是帶有約束 C
的型別引數 P
,則當 C
的型別集中的所有型別都允許該操作時,該操作才允許(目前此處有一些實現限制,但普通程式碼不太可能遇到)。
用作約束的介面可以命名(例如 Ordered
),或者它們可以是內聯在型別引數列表中的字面量介面。例如
[S interface{~[]E}, E interface{}]
在這裡,S
必須是一個切片型別,其元素型別可以是任何型別。
由於這是一個常見情況,位於約束位置的介面可以省略外部的 interface{}
,我們可以直接寫
[S ~[]E, E interface{}]
由於空介面在型別引數列表和普通 Go 程式碼中都很常見,Go 1.18 引入了一個新的預宣告識別符號 any
作為空介面型別的別名。由此,我們得到這種慣用的程式碼寫法
[S ~[]E, E any]
將介面用作型別集是一種強大的新機制,也是 Go 中使型別約束工作的關鍵。目前,使用新語法形式的介面只能用作約束。但很容易想象,顯式型別約束的介面在一般情況下也可能有用。
型別推斷
最後一個新的主要語言特性是型別推斷。在某些方面,這是語言最複雜的改變,但它很重要,因為它允許人們在編寫呼叫泛型函式的程式碼時使用更自然的方式。
函式引數型別推斷
使用型別引數帶來了傳遞型別引數的需要,這可能導致程式碼冗長。回顧我們的泛型 GMin
函式
func GMin[T constraints.Ordered](x, y T) T { ... }
型別引數 T
用於指定普通非型別引數 x
和 y
的型別。如前所述,可以使用顯式型別引數呼叫它
var a, b, m float64
m = GMin[float64](a, b) // explicit type argument
在許多情況下,編譯器可以從普通引數推斷出 T
的型別引數。這使得程式碼更短,同時保持清晰。
var a, b, m float64
m = GMin(a, b) // no type argument
這是透過將引數 a
和 b
的型別與引數 x
和 y
的型別進行匹配來實現的。
這種從函式引數型別推斷型別引數的推斷方式,稱為 函式引數型別推斷。
函式引數型別推斷僅適用於函式引數中使用的型別引數,不適用於僅在函式結果或函式體中使用的型別引數。例如,它不適用於像 MakeT[T any]() T
這樣僅將 T
用於結果的函式。
約束型別推斷
語言支援另一種型別推斷,即 約束型別推斷。為了描述這一點,讓我們從這個對整數切片進行縮放的示例開始
// Scale returns a copy of s with each element multiplied by c.
// This implementation has a problem, as we will see.
func Scale[E constraints.Integer](s []E, c E) []E {
r := make([]E, len(s))
for i, v := range s {
r[i] = v * c
}
return r
}
這是一個適用於任何整數型別切片的泛型函式。
現在假設我們有一個多維的 Point
型別,其中每個 Point
只是一個給出點座標的整數列表。自然,這個型別會有一些方法。
type Point []int32
func (p Point) String() string {
// Details not important.
}
有時我們想縮放一個 Point
。由於 Point
只是一個整數切片,我們可以使用我們之前編寫的 Scale
函式
// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
r := Scale(p, 2)
fmt.Println(r.String()) // DOES NOT COMPILE
}
不幸的是,這無法編譯,並出現錯誤,例如 r.String undefined (type []int32 has no field or method String)
。
問題在於 Scale
函式返回型別為 []E
的值,其中 E
是引數切片的元素型別。當我們使用 Point
型別的值(其底層型別是 []int32
)呼叫 Scale
時,我們得到的是型別為 []int32
的值,而不是 Point
型別。這源於泛型程式碼的編寫方式,但這並不是我們想要的。
為了解決這個問題,我們必須修改 Scale
函式,對切片型別使用型別引數。
// Scale returns a copy of s with each element multiplied by c.
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
r := make(S, len(s))
for i, v := range s {
r[i] = v * c
}
return r
}
我們引入了一個新的型別引數 S
,它是切片引數的型別。我們對其進行了約束,使得底層型別是 S
而不是 []E
,並且結果型別現在是 S
。由於 E
被約束為整數,因此效果與之前相同:第一個引數必須是某種整數型別的切片。函式體唯一的改變是,現在我們在呼叫 make
時傳遞的是 S
,而不是 []E
。
如果我們使用普通切片呼叫新函式,它的行為與之前相同,但如果我們使用 Point
型別呼叫它,我們現在會得到一個 Point
型別的值。這就是我們想要的。有了這個版本的 Scale
,之前的 ScaleAndPrint
函式將按照我們期望的方式編譯和執行。
但公平地說:為什麼在呼叫 Scale
時可以不傳遞顯式型別引數?也就是說,為什麼我們可以寫 Scale(p, 2)
而不帶任何型別引數,而不必寫 Scale[Point, int32](p, 2)
?我們的新 Scale
函式有兩個型別引數,S
和 E
。在呼叫 Scale
時不傳遞任何型別引數的情況下,上面描述的函式引數型別推斷允許編譯器推斷 S
的型別引數是 Point
。但該函式還有一個型別引數 E
,它是乘法因子 c
的型別。對應的函式引數是 2
,並且由於 2
是一個 無型別 常量,函式引數型別推斷無法推斷出 E
的正確型別(充其量它可能會推斷出 2
的預設型別 int
,這是不正確的)。相反,編譯器推斷 E
的型別引數是切片元素型別的過程稱為 約束型別推斷。
約束型別推斷從型別引數約束中推導型別引數。當一個型別引數的約束是根據另一個型別引數定義的時,就會使用它。當其中一個型別引數的型別引數已知時,該約束將用於推斷另一個型別引數的型別引數。
應用這種情況的常見情況是,當一個約束使用 ~
type
的形式表示某個型別,而該型別是使用其他型別引數編寫的。我們在 Scale
示例中看到了這一點。S
是 ~[]E
,即 ~
後跟一個以另一個型別引數表示的型別 []E
。如果我們知道 S
的型別引數,我們就可以推斷出 E
的型別引數。S
是一個切片型別,而 E
是該切片的元素型別。
這只是對約束型別推斷的介紹。更多詳細資訊請參閱提案文件或語言規範。
實際中的型別推斷
型別推斷工作原理的精確細節很複雜,但使用它並不複雜:型別推斷要麼成功,要麼失敗。如果成功,可以省略型別引數,呼叫泛型函式看起來與呼叫普通函式沒有區別。如果型別推斷失敗,編譯器會給出錯誤訊息,在這種情況下,我們只需提供必要的型別引數。
在為語言新增型別推斷時,我們試圖在推斷能力和複雜性之間取得平衡。我們希望確保編譯器推斷出的型別永遠不會令人意外。我們力求謹慎,寧可推斷失敗,也不要推斷出錯誤的型別。我們可能還沒有做到完全正確,未來版本中可能會繼續完善。其效果將是更多程式無需顯式型別引數即可編寫。今天不需要型別引數的程式,明天也同樣不需要。
結論
泛型是 1.18 版本中的一項重要新語言特性。這些新的語言變化需要大量新程式碼,而這些程式碼尚未在生產環境中得到充分測試。只有當更多人編寫和使用泛型程式碼時,才會發生這種情況。我們相信此功能實現得很好,質量也很高。然而,與 Go 的大多數方面不同,我們無法用實際世界經驗來支援這一信念。因此,雖然我們鼓勵在有意義的地方使用泛型,但在生產環境中部署泛型程式碼時請務必謹慎。
拋開謹慎,我們很高興泛型已可用,並且希望它們能使 Go 程式設計師更具生產力。
下一篇文章:Go 如何減輕供應鏈攻擊
上一篇文章:Go 1.18 釋出了!
部落格索引