golang的泛型已經出來了一年多了,從提案被接受開始我就在關註泛型了,如今不管是在生產環境還是開源項目里我都寫了不少泛型代碼,是時候全面得回顧下golang泛型的使用體驗了。 先說說結論,好用是好用,但問題也很多,有些問題比較影響使用體驗,到了不吐不快的地步了。 這篇文章不會教你泛型的基礎語法,並 ...
golang的泛型已經出來了一年多了,從提案被接受開始我就在關註泛型了,如今不管是在生產環境還是開源項目里我都寫了不少泛型代碼,是時候全面得回顧下golang泛型的使用體驗了。
先說說結論,好用是好用,但問題也很多,有些問題比較影響使用體驗,到了不吐不快的地步了。
這篇文章不會教你泛型的基礎語法,並且要求你對golang的泛型使用有一定經驗,如果你還是個泛型的新手,可以先閱讀下官方的教程,然後再閱讀本篇文章。
泛型的實現
實現泛型有很多種方法,常見的主流的是下麵這些:
- 以c++為代表的,類型參數就是個占位符,最後實際上會替換成實際類型,然後以此為模板生成實際的代碼,生成多份代碼,每份的類型都不一樣
- 以TypeScript和Java為代表的類型擦除,把類型參數泛化成一個滿足類型約束的類型(Object或者某個interface),只生成一份代碼
- 以c#為代表,代碼里表現的像類型擦除,但運行的時候實際上和c++一樣採用模板實例化對每個不同的類型都生成一份代碼
那麼golang用的哪種呢?哪種都不是,golang有自己的想法:gcshape
。
什麼是gcshape
?簡單得說,所有擁有相同undelyring type的類型都算同一種shape
,所有的指針都算一種shape
,除此之外就算兩個類型大小相同甚至欄位的類型相同也不算同一個shape
。
那麼這個shape
又是什麼呢?gc編譯器會根據每個shape生成一份代碼,擁有相同shape的類型會共用同一份代碼。
看個簡單例子:
func Output[T any]() {
var t T
fmt.Printf("%#v\n", t)
}
type A struct {
a,b,c,d,e,f,g int64
h,i,j string
k []string
l, m, n map[string]uint64
}
type B A
func main() {
Output[string]()
Output[int]()
Output[uint]()
Output[int64]()
Output[uint64]() // 上面每個都underlying type都不同,儘管int64和uint64大小一樣,所以生成5份不同的代碼
Output[*string]()
Output[*int]()
Output[*uint]()
Output[*A]() // 所有指針都是同一個shape,所以共用一份代碼
Output[A]()
Output[*B]()
Output[B]() // B的underlying tyoe和A一樣,所以和A共用代碼
Output[[]int]()
Output[*[]int]()
Output[map[int]string]()
Output[*map[int]string]()
Output[chan map[int]string]()
}
驗證也很簡單,看看符號表即可:
為啥要這麼做?按提案的說法,這麼做是為了避免代碼膨脹同時減輕gc的負擔,看著是有那麼點道理,有相同shape的記憶體佈局是一樣的,gc處理起來也更簡單,生成的代碼也確實減少了——如果我就是不用指針那生成的代碼其實也沒少多少。
儘管官方拿不出證據證明gcshape有什麼性能優勢,我們還是姑且認可它的動機吧。但這麼實現泛型後導致了很多嚴重的問題:
- 性能不升反降
- 正常來說類型參數是可以當成普通的類型來用的,但golang里有很多時候不能
正因為有了gcshape,想在golang里用對泛型還挺難的。
性能問題
這一節先說說性能。看個例子:
type A struct {
num uint64
num1 int64
}
func (a *A) Add() {
a.num++
a.num1 = int64(a.num / 2)
}
type B struct {
num1 uint64
num2 int64
}
func (b *B) Add() {
b.num1++
b.num2 = int64(b.num1 / 2)
}
type Adder interface {
Add()
}
func DoAdd[T Adder](t T) {
t.Add()
}
func DoAddNoGeneric(a Adder) {
a.Add()
}
func BenchmarkNoGenericA(b *testing.B) {
obj := &A{}
for i := 0; i < b.N; i++ {
obj.Add()
}
}
func BenchmarkNoGenericB(b *testing.B) {
obj := &B{}
for i := 0; i < b.N; i++ {
obj.Add()
}
}
func BenchmarkGenericA(b *testing.B) {
obj := &A{}
for i := 0; i < b.N; i++ {
DoAdd(obj)
}
}
func BenchmarkGenericB(b *testing.B) {
obj := &B{}
for i := 0; i < b.N; i++ {
DoAdd(obj)
}
}
func BenchmarkGenericInterfaceA(b *testing.B) {
var obj Adder = &A{}
for i := 0; i < b.N; i++ {
DoAdd(obj)
}
}
func BenchmarkGenericInterfaceB(b *testing.B) {
var obj Adder = &B{}
for i := 0; i < b.N; i++ {
DoAdd(obj)
}
}
func BenchmarkDoAddNoGeneric(b *testing.B) {
var obj Adder = &A{}
for i := 0; i < b.N; i++ {
DoAddNoGeneric(obj)
}
}
猜猜結果,是不是覺得引入了泛型可以解決很多性能問題?答案揭曉:
哈哈,純泛型和正常代碼比有不到10%的差異,而介面+泛型就慢了接近100%。直接用介面是這裡最快的,不過這是因為介面被編譯器優化了,原因參加這篇。
你說誰會這麼寫代碼啊,沒事,我再舉個更常見的例子:
func Search[T Equaler[T]](slice []T, target T) int {
index := -1
for i := range slice {
if slice[i].Equal(target) {
index = i
}
}
return index
}
type MyInt int
func (m MyInt) Equal(rhs MyInt) bool {
return int(m) == int(rhs)
}
type Equaler[T any] interface {
Equal(T) bool
}
func SearchMyInt(slice []MyInt, target MyInt) int {
index := -1
for i := range slice {
if slice[i].Equal(target) {
index = i
}
}
return index
}
func SearchInterface(slice []Equaler[MyInt], target MyInt) int {
index := -1
for i := range slice {
if slice[i].Equal(target) {
index = i
}
}
return index
}
var slice []MyInt
var interfaces []Equaler[MyInt]
func init() {
slice = make([]MyInt, 100)
interfaces = make([]Equaler[MyInt], 100)
for i := 0; i < 100; i++ {
slice[i] = MyInt(i*i + 1)
interfaces[i] = slice[i]
}
}
func BenchmarkSearch(b *testing.B) {
for i := 0; i < b.N; i++ {
Search(slice, 99*99)
}
}
func BenchmarkInterface(b *testing.B) {
for i := 0; i < b.N; i++ {
SearchInterface(interfaces, 99*99)
}
}
func BenchmarkSearchInt(b *testing.B) {
for i := 0; i < b.N; i++ {
SearchMyInt(slice, 99*99)
}
}
這是結果:
泛型代碼和使用介面的代碼相差無幾,比普通代碼慢了整整六倍!
為啥?因為gcshape的實現方式導致了類型參數T
並不是真正的類型,所以在調用上面的方法的時候得查找一個叫type dict
的東西找到當前使用的真正的類型,然後再把綁定在T上的變數轉換成那個類型。多了一次查找+轉換,這裡的MyInt
轉換後還會被覆制一次,所以能不慢麽。
這也解釋了為什麼把介面傳遞給類型參數是最慢的,因為除了要查一次type dict
,介面本身還得再做一次類型檢查並查找對應的method。
所以想靠泛型大幅提升性能的人還是洗洗睡吧,只有一種情況泛型的性能不會更差:在類型參數上只使用內置的運算符比如加減乘除,不調用任何方法。
但也不該因噎廢食,首先泛型struct和泛型interface受到的影響很小,其次如我所說,如果不使用類型約束上的方法,那性能損耗幾乎沒有,所以像lo、mo這樣的工具庫還是能放心用的。
這個問題1.18就有人提出來了,然而gcshape的實現在這點上太拉胯,小修小補解決不了問題,官方也沒改進的動力,所以哪怕到了1.21還是能復現同樣的問題。
不過噩夢才剛剛開始,更勁爆的還在後面呢。
如何創建對象
首先你不能這麼寫:T{}
,因為int之類的內置類型不支持這麼做。也不能這樣:make(T, 0)
,因為T不是類型占位符,不知道具體類型是什麼,萬一是不能用make的類型編譯會報錯。
那麼對於一個類型T
,想要在泛型函數里創建一個它的實例就只能這樣了:
func F[T any]() T {
var ret T
// 如果需要指針,可以用new(T),但有註意事項,下麵會說
return ret
}
So far, so good。那麼我要把T的類型約束換成一個有方法的interface呢?
type A struct {i int}
func (*A)Hello() {
fmt.Println("Hello from A!")
}
func (a *A) Set(i int) {
a.i = i
}
type B struct{i int}
func (*B)Hello(){
fmt.Println("Hello from B!")
}
func (b *B) Set(i int) {
b.i = i
}
type API interface {
Hello()
Set(int)
}
func SayHello[PT API](a PT) {
a.Hello()
var b PT
b.Hello()
b.Set(222222)
fmt.Println(a, b)
}
func main() {
a := new(A)
a.Set(111)
fmt.Println(a)
SayHello(&A{})
SayHello(&B{})
}
運行結果是啥?啥都不是,運行時會獎勵你一個大大的panic:
你懵了,如果T的約束是any的時候就是好的,雖然不能調用方法,怎麼到這調Set就空指針錯誤了呢?
這就是我要說的第二點嚴重問題了,類型參數不是你期待的那種int,MyInt那種類型,類型參數有自己獨有的類型,叫type parameter
。有興趣可以去看語言規範里的定義,沒興趣就這麼簡單粗暴的理解也夠了:這就是種會編譯期間進行檢查的interface。
理解了這點你的問題就迎刃而解了,因為它類似下麵的代碼:
var a API
a.Set(1)
a沒綁定任何東西,那麼調Set百分百空指針錯誤。同理,SayHello
里的b
也沒綁定任何數據,一樣會空指針錯誤。為什麼b.Hello()
調成功了,因為這個方法里沒對接收器的指針解引用。
同樣new(T)
這個時候是創建了一個type parameter
的指針,和原類型的關係就更遠了。
但對於像這樣~int
、[]int
的有明確的core type的約束,編譯器又是雙標的,可以正常創建實例變數。
怎麼解決?沒法解決,當然不排除是我不會用golang的泛型,如果你知道在不使用unsafe或者給T添加創建實例的新方法的前提下滿足需求的解法,歡迎告訴我。
目前為止這還不是大問題,一般不需要在泛型代碼里創建實例,大部分需要的情況也可以在函數外創建後傳入。而且golang本身沒有構造函數的概念,怎麼創建類型的實例並不是類型的一部分,這點上不支持還是可以理解的。
但下麵這個問題就很難找到合理的藉口了。
把指針傳遞給類型參數
最佳實踐:永遠不要把指針類型作為類型參數,就像永遠不要獲取interface的指針一樣。
為啥,看看下麵的例子就行:
func Set[T *int|*uint](ptr T) {
*ptr = 1
}
func main() {
i := 0
j := uint(0)
Set(&i)
Set(&j)
fmt.Println(i, j)
}
輸出是啥,是編譯錯誤:
$ go build a.go
# command-line-arguments
./a.go:6:3: invalid operation: pointers of ptr (variable of type T constrained by *int | *uint) must have identical base types
這個意思是T不是指針類型,沒法解引用。猜都不用猜,肯定又是type parameter
作怪了。
是的。T是type parameter
,而type parameter
不是指針,不支持解引用操作。
不過比起前一個問題,這個是有解決辦法的,而且辦法很多,第一種,明確表明ptr是個指針:
func Set[T int|uint](ptr *T) {
*ptr = 1
}
第二種,投機取巧:
func Set[T int|uint, PT interface{*T}](ptr PT) {
*ptr = 1
}
第二種為什麼行,因為在類型約束里如果T的約束有具體的core type(包括any),那麼在這裡就會被當成實際的類型用而不是type parameter
。所以PT代表的意思是“有一個類型,它必須是T代表的實際類型的指針類型”。因為PT是指針類型了,所以第二種方法也可以達到目的。
但我永遠只推薦你用第一種方法,別給自己找麻煩。
泛型和類型的方法集
先看一段代碼:
type A struct {i int}
func (*A)Hello() {
fmt.Println("Hello from A!")
}
type B struct{i int}
func (*B)Hello(){
fmt.Println("Hello from B!")
}
func SayHello[T ~*A|~*B](a T) {
a.Hello()
}
func main() {
SayHello(&A{})
SayHello(&B{})
}
輸出是啥?又是編譯錯誤:
$ go build a.go
# command-line-arguments
./a.go:17:4: a.Hello undefined (type T has no field or method Hello)
你猜到了,因為T是類型參數,而不是(*A),所以沒有對應的方法存在。所以你這麼改了:
func SayHello[T A|B](a *T) {
a.Hello()
}
這時候輸出又變了:
$ go build a.go
# command-line-arguments
./a.go:17:4: a.Hello undefined (type *T is pointer to type parameter, not type parameter)
這個報錯好像挺眼熟啊,這不就是取了interface的指針之後在指針上調用方法時報的那個錯嗎?
對,兩個錯誤都差不多,因為type parameter有自己的數據結構,而它沒有任何方法,所以通過指針指向type parameter後再調用方法會報一模一樣的錯。
難道我們只能建個interface裡面放上Hello這個方法了嗎?雖然我推薦你這麼做,但還有別的辦法,我們可以利用上一節的PT,但需要給它加點method:
func SayHello[T A|B, PT interface{*T; Hello()}](a PT) {
a.Hello()
}
原理是一樣的,但現在a還同時支持指針的操作。
直接用interface{Hello()}
不好嗎?絕大部分時間都可以,但如果我只想限定死某些類型的話就不適用了。
如何複製一個對象
大部分情況下直接b := a
即可,不過要註意這是淺拷貝。
對於指針就比較複雜了,因為type parameter的存在,我們得特殊處理:
type A struct {i int}
func (*A)Hello() {
fmt.Println("Hello from A!")
}
func (a *A) Set(i int) {
a.i = i
}
type B struct{i int/*j*/}
func (*B)Hello(){
fmt.Println("Hello from B!")
}
func (b *B) Set(i int) {
b.i = i
}
type API[T any] interface {
*T
Set(int)
}
func DoCopy[T any, PT API[T]](a PT) {
b := *a
(PT(&b)).Set(222222) // 依舊是淺拷貝
fmt.Println(a, b)
}
PT是指針類型,所以可以解引用得到T的值,然後再賦值給b,完成了一次淺拷貝。
註意,拷貝出來的b是T類型的,得先轉成*T
再轉成PT
。
想深拷貝怎麼辦,那隻能定義和實現這樣的介面了:CloneAble[T any] interface{Clone() T}
。這倒也沒那麼不合理,為了避免淺拷貝問題一般也需要提供一個可以複製自身的方法,算是順勢而為吧。
總結
這一年多來我遇到的令人不爽的問題就是這些,其中大部分是和指針相關的,偶爾還要外加一個性能問題。
一些最佳實踐:
- 明確使用
*T
,而不是讓T
代表指針類型 - 明確使用
[]T
和map[T1]T2
,而不是讓T
代表slice或map - 少寫泛型函數,可以多用泛型struct
- 類型約束的core type直接影響被約束的類型可以執行哪些操作,要當心
如果是c++,那不會有這些問題,因為類型參數是占位符,會被替換成真實的類型;如果是ts,java也不會有這些問題,因為它們沒有指針的概念;如果是c#,也不會有問題,至少在8.0的時候編譯器不允許構造類似T*
的東西,如果你這麼寫,會有清晰明確的錯誤信息。
而我們的golang呢?雖然不支持,但給的報錯卻是一個代碼一個樣,對golang的類型系統和泛型實現細節沒點瞭解還真不知道該怎麼處理呢。
我的建議是,在golang想辦法改進這些問題之前,只用別人寫的泛型庫,只用泛型處理slice和map。其他的雜技我們就別玩了,容易摔著。