簡單玩 Go 1.18 泛型 — — One Type Parameter to Rule Them All?

Alan Wang
19 min readMar 20, 2022

--

Photo by Tony Ross on Unsplash

2022 年 3 月 15 日問世的 Go 語言 1.18 帶來了個籌備已久的新功能 — — 泛型(generic)。對不熟的人來說,泛型聽起來很高深,而有稍微碰過 Java 或其他語言的泛型功能的人,也應該知道那些語言裡的泛型可以衍生出非常,呃,令人頭痛的用法…

身為追求安全性的靜態強型別語言,Go 刻意增加跨型別轉換的困難性,藉此強迫你在開發時多加留意資料型別。而既然 Go 沒有函式多載(overloading),想用同一套邏輯處理多種型別的方式,要嘛就撰寫重複的函式,要嘛則使用 interface{},而後者需要額外的轉換跟檢查手續。因此泛型成了調查中最多人敲碗的新功能,但一些死忠 Go 開發者也不怎麼喜歡這種變化,認為它只會把程式碼搞得更複雜。

出處:https://www.reddit.com/r/golang/comments/739bgd/a_gopher_powered_light_bulb/

薛丁格的箱子:interface {}

Photo by Ante Hamersmit on Unsplash

在 Go 語言裡,任何型別只要實作一個介面要求的所有方法,就可被視為符合該介面型別(隱性實作)。這種特性於是衍生出 interface{}(空介面)這個奇特產物 — — 空介面沒有定義任何方法,這意味著任何型別(包括內建型別)都可以代入為空介面。

只是一個值被包進空介面後,Go 就看不到它底下的動態型別(dynamic type)。若想把值拿出來重新運算,你只有兩個選擇:

  1. 使用型別斷言(type assertion)嘗試轉換它
  2. 使用 reflect 套件在執行階段存取/轉換其型別(本文不討論)

舉個例:假如我們想寫一個通用的 sum() 加總函式,能針對不同數值型別的切片傳回總合,而傳回值(至少是本質上)也得是相同的型別。你要怎麼寫出這種東西?

在以下程式中,我們只假設接收和傳回的可能值為 int32 與 float32 兩種:

package mainimport "fmt"func sum(n []interface{}) interface{} {
var s float32 // 用 float32 來加總
// 將值轉為 float32 來加總
for _, item := range n {
// switch 型別斷言
switch t := item.(type) {
case int32:
s += float32(t)
case float32:
s += t
}
}
// 檢查切片第一個元素,並用對應的型別傳回加總值
if len(n) > 0 {
// 型別斷言
if _, ok := n[0].(int32); ok {
return int32(s)
}
}
return s
}
func main() {
data1 := []int32{10, 20, 30, 40, 50}
data2 := []float32{10.1, 20.2, 30.3, 40.4, 50.5}
// 將原資料拷貝到 interface{} 切片
data1Transformed := make([]interface{}, len(data1))
for i := range data1 {
data1Transformed[i] = data1[i]
}
data2Transformed := make([]interface{}, len(data2))
for i := range data2 {
data2Transformed[i] = data2[i]
}
sum1 := sum(data1Transformed)
sum2 := sum(data2Transformed)
// 用 fmt 套件檢視傳回值的動態型別 (目前仍是 interface{})
fmt.Printf("sum1: %v (%T)\n", sum1, sum1)
fmt.Printf("sum2: %v (%T)\n", sum2, sum2)
}

最後懶惰一點,直接用 fmt 套件印出傳回的空介面的動態型別(Printf() 本身會使用 reflect 套件)。執行結果如下:

sum1: 150 (int32)
sum2: 151.5 (float32)

使用泛型

Photo by Jen Theodore on Unsplash

改用泛型的優點顯而易見,它使我們不需要再花力氣把資料轉為 interface{} 再轉回來:

package mainimport "fmt"func sum[T int32 | float32](n []T) T {
var s T
for _, item := range n {
s += item
}
return s
}
func main() {
data1 := []int32{10, 20, 30, 40, 50}
data2 := []float32{10.1, 20.2, 30.3, 40.4, 50.5}
sum1 := sum[int32](data1)
sum2 := sum[float32](data2)
fmt.Printf("sum1: %v (%T)\n", sum1, sum1)
fmt.Printf("sum2: %v (%T)\n", sum2, sum2)
}

sum() 宣告泛型參數(type parameter)T,其型別限制(type constraints)為 int32 | float32 聯集。T 被用在參數跟傳回值(這回主程式收到的 sum1、sum2 就會被推論為對應型別而不是 interface{}),就連函式內加總用的變數 s 也宣告為 T 型別。

程式在編譯時,會將 T 轉成對應的型別,並檢查你程式中的操作對該型別來說是否有問題。例如,string 型別也支援 + 運算子,因此以下程式會通過編譯:

package mainimport "fmt"func sum[T int32 | float32 | string](n []T) T {
var s T
for _, item := range n {
s += item
}
return s
}
func main() {
data1 := []int32{10, 20, 30, 40, 50}
data2 := []float32{10.1, 20.2, 30.3, 40.4, 50.5}
data3 := []string{"a", "b", "c", "d", "e"}
sum1 := sum[int32](data1)
sum2 := sum[float32](data2)
sum3 := sum[string](data3)
fmt.Printf("sum1: %v (%T)\n", sum1, sum1)
fmt.Printf("sum2: %v (%T)\n", sum2, sum2)
fmt.Printf("sum3: %v (%T)\n", sum3, sum3)
}

輸出

sum1: 150 (int32)
sum2: 151.5 (float32)
sum3: abcde (string)

但若聯集中有型別不支援你要求的操作,就會產生錯誤,比如下面加入的 bool 型別:

func sum[T int32 | float32 | bool](n []T) T {
var s T
for _, item := range n {
s += item
}
return s
}

這在編譯時會產生

invalid operation: operator + not defined on s (variable of type T constrained by int32|float32|bool)

泛型介面與型別推論

Photo by Alexander Schimmeck on Unsplash

int32 | float32 聯集可寫成介面:

package mainimport "fmt"type sumType interface {
int32 | float32
}
func sum[T sumType](n []T) T {
var s T
for _, item := range n {
s += item
}
return s
}

事實上型別限制本身就是界面,前面只是用了 inline 的寫法。而函式的泛型引數也可以省略,讓 Go 根據函式引數來推論:

func main() {
...
sum1 := sum(data1)
sum2 := sum(data2)
...
}

泛型的型別限制

Photo by Ludovic Charlet on Unsplash

就像上面提的,泛型型別限制本身是個介面,但其內容只有型別時就可省略 interface[…] 不寫:

[T int]  // 相當於 [T interface{int}]
[T ~int] // 相當於 [T interface{~int}]
[T int | string] // 相當於 [T interface{int | string}]
[T any] // T 為任何型別
[T comparable] // T 為可比較的型別

~int 表示其底層型別為 int 的型別都算數(當然 int 底下就還是 int)。關鍵字 any 則是 interface{} 的同義詞。

型別限制符合以下條件時,也會自動符合 comparable 介面:

  • T 不是介面且支援 ==、!= 與 || 運算(注意:不是 < 或 > 之類的比較運算)
  • T 是介面且底下的所有型別都符合 comparable

對泛型型別使用型別斷言

Photo by Jac Alexandru on Unsplash

若你在程式內需要確定泛型是什麼型別的話,就得再次動用型別斷言,而且得先把它轉成 any 型別:

func sum[T int32 | float32](n []T) T {
var s float32
for _, item := range n {
// switch 型別斷言
switch t := any(item).(type) {
case int32:
s += float32(t)
case float32:
s += t
}
}
return T(s) // 轉成 T 型別傳回
}

泛型結構

Photo by Ambitious Creative Co. - Rick Barrett on Unsplash

泛型型別也能用在 struct 與其方法上。下面我們改寫原本的程式,把泛型函式擴充成一個泛型結構:

package mainimport "fmt"type valueType interface {
int32 | float32
}
type Data[T valueType] struct {
data []T
}
func (d *Data[T]) addData(newValues ...T) {
for _, item := range newValues {
d.data = append(d.data, item)
}
}
func (d *Data[T]) sum() T {
var s T
for _, item := range d.data {
s += item
}
return s
}
func main() {
data1 := []int32{10, 20, 30, 40, 50}
data2 := []float32{10.1, 20.2, 30.3, 40.4, 50.5}
d1 := Data[int32]{}
d2 := Data[float32]{}
d1.addData(data1...)
d2.addData(data2...)
sum1 := d1.sum()
sum2 := d2.sum()
fmt.Printf("sum1: %v (%T)\n", sum1, sum1)
fmt.Printf("sum2: %v (%T)\n", sum2, sum2)
}

這同樣會印出

sum1: 150 (int32)
sum2: 151.5 (float32)

Data 結構有一個使用泛型型別的切片,以及兩個方法(新增資料和計算總合)。方法本身目前不能加入泛型參數,但可沿用結構的泛型參數。結構初始化時也必須填入泛型引數。

總之,這使得我們能夠宣告兩個 Data 結構,分別用於儲存/處理 int32 或 float32 型別資料。

在 channel 使用泛型

我們可以用泛型來定義 channel 並用於並行性運算。下面的例子仍然是在做加總,只是改成用兩個非同步函式和 channel 來做而已:

package mainimport "fmt"type valueType interface {
int32 | float32
}
func channelGen[T valueType]() chan T {
ch := make(chan T)
return ch
}
func sum[T valueType](ch chan T, values []T) {
var result T
for _, v := range values {
result += v
}
ch <- result
}
func main() { data1 := []int32{10, 20, 30, 40, 50}
data2 := []float32{10.1, 20.2, 30.3, 40.4, 50.5}
ch1 := channelGen[int32]()
ch2 := channelGen[float32]()
go sum(ch1, data1) // 讓函式推論泛型型別
go sum(ch2, data2)
fmt.Println("sum1:", <-ch1)
fmt.Println("sum2:", <-ch2)
}

channel 是一個像通道一樣的變數,可以從程式的任何位置傳值給它,其他地方也可以取值出來。這是 Go 在做並行性運算時交換或整理資料的一個最主要手段。以下我們就假設您已經大概有相關概念了。

程式會輸出

sum1: 150
sum2: 151.5

函式 channelGen() 是個工廠函式,會根據我們傳入的泛型參數傳回一個對應型別的 channel(ch1 是 chan int32,而 ch2 是 chan float32)。sum() 函式則以 go 關鍵字來非同步執行,它們會將傳入的資料加總後,將結果傳給指定的 channel。

main() 的最後兩行則會分別從 ch1 和 ch2 讀出一個值並顯示。既然我們沒有給這些 channel 設定緩衝區,最後這兩行一定會等到 channel 有值可取才會繼續下去,因此我們不必擔心 main() 可能提早結束的問題。

總之這邊主旨是在展示,channel 型別一樣是能以泛型來定義的,使得我們得以在同樣的函式中處理不同資料。

自訂泛型型別

Photo by Ryan Stone on Unsplash

你也能用泛型來定義自訂型別:

type customMap[K comparable, T any] map[K]Tfunc main() {
// 初始化 map
map1 := make(customMap[string, int32])
map2 := make(customMap[int, float32])
map1["a"] = 10
map1["b"] = 20
map1["c"] = 30
map1["d"] = 40
map1["e"] = 50
map2[1] = 10.1
map2[2] = 20.2
map2[3] = 30.3
map2[4] = 40.4
map2[5] = 50.5
...
}

和結構一樣,自訂型別初始化時必須填入泛型引數,使我們能用同一個名稱產生不同型別。map 的鍵必須是可比較的(Go 中可比較的型別見此),因此在這裡就設為 comparable,值則是隨意的 any。

上面的程式會使 map1 的型別相當於 map[string]int32,map2 則是 map[int]float32。若你嘗試寫入型別不符的值,就會產生錯誤。

你也可以用別的泛型型別建立自訂型別:

package mainimport "fmt"type customMap[K comparable, T any] map[K]Ttype mapTypeA customMap[string, int32]
type mapTypeB[V string | int] customMap[V, float32]
func main() {
map1 := make(mapTypeA)
map2 := make(mapTypeB[int])
...
}

你可以給自訂泛型型別取別名(alias):

type customMapTypeC = customMap[string, int32]

該不該用泛型?

Photo by Kitera Dent on Unsplash

儘管 Go 在引入泛型的嘗試源自很多年前(可以追溯到 2010 年),泛型的終於到來仍使一些 Go 開發者感到不安。這也許是因為泛型看似打破了 Go 語言的原始設計精神 — — 明確地要求一切清楚指明,就算要多花點力氣也一樣。

說實在,interface{}(或 any)仍有它的好處。例如在解讀內容未知的 JSON 資料時,你可以建一個 map[string]any 丟給 json.Unmarshal(),讓後者能填入任何值(JSON 鍵必是字串)。若要把值讀出來,還是要通過型別斷言這關,但起碼你能確保過程中不會出問題。

介面允許我們將不同的邏輯包裝在同樣的介面底下,透過相同的行為操作它們。反過來說,泛型使得同一套邏輯能依情況套用不同的型別,所以你可以像是寫一個通用的 max() 函式,並在不同套件匯入它:

type Numbers interface {
int | int8 | int16 | int32 | int64 | uint8 | uint16 | uint32 | uint64 | float32 | float64
}
func max[T Numbers](v []T) T {
var r T
for _, item := range v {
if item > r {
r = item
}
}
return r
}
// 可呼叫 max[int](...) 或 max[float64](...) 而不需先把切片轉成特定型別

只是一傳入泛型引數,泛型型別在這套邏輯裡就固定了(除非你把它指定為 any)。至於在執行速度上,Go 泛型似乎不見得會比型別轉換/型別斷言快,但也沒有慢到哪去。

所以,使用泛型似乎並沒有想像中那麼糟糕或困難。只是當然,Go 開發社群對於泛型意見分歧。有些人認為有 reflect 套件就夠了(許多 Go 原生套件畢竟都在用)-— — 即使它無法保證型別安全也一樣。

我個人則覺得 Reddit 上有人說得好:反正照正規的方式寫程式就好,等有必要再用泛型複製程式碼,而不是一開始就濫用它。

Photo by Guilherme Stecanella on Unsplash

無論如何,對 Go 本身來說,泛型也許還有一段路要繼續走。官方就指出他們無法保證 1.18 之後的版本完全不會有 breaking change:

In other words, it is possible that there will be code using generics that will work with the 1.18 release but break in later releases. We do not plan or expect to make any such change. However, breaking 1.18 programs in future releases may become necessary for reasons that we cannot today foresee. We will minimize any such breakage as much as possible, but we can’t guarantee that the breakage will be zero.

因此現在還很難說泛型將來對 Go 本身的內建套件、乃至其他第三方套件會帶來多大的影響。至少目前而言,原生套件似乎並沒有被改變。

此外,release note 還有提到一些希望在 1.19 加入的新泛型功能:

他們並在三個實驗性的外部套件提供了一系列跟泛型有關的東西:

下面我們來用 constraints 套件寫一個通用 Max() 函式:

package mainimport (
"fmt"
"golang.org/x/exp/constraints" // 要安裝這個套件
)
func Max[T constraints.Ordered](v []T) T {
var r T
for _, item := range v {
if item > r {
r = item
}
}
return r
}
func main() {
data1 := []int{10, 40, 30, 50, 20}
data2 := []string{"a", "e", "b", "c", "d"}
fmt.Println("max1:", Max(data1))
fmt.Println("max2:", Max(data2))
}

印出

max1: 50
max2: e

這篇就寫到這邊吧。

Photo by Tarik Haiga on Unsplash

--

--

Alan Wang
Alan Wang

No responses yet