最近看到 Ian Lance Taylor 在 golang-nuts 論壇釋出了 Go 語言泛型的最新改動,去掉了 type 關鍵字[1]。
本文所介紹的内容已經于2020年8月24日更新到最新的設計草案[1]
為了弄明白他講的内容,我周末又研究了一下Go語言泛型設計[2]。在開始之前,先寫一個最新的泛型例子,好讓大家有一個感性的認識。
// Map 對 []T1 的每個元素執行函數 f 得到新的 []T2
func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
r := make([]T2, len(s))
for i, v := range s {
r[i] = f(v)
}
return r
}
有别于常見的 c++/java 泛型,Go沒有使用尖括号(<>)表示泛型清單,而是使用了方括号([],最開始使用圓括号)。
Map後面的[T1, T2 any]表示參數類型。Map函數需要兩個參數,對參數類型沒有任任限制,是以參數類型的類型是any。
下面是一個使用示例
s := []int{1, 2, 3}
floats := Map[int, float64](s, func(i int) float64 { return float64(i) })
// 現在 floats 的值是 []float64{1.0, 2.0, 3.0}.
參數的類型同樣使用方括号指定,
Map[int, float64]
等價于
func Map(s []int, f func(int) float64) []float64
,其功能則是将
[]int
轉化成
[]float64
。
為了簡化調用,Go還支援
泛型推導,也就是根據實際調用參數确定泛型的具體類型。前面的例子還可以簡化成
floats := Map(s, func(i int) float64 { return float64(i) })
T1和T2的類型可以分别通過 s 和 f 的實際入參推導出來。這樣的設計可以
盡量減少泛型函數和普通函使用上的差異,讓Go代碼看起來更加一緻。
好了,讓我們總結一下 Go 泛型的特點
- 使用[]而非<>
- 泛型都有類型
- 支援泛型推導
這基本上是目前最好的設計了。下面我們說說Go語言的泛型是如何演化成如今這個樣子的。
最開始,設計者希望使用圓括号表示泛型。可是圓括号在Go語言裡用途極廣,函數的參數清單就是用圓括号表示的,怎麼區分參數清單跟泛型清單呢?
最簡單的辦法就是插入關鍵字做區分。于是設計者選用了
type關鍵字。一個典型的泛型函長這樣
func Print(type T)(t T) {
fmt.Println(t)
}
請注意,泛型清單的左圓括号之後緊跟着type,而函數參數清單的左括号之後直接跟參數名,這樣就可以把兩者分開。So far so good。
我們再考慮另一個問題,
泛型有沒有類型?泛型不是代表所有類型嗎?怎麼泛型還需要類型呢?讓我們看一段僞代碼
// Stringify 将 []T 的所有成員拼接成一整個字元串
func Stringify(type T)(s []T) (ret []string) {
for _, v := range s {
ret = append(ret, v.String())
}
return ret
}
請大家注意這裡的
v.String()
,v的類型為T,我們在函數Stringfy中要調用v的
String()
方法,T又是不确定的,我們怎麼保證 v 一定實作了
String()
方法呢?顯然,這種寫法是不能的。我們需要對T的取值(也就是 v 的類型)作一下
限制(constraint),這種限制在最初設計[3]中叫
contract。如果你想限制T一定要實作
String()
方法,你可以定義如下 contract
contract stringer(T) {
T String() string
}
然後将
Stringify
定義為
func Stringify(type T stringer)(s []T) (ret []string)
,這樣編譯器就能確定所有 v 都實作
String()
方法了。
大家有沒有覺得 contract 跟 interface 有點像呢?确實,當contract設計方案釋出的時候大家都說很容易跟interface混淆。可為什麼設計者一開始沒有使用 interface 呢?那是interface隻能規定實作的方法,無法限制
運算符、
len() 函數等 go 語言内部的操作。我們舉一個例子。
// Max 傳回兩個參數中較大一個
func Max(type T)(a, b T) T {
if a > b {
return a
}
return b
}
這裡需要比較a和b的大小。問題來了,在Go語言裡
隻有少部分内建類型才能比較大小,你沒法直接比較兩個 struct 或者 map,Go語言本身又
不支援運算符重載,是以你沒法使用 interface 來確定 T 是支援比較運算符的。據此,設計者才引入了 contract 的概念。對于這一類類型,你可以聲明如下 contract
contract Ordered(T) {
T int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64,
string
}
說白了就是把 go 語言中支援
>
運算符的類型都
列出來,雖然看起來有點 Low,但有效。這個時候你可以以把 Max 函數改寫成
func Max(type T Ordered)(a, b T) T
當你嘗試調用
Max([]int{1}, []int{2})
的時候,編譯器就能确定
[]int{}
不符合
Ordered
規定,進而給出具體的報錯資訊。
到現在一切都好~唯一的問題就是需要引入 contract 這個新概念。概念越多越不容易學習,社群也普遍表示了對 contract 的反對。大約一年後,設計者又給一個新的設計[2],這次
移除了 contract。但正如我們前面所講,原來的 interface 并不能滿足泛型的需要,
必需對 interface 做一下擴充。
于是,新的草案給 interface 引入了 type list 支援,說白了就是
把 contract 的功能合并到了 interface 中。你可以在定義 interface 的時候通過 type 指定一組類型清單。前面的 Ordered contract 可以定義為
type Ordered interface{
type int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64,
string
}
這樣你可以像使用 contract 那樣使用
Ordered interface
作為泛型限制了。如此,便不再需要引入新的 contract 概念。
到現在,Go泛型的主要設計就講完了。除了寫起來
括号有點多之外,沒有什麼大毛病。
但大毛病沒有,
幾個小毛病卻又影響到了泛型的文法設計。
我們前面說過,為了跟函數清單相區分,設計者給泛型清單引入了 type 關鍵字。除此之外,泛型在編譯的時候還有幾處二義性需要處理。
如果
T
是泛型,則
T(int)
表示将
T
具體化(instantiating) 為
int
。單純這樣看是沒有歧義的。但是如果跟其他文法寫到一起就不一樣定了。
比如
func f(T(int))
是表示
func f(T int)
呢(在這裡T是參數名,其類型為int)還是表示
func ((T(int)))
呢(在這裡省去了函數的參數名,T為泛型類型,并具體化為int)?
再比如
struct{ T(int) }
是表示
struct { T int }
呢(在這裡 T 是屬性名,其類型為int)還是表示
struct { (T(int)) }
呢(在這裡T為泛型類型,具體化為int後嵌入struct)?
再比如
interface{ T(int) }
是表示
interface{ T(int) }
呢(在這裡T為函數名,入參為int)還是表示
interface{ (T(int)) }
呢(在這裡T為泛型類型,具體化為int後也可能是一個 interface,并嵌入原interface)?
最後比如
[]T(int){}
是表示
([]T)(int){}
呢(在這裡表示初始化slice,值的類型為int)還是表示
[](T(int)){}
呢(在這裡表示初始化為slice,值的類型是泛型T具體化為int後的類型,不一定是int)?
如何消除這種二義性呢?加括号!是以,為了能正常使用泛型,你不得不寫成這個樣子
func ((T(int)))
struct { (T(int)) }
interface{ (T(int)) }
[](T(int)){}
括号太多了。為了少寫點括号,設計者絞盡腦汁,最終發現隻有方括号可堪此重任。
最開始不選用方括号是因為會帶來更多的二義性問題。
一個歧義就是
type A [T] int
是表示
type A[T] int
呢(A是泛型類型,泛型T沒有用到)還是
type A [T]int
(A是長度為T的[]int)。不過這個問題可以能運引入 type 消除。
另一個就是編譯器在分析
func f(A[T]int)
和
func f(A[T], int)
兩種定義的時候需要适當
向後看(lookahead),會增加分析器的複雜度。
最開始設計者沒有想到前面所講的
T(int)
二義性問題,于是選用了圓括号。現在回頭看,發現與其寫那麼多括号,不如稍稍擴充一下分析器,于是就可以消除
T(int)
的二義性問題了,你可以這樣寫
func (T[int]))
struct { T[int] }
interface{ T[int] }
[]T[int]{}
一下子少好多括号,棒棒的!就是他了!原來的
Print
方法寫成這個樣子
func Print[type T](t T)
設計者還是覺得這個 type 關建字不清真,沒有辦法将 type 也省掉呢(真是得隴望蜀)?設計者想到了一個妙招,
強行規定所有泛型都必須寫 constraint(這樣可以跟函數參數清單保持統一,所有參數都有類型)。分析器碰到左方括号之後會繼續向前看,如果發現有 constraint 就能确定是泛型清單。是以,
Print
方法需要改寫為
func Print[T interface{}](t T)
這下好了,不用寫 type。可是慢着,不寫 type 省4個字元,強制寫 constraint 需要寫 interface{},這是11個字元呀,得不嘗失!設計者又開動大腦,想了一個絕招,
我們給 interface{} 設一個别名吧,就叫 any,這樣比寫 type 還少寫一個字元呢,于是
Print
方法最終變成了
func Print[T any](t T)
不管你服不服,反正我是服了!
對于 any,社群還有一些争議,但感覺問題不大。期待 Go 泛型正式上線。
最後提一下,Go語言的設計草案[2]很值得一讀,裡面記錄了各種
設計上的取舍,很有啟發意義。
- 如果你想檢視更多泛型示例,可以移步這裡[4]。
- 如果你對泛型的設計還有疑問,可以先看看這裡[5]。
- 如果你想測試泛型代碼,則可移步此處[6]。
- https://groups.google.com/g/golang-nuts/c/iAD0NBz3DYw
- https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md
- https://go.googlesource.com/proposal/+/master/design/go2draft-contracts.md
- https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md#examples
- https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md#discarded-ideas
- https://go2goplay.golang.org/