這是我在 Google Open Source Live 演講的部落格文章版本:
https://youtu.be/nr8EpUO9jhw?si=jlWTapr6NM6isLgt&embedable=true
以及 GopherCon 2021:
https://youtu.be/Pae9EeCdy8?si=M-87Eisb2nU1qmJ&embedable=true
\ 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 這樣的介面類型。


