Go 1.18 泛型全面講解:一篇講清泛型的全部

WonderfulSoap發表於2022-03-31

2022年3月15日,爭議非常大但同時也備受期待的泛型終於伴隨著Go1.18釋出了。

可是因為Go對泛型的支援時間跨度太大,有非常多的以“泛型”為關鍵字的文章都是在介紹Go1.18之前的舊泛型提案或者設計。而很多設計最終在Go1.18中被廢棄或發生了更改。並且很多介紹Go1.18泛型的文章(包括官方的)都過於簡單,並沒對Go的泛型做完整的介紹,也沒讓大家意識到這次Go引入泛型給語言增加了多少複雜度(當然也可能單純是我沒搜到更好的文章)

出於這些原因,我決定參考 The Go Programming Language Specification ,寫一篇比較完整系統介紹Go1.18 泛型的文章。這篇文章可能是目前介紹Go泛型比較全面的文章之一了

? 本文力求能讓未接觸過泛型程式設計的人也能較好理解Go的泛型,所以行文可能略顯囉嗦。但是請相信我,看完這篇文章你能獲得對Go泛型非常全面的瞭解

1. 一切從函式的形參和實參說起

假設我們有個計算兩數之和的函式

func Add(a int, b int) int {
    return a + b
}

這個函式很簡單,但是它有個問題——無法計算int型別之外的和。如果我們想計算浮點或者字串的和該怎麼辦?解決辦法之一就是像下面這樣為不同型別定義不同的函式

func AddFloat32(a float32, b float32) float32 {
    return a + b
}

func AddString(a string, b string) string {
    return a + b
}

可是除此之外還有沒有更好的方法?答案是有的,我們可以來回顧下函式的 形參(parameter)實參(argument) 這一基本概念:

func Add(a int, b int) int {  
    // 變數a,b是函式的形參   "a int, b int" 這一串被稱為形參列表
    return a + b
}

Add(100,200) // 呼叫函式時,傳入的100和200是實參

我們知道,函式的 形參(parameter) 只是類似佔位符的東西並沒有具體的值,只有我們呼叫函式傳入實參(argument) 之後才有具體的值。

如果我們將函式形參實參這個概念推廣一下,給變數的型別引入和函式形參實參類似的概念的話,問題就迎刃而解:在這裡我們將其稱之為 型別形參(type parameter)型別實參(type argumetn)

// 假設 T 是型別形參,在定義函式時它的型別是不確定的,類似佔位符
func Add(a T, b T) T {  
    return a + b
}

在上面這段虛擬碼中, T 被稱為 型別形參, 它不是具體的型別,在定義函式時型別並不確定。因為 T 的型別並不確定,所以我們可以像函式的形參那樣,在呼叫函式的時候再傳入具體的型別。這樣我們不就能一個函式同時支援多個不同的型別了嗎?

就像下面的虛擬碼一樣:

// [T=int]中的 int 是型別實參,代表著函式Add()定義中的型別形參 T 全都被 int 替換
Add[T=int](100, 200)  
// 傳入型別實參int後,Add()函式的定義可近似看成下面這樣:
func Add( a int, b int) int {
    return a + b
}

// 另一個例子,[T=string]中的string是型別實參
Add[T=string]("Hello", "World") 
// 型別實參string傳入後,Add()函式的開一可以近似是為下面這樣
func Add( a string, b string) string {
    return a + b
}

通過上面這樣引入了 型別形參型別實參 後,我們就讓一個函式獲得了處理多個不同型別的能力,我們稱為 泛型程式設計

可能你會已奇怪,這種型別動態處理型別的功能,我通過Go的介面和反射似乎也能實現?泛型能比介面+反射更加輕鬆高效能地實現很多功能,但本身也有很多限制。至於該選擇泛型還是介面+反射,記住下面這樣的一條規則:

如果你經常要分別為不同的型別寫完全同樣邏輯的程式碼,那麼使用泛型將是最合適的選擇

2. Go的泛型

通過上面的內容,我們實際上已經對Go的泛型程式設計有了最初步也是最重要的認識—— 型別形參 和 型別實參。而Go1.18也是通過這種方式實現的泛型,但是單純的形參實參是遠遠不能實現泛型程式設計的,所以Go還引入了非常多全新的概念:

  • 型別形參 (Type parameter)
  • 型別實參(Type argument)
  • 型別形參列表( Type parameter list)
  • 型別約束(Type constraint)
  • 例項化(Instantiations)
  • 泛型型別(Generic type)
  • 泛型接收器(Generic receiver)
  • 泛型函式(Generic function)

等等等等。

啊,實在概念太多了頭暈?沒事請跟著我慢慢來,首先從 泛型型別(generic type) 講起

3. 型別形參、型別實參、型別約束和泛型型別

觀察下面這個簡單的例子:

type IntSlice []int

var a IntSlice = []int{1, 2, 3} // 正確
var b IntSlice = []float32{1.0, 2.0, 3.0} // ✗ 錯誤,因為IntSlice的底層型別是[]int,浮點型別的切片無法賦值

這裡定義了一個新的型別 IntSlice ,它的底層型別是 []int ,理所當然只有int型別的切片能賦值給 IntSlice 型別的變數。

接下來如果我們想要定個可以容納 float32string 等其他型別的切片的話該怎麼辦?很簡單,再定義對應的型別

type StringSlice []string
type Float32Slie []float32
type Float64Slice []float64

但是這樣做的問題顯而易見,它們結構都是一樣的只是成員型別不同就需要重新定義這麼多新型別。那麼有沒有一個辦法能只定義一個型別就能代表上面這所有的型別呢?答案是可以的,這時候就需要用到泛型了:

type Slice[T int|float32|float64 ] []T

不同於一般的型別定義,這裡型別名稱 Slice 後帶了中括號,對各個部分做一個解說就是:

  • T 就是上面介紹過的型別形參(Type parameter),在定義Slice型別的時候T代表的具體型別並不確定類似一個佔位符
  • int|float32 這部分被稱為型別約束(Type constraint),中間的 | 的意思是告訴編譯器,型別形參T可以接收 int 或 float32 這兩種型別
  • 中括號裡的 T int|float32 這一串因為定義了所有的型別形參(在這個例子裡只有一個型別形參),所以我們稱其為 型別形參列表(type parameter list)
  • 這裡新定義的型別名稱叫 Slice[T]

很明顯,這種型別定義的方式中帶了型別形參,和普通的型別定義非常不一樣,所以我們將這種

型別定義中帶 型別形參 的型別,稱之為 泛型型別(Generic type)

泛型型別不能直接拿來使用,必須傳入型別實參(Type argument) 將其確定為具體的型別之後才可使用。而傳入型別實參確定稱具體的型別,這一操作被稱為 例項化(Instantiations)

// ✗ 錯誤。Slice[T]是泛型型別,不可直接使用必須例項化
var x Slice[T] = []int{1, 2, 3} 

// ✓ 正確。 這裡傳入了型別實參int,將泛型型別Slice[T]實 例化為具體的型別 Slice[int]
var a Slice[int] = []int{1, 2, 3}  
fmt.Printf("Type Name: %T",a)  //輸出:Type Name: Slice[int]

// 傳入型別實參float32, 將泛型型別Slice[T]例項化為具體的型別 Slice[string]
var b Slice[float32] = []float32{1.0, 2.0, 3.0} 
fmt.Printf("Type Name: %T",b)  //輸出:Type Name: Slice[float32]

// ✗ 錯誤。因為變數a的型別為Slice[int],b的型別為Slice[float32],兩者型別不同
a = b  

// ✗ 錯誤。string不在型別約束 int|float32 中,不能用來例項化泛型型別
var c Slice[string] = []string{"Hello", "World"} 

在上面的例子中,我們首先通過給泛型型別 Slice[T] 傳入了型別實參 int ,將其例項化為了具體的型別 Slice[int] 。這時候我們就可以把它的型別定義視為 type Slice[int] []int 。其中例項化後的型別名為 Slice[int] ,其底層型別是 []int 。後面傳入float32例項化同理。

並且因為經過例項化之後,變數 a 和 b 就是具體的不同型別了(一個 Slice[int] ,一個 Slice[float32]),所以 a = b 這樣不同型別之間的變數賦值是不允許的。

同時,因為 Slice[T] 的型別約束限定了只能使用 int 和 float32 來例項化自己,所以所以 Slice[string] 這樣使用 string 型別來例項化是錯誤的。

上面只是個最簡單的例子,實際上型別形參可以遠遠不止一個,並且也可以使用在任何型別的定義之中,如下

// MyMap型別定義了兩個型別形參 KEY 和 VALUE。分別為兩個形參制定了不同的型別約束
type MyMap[KEY int | string, VALUE float32 | float64] map[KEY]VALUE  

// 分別用型別實參 string flaot64 替換了型別形參 KEY 和 VALUE 來例項化泛型型別MyMap[KEY, VALUE]
var a MyMap[string, float64] = map[string]float64{
    "jack_score": 9.6,
    "bob_score":  8.4,
}

用上面的例子重新複習下各種概念的話:

  • KEY和VALUE是型別形參
  • int|string 是KEY的型別約束float32|float64 是型別VALUE的型別約束
  • KEY int|string, VALUE float32|float64 整個一串文字被稱為型別形參列表
  • Map[KEY, VALUE] 是泛型型別,型別名稱為 Map[KEY, VALUE]
  • var a MyMap[string, float64] = xx 中的string和float64是型別實參,用於分別替換KEY和VALUE,例項化出了具體的型別 MyMap[string, float64]

還有點頭暈?沒事,的確一下子有太多概念了,這裡用一張圖就能簡單說清楚:

Go泛型概念一覽

3.1 其他的泛型型別

除此之外還有諸如結構體以及介面之類的定義也能使用型別形參:

// 一個泛型型別的結構體。可用 int 或 sring 型別例項化
type MyStruct[T int | string] struct {  
    Name string
    Data T
}

// 一個泛型介面
type PrintData[T int | float32 | string] interface {
    Print(data T)
}

// 一個泛型型別通道,可用型別實參 int 或 string 例項化
type MyChan[T int | string] chan T

3.2 型別形參的互相套用

在型別形參列表中的型別形參是可以互相套用的,如下

type WowStruct[T int | float32, S []T] struct {
    Data     S
    MaxValue T
    MinValue T
}

這個例子看起來有點複雜且難以理解,但實際上只要記住一點,任何泛型型別都必須傳入型別實參例項化才可以使用就容易理解了。我們這就嘗試傳入下型別實參看看就:

ws := WowStruct[int, []int]{
        Data:     []int{1, 2, 3},
        MaxValue: 3,
        MinValue: 1,
    }

在這個例子中,型別形參的定義是 []T ,而我們給 T 傳入了型別實參 int ,所以 S 就應該傳入型別實參 []int 。如果像下面這樣的話則是錯誤的:

// 錯誤。S的定義是[]T,這裡T傳入了實參int,所以S的型別應當為 []int 而不是 []float
ws := WowStruct[int, []float]{
        Data:     []float{1.0, 2.0, 3.0},
        MaxValue: 3,
        MinValue: 1,
    }

傳入型別實參後,泛型型別 WowStuct[T, S] 被例項化,生成了一個新的具體的型別 WowStruct[int, []int] ,這個型別的定義可近似視為如下:

type WowStruct[int, []int] struct {
    Data     []iont
    MaxValue int
    MinValue int
}

3.3 幾種語法錯誤

  1. 定義泛型型別的時候,不能只有型別形參,如下:

    // 錯誤,型別形參不能單獨使用
    type CommonType[T int|string|float32] T
  2. 當型別約束的一些寫法會被編譯器誤認為表示式時會報錯。如下:

    //✗ 錯誤。T *int會被編譯器誤認為是表示式 (T乘以int),所以在編譯器眼中這行程式碼是下面這樣的:
    type NewType[T *int] []T
    // 編譯器眼中的程式碼:認為要定義一個存放切片的陣列,陣列長度由 T * int 計算得到
    type NewType [T * int][]T 
    
    //✗ 錯誤。和上面一樣,這裡不光*被會認為是乘號,|還會被認為是按位或操作
    type NewType2[T *int|*float64] []T 
    
    //✗ 錯誤
    type NewType2 [T (int)] []T 

    為了避免這種誤解,解決辦法就是給型別約束包上 interface{} 或加上逗號(具體關於介面相關的用法會在後半篇提及)

    type NewType[T interface{*int}] []T
    type NewType2[T interface{*int|*float64}] []T 
    
    // 如果型別約束中只有一個型別,可以新增個逗號
    type NewType3[T *int,] []T
    
    //✗ 錯誤。如果型別約束不止一個型別,加逗號也會報錯
    type NewType4[T *int|*float32,] []T 

    因為上面逗號的用法限制比較大而且記憶負擔較重,這裡推薦不使用逗號而是清一色全用interface{}解決問題

3.4 特殊的泛型型別

這裡討論種比較特殊的泛型型別,如下:

type Wow[T int | string] int

var a Wow[int] = 123     // 編譯正確
var b Wow[string] = 123  // 編譯正確
var c Wow[string] = "hello" // 編譯錯誤,因為"hello"不能賦值給底層型別int

這裡雖然使用了型別形參,但因為型別定義是 type Wow[T int|string] int ,所以無論傳入什麼型別實參,例項化後的新型別的底層型別都是 int 。所以int型別的數字123可以賦值給變數a和b,但string型別的字串 “hello” 不能賦值給c

這個例子沒有什麼具體意義,但是可以讓我理解泛型型別的例項化的機制

3.5 泛型型別的套娃

泛型和普通的型別一樣,可以互相巢狀定義出更加複雜的新型別,如下:

type Slice[T int|string|float32|float64] []T

// ✗ 錯誤。泛型型別Slice的型別約束中不包含uint, uint8
type UintSlice[T uint|uint8] Slice[T]  

// ✓ 正確。基於泛型型別Slice定義了新的泛型型別 FloatSlice 。FloatSlice只接受float32和float64兩種型別
type FloatSlice[T float32|float64] Slice[T] 

// ✓ 正確。基於泛型型別Slice定義的新泛型型別
type IntAndStringSlice[T int|string] Slice[T]  
// ✓ 也正確 基於IntAndStringSlice定義出的新泛型型別
type IntSlice[T int] IntAndStringSlice[T] 

// 在map中套一個泛型型別Slice[T]
type WowMap[T int|string] map[string]Slice[T]
// 在map中套Slice的另一種寫法
type WowMap2[T Slice[int] | Slice[string]] map[string]T

3.6 泛型約束的兩種選擇

觀察下面兩種型別約束的寫法

type WowStruct[T int|string] struct {
    Name string
    Data []T
}

type WowStruct2[T []int|[]string] struct {
    Name string
    Data T
}

僅限於這個例子,這兩種寫法和實現的功能其實是差不多的,例項化之後內部結構體的相同。但是但是像下面這種情況的時候,我們使用前一種寫法會更好:

type WowStruct3[T int | string] struct {
    Data     []T
    MaxValue T
    MinValue T
}

3.7 匿名結構體不支援泛型

我們有時候會經常使用到匿名的結構體(struct)並在定義之後直接初始化匿名結構體,如下:

testCase := struct {
        caseName string
        got      int
        want     int
    }{
        caseName: "test OK",
        got:      100,
        want:     100,
    }

那麼匿名結構體能不能使用泛型呢?答案是不能,所以下面的用法是錯誤的:

testCase := struct[T int|string] {
        caseName string
        got      T
        want     T
    }[int]{
        caseName: "test OK",
        got:      100,
        want:     100,
    }

解決辦法就是用泛型的時候給結構體命名,不用匿名結構,。對於很多場景的使用來說確實比較麻煩(最主要麻煩集中在單元測試的時候,對泛型函式之類的做單元測試會變得非常麻煩,這點我之後的文章將會詳細闡述)

4. 泛型receiver

看了上的例子,你一定會說,介紹了這麼多複雜的概念,但好像泛型型別根本沒什麼用處啊?

是的,單純的泛型型別實際上對開發來說用處並不大。但是如果將泛型型別和接下來要介紹的泛型receiver相結合的話,泛型就有了非常大的實用性了

我們知道,定義了新的普通型別之後可以給型別新增方法。那麼可以給泛型型別新增方法嗎?答案自然是可以的,如下:

type MySlice[T int | float32] []T

func (s MySlice[T]) Sum() T {
    var sum T
    for _, value := range s {
        sum += value
    }
    return sum
}

這個例子為泛型型別 MySlice[T] 新增了一個計算成員總和的方法 Sum() 。注意觀察這個方法的定義:

  • 首先看receiver (s MySlice[T]) ,因為上面這種泛型型別的名稱叫 MySlice[T] ,所以我們直接把型別名寫入了receiverr中
  • 然後方法的返回引數我們也使用了型別形參(實際上如果有需要的話,方法的接收引數也可以實用型別形參)
  • 在方法的定義中,我們也可以實用型別形參T,這裡我們定義了一個新的變數sum : var sum T

對於這個泛型型別 MySlice[T] 我們該如何使用?還記不記得之前強調過很多次的,泛型型別無論如何都需要先用型別實參例項化,所以用法如下:

var s MySlice[int] = []int{1, 2, 3, 4}
fmt.Println(s.Sum()) // 輸出:10

var s2 MySlice[float32] = []float32{1.0, 2.0, 3.0, 4.0}
fmt.Println(s2.Sum()) // 輸出:10.0

該如何理解上面的例項化?首先我們用型別實參 int 例項化了泛型型別 MySlice[T],所以泛型型別定義中的所有T都被替換為int,最終我們可以把程式碼看作下面這樣這樣:

type MySlice[int] []int // 例項化後的型別名叫 MyIntSlice[int]

// 方法 中所有型別形參 T 被替換為型別實參
func (s MySlice[int]) Sum() int {
    var sum int 
    for _, value := range s {
        sum += value
    }
    return sum
}

用float32例項化和用int例項化同理,此處不再贅述。

通過泛型receiver,泛型的實用性一下子得到了巨大的擴充套件。在沒有泛型之前,如果想實現諸如堆,棧、佇列、連結串列之類的資料結構,我們要麼

  1. 為每種型別寫一個實現
  2. 使用 interface{} 介面

而有了泛型之後,我們就能非常簡單地建立通用地資料結構結構了。接下來用一個更加實用地例子——佇列來講解

4.1 基於泛型的佇列

佇列是一種先入先出的資料結構,它和現實中排隊一樣,資料只能從隊尾部放入和從隊首取出,先放入的資料優先被取出來

// 這裡型別約束使用了空介面,代表的意思是所有型別都可以用來例項化泛型型別 Queue[T]
type Queue[T interface{}] struct {
    elements []T
}

// 將資料放入佇列尾部
func (q *Queue[T]) Put(value T) {
    q.elements = append(q.elements, value)
}

// 從佇列頭部取出並從頭部刪除對應資料
func (q *Queue[T]) Pop() (T, bool) {
    var value T
    if len(q.elements) == 0 {
        return value, true
    }

    value = q.elements[0]
    q.elements = q.elements[1:]
    return value, false
}

// 佇列大小
func (q Queue[T]) Size() int {
    return len(q.elements)
}

? 為了方便說明,上面是佇列非常簡單的一種實現方法,沒有考慮執行緒安全等很多問題

首先觀察結構體的型別形參列表 T interface{} ,型別約束使用了一個空介面,當型別約束使用空介面的時候並不代表這個泛型型別只能像下面這樣使用空介面例項化: var q Queue[interface{}],而是所有型別都可用來例項化(關於介面相關地詳細說明參考後半部分說明)

var q1 Queue[int]  // 可存放int型別資料的佇列
q1.Put(1)
q1.Put(2)
q1.Put(3)
q1.Pop() // 1
q1.Pop() // 2
q1.Pop() // 3

var q2 Queue[string]  // 可存放string型別資料的佇列
q2.Put("A")
q2.Put("B")
q2.Put("C")
q2.Pop() // "A"
q2.Pop() // "B"
q2.Pop() // "C"

var q3 Queue[struct{Name string}] 
var q4 Queue[[]int] // 可存放[]int切片的佇列
var q5 Queue[chan int] // 可存放int通道的佇列
var q6 Queue[io.Reader] // 可存放介面的佇列
// ......

4.2 動態判斷變數的型別

使用介面的時候經常會用到型別斷言或 type swith 來確定介面具體的型別,然後對不同型別做出不同的處理,如:

var i interface{} = 123
i.(int) // 型別斷言

// type switch
switch i.(type) {
    case int:
        // do something
    case string:
        // do something
    default:
        // do something
    }
}

那麼你一定會想到,對於 valut T 這樣通過型別形參定義的變數,我們能不能判斷具體型別然後對不同型別做出不同處理呢?答案是不允許的,如下:

func (q *Queue[T]) Put(value T) {
    // 錯誤。不允許使用type switch 來判斷 value 的具體型別
    switch T.(type) {
    case int:
        // do something
    case string:
        // do something
    default:
        // do something
    }
    ...
}

雖然type switch不能,可通過反射機制我們就能曲線救國完成對應的功能:

func (receiver Queue[T]) Put(value T) {
    // Printf() 可輸出變數value的型別(底層就是通過反射實現的)
    fmt.Printf("%T", value) 

  // 通過反射可以動態獲得變數value型別從而分情況處理
    valueType := reflect.ValueOf(value)

    switch valueType.Kind() {
    case reflect.Int:
        // do something
    case reflect.String:
        // do something
    }

    ...
}

這看起來達到了我們的目的,可是當你寫出上面這樣的程式碼時候就出現了一個問題:

你為了避免使用反射而選擇了泛型,結果到頭來又為了一些功能在在泛型中使用反射。當出現這種情況的時候你可能需要重新思考一下,自己的需求是不是真的需要用泛型(畢竟泛型機制本身就很複雜了,再加上反射的複雜度,增加的複雜度並不一定值得)

當然,這一切選擇權都在你自己的手裡,根據具體情況斟酌

5. 泛型函式

在介紹完泛型型別和泛型receiver之後,我們來介紹最後一個可以使用泛型的地方——泛型函式。有了上面的知識,寫泛型函式也十分簡單。假設我們想要寫一個計算兩個數之和的函式:

func Add(a int, b int) int {
    return a + b
}

這個函式理所當然只能計算int的和,而浮點的計算是不支援的。這時候我們可以像下面這樣定義一個泛型函式:

func Add[T int | float32 | float64](a T, b T) T {
    return a + b
}

上面就是泛型函式的定義——這種帶型別形參的函式被稱為泛型函式。它和普通函式的不同在於函式名之後帶了型別形參。這裡的型別形參的意義、寫法和用法因為與泛型型別是一模一樣的,就不再贅述了。

和泛型型別一樣,泛型函式也是不能直接呼叫的,要使用泛型函式的話必須傳入型別實參之後才能呼叫。

Add[int](1,2) // 傳入型別實參int,計算結果為 3
Add[float32](1.0, 2.0) // 傳入型別實參float32, 計算結果為 3.0

Add[string]("hello", "world") // 錯誤。因為泛型函式Add的型別約束中並不包含string

或許你會覺得這樣每次都要手動指定型別實參太不方便了。所以Go還支援型別實參的自動推導:

Add(1, 2)  // 1,2是int型別,編譯請自動推匯出型別實參T是int
Add(1.0, 2.0) // 1.0, 2.0 是浮點,編譯請自動推匯出型別實參T是float32

自動推導的寫法就好像免去了傳入實參的步驟一樣,但請記住這僅僅只是編譯器幫我們推匯出了型別實參,實際上傳入實參步驟還是發生了的。

5.1 匿名函式不支援泛型

在Go中我們經常會使用匿名函式,如:

fn := func(a, b int) int {
  return a + b 
}  // 定義了一個匿名函式並賦值給 fn 

fmt.Println(fn(1, 2)) // 輸出: 3

那麼Go支不支援匿名泛型函式呢?答案是不能——匿名函式簽名中不能包含型別形參:

// 錯誤,不支援匿名泛型函式
fnGeneric := func[T int | float32](a, b T) T {
        return a + b
} 

fmt.Println(fnGeneric(1, 2))

但是在匿名函式中使用型別形參是可以:

func MyFunc[T int | string](a, b T) {

    fn := func() {
        var c T     // 匿名函式可使用型別形參
        c = a + b
        fmt.Println(c)
    }

    fn()
}

5.2 既然支援泛型函式,那麼泛型方法呢?

既然函式都支援了泛型了,那你應該自然會想到,方法支不支援泛型?很不幸,目前Go的方法並不支援泛型,如下:

type A struct {
}

// 不支援泛型方法
func (receiver A) Add[T int | float32 | float64](a T, b T) T {
    return a + b
}

但是因為receiver支援泛型, 所以如果想在方法中使用泛型的話,目前唯一的辦法就是曲線救國,迂迴地在型別中定義形參:

type A[T int | float32 | float64] struct {
}

// 方法可以使用型別定義中的形參 T 
func (receiver A[T]) Add(a T, b T) T {
    return a + b
}

// 用法:
var a A[int]
a.Add(1, 2)

var aa A[float32]
aa.Add(1.0, 2.0)

前半小結

講完了泛型型別、泛型receiver、泛型函式後,Go的泛型算是介紹完一半多了。在這裡我們做一個概念的小結:

  1. Go的泛型目前可使用在3個地方

    1. 泛型型別 - 型別定義中帶型別形參的型別
    2. 泛型receiver - 泛型型別的receiver
    3. 泛型函式 - 帶型別形參的函式
  2. 為了實現泛型,Go引入了一些新的概念:

    1. 型別形參
    2. 型別形參列表
    3. 型別實參
    4. 型別約束
    5. 例項化 - 泛型型別不能直接使用,要使用的話必須傳入型別實參進行例項化

什麼,這文章已經很長很複雜了,才講了一半?是的,Go這次1.18引入泛型為語言增加了較大的複雜度,目前還只是新概念的介紹,下面後半段將介紹Go引入泛型後對介面做出的重大調整。那麼做好心理準備,我們出發吧。

6. 變得複雜的介面

有時候使用泛型程式設計時,我們會書寫長長的型別約束,如下:

// 一個可以容納所有int,uint以及浮點型別切片的泛型型別
type Slice[T int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64] []T

理所當然,這種寫法是我們無法忍受也難以維護的,而Go支援將型別約束單獨拿出來定義到介面中,從而讓程式碼更容易維護:

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

type Slice[T IntUintFloat] []T

這段程式碼把型別約束給單獨拿出來,寫入了介面型別 IntUintFloat 當中。需要指定型別約束的時候直接使用介面 IntUintFloat 即可。

不過這樣的程式碼依舊不好維護,而介面和介面、介面和普通型別之間也是可以通過 | 進行組合:

type Int interface {
    int | int8 | int16 | int32 | int64
}

type Uint interface {
    uint | uint8 | uint16 | uint32
}

type Float interface {
    float32 | float64
}

type Slice[T Int | Uint | Float] []T  // 使用 '|' 將多個介面型別組合

上面的程式碼中,我們分別定義了 Int, Uint, Float 三個介面型別,並最終在 Slice[T] 的型別約束中中通過使用 | 將它們組合到一起。

同時,介面也能組合其他介面,所以還可以像下面這樣:

type SliceElement interface {
    Int | Uint | Float | string // 組合了三個介面型別並額外增加了一個 string 型別
}

type Slice[T SliceElement] []T 

6.1 ~ : 指定底層型別

上面定義的Slie[T]雖然可以達到目的,但是有一個缺點:

var s1 Slice[int] // 正確 

type MyInt int
var s2 Slice[MyInt] // ✗ 錯誤。MyInt型別底層型別是int但並不是int型別,不符合 Slice[T] 的型別約束

這裡發生錯誤的原因是,泛型型別 Slice[T] 允許的是 int 作為型別實參,而不是 MyInt (雖然 MyInt 型別底層型別是 int ,但它依舊不是 int 型別)。

為了從根本上解決這個問題,Go新增了一個符號 ~ ,在型別約束中使用類似 ~int 這種寫法的話,就代表著不光是 int ,所有以 int 為底層型別的型別也都可用於例項化。

使用 ~ 對程式碼進行改寫之後如下:

type Int interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Uint interface {
    ~uint | ~uint8 | ~uint16 | ~uint32
}
type Float interface {
    ~float32 | ~float64
}

type Slice[T Int | Uint | Float] []T 

var s Slice[int] // 正確

type MyInt int
var s2 Slice[MyInt]  // MyInt底層型別是int,所以可以用於例項化

type MyMyInt MyInt
var s3 Slice[MyMyInt]  // 正確。MyMyInt 雖然基於 MyInt ,但底層型別也是int,所以也能用於例項化

type MyFloat32 float32  // 正確
var s4 Slice[MyFloat32]

限制:使用 ~ 時有一定的限制:

  1. ~後面的型別不能為介面
  2. ~後面的型別必須為底層型別
type MyInt int

type _ interface {
    ~[]byte  // 正確
    ~MyInt   // 錯誤,~後的型別必須為底層型別
    ~error   // 錯誤,~後的型別不能為介面
}

6.2 從方法集(Method set)到型別集(Type set)

上面的例子中,我們學習到了一種介面的全新寫法,而這種寫法在Go1.18之前是不存在的。如果你比較敏銳的話,一定會隱約認識到這種寫法的改變這也一定意味著Go語言中 介面(interface{}) 這個概念發生了非常大的變化。

是的,在Go1.18之前,Go官方對 介面(interface) 的定義為:介面是一個方法集(method set)

An interface type specifies a method set called its interface

就如下面這個程式碼一樣, ReadWriter 介面定義了一個介面(方法集),這個集合中包含了 Read()Write() 這兩個方法。所有同時定義了這兩種方法的型別被視為實現了這一介面。

type ReadWriter interface {
  Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

但是,我們如果換一個角度來重新思考上面這個介面的話,會發現介面的定義實際上還能這樣理解:

我們可以把 ReaderWriter 介面看成代表了一個 型別的集合,所有實現了 Read() Writer() 這兩個方法的型別都在介面代表的型別集合當中

通過換個角度看待介面,在我們眼中介面的定義就從 方法集(method set) 變為了 型別集(type set)。而Go1.18開始就是依據這一點將介面的定義正式更改為了 型別集(Type set)

An interface type defines a *type set
(*一種介面型別定義了一個型別集)

你或許會覺得,這不就是改了下概念上的定義實際上沒什麼用嗎?是的,如果介面功能沒變化的話確實如此。但是還記得下面這種用介面來簡化型別約束的寫法嗎:

type Float interface {
    ~float32 | ~float64
}

type Slice[T Float] []T 

這就體現出了為什麼要更改介面的定義了。用 方法集 的概念重新理解下上面的程式碼:

介面型別 Float 代表了一個 型別集合, 所有以 float32 float64為底層型別的型別,都在這一型別集之中

而泛型型別 Slice[T] 的 型別約束 的真正意思是: 型別約束指定了對應型別形參可用的型別集合,只有屬於這個集合中的型別才能替換形參用於泛型型別的例項化,如:

var s Slice[int]          // int 型別屬於 T 的型別約束限定的型別集,所以int可以作為型別實參
var s Slice[chan int] // chan int 型別不在T的型別約束限定的型別集中,所以錯誤

6.2.1 介面實現(implement)定義的變化

既然介面定義發生了變化,那麼從Go1.18開始 介面實現(implement) 的定義自然也發生了變化:

當滿足以下條件時,我們可以說 型別 T 實現了介面 I ( type T implements interface I)

  • T 不是介面時:型別 T 時介面代表的型別集中的一個成員 (T is an element of the type set of I)
  • T 是介面時: T 介面代表的型別集是 I 代表的型別集的子集(Type set of T is a subset of the type set of I)

6.2.2 型別的並集

並集我們已經很熟悉了,之前一直使用的 | 符號就是求型別的並集( union )

type Uint interface {  // 型別集 Uint 是 ~uint 和 ~uint8 等型別的並集
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

6.2.3 型別的交集

介面可以不止書寫一行,如果一個介面有多行型別定義,那麼取它們之間的 交集

type AllInt interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint32
}

type Uint interface {
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type A interface { // 介面A代表的型別集是 AllInt 和 Uint 的交集
    AllInt
    Uint
}

type B interface { // 介面B代表的型別集是 AllInt 和 ~int 的交集
    AllInt
  ~int
}

上面這個例子中

  • 介面 A 代表的是 AllInt 與 Uint 的 交集,即 ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
  • 介面 B 代表的則是 AllInt 和 ~int 的交集

上面的程式碼等價於如下:

type A interface {
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type B interface {
  ~int
}

除了上面的交集,下面也是一種交集:

type C interface {
    ~int
    int
}

很顯然,~int 和 int 的交集只有int一種型別,所以介面C代表的型別集中只有int一種型別

6.2.4 空集

當多個型別的交集如下面 Bad 這樣為空的時候, Bad 這個介面代表的型別集為一個空集

type Bad interface {
    int
    float32 
} // 型別 int 和 float32 沒有相交的型別,所以介面 Bad 代表的型別集為空

沒有任何一種型別屬於空集。所以雖然Bad這樣的寫法是可以編譯的,但實際上並沒有什麼意義

6.2.5 空介面和 any

上面說了空集,接下來說一個特殊的集合,空介面 interface{} 。因為,Go1.18開始介面的定義發生了改變,所以 interface{} 的定義也發生了一些變更:

空介面代表所有型別的集合

所以,對於Go1.18之後的空介面應該這樣理解:

  1. 雖然空介面內沒有寫入任何的型別,但它代表的是所有型別的集合,而非一個 空集
  2. 型別約束中指定 空介面 的意思是指定了一個包含所有型別的型別集,並不是型別約束限定了只能使用 空介面 來做型別形參

    // 空介面代表所有型別的集合。寫入型別約束意味著所有型別都可拿來做型別實參
    type Slice[T interface{}] []T
    
    var s1 Slice[int] []T    // 正確
    var s2 Slice[map[string]string] T  // 正確
    var s3 Slice[chan int]  // 正確
    var s4 Slice[interface{}]  // 正確

因為空介面是一個包含了所有型別的型別集,所以我們經常會用到它。於是,Go1.18開始提供了一個和空介面 interface{} 等價的新關鍵詞 any ,用來使程式碼更簡單:

type Slice[T any] []T // 程式碼等價於 type Slice[T interface{}] []T

實際上 any 的定義就位於Go語言的 builtin.go 檔案中(參考如下), any 實際上就是 interaface{} 的別名(alias),兩者完全等價

// any is an alias for interface{} and is equivalent to interface{} in all ways.
type any = interface{} 

所以從go 1.18開始,所有可以用到空介面的地方其實都可以直接替換為any。如:

var s []any // 等價於 var s []interface{}
var m map[string]any // 等價於 var m map[string]interface{}

func MyPrint(value any){
  fmt.Println(value)
}

如果你高興得話,專案遷移到1.18之後可以使用下面這行命令直接把整個專案中的空介面全都替換成 any。當然因為並不強制,所以到底是用 interface{} 還是 any 全看自己喜好

gofmt -w -r 'interface{} -> any' ./...
? Go語言專案中就曾經有人提出過把Go語言中所有 interface{ }替換成 any 的 issue,然後因為影響範圍過大過而且影響因素不確定,理所當然被駁回了

6.2.6 comparable(可比較) 和 可排序(ordered)

對於一些資料型別,我們需要在型別約束中限制只能接受可用 !=== 對比的型別,如map:

// 錯誤。因為 map 中鍵的型別必須是可進行 != 和 == 比較的型別
type MyMap[KEY any, VALUE any] map[KEY]VALUE 

所以Go直接內建了一個叫 comparable 的介面,它代表了所有可用 != 以及 == 對比的型別:

type MyMap[KEY comparable, VALUE any] map[KEY]VALUE // 正確

comparable 比較容易引起誤解的一點是很多人容易把他與可排序搞混淆。可比較指的是 可以執行 != == 操作的型別,並沒確保這個型別可以執行大小比較( >,<,<=,>= )。如下:

type OhMyStruct struct {
    a int
}

var a, b OhMyStruct

a == b // 正確。結構體可使用 == 進行比較
a != b // 正確

a > b // 錯誤。結構體不可比大小

而可進行大小比較的型別被稱為 Orderd 。目前Go語言並沒有像 comparable 這樣直接內建對應的關鍵詞,所以想要的話需要自己來定義相關介面,比如我們可以參考Go官方包golang.org/x/exp/constraints 如何定義:

type Ordered interface {
    Integer | Float | ~string
}

type Integer interface {
    Signed | Unsigned
}

type Signed interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Unsigned interface {
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

type Float interface {
    ~float32 | ~float64
}

? 這裡雖然可以直接使用官方包 golang.org/x/exp/constraints ,但因為這個包屬於實驗性質的 x 包,今後可能會發生非常大變動,所以並不推薦直接使用

6.3 介面兩種型別

我們接下來再觀察一個例子,這個例子是闡述介面型別集概念最好的例子:

type ReadWriter interface {
    ~string | | ~[]byte

    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

最開始看到這一例子你一定有點懵不太理解它代表的意思,但是沒關係,我們用型別集的概念就能比較輕鬆理解這個介面的意思:

介面型別 ReadWriter 代表了一個型別集合,所有以 string 或 []byte 為底層型別,並且含有 Read() Write() 這兩個方法的型別都在 ReadWriter 代表的型別集當中

如下面程式碼中,StringReadWriter 屬於介面 ReadWriter 代表的型別集中,而因為 BytesReadWriter 的底層型別是 []byte ,所以它不屬於 ReadWriter

// 型別 StringReadWriter 實現了介面 Readwriter
type StringReadWriter string 

func (s StringReadWriter) Read(p []byte) (n int, err error) {
  ...
}

func (s StringReadWriter) Write(p []byte) (n int, err error) {
 ...
}

//  型別BytesReadWriter 沒有實現介面 Readwriter
type BytesReadWriter []byte 

func (s BytesReadWriter) Read(p []byte) (n int, err error) {
 ...
}

func (s BytesReadWriter) Write(p []byte) (n int, err error) {
 ...
}

你一定會說,啊等等,這介面也變得太複雜了把,那我定義一個 ReadWriter 介面,然後賦值的時候不光要考慮到方法的實現,還必須考慮到具體底層型別?心智負擔也太大了吧。是的,為了解決這個問題也為了保持Go語言的相容性,Go1.18開始將介面分為了兩種型別

  • 基本介面(Basic interface)
  • 一般介面(General interface)

6.3.1 基本介面(Basic interface)

介面定義中如果只有方法的話,那麼這種介面被稱為基本介面(Basic interface)。這種寫法就是Go1.18之前介面,其用法也基本和Go1.18之前保持一致。基本介面可以用於如下幾個地方:

  • 最常用的,定義介面變數

    type MyError interface { // 介面中只有方法,所以是基本介面
        Error() string
    }
    
    var err MyError = fmt.Errorf("hello world")
  • 基本介面因為也代表了一個型別集,所以可用在型別約束中

    type MySlice[T io.Reader | io.Writer]  []Slice

6.3.2 一般介面(General interface)

如果介面內不光只有方法,還有型別的話,這種介面被稱為 一般介面(General interface) ,如下例子都是一般介面:

type Uint interface { // 介面 Uint 帶型別所以是一般介面
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type ReadWriter interface {  // 介面帶方法也帶型別,所以是一般介面
    ~string | | ~[]byte

    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

一般介面不能用於變數定義和賦值,只能用於泛型的型別約束中。所以以下的用法是錯誤的:

type Uint interface {
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

var uintInf Uint // 錯誤。Uint是一般介面,只能用於型別約束,不得用於變數定義

這一限制保證了一般介面的使用被限定在了泛型之中,不會影響到Go1.18之前的程式碼,同時也極大減少了書寫程式碼時的心智負擔

6.4 泛型介面

介面也可以使用型別形參,觀察下面這兩個例子:

type DataProcessor[T any] interface {
    Process(oriData T) (newData T)
    Save(data T) error
}

type DataProcessor2[T any] interface {
    int | ~struct{ Data interface{} }

    Process(data T) (newData T)
    Save(data T) error
}

因為引入了型別形參,所以這兩個介面是泛型型別(帶型別形參的型別是泛型型別),我們可以稱之為泛型介面。而泛型型別要使用的話必須傳入型別實參例項化才有意義。所以我們來嘗試例項化一下這兩個介面。因為 T 的型別約束是 any,所以我們可以隨便挑一個型別來當實參(比如string):

DataProcessor[string]

// 例項化之後的介面定義相當於如下所示:
type DataProcessor[string] interface {
    Process(oriData string) (newData string)
    Save(data string) error
}

經過例項化之後就好理解了, DataProcessor[string] 因為只有方法,所以它實際上就是個 基本介面(Basic interface),這個介面包含兩個能處理string型別的方法。只要像下面這樣實現了這兩個能處理string型別的方法才算實現了這個介面:

type CSVProcessor struct {
}

func (c CSVProcessor) Process(oriData string) (newData string) {
    ....
}

func (c CSVProcessor) Save(oriData string) error {
    ...
}

// 正確。CSVProcessor實現了介面 DataProcessor[string]
var processor DataProcessor[string] = CSVProcessor{}  
processor.Process("name,age\nbob,12\njack,30")
processor.Save("name,age\nbob,13\njack,31")

// 錯誤。CSVProcessor沒有實現介面 DataProcessor[int]
var processor2 DataProcessor[int] = CSVProcessor{}

再用同樣的方法例項化 DataProcessor2[T]

DataProcessor2[string]

type DataProcessor2[T string] interface {
  ~int | ~struct{ Data interface{} }

    Process(data string) (newData string)
    Save(data string) error
}

DataProcessor2[string] 因為帶有型別並集所以它是 一般介面(General interface),所以例項化之後的這個介面代表的意思是:

  1. 只有實現了 Process(string) stringSave(string) error 這兩個方法,並且以 intstruct{ Data interface{} } 為底層型別的型別才算實現了這個介面
  2. 一般介面不能用於變數定義只能用於型別約束,所以介面 DataProcessor2[string] 只是定義了一個用於型別約束的型別集
// XMLProcessor 未實現 DataProcessor2[string],因為它的底層型別是 []byte
type XMLProcessor []byte

func (c XMLProcessor) Process(oriData string) (newData string) {

}

func (c XMLProcessor) Save(oriData string) error {

}

// JsonProcessor 實現了介面 DataProcessor2[string],因為它底層型別是 struct{ Data interface{} }
type JsonProcessor struct {
    Data interface{}
}

func (c JsonProcessor) Process(oriData string) (newData string) {

}

func (c JsonProcessor) Save(oriData string) error {

}

// 錯誤。雖然JsonProcessor實現了DataProcessor2[string]介面,但DataProcessor2[string]是一般介面不能用於建立變數
var processor DataProcessor2[string] = JsonProcessor{} 

// 正確,例項化之後的 DataProcessor2[string] 可用於泛型的型別約束
type ProcessorList[T DataProcessor2[string]] []T

// 正確,介面可以併入其他介面
type StringProcessor interface {
    DataProcessor2[string]

    PrintString()
}

// 錯誤,帶方法的一般介面不能作為型別並集的成員(參考
type StringProcessor interface {
    DataProcessor2[string] | DataProcessor2[[]byte]

    PrintString()
}

6.5 介面定義的種種限制規則

Go1.18從開始,在定義型別集(介面)的時候增加了非常多十分瑣碎的限制規則,因為找不到好的地方介紹,所以在這裡統一介紹下:

  1. | 連線多個型別的時候,型別之間不能有相交的部分(即必須是不交集合):

    type MyInt int
    
    // 錯誤,MyInt的底層型別是int,和 ~int 有相交的部分
    type _ interface {
        ~int | MyInt
    }

    但是相交的型別中有介面的話,則不受這一限制:

    type MyInt int
    
    type _ interface {
        ~int | interface{ MyInt }  // 正確
    }
  2. 型別的並集中不能有型別形參

    type MyInf[T ~int | ~string] interface {
        ~float32 | T  // 錯誤。T是型別形參
    }
    
    type MyInf2[T ~int | ~string] interface {
        T  // 錯誤
    }
  3. 介面不能直接間接地併入自己(即便是通過型別集也不行)

    type Bad interface {
        Bad // 錯誤,介面不能直接併入自己
    }
    
    type Bad2 interface {
        Bad1
    }
    type Bad1 interface {
        Bad2 // 錯誤,介面Bad1通過Bad2間接併入了自己
    }
    
    type Bad3 interface {
        ~int | ~string | Bad3 // 錯誤,通過型別集合類併入了自己
    }
    
  4. 型別並集大於一個型別的時候,不能直接或間接包含預定義的 comparable 介面,也不能直接或間接包含有帶方法的介面

    type OK interface {
        comparable // 正確。只有一個型別的時候可以使用 comparable
    }
    
    type Bad1 interface {
        []int | comparable // 錯誤,型別並集不能直接併入 comparable 介面
    }
    
    type CmpInf interface {
        comparable
    }
    type Bad2 interface {
        chan int | CmpInf  // 錯誤,型別並集通過 CmpInf 間接併入了comparable
    }
    type Bad3 interface {
        chan int | interface{comparable}  // 理所當然這樣也是不行的
    }
    
    type InfWithMethod interface {
        ~string
        Hello()
    }
    type Bad4 interface {
        int | InfWithMethod // 錯誤,型別並集併入了帶方法的介面
    }
    
    type OK2 interface {
        InfWithMethod // 正確,這裡是直接內嵌了 InfWithMethod 介面
    }
    type Bad5 interface {
        ~int | Bad5  // 錯誤,型別並集中間接併入帶方法的介面也是也不行
    }
  5. 帶方法的介面(無論是基本介面還是一般介面),都不能寫入介面的型別並集中:

    type _ interface {
        ~int | ~string | error // 錯誤,error帶方法(是一般介面),不能寫入並集中
    }
    
    type DataProcessor[T any] interface {
      ~string | ~[]byte
    
        Process(data T) (newData T)
        Save(data T) error
    }
    
    // 錯誤,例項化之後的 DataProcessor[string] 是帶方法的一般介面,不能寫入型別並集
    type _ interface {
        ~int | ~string | DataProcessor[string] 
    }
    

7. 總結

至此,終於是從頭到位把Go1.18的泛型給介紹完畢了。因為Go這次引入泛型帶入了挺大的複雜度,也增加了挺多比較零散瑣碎的規則限制。所以寫這篇文章斷斷續續花了我差不多一星期時間。泛型雖然很受期待,但實際上推薦的使用場景也並沒有那麼廣泛,對於泛型的使用,我們應該遵守下面的規則:

泛型並不取代Go1.18之前用介面實現的動態型別,在下面情景的時候非常適合使用泛型:當你需要針對不同型別書寫同樣邏輯的程式碼的時候,使用泛型來簡化程式碼是最好的(如你想寫個佇列

參考資料

相關文章