簡單易懂的 Go 泛型使用和實現原理介紹

原文:A gentle introduction to generics in Go by Dominik Braun

萬俊峯Kevin:我看了覺得文章非常簡單易懂,就徵求了作者同意,翻譯出來給大家分享一下。

本文是對泛型的基本思想及其在 Go 中的實現的一個比較容易理解的介紹,同時也是對圍繞泛型的各種性能討論的簡單總結。首先,我們來看看泛型所解決的核心問題。

問題

假設我們想實現一個簡單的 tree 數據結構。每個節點持有一個值。在 Go 1.18 之前,實現這種結構的典型方法如下。

type Node struct {
    value interface{}
}

這在大多數情況下都很好用,但它也有一些缺點。

首先,interface{} 可以是任何東西。如果我們想限制 value 可能持有的類型,例如整數和浮點數,我們只能在運行時檢查這個限制。

func (n Node) IsValid() bool {
    switch n.value.(type) {
        case int, float32, float64:
            return true
        default:
            return false
    }
}

這樣並不可能在編譯時限制類型,像上面這樣的類型判斷在許多 Go 庫中都是很常見的做法。這裏有 go-zero 項目中的例子

第二,對 Node 中的值進行處理是非常繁瑣和容易出錯的。對值做任何事情都會涉及到某種類型的斷言,即使你可以安全地假設值持有一個 int 值。

number, ok := node.value.(int)
if !ok {
    // ...
}

double := number * 2

這些只是使用 interface{} 的一些不便之處,它沒有提供類型安全,並有可能導致難以恢復的運行時錯誤。

解決方法

我們不打算接受任意數據類型或具體類型,而是定義一個叫做 T佔位符類型 作爲值的類型。請注意,這段代碼還不會通過編譯。

type Node[T] struct {
    value T
}

首先需要聲明泛型類型 T,這是在結構或函數名稱後面方括號裏面使用的。

T 可以是任何類型,只有在實例化一個具有明確類型的 Node 時,T 纔會被推導爲該類型。

n := Node[int]{
    value: 5,
}

泛型 Node 被實例化爲 Node[int](整數節點),所以 T 是一個 int

類型約束

上面的實現裏,T 的聲明缺少一個必要的信息:類型約束。

類型約束用於進一步限制可以作爲 T 的可能類型。Go 本身提供了一些預定義的類型約束,但也可以使用自定義的類型約束。

type Node[T any] struct {
    value T
}

任意類型(any)約束允許 T 實際上是任何類型。如果節點值需要進行比較,有一個 comparable 類型約束,滿足這個預定義約束的類型可以使用 == 進行比較。

type Node[T comparable] struct {
    value T
}

任何類型都可以作爲一個類型約束。Go 1.18 引入了一種新的 interface 語法,可以嵌入其他數據類型。

type Numeric interface {
    int | float32 | float64
}

這意味着一個接口不僅可以定義一組方法,還可以定義一組類型。使用 Numeric 接口作爲類型約束,意味着值可以是整數或浮點數。

type Node[T Numeric] struct {
    value T
}

重獲類型安全

相對於使用 interface{},泛型類型參數的巨大優勢在於,T 的最終類型在編譯時就會被推導出來。爲 T 定義一個類型約束,完全消除了運行時檢查。如果用作 T 的類型不滿足類型約束,代碼就不會編譯通過。

在編寫泛型代碼時,你可以像已經知道 T 的最終類型一樣寫代碼。

func (n Node[T]) Value() T {
    return n.value
}

上面的函數返回 n.Value,它的類型是 T。因此,返回值是 T,如果 T 是一個整數,那麼返回類型就已知是 int。因此,返回值可以直接作爲一個整數使用,不需要任何類型斷言。

n := Node[int]{
    value: 5,
}

double := n.Value() * 2

在編譯時恢復類型安全使 Go 代碼更可靠,更不容易出錯。

泛型使用場景

Ian Lance TaylorWhen To Use Generics 中列出了泛型的典型使用場景,歸結爲三種主要情況:

  1. 使用內置的容器類型,如 slicesmapschannels
  2. 實現通用的數據結構,如 linked listtree
  3. 編寫一個函數,其實現對許多類型來說都是一樣的,比如一個排序函數

一般來說,當你不想對你所操作的值的內容做出假設時,可以考慮使用泛型。我們例子中的 Node 並不太關心它持有的值。

當不同的類型有不同的實現時,泛型就不是一個好的選擇。另外,不要把 Read(r io.Reader) 這樣的接口函數簽名改爲 Read[T io.Reader](r T) 這樣的通用簽名。

性能

要了解泛型的性能及其在 Go 中的實現,首先需要了解一般情況下實現泛型的兩種最常見方式。

這是對各種性能的深入研究和圍繞它們進行的討論的簡要介紹。你大概率不太需要關心 Go 中泛型的性能。

虛擬方法表

在編譯器中實現泛型的一種方法是使用 Virtual Method Table。泛型函數被修改成只接受指針作爲參數的方式。然後,這些值被分配到堆上,這些值的指針被傳遞給泛型函數。這樣做是因爲指針看起來總是一樣的,不管它指向的是什麼類型。

如果這些值是對象,而泛型函數需要調用這些對象的方法,它就不能再這樣做了。該函數只有一個指向對象的指針,不知道它們的方法在哪裏。因此,它需要一個可以查詢方法的內存地址的表格:Virtual Method Table。這種所謂的動態調度已經被 Go 和 Java 等語言中的接口所使用。

Virtual Method Table 不僅可以用來實現泛型,還可以用來實現其他類型的多態性。然而,推導這些指針和調用虛擬函數要比直接調用函數慢,而且使用 Virtual Method Table 會阻止編譯器進行優化。

單態化

一個更簡單的方法是單態化(Monomorphization),編譯器爲每個被調用的數據類型生成一個泛型函數的副本。

func max[T Numeric](a, b T) T {
    // ...
}

larger := max(3, 5)

由於上面顯示的max函數是用兩個整數調用的,編譯器在對代碼進行單態化時將爲 int 生成一個 max 的副本。

func maxInt(a, b int) int {
    // ...
}

larger := maxInt(3, 5)

最大的優勢是,Monomorphization 帶來的運行時性能明顯好於使用 Virtual Method Table。直接方法調用不僅更有效率,而且還能適用整個編譯器的優化鏈。不過,這樣做的代價是編譯時長,爲所有相關類型生成泛型函數的副本是非常耗時的。

Go 的實現

這兩種方法中哪一種最適合 Go?快速編譯很重要,但運行時性能也很重要。爲了滿足這些要求,Go 團隊決定在實現泛型時混合兩種方法。

Go 使用 Monomorphization,但試圖減少需要生成的函數副本的數量。它不是爲每個類型創建一個副本,而是爲內存中的每個佈局生成一個副本:intfloat64Node 和其他所謂的 "值類型" 在內存中看起來都不一樣,因此泛型函數將爲所有這些類型複製副本。

與值類型相反,指針和接口在內存中總是有相同的佈局。編譯器將爲指針和接口的調用生成一個泛型函數的副本。就像 Virtual Method Table 一樣,泛型函數接收指針,因此需要一個表來動態地查找方法地址。在 Go 實現中的字典與虛擬方法表的性能特點相同。

結論

這種混合方法的好處是,你在使用值類型的調用中獲得了 Monomorphization 的性能優勢,而只在使用指針或接口的調用中付出了 Virtual Method Table 的成本。

在性能討論中經常被忽略的是,所有這些好處和成本只涉及到函數的調用。通常情況下,大部分的執行時間是在函數內部使用的。調用方法的性能開銷可能不會成爲性能瓶頸,即使是這樣,也要考慮先優化函數實現,再考慮調用開銷。

更多閱讀

Vicent Marti: Generics can make your Go code slower (PlanetScale)

Andy Arthur: Generics and Value Types in Golang (Dolthub)

Virtual method table (Wikipedia)

Monomorphization (Wikipedia)

Dynamic dispatch (Wikipedia)

對標準庫的影響

作爲 Go 1.18 的一部分,不改變標準庫 是一個謹慎的決定。目前的計劃是收集泛型的經驗,學習如何適當地使用它們,並在標準庫中找出合理的用例。

Go 有一些關於通用包、函數和數據結構的提議:

  • constraints, providing type constraints (#47319)
  • maps, providing generic map functions (#47330)
  • slices, providing generic slice functions (#47203)
  • sort.SliceOf, a generic sort implementation (#47619)
  • sync.PoolOf and other generic concurrent data structures (#47657)

關於 go-zero 泛型的計劃

對 go-zero 支持用泛型改寫,我們持謹慎態度,因爲一旦使用泛型,那麼 Go 版本必須從 1.15 升級到 1.18,很多用戶的線上服務現在還未升級到最新版,所以 go-zero 的泛型改寫會延後 Go 兩三個版本,確保用戶線上服務大部分已經升級到 Go 1.18

go-zero 也在對泛型做充分的調研和嘗試。

其中的 mr 包已經新開倉庫支持了泛型:

https://github.com/kevwan/mapreduce

其中的 fx 包也已新開倉庫嘗試支持泛型,但是由於缺少 template method,未能完成,期待後續 Go 泛型的完善

https://github.com/kevwan/stream

當後續 go-zero 支持泛型的時候,我們就會合入這些已經充分測試的泛型實現。

項目地址

https://github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支持我們!

微信交流羣

關注『微服務實踐』公衆號並點擊 交流羣 獲取社區羣二維碼。

如果你有 go-zero 的使用心得文章,或者源碼學習筆記,歡迎通過公衆號聯繫投稿!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章