前言
Go泛型的設計者Ian Lance Taylor在官方部落格網站上發表了一篇文章when to use generics,詳細說明了在什麼場景下應該使用泛型,什麼場景下不要使用泛型。這對於我們寫出符合最佳實踐的Go泛型程式碼非常有指導意義。
本人對原文在翻譯的基礎上做了一些表述上的優化,方便大家理解。
原文翻譯
Ian Lance Taylor
2022.04.14
這篇部落格彙總了我在2021年Google開源活動日和GopherCon會議上關於泛型的分享。
Go 1.18版本新增了一個重大功能:支援泛型程式設計。本文不會介紹什麼是泛型以及如何使用泛型,而是把重點放在講解Go程式設計實踐中,什麼時候應該使用泛型,什麼時候不要使用泛型。
需要明確的是,我將會提供一些通用的指引,這並不是硬性規定,大家可以根據自己的判斷來決定,但是如果你不確定如何使用泛型,那建議參考本文介紹的指引。
寫程式碼
Go程式設計有一條通用準則:write Go programs by writing code, not by defining types.
具體到泛型,如果你寫程式碼的時候從定義型別引數約束(type parameter constraints)開始,那你可能搞錯了方向。從編寫函式開始,如果寫的過程中發現使用型別引數更好,那再使用型別引數。
型別引數何時有用?
接下來我們看看在什麼情況下,使用型別引數對我們寫程式碼更有用。
使用Go內建的容器型別
如果函式使用了語言內建的容器型別(包括slice, map和channel)作為函式引數,並且函式程式碼對容器的處理邏輯並沒有預設容器裡的元素型別,那使用型別引數(type parameter)可能就會有用。
舉個例子,我們要實現一個函式,該函式的入參是一個map,要返回該map的所有key組成的slice,key的型別可以是map支援的任意key型別。
// MapKeys returns a slice of all the keys in m.
// The keys are not returned in any particular order.
func MapKeys[Key comparable, Val any](m map[Key]Val) []Key {
s := make([]Key, 0, len(m))
for k := range m {
s = append(s, k)
}
return s
}
這段程式碼沒有對map裡key的型別做任何限定,並且沒有用map裡的value,因此這段程式碼適用於所有的map型別。這就是使用型別引數的一個很好的示例。
這種場景下,也可以使用反射(reflection),但是反射是一種比較彆扭的程式設計模型,在編譯期沒法做靜態型別檢查,並且會導致執行期的速度變慢。
實現通用的資料結構
對於通用的資料結構,型別引數也會有用。通用的資料結構類似於slice和map,但是並不是語言內建的資料結構,比如連結串列或者二叉樹。
在沒有泛型的時候,如果要實現通用的資料結構,有2種方案:
- 方案1:針對每個元素型別分別實現一個資料結構
- 方案2:使用interface型別
泛型相對方案1的優點是程式碼更精簡,也更方便給其它模組呼叫。泛型相對方案2的優點是資料儲存更高效,節約記憶體資源,並且可以在編譯期做靜態型別檢查,避免程式碼裡使用型別斷言。
下面的例子就是使用型別引數實現的通用二叉樹資料結構:
// Tree is a binary tree.
type Tree[T any] struct {
cmp func(T, T) int
root *node[T]
}
// A node in a Tree.
type node[T any] struct {
left, right *node[T]
val T
}
// find returns a pointer to the node containing val,
// or, if val is not present, a pointer to where it
// would be placed if added.
func (bt *Tree[T]) find(val T) **node[T] {
pl := &bt.root
for *pl != nil {
switch cmp := bt.cmp(val, (*pl).val); {
case cmp < 0:
pl = &(*pl).left
case cmp > 0:
pl = &(*pl).right
default:
return pl
}
}
return pl
}
// Insert inserts val into bt if not already there,
// and reports whether it was inserted.
func (bt *Tree[T]) Insert(val T) bool {
pl := bt.find(val)
if *pl != nil {
return false
}
*pl = &node[T]{val: val}
return true
}
二叉樹的每個節點包含一個型別為T
的變數val
。當二叉樹例項化的時候,需要傳入型別實參,這個時候val
的型別已經確定下來了,不會被存為interface型別。
這種場景使用型別引數是合理的,因為Tree
是個通用的資料結構,包括方法裡的程式碼實現都和T
的型別無關。
Tree
資料結構本身不需要知道如何比較二叉樹節點上型別為T
的變數val
的大小,它有一個成員變數cmp
來實現val
大小的比較,cmp
是一個函式型別變數,在二叉樹初始化的時候被指定。因此二叉樹上節點值的大小比較是Tree
外部的一個函式來實現的,你可以在find
方法的第4行看到對cmp
的使用。
型別引數優先使用在函式而不是方法上
上面的 Tree
資料結構示例闡述了另外一個通用準則:當你需要類似cmp
的比較函式時,優先考慮使用函式而不是方法。
對於上面Tree
型別,除了使用函式型別的成員變數cmp
來比較val
的大小之外,還有另外一種方案是要求型別T
必須有一個Compare
或者Less
方法來做大小比較。要做到這一點,就需要定義一個型別約束(type constraint)用於限定型別T
必須實現這個方法。
這造成的結果是即使T
只是一個普通的int型別,那使用者也必須定義一個自己的int型別,實現型別約束裡的方法(method),然後把這個自定義的int型別作為型別實參傳參給型別引數T
。
但是如果我們參照上面Tree
的程式碼實現,定義一個函式型別的成員變數cmp
用來做T
型別的大小比較,程式碼實現就比較簡潔。
換句話說,把方法轉為函式比給一個型別增加方法容易得多。因此對於通用的資料型別,優先考慮使用函式,而不是寫一個必須有方法的型別限制。
不同型別需要實現公用方法
型別引數另一個有用的場景是不同的型別要實現一些公用方法,並且對於這些方法,不同型別的實現邏輯是一樣的。
下面舉個例子,Go標準庫裡有一個sort包,可以對儲存不同資料型別的slice做排序,比如Float64s(x)
可以對[]float64
做排序,Ints(x)
可以對[]int
做排序。
同時sort包還可以對使用者自定義的資料型別(比如結構體、自定義的int型別等)呼叫sort.Sort()
做排序,只要該型別實現了sort.Interface
這個介面型別裡Len()
、Less()
和Swap()
這3個方法即可。
下面我們對sort包可以使用泛型來做一些改造,就可以對儲存不同資料型別的slice統一呼叫sort.Sort()
來做排序,而不用專門為[]int
呼叫Ints(x)
,為[]float64
呼叫Float64s(x)
做差異化處理了,可以簡化程式碼邏輯。
下面的程式碼實現了一個泛型的結構體型別SliceFn
,這個結構體型別實現了sort.Interface
。
// SliceFn implements sort.Interface for a slice of T.
type SliceFn[T any] struct {
s []T
less func(T, T) bool
}
func (s SliceFn[T]) Len() int {
return len(s.s)
}
func (s SliceFn[T]) Swap(i, j int) {
s.s[i], s.s[j] = s.s[j], s.s[i]
}
func (s SliceFn[T] Less(i, j int) bool {
return s.less(s.s[i], s.s[j])
}
對於不同的slice型別, Len
和 Swap
方法的實現是一樣的。Less
方法需要對slice裡的2個元素做比較,比較邏輯實現在SliceFn
裡的成員變數less
裡頭,less
是一個函式型別的變數,在結構體初始化的時候進行傳參賦值。這點和上面Tree
這個二叉樹通用資料結構的處理類似。
我們再將sort.Sort
按照泛型風格封裝為SortFn
泛型函式,這樣對於所有slice型別,我們都可以統一呼叫SortFn
做排序。
// SortFn sorts s in place using a comparison function.
func SortFn[T any](s []T, less func(T, T) bool) {
sort.Sort(SliceFn[T]{s, cmp})
}
這和標準庫裡的sort.Slice很類似,只不過這裡的less
比較函式的引數是具體的值,而sort.Slice
裡比較函式less
比較函式的引數是slice的下標索引。
這種場景使用型別引數比較合適,因為不同型別的SliceFn
的方法實現邏輯都是一樣的,只是slice
裡儲存的元素的型別不一樣而已。
型別引數何時不要用
現在我們談談型別引數不建議使用的場景。
不要把interface型別替換為型別引數
我們大家都知道Go語言有interface型別,interface支援某種意義上的泛型程式設計。
舉個例子,被廣泛使用的io.Reader
介面提供了一種泛型機制用於讀取資料,比如支援從檔案和隨機數生成器裡讀取資料。
如果你對某些型別的變數的操作只是呼叫該型別的方法,那就直接使用interface型別,不要使用型別引數。io.Reader
從程式碼角度易於閱讀且高效,沒必要使用型別引數。
舉個例子,有人可能會把下面第1個基於interface型別的ReadSome
版本修改為第2個基於型別引數的版本。
func ReadSome(r io.Reader) ([]byte, error)
func ReadSome[T io.Reader](r T) ([]byte, error)
不要做這種修改,使用第1個基於interface的版本會讓函式更容易編寫和閱讀,並且函式執行效率也幾乎一樣。
注意:儘管可以使用不同的方式來實現泛型,並且泛型的實現可能會隨著時間的推移而發生變化,但是Go 1.18中泛型的實現在很多情況下對於型別為interface的變數和型別為型別引數的變數處理非常相似。這意味著使用型別引數通常並不會比使用interface快,所以不要單純為了程式執行速度而把interface型別修改為型別引數,因為它可能並不會執行更快。
如果方法的實現不同,不要使用型別引數
當決定要用型別引數還是interface時,要考慮方法的邏輯實現。正如我們前面說的,如果方法的實現對於所有型別都一樣,那就是用型別引數。相反,如果每個型別的方法實現是不同的,那就是用interface型別,不要用型別引數。
舉個例子,從檔案裡Read
的實現和從隨機數生成器裡Read
的實現完全不一樣,在這種場景下,可以定義一個io.Reader
的interface型別,該型別包含有一個Read
方法。檔案和隨機數生成器實現各自的Read
方法。
在適當的時候可以使用反射(reflection)
Go有 執行期反射。反射機制支援某種意義上的泛型程式設計,因為它允許你編寫適用於任何型別的程式碼。如果某些操作需要支援以下場景,就可以考慮使用反射。
- 操作沒有方法的型別,interface型別不適用。
- 每個型別的操作邏輯不一樣,泛型不適用。
一個例子是encoding/json包的實現。我們並不希望要求我們編碼的每個型別都實現MarshalJson
方法,因此我們不能使用interface型別。而且不同型別編碼的邏輯不一樣,因此我們不應該用泛型。
因此對於這種情況,encoding/json使用了反射來實現。具體實現細節可以參考原始碼。
一個簡單原則
總結一下,何時使用泛型可以簡化為如下的一個簡單原則。
如果你發現重複在寫幾乎完全一樣的程式碼,唯一的區別是程式碼裡使用的型別不一樣,那就要考慮是否可以使用泛型來實現。
開源地址
文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程。
公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。
個人網站:Jincheng's Blog。
知乎:無忌
References
- Go Blog on When to Use Generics: https://go.dev/blog/when-gene...
- Go Day 2021 on Google Open Source : https://www.youtube.com/watch...
- GopherCon 2021: https://www.youtube.com/watch...