『每周譯Go』【Go官方教程】何時使用泛型
1
介紹
這是作者在 Google Open Source Live 和 GopherCon 2021 上演講的博客版本:
https://youtu.be/nr8EpUO9jhw;https://youtu.be/Pa_e9EeCdy8
Go 1.18 版本增加了一個主要的新語言特性:支持泛型編程。在本文中,不會描述什么是泛型,也不會描述如何使用它們。本文將關(guān)注在 Go 編程中何時使用泛型,什么時候不適合使用泛型。
需要明確的是,本文提供的是一般的指導(dǎo)準(zhǔn)則,而不是硬性規(guī)定。是否采用取決于你自己的判斷,但如果你不確定,建議使用這里討論的指導(dǎo)準(zhǔn)則。
2
編寫代碼
讓我們從編寫 Go 的一般準(zhǔn)則開始:通過編寫代碼來編寫 Go 程序,而不是通過定義類型。當(dāng)涉及泛型時,如果你通過定義類型參數(shù)約束來開始編寫程序,則可能走錯了方向。所以,首先應(yīng)編寫函數(shù),然后當(dāng)你清楚地看到可以使用類型參數(shù)時,再添加類型參數(shù)就很容易了。
3
類型參數(shù)什么時候有用?
再說明了上面這一點之后,現(xiàn)在讓我們看看類型參數(shù)在哪些情況下可能有作用。
當(dāng)使用語言自身定義的容器類型
一種情況是,對該語言中定義的特殊容器類型進行操作的函數(shù)(slices, maps, channels),如果函數(shù)具有這些類型的參數(shù),并且函數(shù)代碼沒有對元素類型做出任何特定的假設(shè),那么使用類型參數(shù)可能會很有用。
例如下面這個函數(shù),它返回一個 map 中所有鍵的 slice,且函數(shù)中沒有對 map 中的鍵值對類型做任何約束:
// 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
}
這段代碼沒有假設(shè)任何關(guān)于 map 鍵類型的內(nèi)容,它也根本不依賴 map 值類型。它適用于任何 map 類型,這就使它成為使用類型參數(shù)的好選擇。
對于這類函數(shù),類型參數(shù)的替代方法通常是使用反射,但這是一種更笨拙的編程模型,在構(gòu)建時不會進行靜態(tài)類型檢查,而且通常運行時也更慢。
通用的數(shù)據(jù)結(jié)構(gòu)
類型參數(shù)可能有用的另一種情況是用于通用數(shù)據(jù)結(jié)構(gòu)。這里所說的通用數(shù)據(jù)結(jié)構(gòu)類似于 slice 或者 map,但沒有內(nèi)置在語言中,例如鏈表或二叉樹。
目前,需要此類數(shù)據(jù)結(jié)構(gòu)的程序通常會采用下面兩種方法實現(xiàn):使用特定的元素類型進行編寫,或者使用接口類型。而將特定的元素類型替換為類型參數(shù),可以生成更通用的數(shù)據(jù)結(jié)構(gòu),以用在程序的其他部分或其他程序中。將接口類型替換為類型參數(shù),可以更高效地存儲數(shù)據(jù),節(jié)省內(nèi)存資源;它還意味著代碼可以避免類型斷言,而且在編譯時就進行全面的類型檢查。
例如,使用類型參數(shù)的二叉樹數(shù)據(jù)結(jié)構(gòu),看上去可能是這樣的:
// 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
}
樹中的每個節(jié)點都包含類型參數(shù) T 的值。當(dāng)使用特定的類型參數(shù)將該二叉樹實例化時,該類型實參的值將直接存儲在節(jié)點中,而不會作為接口類型存儲。
這是對類型參數(shù)的合理使用,因為該Tree的數(shù)據(jù)結(jié)構(gòu)甚至方法中的代碼,在很大程度上獨立于元素類型 T。
該Tree的數(shù)據(jù)結(jié)構(gòu)需要知道如何比較元素類型 T 的值;它使用傳入的比較函數(shù)來實現(xiàn)這目的。可以在 find 方法的第四行對 bt.cmp 的調(diào)用中看到這一點。除此之外,類型參數(shù)沒有任何其他作用。
對于類型參數(shù),首選函數(shù)而不是方法
上面Tree的示例說明了另一條準(zhǔn)則:當(dāng)你需要使用比較函數(shù)等功能時,最好選擇函數(shù)而不是方法來實現(xiàn)。
我們可以定義 Tree 類型,該元素類型需要一個 Compare 或 Less 方法。為此可以通過編寫一個需要該方法的約束條件來實現(xiàn),這也意味著用于實例化 Tree 類型的任何類型實參都需要具有該方法。
而結(jié)果是,任何想將 Tree 與 int 這樣的簡單數(shù)據(jù)類型一起使用的人都必須定義自己的 int 類型,并編寫自己的 Compare 方法。如果我們將 Tree 定義可以接受一個比較函數(shù),就像上面看到的示例代碼那樣,那么很容易傳入所需的函數(shù)。且編寫比較函數(shù)就像編寫方法一樣容易。
如果 Tree 的元素類型碰巧已經(jīng)有了一個 Compare 方法,那么我們可以簡單地傳入類似 ElementType.Compare 的方法表達式作為比較函數(shù)。
換句話說,將方法轉(zhuǎn)換為函數(shù)要比向類型中添加方法簡單得多。因此,對于通用數(shù)據(jù)類型,最好使用函數(shù),而不是限制需要編寫一個方法。
實現(xiàn)一個通用方法
類型參數(shù)可能有用的另一種情況是,當(dāng)不同的類型需要實現(xiàn)一些公共方法,而針對各種類型的實現(xiàn)看起來都一樣時。
例如,考慮標(biāo)準(zhǔn)庫的 sort.Interface 接口。它要求一個類型實現(xiàn)三種方法:Len、Swap和 Less 。
下面是一個實現(xiàn) sort.Interface 接口的泛型類型 SliceFn 的示例。該泛型類型適用于任何切片類型:
// 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 方法則需要一個比較函數(shù),也就是 SliceFn 中的 Fn 部分。與前面的 Tree 示例一樣,我們將在創(chuàng)建 SliceFn 時傳入一個這樣的函數(shù)。
下面代碼顯示了如何通過比較函數(shù)來使用 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, cmp})
}
這與標(biāo)準(zhǔn)庫函數(shù) sort.Slice 類似,但是比較函數(shù)是使用值而不是 slice 的索引編寫的。
對這類代碼使用類型參數(shù)是合適的,因為所有切片類型對應(yīng)的方法看起來都完全相同。
(我應(yīng)該提到的是,Go 1.19,而不是 1.18,很可能會包含一個泛型函數(shù),該函數(shù)使用比較函數(shù)對切片進行排序,并且該泛型函數(shù)很可能不會使用 sort.Interface 。見提案#47619。但是,即使這個特定的示例今后很可能沒有用處,其總體觀點仍然是正確的:當(dāng)你需要實現(xiàn)對所有相關(guān)類型看起來都相同的方法時,使用類型參數(shù)是合理的。)
4
類型參數(shù)什么時候沒有用?
現(xiàn)在,讓我們談?wù)剢栴}的另一面:什么時候不使用類型參數(shù)。
不要用類型參數(shù)替換接口類型
眾所周知,Go 具有接口類型,接口類型已經(jīng)可以允許某種泛型編程。
例如,廣泛使用的 io.Reader 接口提供了一種泛型機制,用于從包含信息(如文件)或生成信息(如隨機數(shù)生成器)的任何值中讀取數(shù)據(jù)。對于某個類型的值,如果只需要對該值調(diào)用一個方法,請使用接口類型,而不是類型參數(shù)。io.Reader 接口簡單易讀且高效。從值中讀取數(shù)據(jù)時,比如像調(diào)用 Read 方法,不需要使用類型參數(shù)。
例如,下面的第一個函數(shù)簽名(僅使用接口類型)可能很容易更改為第二個版本(使用類型參數(shù))。
func ReadSome(r io.Reader) ([]byte, error)
func ReadSome[T io.Reader](r T) ([]byte, error)
但不要做這種改變。使用接口類型會使函數(shù)更易于編寫和閱讀,并且執(zhí)行時間可能相同。
最后值得強調(diào)的一點是。雖然可以用幾種不同的方式實現(xiàn)泛型,而且實現(xiàn)會隨著時間的推移而改變和改進。但在 Go 1.18 許多情況下的實現(xiàn)是,對于處理類型為類型參數(shù)的值,就像處理類型為接口類型的值一樣。這意味著使用類型參數(shù)通常不會比使用接口類型更快。所以不要為了速度而從接口類型更改為類型參數(shù),因為它很可能不會運行得更快。
如果方法實現(xiàn)不同,不要使用類型參數(shù)
在決定是否使用類型參數(shù)或接口類型時,請先考慮方法的實現(xiàn)。前面我們說過,如果一個方法的實現(xiàn)對于所有類型都是相同的,那么就使用一個類型參數(shù)。相反,如果每種類型的實現(xiàn)不同,則使用接口類型并編寫不同的方法實現(xiàn),不要使用類型參數(shù)。
例如,從文件中 Read 數(shù)據(jù)的實現(xiàn)與從隨機數(shù)生成器中 Read 的實現(xiàn)完全不同。這意味著我們應(yīng)該編寫兩種不同的讀取方法,并使用 io.Reader 的接口類型。
適當(dāng)?shù)臅r候使用反射
Go 也有運行時反射的功能。反射也能實現(xiàn)某種泛型編程,因為它允許你編寫適用于任何類型的代碼。
如果某些操作必須支持甚至沒有方法的類型,那么接口類型就不起作用。并且如果每個類型的操作不同,那么類型參數(shù)也不合適。這個時候請使用反射。
encoding/json 包就是一個例子。我們不能要求我們編碼的每個類型都支持 MarshalJSON 方法,所以我們不能使用接口類型。但是對接口類型和對結(jié)構(gòu)類型的編碼完全不同,所以我們不應(yīng)該使用類型參數(shù)。因此,標(biāo)準(zhǔn)庫中使用的是反射,代碼并不簡單,但卻很有效。有關(guān)詳細信息,請參閱源代碼.
5
一個簡單的準(zhǔn)則
最后,關(guān)于何時使用泛型的討論可以簡化為一個簡單的準(zhǔn)則。
如果你發(fā)現(xiàn)自己多次編寫完全相同的代碼,其中代碼之間的唯一差異是使用不同的類型,那么考慮是否可以使用類型參數(shù)。
另一種說法是,應(yīng)該避免使用類型參數(shù),直到你注意到自己編寫多次完全相同的代碼。
原文信息
原文地址:https://go.dev/blog/when-generics
原文作者:Ian Lance Taylor(Go Team)
本文永久鏈接:https:/github.com/gocn/translator/blob/master/2022/w16_When_To_Use_Generics.md
譯者:haoheipi
校對:zxmfke
想要了解更多 Golang 相關(guān)的內(nèi)容,歡迎掃描下方?? 關(guān)注 公眾號,回復(fù)關(guān)鍵詞 [實戰(zhàn)群] ,就有機會進群和我們進行交流~
