Go 部落格

何時使用泛型

Ian Lance Taylor
2022 年 4 月 12 日

簡介

這是我在 Google Open Source Live

和 GopherCon 2021

演講的部落格文章版本

為了清楚說明,我會提供一般準則,而不是嚴格的規定。請自行判斷。但是,如果您不確定,我建議使用此處討論的準則。

撰寫程式碼

讓我們從 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 型別,讓元素型別需要有 CompareLess 方法。方法是撰寫需要方法的約束,表示用於實例化 Tree 型別的任何型別引數都需要有該方法。

後果是任何想要使用 Tree 和簡單資料型別(例如 int)的人,都必須定義自己的整數型別並撰寫自己的比較方法。如果我們定義 Tree 以接受比較函數,如上方的程式碼所示,便可以輕鬆傳入所需的函數。撰寫該比較函數就像撰寫方法一樣容易。

如果 Tree 元素型別剛好已經有 Compare 方法,我們可以用方法表示式(例如 ElementType.Compare)當作比較函數。

換句話說,將方法轉換為函數要比將方法新增至型別的動作簡單許多。因此,對於一般用途的資料型別,請優先使用函數,而不是撰寫需要方法的約束。

實作共用方法

另外一個型別參數有用的情況,是在不同型別需要實作某個共用方法時,而且不同型別的實作看起來都相同。

例如,考慮標準程式庫中的 sort.Interface。它需要型別實作三個方法:LenSwapLess

以下是 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])
}

對於任何片段型別,LenSwap 方法完全相同。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 方法,因此無法使用介面類型。但是,編碼介面類型與編碼結構類型截然不同,因此不應使用類型參數。相反地,這個套件使用反射機制。程式碼並不容易,但會執行。有關詳細資訊,請參閱 原始程式碼

一個簡單的準則

在討論何時使用泛型時,最後歸結為一個簡單的準則。

假設您發現自己多次撰寫完全相同的程式碼,而副本之間唯一的差異在於程式碼使用不同的類型,請思考您是否可以使用類型參數。

用另一種方式來說,在您發現即將多次撰寫完全相同的程式碼之前,您應該避免類型參數。

下一篇文章:2021 年 Go 開發人員調查結果
上一篇文章:熟悉工作區
部落格索引