Go 部落格
何時使用泛型
引言
這是我在 Google 開源直播會議上的演講的部落格文章版本
以及 GopherCon 2021 會議
Go 1.18 版本增加了一個主要的新語言特性:支援泛型程式設計。在本文中,我不會描述泛型是什麼或如何使用它們。本文是關於何時在 Go 程式碼中使用泛型以及何時不使用泛型。
需要明確的是,我將提供一般性指導,而不是死板的規則。請自行判斷。但如果您不確定,我建議遵循此處討論的指導。
編寫程式碼
讓我們從 Go 程式設計的一般性指導開始:透過編寫程式碼來編寫 Go 程式,而不是透過定義型別。說到泛型,如果您透過定義型別引數約束來開始編寫程式,您很可能走錯了路。從編寫函式開始。當型別引數的用途明確時,稍後再新增它們很容易。
型別引數何時有用?
話雖如此,讓我們看看型別引數可能有用的情況。
使用語言定義的容器型別時
一種情況是編寫操作語言定義的特殊容器型別(切片、對映和通道)的函式時。如果一個函式的引數具有這些型別,並且函式程式碼對元素型別沒有任何特定假設,那麼使用型別引數可能很有用。
例如,這裡有一個函式,它返回任何型別對映中的所有鍵的切片
// 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
}
這段程式碼對對映鍵型別沒有任何假設,並且完全不使用對映值型別。它適用於任何對映型別。這使得它成為使用型別引數的良好候選。
對於這類函式,型別引數的替代方案通常是使用反射,但這是一種更笨拙的程式設計模型,在構建時不會進行靜態型別檢查,並且在執行時通常更慢。
通用資料結構
型別引數可能有用的另一種情況是通用資料結構。通用資料結構類似於切片或對映,但不是語言內建的,例如連結串列或二叉樹。
今天,需要這種資料結構的程式通常會做兩件事之一:用特定的元素型別編寫它們,或使用介面型別。用型別引數替換特定元素型別可以生成更通用的資料結構,可在程式的其他部分或由其他程式使用。用型別引數替換介面型別可以更有效地儲存資料,節省記憶體資源;它還可以使程式碼避免型別斷言,並在構建時進行完全型別檢查。
例如,這裡是用型別引數實現二叉樹資料結構的一部分可能的樣子
// 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
的值。當樹用特定的型別實參例項化時,該型別的值將直接儲存在節點中。它們不會儲存為介面型別。
這是型別引數的合理用法,因為 Tree
資料結構,包括方法中的程式碼,很大程度上獨立於元素型別 T
。
Tree
資料結構確實需要知道如何比較元素型別 T
的值;它為此使用傳入的比較函式。您可以在 find
方法的第四行,在呼叫 bt.cmp
中看到這一點。除此之外,型別引數根本不重要。
對於型別引數,優先使用函式而非方法
Tree
示例說明了另一個一般性指導:當您需要比較函式之類的東西時,優先選擇函式而非方法。
我們可以定義 Tree
型別,使其元素型別必須具有 Compare
或 Less
方法。這將透過編寫一個要求該方法的約束來實現,這意味著任何用於例項化 Tree
型別的型別實參都需要具有該方法。
結果是,任何想將 Tree
與 int
等簡單資料型別一起使用的人都必須定義自己的整數型別並編寫自己的比較方法。如果我們將 Tree
定義為接受一個比較函式,如上面顯示的程式碼所示,那麼很容易傳入所需的函式。編寫那個比較函式就像編寫一個方法一樣容易。
如果 Tree
元素型別恰好已經有一個 Compare
方法,那麼我們可以簡單地使用像 ElementType.Compare
這樣的方法表示式作為比較函式。
換句話說,將方法轉換為函式比向型別新增方法簡單得多。因此,對於通用資料型別,優先選擇函式,而不是編寫要求方法的約束。
實現一個通用方法
型別引數可能有用的另一種情況是,當不同型別需要實現某個通用方法,並且不同型別的實現看起來完全相同時。
例如,考慮標準庫的 sort.Interface
。它要求一個型別實現三個方法:Len
、Swap
和 Less
。
這裡是一個泛型型別 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])
}
對於任何切片型別,Len
和 Swap
方法是完全相同的。Less
方法需要一個比較,這是 SliceFn
名稱的 Fn
部分。與前面的 Tree
示例一樣,我們在建立 SliceFn
時將傳入一個函式。
這裡是如何使用 SliceFn
透過比較函式對任何切片進行排序
// 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, less})
}
這類似於標準庫函式 sort.Slice
,但比較函式是使用值而不是切片索引編寫的。
對這類程式碼使用型別引數是合適的,因為所有切片型別的方法看起來完全相同。
(我應該提到,Go 1.19——而不是 1.18——很可能會包含一個泛型函式,用於使用比較函式對切片進行排序,並且該泛型函式很可能不使用 sort.Interface
。請參閱提案 #47619。但即使這個具體示例很可能沒有用,一般性的觀點仍然是正確的:當您需要實現對於所有相關型別看起來相同的方法時,使用型別引數是合理的。)
型別引數何時無用?
現在讓我們談談問題的另一面:何時不使用型別引數。
不要用型別引數替換介面型別
眾所周知,Go 有介面型別。介面型別允許一種泛型程式設計。
例如,廣泛使用的 io.Reader
介面提供了一種通用機制,用於從任何包含資訊(例如檔案)或產生資訊(例如隨機數生成器)的值讀取資料。如果您只需要對某種型別的值執行的操作是呼叫該值上的方法,請使用介面型別,而不是型別引數。io.Reader
易於閱讀、高效且有效。無需使用型別引數透過呼叫 Read
方法從值中讀取資料。
例如,將這裡的第一個函式簽名(只使用介面型別)更改為第二個版本(使用型別引數)可能很誘人。
func ReadSome(r io.Reader) ([]byte, error)
func ReadSome[T io.Reader](r T) ([]byte, error)
不要進行這種更改。省略型別引數會使函式更容易編寫、更容易閱讀,並且執行時間很可能相同。
值得強調最後一點。雖然可以通過幾種不同的方式實現泛型,並且實現會隨著時間而改變和改進,但在 Go 1.18 中使用的實現在許多情況下會將型別為型別引數的值與型別為介面型別的值非常相似地處理。這意味著使用型別引數通常不會比使用介面型別更快。因此,不要僅僅為了速度而從介面型別改為型別引數,因為它可能不會執行得更快。
如果方法實現不同,請勿使用型別引數
在決定是使用型別引數還是介面型別時,請考慮方法的實現。前面我們說過,如果方法的實現對於所有型別都是相同的,則使用型別引數。反之,如果實現對於每種型別都不同,則使用介面型別並編寫不同的方法實現,不要使用型別引數。
例如,從檔案讀取 Read
的實現與從隨機數生成器讀取 Read
的實現完全不同。這意味著我們應該編寫兩個不同的 Read
方法,並使用像 io.Reader
這樣的介面型別。
在適當的地方使用反射
Go 有 執行時反射。反射允許一種泛型程式設計,因為它允許您編寫適用於任何型別的程式碼。
如果某些操作必須支援即使沒有方法的型別(因此介面型別無濟於事),並且如果操作對於每種型別都不同(因此型別引數不合適),請使用反射。
一個例子是 encoding/json 包。我們不希望要求我們編碼的每個型別都有一個 MarshalJSON
方法,所以我們不能使用介面型別。但是編碼介面型別與編碼結構型別完全不同,所以我們不應該使用型別引數。相反,該包使用反射。程式碼不簡單,但它有效。有關詳細資訊,請參閱 原始碼。
一個簡單指導
最後,何時使用泛型的討論可以歸結為一個簡單的指導。
如果您發現自己多次編寫完全相同的程式碼,而副本之間的唯一區別在於程式碼使用了不同的型別,請考慮是否可以使用型別引數。
另一種說法是,您應該避免使用型別引數,直到您注意到自己即將多次編寫完全相同的程式碼。
下一篇文章:Go 開發者調查 2021 結果
上一篇文章:熟悉工作區
部落格索引