博客 / 詳情

返回

三分鐘, 讓你學會 Go 泛型

Go 自從 1.18 版本正式推出泛型之後至今也超過半年了,但是筆者發現在實際業務開發中,大家沒有如想象中那麼廣泛地使用泛型。於是決定簡單撰一文,儘可能簡單地講解 Go 的泛型代碼的寫法。

Go 泛型的作用

Go 語言在推出之後,要求支持泛型的呼聲就一直不絕於耳。Go 在 1.17 版實驗性地推出,並且在 1.18 正式發佈。泛型要解決的問題以及適用的場景是所謂的 ”DRY“(Don't Repeat Yourself),也就是説,一份代碼,請不要重複寫多遍,系統中的每一部份的實現都應該只有一份代碼。

下面我會給幾個例子,説明 Go 泛型應該如何使用以達到 DRY 原則。


泛型函數

實現一個泛型函數

我先給出一個最簡單的實現:將任意類型轉換為 JSON 格式的 string 並輸出:

func ToJSON[T any](v T) string {
    b, _ := json.Marshal(v)
    return string(b)
}

相比標準的 Go 泛型代碼,上面的這個函數多了一些奇怪的參數:

  • [T any]: 函數的名字前面多了這一段,這段是使用中括號包圍起來的。這一段就是 Go 的泛型聲明。我們需要注意的是,與 C++ 的泛型使用尖括號 <> 包圍不同,Go 泛型的聲明是使用中括號 [] 包圍的

    • T: 表示在後面的函數中,需要使用一個泛型類型,在代碼中,開發者將這個類型命名為 “T”。當然你想命名為其他名字也可以,並且大小寫目前暫時沒有強制的約束。只是為了便於區分,大家習慣性用大寫
    • any: 在 Go 1.17 之後,如果在普通場景下,any 等同於 interface{};在泛型聲明的中括號範圍內,這個 any 也表示 “任意類型”。但請注意,在泛型聲明中的 any 並不等於 interface{}
  • (v T): 這是在函數入參段,呼應前面已經聲明的 T。這裏就表示一個類型為 T 的入參, 參數名為 v

調用的時候,其實也很簡單:

m := map[string]string{"msg": "Hello, generics!"}
fmt.Println(ToJSON(m))
// 輸出: {"msg":"Hello, generics!"}

泛型類型的約束

泛型化的數據類型

前面我們看了一個極為簡單的泛型函數例子,但那個例子其實意義不大,底層調用的 json.Marshal 實際上也只是使用 any 來實現。接下來我們給一個更加有實際意義的例子:取絕對值。

我們知道,在 Go 中有很多中數字類型,除了 float64, float64, int, uint 之外,還有包括 8、16、32、64 位的四組有符號/無符號整型類型。因為 Go 沒有宏,在泛型推出之前,我們只能為一個類型定義一個函數,還不能重名。

有了泛型之後,這個問題也就迎刃而解。不過這裏需要涉及的知識點一下子就多了起來,讓我們一點一點來講。

首先,我們需要聲明一個泛型類型,並且定義這個泛型類型是包含了哪些真實類型的:

type Number interface{
    float64 | float32 | int | uint | int8 | uint8 | int16 | uint16 | int32 | uint32 | int64 | uint64
}

這段代碼看着也很暈是吧?筆者也很想吐槽:對 Go 泛型的定義,借用了 interface{} 這個關鍵字。但是與真正的 “接口” 不同的是,“接口” 的定義內容是函數,而泛型類型的定義內容是數據類型。而 | 標識則也簡單易懂,表示 “或” 的意思。上面的定義應該很好理解,就表示 Number 代表了下面那一長串的類型中的任意一種都行。

話説,在泛型 interface 的定義中,是可以再進一步定義方法的。但是這種應用場景筆者目前還沒遇到,所以就不展開講了。

這樣,一個泛型化的數據類型就定義完成了,接着我們可以參照上面的例子實現函數:

func Abs[N Number](n N) N {
    if n >= N(0) {
        return n
    }
    return -n
}

可以看到,函數的出參也是一個泛型 N,這表示函數的出參與入參類型相同,都是 Number 類型。

非基礎類型

前面的定義其實有一個缺陷。我們知道,一個類型經過 type 轉換之後,就變成了 “不同” 的類型。比如 byte 雖然實際上是使用 int8 實現,但除非經過強制類型轉換,在 Go 代碼中是視為不同類型的。如果我們傳入的參數是一個 byte 類型,那是無法通過 Number 類型檢查的。為此,Go 泛型聲明中還適用一個符號 ~,表示同事包含由指定基礎類型派生出的其他類型。此時,我們可以將上面的 Number 改寫為:

type Number interface{
    ~float64 | ~float32 | ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64
}

這樣,諸如 byterune 等類型也能順利使用 Abs 函數了。

但是,如果每次我定義一個數字類型的時候都要寫這麼一長串總歸不是個事兒。好在官方的 golang.org/x/exp/constraints 提供了一些常用定義,我們可以進一步簡化為:

type Number interface {
    constraints.Float | constraints.Integer
}

泛型的隱式類型判斷/顯式類型指定

前面的例子中調用一個泛型函數的時候,Go 編譯器實際上在底層會為這個類型專門生成一個函數入口。但是我們在 ToJSON 函數的調用中,並沒有傳遞任何與類型有關的關鍵字,Go 編譯器似乎也沒有報錯。Go 語言中,編譯器在編譯泛型代碼的時候,會根據入參猜測函數類型。我們寫一個調用例子:

    fmt.Println(Abs(-1))

這個調用是可以正常編譯通過的。這是因為在 Go 中,一個整型數字如果未做任何類型約束,那麼會被默認編譯為 int 類型。但我們換一個方法:

    var res int64
    res = Abs(-1)
    fmt.Println(res)

嘗試編譯,會獲得錯誤:

./main.go:xx:yy: cannot use Abs(-1) (value of type int) as int64 value in assignment

根據筆者的試驗,Go 似乎暫時無法根據出參的類型來修改入參泛型類型。在這種情況下,我們就必須顯式地指定泛型函數的類型了。比如上述例子,我們可以寫為:

    res := Abs[int64](-1)
    fmt.Println(reflect.TypeOf(res), res)
    // 輸出: int64 1

在這裏,中括號再次發揮了泛型的作用。如果你對泛型不熟悉的話,粗看可能會有點 “地鐵老人手機.jpg” 的感覺,因為中括號在傳統上一般是與字典、數組等類型相綁定的。但實際上,在各種 IDE 的加持下,我相信你很快就能夠適應這種寫法了。

多個泛型參數

泛型也支持多個泛型參數。比如説,實現一個支持傳入任意類型數字的(極為粗略的)加法函數,我們可以這麼定義:

func Add[T1, T2 Number](a T1, b T2) float64 {
    return float64(a) + float64(b)
}

如果把函數定義為 Add[T Number](a, b T) float64,那麼在調用泛型函數的時候,a 和 b 的類型必須相同,否則報類型錯誤。但如果改為上述實現,那麼 a 和 b 是完全允許不同的。比如:

    a := int(-2)
    b := float64(0.5)
    fmt.Println(Add(a, b))
    // 輸出: -1.5

Go 泛型接收器

前面,我們用泛型約束來修飾一個函數。而 Go 的泛型也可以用來修飾類型。最典型的用法,就是用來聲明一個 “集合” 類型。在 Go 中 “集合” 一般是用 map 來實現的,可以直接定義 map,也可以用一個 struct 內嵌一個 map。這兩種方法,我們可以這樣定義:

type Collection[T comparable] map[T]struct{}

或:

type Collection[T comparable] struct {
    m map[T]struct{}
}

這兩種聲明方式沒有本質上的差別。有了前面泛型函數的經驗之後,相信讀者很快就能瞭解這兩個定義所表達的意思。這裏同樣是分別定義了一個類型 T。但與前面 any 不同,這裏用到了另外一個類型 comparable。這是 Go 內置的除了 any 之外的另外一個泛型標識符,代表所有能夠作 == 比較的類型。這也很好理解,如果是 any 類型,那麼是無法作為 map 的 key 的,在編譯階段無法通過。

泛型方法

泛型接收器的方法寫法,我們還是用上面的第一個 Collection 來舉一個例子:

type Collection[T comparable] map[T]struct{}

func (c Collection[T]) Has(key T) bool {
    _, exist := c[key]
    return exist
}

泛型接收器的實例化

泛型方法的泛型標識符作用於接收器類型上,Collection[T] 實際上就對應着前文的定義。T 與類型定義中的 [T comparable] 聲明一一對應,不需要(也沒辦法)再重新定義 T 的類型約束。

調用泛型接收器的方法呢,首先得把泛型接收器給實例化了。和函數一樣,Go 編譯器也能基於入參進行實際類型的推斷, 或者是顯式地聲明類型(當沒有入參的時候):

    col := Collection[string]{}

調用呢,因為在實例化的時候就已經限定了泛型約束,因此調用這個實例的方法的時候,就再也不用指定泛型約束了:

    if col.Has("Hello!") { /* do something */ }

但是後續的事情就比較遺憾了——Go 支持泛型函數,支持泛型化的類型,但是不支持泛型接收器再定義方法。換句話説,不支持諸如 func (t SomeType[T1]) DoSomething[T2 any]() 的方法。


其他

Go 1.21 推出之後,官方內置了部分泛型包,包括 cmp、 maps 和 slices,提供了很多非常方便的工具,非常好用。如果讀者還不方便使用 go 1.21, 那麼也可以用 Go 的官方實驗包 golang.org/x/exp/maps 和 golang.org/x/exp/slices, cmp 包可能就得自己封裝了。

此外,官方實驗包 golang.org/x/exp/constraints 則提供了幾個非常實用的泛型類型,開發者可以在實際操作中使用。筆者常用的幾個泛型類型為:

  • any: 內置類型,正如前文所説, 就是任意類型
  • comparable: 內置類型,表示所有可以作 == 比較的類型,非常適合用來做 map 的 key 類型。需要注意的是,諸如 bool, reflect.Type 之類也符合這一類。
  • cmp.Ordered: 這個類型在 comparable 的基礎上,多了一層限制,就是可以進行 >< 比較,適合用來做為樹結構類型的 key。需要注意的是 string 也是可以做大小比較的。
  • constraints.Float, constraints.Integer, 分別代表所有的浮點數和整型類型, 筆者經常 constraints.Float | constraints.Integer

結語

讀過上文,相信讀者就能瞭解 Go 泛型的絕大部分應用方法了。目前 Go 泛型還並不能達到玩出花的程度,也有更多的 Go 泛型提案,各位可以多看看~~

擴展閲讀:

  • Go 泛型提案
  • Go泛型不支持泛型方法,這是一個悲傷的故事

本文章採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。

原作者: amc,原文發佈於騰訊雲開發者社區,也是本人的博客。歡迎轉載,但請註明出處。

原文標題:《三分鐘, 讓你學會 Go 泛型》

發佈日期:2023-10-25

原文鏈接:https://cloud.tencent.com/developer/article/2351389。

CC BY-NC-SA 4.0 DEED.png

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.