Go 部落格
何時使用泛型
引言
這是我在 Google Open Source Live 和 GopherCon 2021 上的演講的部落格版本。
和 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`。請參閱 proposal #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 年結果
上一篇文章:熟悉工作區
部落格索引