泛型最佳實踐:Go泛型設計者教你如何用泛型

coding進階發表於2022-04-23

前言

Go泛型的設計者Ian Lance Taylor在官方部落格網站上發表了一篇文章when to use generics,詳細說明了在什麼場景下應該使用泛型,什麼場景下不要使用泛型。這對於我們寫出符合最佳實踐的Go泛型程式碼非常有指導意義。

本人對原文在翻譯的基礎上做了一些表述上的優化,方便大家理解。

原文翻譯

Ian Lance Taylor

2022.04.14

這篇部落格彙總了我在2021年Google開源活動日和GopherCon會議上關於泛型的分享。

Go 1.18版本新增了一個重大功能:支援泛型程式設計。本文不會介紹什麼是泛型以及如何使用泛型,而是把重點放在講解Go程式設計實踐中,什麼時候應該使用泛型,什麼時候不要使用泛型。

需要明確的是,我將會提供一些通用的指引,這並不是硬性規定,大家可以根據自己的判斷來決定,但是如果你不確定如何使用泛型,那建議參考本文介紹的指引。

寫程式碼

Go程式設計有一條通用準則:write Go programs by writing code, not by defining types.

具體到泛型,如果你寫程式碼的時候從定義型別引數約束(type parameter constraints)開始,那你可能搞錯了方向。從編寫函式開始,如果寫的過程中發現使用型別引數更好,那再使用型別引數。

型別引數何時有用?

接下來我們看看在什麼情況下,使用型別引數對我們寫程式碼更有用。

使用Go內建的容器型別

如果函式使用了語言內建的容器型別(包括slice, map和channel)作為函式引數,並且函式程式碼對容器的處理邏輯並沒有預設容器裡的元素型別,那使用型別引數(type parameter)可能就會有用。

舉個例子,我們要實現一個函式,該函式的入參是一個map,要返回該map的所有key組成的slice,key的型別可以是map支援的任意key型別。

// 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
}

這段程式碼沒有對map裡key的型別做任何限定,並且沒有用map裡的value,因此這段程式碼適用於所有的map型別。這就是使用型別引數的一個很好的示例。

這種場景下,也可以使用反射(reflection),但是反射是一種比較彆扭的程式設計模型,在編譯期沒法做靜態型別檢查,並且會導致執行期的速度變慢。

實現通用的資料結構

對於通用的資料結構,型別引數也會有用。通用的資料結構類似於slice和map,但是並不是語言內建的資料結構,比如連結串列或者二叉樹。

在沒有泛型的時候,如果要實現通用的資料結構,有2種方案:

  • 方案1:針對每個元素型別分別實現一個資料結構
  • 方案2:使用interface型別

泛型相對方案1的優點是程式碼更精簡,也更方便給其它模組呼叫。泛型相對方案2的優點是資料儲存更高效,節約記憶體資源,並且可以在編譯期做靜態型別檢查,避免程式碼裡使用型別斷言。

下面的例子就是使用型別引數實現的通用二叉樹資料結構:

// 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的變數val。當二叉樹例項化的時候,需要傳入型別實參,這個時候val的型別已經確定下來了,不會被存為interface型別。

這種場景使用型別引數是合理的,因為Tree是個通用的資料結構,包括方法裡的程式碼實現都和T的型別無關。

Tree資料結構本身不需要知道如何比較二叉樹節點上型別為T的變數val的大小,它有一個成員變數cmp來實現val大小的比較,cmp是一個函式型別變數,在二叉樹初始化的時候被指定。因此二叉樹上節點值的大小比較是Tree外部的一個函式來實現的,你可以在find方法的第4行看到對cmp的使用。

型別引數優先使用在函式而不是方法上

上面的 Tree資料結構示例闡述了另外一個通用準則:當你需要類似cmp的比較函式時,優先考慮使用函式而不是方法。

對於上面Tree型別,除了使用函式型別的成員變數cmp來比較val的大小之外,還有另外一種方案是要求型別T必須有一個Compare或者Less方法來做大小比較。要做到這一點,就需要定義一個型別約束(type constraint)用於限定型別T必須實現這個方法。

這造成的結果是即使T只是一個普通的int型別,那使用者也必須定義一個自己的int型別,實現型別約束裡的方法(method),然後把這個自定義的int型別作為型別實參傳參給型別引數T

但是如果我們參照上面Tree的程式碼實現,定義一個函式型別的成員變數cmp用來做T型別的大小比較,程式碼實現就比較簡潔。

換句話說,把方法轉為函式比給一個型別增加方法容易得多。因此對於通用的資料型別,優先考慮使用函式,而不是寫一個必須有方法的型別限制。

不同型別需要實現公用方法

型別引數另一個有用的場景是不同的型別要實現一些公用方法,並且對於這些方法,不同型別的實現邏輯是一樣的。

下面舉個例子,Go標準庫裡有一個sort包,可以對儲存不同資料型別的slice做排序,比如Float64s(x)可以對[]float64做排序,Ints(x)可以對[]int做排序。

同時sort包還可以對使用者自定義的資料型別(比如結構體、自定義的int型別等)呼叫sort.Sort()做排序,只要該型別實現了sort.Interface這個介面型別裡Len()Less()Swap()這3個方法即可。

下面我們對sort包可以使用泛型來做一些改造,就可以對儲存不同資料型別的slice統一呼叫sort.Sort()來做排序,而不用專門為[]int呼叫Ints(x),為[]float64呼叫Float64s(x)做差異化處理了,可以簡化程式碼邏輯。

下面的程式碼實現了一個泛型的結構體型別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])
}

對於不同的slice型別, LenSwap 方法的實現是一樣的。Less 方法需要對slice裡的2個元素做比較,比較邏輯實現在SliceFn裡的成員變數less裡頭,less是一個函式型別的變數,在結構體初始化的時候進行傳參賦值。這點和上面Tree這個二叉樹通用資料結構的處理類似。

我們再將sort.Sort按照泛型風格封裝為SortFn泛型函式,這樣對於所有slice型別,我們都可以統一呼叫SortFn做排序。

// 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})
}

這和標準庫裡的sort.Slice很類似,只不過這裡的less比較函式的引數是具體的值,而sort.Slice裡比較函式less比較函式的引數是slice的下標索引。

這種場景使用型別引數比較合適,因為不同型別的SliceFn的方法實現邏輯都是一樣的,只是slice裡儲存的元素的型別不一樣而已。

型別引數何時不要用

現在我們談談型別引數不建議使用的場景。

不要把interface型別替換為型別引數

我們大家都知道Go語言有interface型別,interface支援某種意義上的泛型程式設計。

舉個例子,被廣泛使用的io.Reader介面提供了一種泛型機制用於讀取資料,比如支援從檔案和隨機數生成器裡讀取資料。

如果你對某些型別的變數的操作只是呼叫該型別的方法,那就直接使用interface型別,不要使用型別引數。io.Reader從程式碼角度易於閱讀且高效,沒必要使用型別引數。

舉個例子,有人可能會把下面第1個基於interface型別的ReadSome版本修改為第2個基於型別引數的版本。

func ReadSome(r io.Reader) ([]byte, error)

func ReadSome[T io.Reader](r T) ([]byte, error)

不要做這種修改,使用第1個基於interface的版本會讓函式更容易編寫和閱讀,並且函式執行效率也幾乎一樣。

注意:儘管可以使用不同的方式來實現泛型,並且泛型的實現可能會隨著時間的推移而發生變化,但是Go 1.18中泛型的實現在很多情況下對於型別為interface的變數和型別為型別引數的變數處理非常相似。這意味著使用型別引數通常並不會比使用interface快,所以不要單純為了程式執行速度而把interface型別修改為型別引數,因為它可能並不會執行更快。

如果方法的實現不同,不要使用型別引數

當決定要用型別引數還是interface時,要考慮方法的邏輯實現。正如我們前面說的,如果方法的實現對於所有型別都一樣,那就是用型別引數。相反,如果每個型別的方法實現是不同的,那就是用interface型別,不要用型別引數。

舉個例子,從檔案裡Read的實現和從隨機數生成器裡Read的實現完全不一樣,在這種場景下,可以定義一個io.Reader的interface型別,該型別包含有一個Read方法。檔案和隨機數生成器實現各自的Read方法。

在適當的時候可以使用反射(reflection)

Go有 執行期反射。反射機制支援某種意義上的泛型程式設計,因為它允許你編寫適用於任何型別的程式碼。如果某些操作需要支援以下場景,就可以考慮使用反射。

  • 操作沒有方法的型別,interface型別不適用。
  • 每個型別的操作邏輯不一樣,泛型不適用。

一個例子是encoding/json包的實現。我們並不希望要求我們編碼的每個型別都實現MarshalJson方法,因此我們不能使用interface型別。而且不同型別編碼的邏輯不一樣,因此我們不應該用泛型。

因此對於這種情況,encoding/json使用了反射來實現。具體實現細節可以參考原始碼

一個簡單原則

總結一下,何時使用泛型可以簡化為如下的一個簡單原則。

如果你發現重複在寫幾乎完全一樣的程式碼,唯一的區別是程式碼裡使用的型別不一樣,那就要考慮是否可以使用泛型來實現。

開源地址

文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程

公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。

個人網站:Jincheng's Blog

知乎:無忌

References

相關文章