Go 部落格
泛型介面
有一個想法,在你第一次聽到它之前可能並不明顯:由於介面本身就是型別,它們也可以擁有型別引數。事實證明,在表達泛型函式和型別的約束時,這個想法出奇地強大。在這篇文章中,我們將透過討論在一些常見場景中使用帶有型別引數的介面來演示這一點。
一個簡單的樹集合
作為激勵性示例,假設我們需要一個二叉搜尋樹的泛型版本。儲存在此類樹中的元素需要是有序的,因此我們的型別引數需要一個約束來確定要使用的排序。一個簡單的選擇是使用 Go 1.21 中引入的 cmp.Ordered 約束。它將型別引數限制為有序型別(字串和數字),並允許型別的方法使用內建的排序運算子。
// The zero value of a Tree is a ready-to-use empty tree.
type Tree[E cmp.Ordered] struct {
root *node[E]
}
func (t *Tree[E]) Insert(element E) {
t.root = t.root.insert(element)
}
type node[E cmp.Ordered] struct {
value E
left *node[E]
right *node[E]
}
func (n *node[E]) insert(element E) *node[E] {
if n == nil {
return &node[E]{value: element}
}
switch {
case element < n.value:
n.left = n.left.insert(element)
case element > n.value:
n.right = n.right.insert(element)
}
return n
}
(演練)
然而,這種方法有一個缺點,即它只適用於定義了 <
的基本型別;你不能插入像 time.Time 這樣的結構體型別。
我們可以透過要求使用者提供一個比較函式來彌補這一點
// A FuncTree must be created with NewTreeFunc.
type FuncTree[E any] struct {
root *funcNode[E]
cmp func(E, E) int
}
func NewFuncTree[E any](cmp func(E, E) int) *FuncTree[E] {
return &FuncTree[E]{cmp: cmp}
}
func (t *FuncTree[E]) Insert(element E) {
t.root = t.root.insert(t.cmp, element)
}
type funcNode[E any] struct {
value E
left *funcNode[E]
right *funcNode[E]
}
func (n *funcNode[E]) insert(cmp func(E, E) int, element E) *funcNode[E] {
if n == nil {
return &funcNode[E]{value: element}
}
sign := cmp(element, n.value)
switch {
case sign < 0:
n.left = n.left.insert(cmp, element)
case sign > 0:
n.right = n.right.insert(cmp, element)
}
return n
}
(演練)
這可行,但也伴隨著缺點。我們不能再使用容器型別的零值,因為它需要有一個顯式初始化的比較函式。而且函式欄位的使用使得編譯器更難內聯比較呼叫,這可能會引入顯著的執行時開銷。
在元素型別上使用方法可以解決這些問題,因為方法直接與型別關聯。方法不必顯式傳遞,編譯器可以看到呼叫的目標,並且可能能夠內聯它。但是我們如何表達約束以要求元素型別提供必要的方法呢?
在約束中使用接收者
我們可能嘗試的第一種方法是定義一個帶有 Compare
方法的普通舊介面
type Comparer interface {
Compare(Comparer) int
}
然而,我們很快意識到這效果不佳。要實現此介面,方法的引數本身必須是 Comparer
。這不僅意味著此方法的實現必須將其引數型別斷言為其自己的型別,而且還要求每個型別都必須顯式地按名稱引用我們的帶有 Comparer
型別的包(否則方法簽名將不相同)。這不是很正交。
更好的方法是使 Comparer
介面本身泛型化
type Comparer[T any] interface {
Compare(T) int
}
這個 Comparer
現在描述了一整個介面家族,每個 Comparer
可能例項化的型別都有一個。一個實現了 Comparer[T]
的型別宣告“我可以與一個 T
進行比較”。例如,time.Time
自然地實現了 Comparer[time.Time]
,因為它有一個匹配的 Compare
方法
// Implements Comparer[Time]
func (t Time) Compare(u Time) int
這更好,但還不夠。我們真正想要的是一個約束,它表示型別引數可以與自身進行比較:我們希望約束是自引用的。微妙的洞察是,自引用方面不必是介面定義本身的一部分;具體來說,Comparer
型別中 T
的約束只是 any
。相反,它是我們如何將 Comparer
用作 MethodTree
的型別引數的約束的結果
// The zero value of a MethodTree is a ready-to-use empty tree.
type MethodTree[E Comparer[E]] struct {
root *methodNode[E]
}
func (t *MethodTree[E]) Insert(element E) {
t.root = t.root.insert(element)
}
type methodNode[E Comparer[E]] struct {
value E
left *methodNode[E]
right *methodNode[E]
}
func (n *methodNode[E]) insert(element E) *methodNode[E] {
if n == nil {
return &methodNode[E]{value: element}
}
sign := element.Compare(n.value)
switch {
case sign < 0:
n.left = n.left.insert(element)
case sign > 0:
n.right = n.right.insert(element)
}
return n
}
(演練)
因為 time.Time
實現了 Comparer[time.Time]
,所以它現在是這個容器的有效型別實參,我們仍然可以使用零值作為空容器
var t MethodTree[time.Time]
t.Insert(time.Now())
為了獲得完全的靈活性,庫可以提供所有三個 API 版本。如果我們要最大限度地減少重複,所有版本都可以使用共享實現。我們可以為此使用函式版本,因為它最通用
type node[E any] struct {
value E
left *node[E]
right *node[E]
}
func (n *node[E]) insert(cmp func(E, E) int, element E) *node[E] {
if n == nil {
return &node[E]{value: element}
}
sign := cmp(element, n.value)
switch {
case sign < 0:
n.left = n.left.insert(cmp, element)
case sign > 0:
n.right = n.right.insert(cmp, element)
}
return n
}
// Insert inserts element into the tree, if E implements cmp.Ordered.
func (t *Tree[E]) Insert(element E) {
t.root = t.root.insert(cmp.Compare[E], element)
}
// Insert inserts element into the tree, using the provided comparison function.
func (t *FuncTree[E]) Insert(element E) {
t.root = t.root.insert(t.cmp, element)
}
// Insert inserts element into the tree, if E implements Comparer[E].
func (t *MethodTree[E]) Insert(element E) {
t.root = t.root.insert(E.Compare, element)
}
(演練)
這裡的一個重要觀察是,共享實現(基於函式的變體)沒有任何限制。它必須保持最大限度的靈活性以作為公共核心。我們也不會將比較函式儲存在結構欄位中。相反,我們將其作為引數傳遞,因為函式引數比結構欄位更容易讓編譯器分析。
當然,仍然存在一定量的樣板程式碼。所有匯出的實現都需要使用略有不同的呼叫模式來複制完整的 API。但這一部分編寫和閱讀起來都很簡單。
組合方法和型別集
我們可以使用我們的新樹資料結構來實現一個有序集合,以對數時間提供元素查詢。現在讓我們想象一下我們需要使查詢以常數時間執行;我們可能會嘗試透過在樹旁邊維護一個普通的 Go map 來實現這一點
type OrderedSet[E Comparer[E]] struct {
tree MethodTree[E] // for efficient iteration in order
elements map[E]bool // for (near) constant time lookup
}
func (s *OrderedSet[E]) Has(e E) bool {
return s.elements[e]
}
func (s *OrderedSet[E]) Insert(e E) {
if s.elements == nil {
s.elements = make(map[E]bool)
}
if s.elements[e] {
return
}
s.elements[e] = true
s.tree.Insert(e)
}
func (s *OrderedSet[E]) All() iter.Seq[E] {
return func(yield func(E) bool) {
s.tree.root.all(yield)
}
}
func (n *node[E]) all(yield func(E) bool) bool {
return n == nil || (n.left.all(yield) && yield(n.value) && n.right.all(yield))
}
(演練)
然而,編譯此程式碼將產生錯誤
無效的 map 鍵型別 E(缺少 comparable 約束)
錯誤訊息告訴我們,我們需要進一步約束我們的型別引數才能將其用作 map 鍵。`comparable` 約束是一個特殊的預宣告約束,所有定義了相等運算子 `==` 和 `!=` 的型別都滿足它。在 Go 中,這也是可以作為內建 `map` 型別的鍵的型別集。
我們有三種選擇來將此約束新增到我們的型別引數中,所有這些都有不同的權衡
-
我們可以將 `comparable` 嵌入到我們原始的 `Comparer` 定義中(演練)
type Comparer[E any] interface { comparable Compare(E) int }
這有一個缺點,它還會使我們的 `Tree` 型別只能用於 `comparable` 的型別。通常,我們不希望不必要地限制泛型型別。
-
我們可以新增一個新的約束定義(演練)。
type Comparer[E any] interface { Compare(E) int } type ComparableComparer[E any] interface { comparable Comparer[E] }
這很整潔,但它在我們的 API 中引入了一個新的識別符號(`ComparableComparer`),而命名是困難的。
-
我們可以將約束內聯到我們的受更多約束的型別中(演練)
type OrderedSet[E interface { comparable Comparer[E] }] struct { tree Tree[E] elements map[E]struct{} }
這可能會變得有點難以閱讀,特別是如果它需要經常發生。它也使得在其他地方重用約束變得更加困難。
使用哪種方法是一種風格選擇,最終取決於個人偏好。
(不)約束泛型介面
此時值得討論泛型介面的約束。您可能想為一個泛型容器型別定義一個介面。例如,假設您有一個演算法需要一個集合資料結構。有許多不同種類的集合實現,具有不同的權衡。為所需的集合操作定義一個介面可以增加您的包的靈活性,將適用於特定應用程式的權衡決策留給使用者
type Set[E any] interface {
Insert(E)
Delete(E)
Has(E) bool
All() iter.Seq[E]
}
這裡一個自然的問題是,這個介面應該有什麼約束。如果可能的話,泛型介面上的型別引數應該使用 `any` 作為約束,允許任意型別。
從我們上面的討論來看,原因應該很清楚:不同的具體實現可能需要不同的約束。我們上面檢查過的所有 `Tree` 型別,以及 `OrderedSet` 型別,都可以為其元素型別實現 `Set`,儘管這些型別有不同的約束。
定義介面的目的是將實現留給使用者。由於無法預測使用者可能希望對其實現施加什麼樣的約束,因此請儘量將任何約束(強於 `any` 的)留給具體實現,而不是介面。
指標接收者
讓我們嘗試在一個例子中使用 `Set` 介面。考慮一個從序列中刪除重複元素的函式
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E comparable](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
seen := make(map[E]bool)
for v := range input {
if seen[v] {
continue
}
if !yield(v) {
return
}
seen[v] = true
}
}
}
(演練)
這使用 `map[E]bool` 作為 `E` 元素的簡單集合。因此,它只適用於 `comparable` 型別,這些型別定義了內建的相等運算子。如果我們要將其推廣到任意型別,我們需要用一個泛型集合替換它
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E any](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
var seen Set[E]
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
(演練)
然而,這不起作用。`Set[E]` 是一個介面型別,`seen` 變數將被初始化為 `nil`。我們需要使用 `Set[E]` 介面的具體實現。但是正如我們在本文中看到的那樣,沒有通用的集合實現適用於 `any` 元素型別。
我們必須要求使用者提供一個我們可以使用的具體實現,作為額外的型別引數
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E any, S Set[E]](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
var seen S
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
(演練)
然而,如果我們將它與我們的集合實現一起例項化,我們就會遇到另一個問題
// OrderedSet[E] does not satisfy Set[E] (method All has pointer receiver)
Unique[E, OrderedSet[E]](slices.Values(s))
// panic: invalid memory address or nil pointer dereference
Unique[E, *OrderedSet[E]](slices.Values(s))
第一個問題從錯誤訊息中就很清楚:我們的型別約束表明 `S` 的型別實參需要實現 `Set[E]` 介面。由於 `OrderedSet` 上的方法使用指標接收者,因此型別實參也必須是指標型別。
當嘗試這樣做時,我們遇到了第二個問題。這源於我們在實現中聲明瞭一個變數的事實
var seen S
如果 `S` 是 `*OrderedSet[E]`,則變數將像以前一樣用 `nil` 初始化。呼叫 `seen.Insert` 會導致 panic。
如果我們只有指標型別,我們就無法獲得值型別的有效變數。如果我們只有值型別,我們就無法呼叫它的指標方法。結果是我們需要值和指標型別。因此我們必須引入一個額外的型別引數 `PS` 和一個新的約束 `PtrToSet`
// PtrToSet is implemented by a pointer type implementing the Set[E] interface.
type PtrToSet[S, E any] interface {
*S
Set[E]
}
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E, S any, PS PtrToSet[S, E]](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
// We convert to PS, as only that is constrained to have the methods.
// The conversion is allowed, because the type set of PS only contains *S.
seen := PS(new(S))
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
(演練)
這裡的訣竅是透過 `PtrToSet` 介面上的額外型別引數在函式簽名中連線兩個型別引數。`S` 本身沒有約束,但 `PS` 必須是型別 `*S`,並且它必須具有我們需要的方法。所以實際上,我們限制 `S` 具有一些方法,但這些方法需要使用指標接收者。
雖然用這種約束定義函式需要一個額外的型別引數,但重要的是這並不會使使用它的程式碼複雜化:只要這個額外的型別引數位於型別引數列表的末尾,它就可以被推斷出來
// The third type argument is inferred to be *OrderedSet[int]
Unique[int, OrderedSet[int]](slices.Values(s))
這是一個通用模式,值得記住:當你在別人的作品中遇到它,或者當你想在自己的作品中使用它時。
func SomeFunction[T any, PT interface{ *T; SomeMethods }]()
如果您有兩個型別引數,其中一個被約束為指向另一個的指標,則該約束確保相關方法使用指標接收者。
您應該約束為指標接收者嗎?
此時,您可能會感到非常不知所措。這相當複雜,期望每個 Go 程式設計師都能理解這個函式簽名中發生了什麼似乎是不合理的。我們還不得不在我們的 API 中引入更多的名稱。當人們最初警告不要向 Go 新增泛型時,這就是他們擔心的事情之一。
所以,如果您發現自己陷入了這些問題,值得退一步思考。我們通常可以透過以不同的方式思考我們的問題來避免這種複雜性。在這個例子中,我們構建了一個函式,它接受一個 `iter.Seq[E]` 並返回一個帶有唯一元素的 `iter.Seq[E]`。但要進行去重,我們需要將唯一元素收集到一個集合中。由於這要求我們為整個結果分配空間,所以將結果表示為流並不能真正受益。
如果我們重新思考這個問題,我們可以透過使用 `Set[E]` 作為常規介面值來完全避免額外的型別引數
// InsertAll adds all unique elements from seq into set.
func InsertAll[E any](set Set[E], seq iter.Seq[E]) {
for v := range seq {
set.Insert(v)
}
}
(演練)
透過將 `Set` 用作普通的介面型別,很明顯呼叫者必須提供其具體實現的有效值。這是一種非常常見的模式。如果他們需要 `iter.Seq[E]`,他們只需呼叫 `set` 上的 `All()` 即可獲取一個。
這會稍微使呼叫者的事情複雜化,但它比對指標接收器的約束有另一個優勢:請記住,我們最初使用 `map[E]bool` 作為簡單的集合型別。在此基礎上很容易實現 `Set[E]` 介面
type HashSet[E comparable] map[E]bool
func (s HashSet[E]) Insert(v E) { s[v] = true }
func (s HashSet[E]) Delete(v E) { delete(s, v) }
func (s HashSet[E]) Has(v E) bool { return s[v] }
func (s HashSet[E]) All() iter.Seq[E] { return maps.Keys(s) }
(演練)
此實現不使用指標接收器。因此,儘管這完全有效,但它無法與複雜的指標接收器約束一起使用。但它適用於我們的 `InsertAll` 版本。與許多約束一樣,強制方法使用指標接收器對於許多實際用例來說可能過於嚴格。
結論
我希望這能說明介面上的型別引數所帶來的一些模式和權衡。它是一個強大的工具,但也伴隨著成本。主要收穫是
- 使用泛型介面透過自引用方式表達對接收者的約束。
- 使用它們在不同的型別引數之間建立受約束的關係。
- 使用它們抽象不同實現和不同型別約束。
- 當您發現自己需要約束為指標接收器時,請考慮是否可以重構程式碼以避免額外的複雜性。請參閱“您應該約束為指標接收器嗎?”。
一如既往,不要過度設計:一個靈活性較低但更簡單、更易讀的解決方案最終可能是更明智的選擇。
下一篇文章:FIPS 140-3 Go 密碼模組
上一篇文章:[ 有 | 無 ] 錯誤處理的語法支援
部落格索引