跟著 Go 作者掌握泛型
閱讀本文大概需要 5 分鐘。
大家好,我是 polarisxu。
在 GopherCon 2021 年大會上,Go 兩位作者 Robert Griesemer 和 Ian Lance Taylor 做了泛型相關(guān)的演講,即將在 Go1.18 發(fā)布的 Go 泛型,正是兩位設(shè)計的。一直想著把他們的演講做一個梳理,然后分享給大家。拖的有點久,趁春節(jié)假期整理出來了。
注意,本文中的 constraints 包,已經(jīng)確定在 Go1.18 正式版中去除,放入 golang.org/x/exp 中。詳細可以參考該文:Go1.18 這個包確定沒了。
Go1.18 關(guān)于泛型部分,主要有三個特性:
Type parameters for functions and types,即函數(shù)和類型的類型參數(shù) Type sets defined by interfaces,即由接口定義的類型集合 Type inference,即類型推斷
1、類型參數(shù)
先看函數(shù)的類型參數(shù)。
類型參數(shù)列表(Type parameter lists)
類型參數(shù)列表看起來是帶方括號的普通參數(shù)列表。通常,類型參數(shù)以大寫字母開頭,以強調(diào)它們是類型:
[P,?Q?constraint1,?R?constraint2]
看一個例子。
非泛型版本的求最小值:
func?min(x,?y?float64)?float64?{
??if?x?????return?x
??}
??return?y
}
如果有 int 類型的 min 版本需求,得另外寫一個類似的函數(shù),這完全是重復(fù)代碼。
那泛型版本呢?
func?min[T?constraints.Ordered](x,?y?T)?T?{
??if?x?????return?x
??}
??return?y
}
注意和上面版本的區(qū)別。
多了一個 [T constraints.Ordered],這就是類型參數(shù)列表,聲明了一個類型 T,它的約束是 constraints.Ordered,即類型 T 滿足它規(guī)定的條件參數(shù)類型 float64 變成了 T,而不是具體的某個類型
那這個泛型函數(shù)如何調(diào)用呢?
m?:=?min[int](2,?3)
是不是很奇怪?其實仔細一琢磨,好像沒問題。因為函數(shù)聲明中有 ?[T constraints.Ordered],跟普通的函數(shù)參數(shù)有點像。調(diào)用時,提供 int,表明普通函數(shù)參數(shù)是 int 類型。
實例化
在調(diào)用時,會進行實例化過程:
1)用類型實參(type arguments)替換類型形參(type parameters)
2)檢查類型實參(type arguments)是否實現(xiàn)了類型約束
如果第 2 步失敗,實例化(調(diào)用)失敗。
所以,調(diào)用過程可以分解為以下兩步:
fmin?:=?min[float64]
m?:=?fmin(2.3,?3.4)
//?和下面等價
m?:=?min[float64](2.3,?3.4)
//?相當(dāng)于?m?:=?(min[float64])(2.3,?3.4)
所以,實例化產(chǎn)生了一個非泛型函數(shù)。
類型的類型參數(shù)
類型也可以有類型參數(shù)。通過一個例子理解一下。
一個泛型版二叉樹:
type?Tree[T?interface{}]?struct?{
?left,?right?*Tree[T]
?data????????T
}
func?(t?*Tree[T])?Lookup(x?T)?*Tree[T]
var?stringTree?Tree[string]
注意其中的 [T interface{}] ,跟函數(shù)的類型參數(shù)語法是一樣的,T 相當(dāng)于是一個類型,所以,之后用到 Tree 的地方,T 都跟隨著,即 Tree[T],包括方法的接收者(receiver)。
注意實例化的地方:var stringTree Tree[string],和上面兩個實例化步驟中的第一步一樣。
2、類型集合(Type sets)
先看值參數(shù)的類型(the type of value parameters)。
函數(shù)普通參數(shù)列表中的每個值參數(shù)都有一個類型,這個類型定義值的集合。比如 float64 定義了浮點數(shù)值的集合。
相應(yīng)的有類型參數(shù)的類型(the type of type parameters),也就是說,類型參數(shù)列表中的每個類型參數(shù)都有一個類型,這個類型定義了類型的集合,這叫做類型約束(type constraint):
func?min[T?constraints.Ordered](x,?y?T)?T
這里的 constraints.Ordered 是類型參數(shù)列表中的 T 參數(shù)的類型,它定義了類型的集合,即類型約束。
constraints.Ordered 是 Go1.18 內(nèi)置的一個類型約束,它有兩個功能:
只有值支持排序的類型才能傳遞給類型參數(shù) T; T 類型的值必須支持 <操作符,因為函數(shù)體中使用了該操作符。
類型約束是接口
大家都知道接口定義了方法集(method sets),演講中給了一張圖:

根據(jù) Go 的規(guī)則,類型 P、Q、R 方法中包含了 a、b、c,因此它們實現(xiàn)了接口。
所以,反過來可以說,接口也定義了類型集(type sets):

上圖中,類型 P、Q、R 都實現(xiàn)了左邊的接口(因為都實現(xiàn)了接口的方法集),因此我們可以說該接口定義了類型集。
既然接口是定義類型集,只不過是間接定義的:類型實現(xiàn)接口的方法集。而類型約束是類型集,因此完全可以重用接口的語義,只不過這次是直接定義類型集:

這就是類型約束的語法,通過接口直接定義類型集:
type?Xxx?interface?{
??int?|?string?|?bool
}
而 constraints.Ordered 的定義如下:
//?Ordered?is?a?constraint?that?permits?any?ordered?type:?any?type
//?that?supports?the?operators?<=?>=?>.
//?If?future?releases?of?Go?add?new?ordered?types,
//?this?constraint?will?be?modified?to?include?them.
type?Ordered?interface?{
?Integer?|?Float?|?~string
}
Ordered 定義了所有 interger、浮點數(shù)和字符串類型的集合。所以,< 操作符也是支持的。這其中的 Integer、Float 也在 constraints 包有定義。
細心的朋友應(yīng)該發(fā)現(xiàn)了 ~string,類型前面的 ~。~T 意味著包含底層類型 T 的所有類型集合。
如果約束中的所有類型都支持一個操作,則該操作可以與相應(yīng)的類型參數(shù)一起使用
除了將約束單獨定義為類型外,還可以寫成字面值的形式,比如:
[S?interface{~[]E},?E?interface{}]
這看著有點暈,其實可以直接這么寫:
[S?~[]E,?E?interface{}]
Go1.18 中,any 是 interface{} 的別名,因此可以進一步寫為:
[S?~[]E,?E?any]
E 是切片的元素類型,~[]E 表示底層是 []E 切片類型的都符合該約束。
3、類型推斷
在調(diào)用泛型函數(shù)時,提供類型實參感覺有點多余。Go 雖然是靜態(tài)類型語言,但擅長類型推斷。因此泛型這里,Go 也實現(xiàn)了類型推斷。
調(diào)用泛型版的 min,可以不提供類型實參,而是直接由 Go 進行類型推斷:
var?a,?b,?m?float64
m?:=?min[float64](a,?b)
類型推斷的細節(jié)很復(fù)雜,但使用起來還是很簡單,大部分時候,跟普通函數(shù)調(diào)用沒有區(qū)別。
關(guān)于類型推斷,演講中給了一個例子:
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
}
這個函數(shù)的目的是希望對 s 中的每個元素都乘以參數(shù) c,最后返回一個新的切片。
接著定義一個類型:
type?Point?[]int32
func?(p?Point)?String()?string?{
?//?實現(xiàn)細節(jié)不重要,忽略
?return?"point"
}
很顯然,Point 類型的切片可以傳遞給 Scale:
func?ScaleAndPrint(p?Point)?{
?r?:=?Scale(p,?2)
?fmt.Println(r.String())
}
我們希望對 p 進行 Scale,得到一個新的 p,但發(fā)現(xiàn)返回的 r 根本不是 Point:
func?main()?{
?p?:=?Point{3,?2,?4}
?ScaleAndPrint(p)
}
會報錯:r.String undefined (type []int32 has no field or method String)。
所以,我們應(yīng)該這樣修改 Scale 函數(shù):
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,以及額外的類型約束 ~[]E。
調(diào)用 Scale 時,不需要 r := Scale[Point, int32](p, 2),因為 Go 會進行類型推斷。
正確的完整代碼如下:
package?main
import?(
?"constraints"
?"fmt"
)
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
}
type?Point?[]int32
func?(p?Point)?String()?string?{
?//?實現(xiàn)細節(jié)不重要,忽略
?return?"point"
}
func?ScaleAndPrint(p?Point)?{
?r?:=?Scale(p,?2)
?fmt.Println(r.String())
}
func?main()?{
?p?:=?Point{3,?2,?4}
?ScaleAndPrint(p)
}
4、什么時候用泛型
泛型的加入,無疑增加了復(fù)雜度。我個人認為,能不用泛型就不用泛型。在演講中,兩位大佬提到,在以下場景可以考慮使用泛型:
對于 slice、map、channel 等類型,如果它們的元素類型是不確定的,操作這類類型的函數(shù)可以考慮用泛型 一些通用目的的數(shù)據(jù)結(jié)構(gòu),比如前面提到的二叉樹等 如果一些函數(shù)行為相同,只是類型不同,可以考慮用泛型重構(gòu)
注意,目前 Go 方法不支持類型參數(shù),所以,如果方法有需要泛型的場景,可以轉(zhuǎn)為函數(shù)的形式。
此外,不要為了泛型而泛型。比如這樣的泛型就很糟糕:
func?ReadFour[T?io.Reader](r?T)?([]byte,?error)
而應(yīng)該使用非泛型版本:
func?ReadFour(r?io.Reader)?([]byte,?error)
5、總結(jié)
泛型是一把雙刃劍。泛型的加入,讓 Go 不那么簡單了。有些代碼寫出來,可讀性可能非常差。我們應(yīng)該按沒有泛型時候?qū)懘a,當(dāng)發(fā)現(xiàn)在 Repeat Yourself 時,再考慮能不能用泛型重構(gòu),千萬別玩什么花樣!
最后,放上演講的視頻地址,有興趣的可以觀看:https://www.youtube.com/watch?v=Pa_e9EeCdy8。
我是 polarisxu,北大碩士畢業(yè),曾在 360 等知名互聯(lián)網(wǎng)公司工作,10多年技術(shù)研發(fā)與架構(gòu)經(jīng)驗!2012 年接觸 Go 語言并創(chuàng)建了 Go 語言中文網(wǎng)!著有《Go語言編程之旅》、開源圖書《Go語言標準庫》等。
堅持輸出技術(shù)(包括 Go、Rust 等技術(shù))、職場心得和創(chuàng)業(yè)感悟!歡迎關(guān)注「polarisxu」一起成長!也歡迎加我微信好友交流:gopherstudio
