Go 1-18 泛型全面講解:一篇講清泛型的全部

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 argument),如下:

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

在上面這段僞代碼中, T 被稱爲 類型形參 (type parameter), 它不是具體的類型,在定義函數時類型並不確定。因爲 T 的類型並不確定,所以我們需要像函數的形參那樣,在調用函數的時候再傳入具體的類型。這樣我們不就能一個函數同時支持多個不同的類型了嗎?在這裏被傳入的具體類型被稱爲 類型實參 (type argument):

下面一段僞代碼展示了調用函數時傳入類型實參的方式:

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

// 另一個例子:當我們想要計算兩個字符串之和的時候,就傳入string類型實參
Add[T=string]("Hello", "World") 
// 類型實參string傳入後,Add()函數的定義可近似視爲如下
func Add( a string, b string) string {
    return a + b
}

通過引入 類型形參類型實參 這兩個概念,我們讓一個函數獲得了處理多種不同類型數據的能力,這種編程方式被稱爲 泛型編程

可能你會已奇怪,我通過 Go 的 接口 + 反射 不也能實現這樣的動態數據處理嗎?是的,泛型能實現的功能通過接口 + 反射也基本能實現。但是使用過反射的人都知道反射機制有很多問題:

  1. 用起來麻煩
  2. 失去了編譯時的類型檢查,不仔細寫容易出錯
  3. 性能不太理想

而在泛型適用的時候,它能解決上面這些問題。但這也不意味着泛型是萬金油,泛型有着自己的適用場景,當你疑惑是不是該用泛型的話,請記住下面這條經驗:

如果你經常要分別爲不同的類型寫完全相同邏輯的代碼,那麼使用泛型將是最合適的選擇

  1. Go 的泛型

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

等等等等。

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

  1. 類型形參、類型實參、類型約束和泛型類型

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

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 後帶了中括號,對各個部分做一個解說就是:

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

類型定義中帶 類型形參 的類型,稱之爲 泛型類型 (Generic type)**

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

// 這裏傳入了類型實參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|float64 中,不能用來實例化泛型類型
var c Slice[string] = []string{"Hello", "World"} 

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

對於上面的例子,我們先給泛型類型 Slice[T] 傳入了類型實參 int ,這樣泛型類型就被實例化爲了具體類型 Slice[int] ,被實例化之後的類型定義可近似視爲如下:

type Slice[int] []int     // 定義了一個普通的類型 Slice[int] ,它的底層類型是 []int

我們用實例化後的類型 Slice[int] 定義了一個新的變量 a ,這個變量可以存儲 int 類型的切片。之後我們還用同樣的方法實例化出了另一個類型 Slice[float32] ,並創建了變量 b

因爲變量 a 和 b 就是具體的不同類型了 (一個 Slice[int] ,一個 Slice[float32]),所以 a = b 這樣不同類型之間的變量賦值是不允許的。

同時,因爲 Slice[T] 的類型約束限定了只能使用 int 或 float32 或 float64 來實例化自己,所以 Slice[string] 這樣使用 string 類型來實例化是錯誤的。

上面只是個最簡單的例子,實際上類型形參的數量可以遠遠不止一個,如下:

// MyMap類型定義了兩個類型形參 KEY 和 VALUE。分別爲兩個形參指定了不同的類型約束
// 這個泛型類型的名字叫: MyMap[KEY, VALUE]
type MyMap[KEY int | string, VALUE float32 | float64] map[KEY]VALUE  

// 用類型實參 string 和 flaot64 替換了類型形參 KEY 、 VALUE,泛型類型被實例化爲具體的類型:MyMap[string, float64]
var a MyMap[string, float64] = map[string]float64{
    "jack_score": 9.6,
    "bob_score":  8.4,
}

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

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

Go 泛型概念一覽

3.1 其他的泛型類型

所有類型定義都可使用類型形參,所以下面這種結構體以及接口的定義也可以使用類型形參:

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

// 一個泛型接口(關於泛型接口在後半部分會詳細講解)
type IPrintData[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
}

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

var ws WowStruct[int, []int]
// 泛型類型 WowStuct[T, S] 被實例化後的類型名稱就叫 WowStruct[int, []int]

上面的代碼中,我們爲 T 傳入了實參 int,然後因爲 S 的定義是 []T ,所以 S 的實參自然是 []int 。經過實例化之後 WowStruct[T,S] 的定義類似如下:

// 一個存儲int類型切片,以及切片中最大、最小值的結構體
type WowStruct[int, []int] struct {
    Data     []int
    MaxValue int
    MinValue int
}

因爲 S 的定義是 []T ,所以 T 一定決定了的話 S 的實參就不能隨便亂傳了,下面這樣的代碼是錯誤的:

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

3.3 幾種語法錯誤

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

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

    //✗ 錯誤。T *int會被編譯器誤認爲是表達式 T乘以int,而不是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 泛型類型的套娃

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

// 先定義個泛型類型 Slice[T]
type Slice[T int|string|float32|float64] []T

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

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

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

// 在map中套一個泛型類型Slice[T]
type WowMap[T int|string] map[string]Slice[T]
// 在map中套Slice[T]的另一種寫法
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 匿名結構體不支持泛型

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

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,
    }

所以在使用泛型的時候我們只能放棄使用匿名結構體,對於很多場景來說這會造成麻煩(最主要麻煩集中在單元測試的時候,爲泛型做單元測試會非常麻煩,這點我之後的文章將會詳細闡述)

  1. 泛型 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() 。注意觀察這個方法的定義:

對於這個泛型類型 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 都被替換爲類型實參 int
func (s MySlice[int]) Sum() int {
    var sum int 
    for _, value := range s {
        sum += value
    }
    return sum
}

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

通過泛型 receiver,泛型的實用性一下子得到了巨大的擴展。在沒有泛型之前如果想實現通用的數據結構,諸如:堆、棧、隊列、鏈表之類的話,我們的選擇只有兩個:

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

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, len(q.elements) == 0
}

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

💡 爲了方便說明,上面是隊列非常簡單的一種實現方法,沒有考慮線程安全等很多問題

Queue[T] 因爲是泛型類型,所以要使用的話必須實例化,實例化與使用方法如下所示:

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) {
    value.(int) // 錯誤。泛型類型定義的變量不能使用類型斷言

    // 錯誤。不允許使用type switch 來判斷 value 的具體類型
    switch value.(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的類型從而分情況處理
    v := reflect.ValueOf(value)

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

    // ...
}

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

你爲了避免使用反射而選擇了泛型,結果到頭來又爲了一些功能在在泛型中使用反射

當出現這種情況的時候你可能需要重新思考一下,自己的需求是不是真的需要用泛型(畢竟泛型機制本身就很複雜了,再加上反射的複雜度,增加的複雜度並不一定值得)

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

  1. 泛型函數

在介紹完泛型類型和泛型 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 | float32 | float64](a, b T) {

    // 匿名函數可使用已經定義好的類型形參
    fn2 := func(i T, j T) T {
        return i*2 - j*2
    }

    fn2(a, b)
}

5.2 既然支持泛型函數,那麼泛型方法呢?

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

type A struct {
}

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

但是因爲 receiver 支持泛型, 所以如果想在方法中使用泛型的話,目前唯一的辦法就是曲線救國,迂迴地通過 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 引入泛型後對接口做出的重大調整。那麼做好心理準備,我們出發吧。

  1. 變得複雜的接口

有時候使用泛型編程時,我們會書寫長長的類型約束,如下:

// 一個可以容納所有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 爲底層類型的類型,都在這一類型集之中

type Slice[T Float] []T 中, 類型約束 的真正意思是:

類型約束 指定了類型形參可接受的類型集合,只有屬於這個集合中的類型才能替換形參用於實例化

如:

var s Slice[int]      // int 屬於類型集 Float ,所以int可以作爲類型實參
var s Slice[chan int] // chan int 類型不在類型集 Float 中,所以錯誤

6.2.1 接口實現 (implement) 定義的變化

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

當滿足以下條件時,我們可以說 類型 T 實現了接口 I (type T implements interface 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
}

上面這個例子中

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

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]    // 正確
    var s2 Slice[map[string]string]  // 正確
    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)
}

如果你高興的話,項目遷移到 Go1.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 如何定義:

// Ordered 代表所有可比大小排序的類型
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 | ~[]rune

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

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

接口類型 ReadWriter 代表了一個類型集合,所有以 string 或 []rune 爲底層類型,並且實現了 Read() Write() 這兩個方法的類型都在 ReadWriter 代表的類型集當中

如下面代碼中,StringReadWriter 存在於接口 ReadWriter 代表的類型集中,而 BytesReadWriter 因爲底層類型是 []byte(既不是 string 也是不 []rune) ,所以它不屬於 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 開始將接口分爲了兩種類型

6.3.1 基本接口 (Basic interface)

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

6.3.2 一般接口 (General interface)

如果接口內不光只有方法,還有類型的話,這種接口被稱爲 一般接口 (General interface) ,如下例子都是一般接口:

type Uint interface { // 接口 Uint 中有類型,所以是一般接口
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type ReadWriter interface {  // ReadWriter 接口既有方法也有類型,所以是一般接口
    ~string | ~[]rune

    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 {
}

// 注意,方法中 oriData 等的類型是 string
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. 一般接口 (General interface) 不能用於變量定義只能用於類型約束,所以接口 DataProcessor2[string] 只是定義了一個用於類型約束的類型集
// XMLProcessor 雖然實現了接口 DataProcessor2[string] 的兩個方法,但是因爲它的底層類型是 []byte,所以依舊是未實現 DataProcessor2[string]
type XMLProcessor []byte

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

}

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

}

// JsonProcessor 實現了接口 DataProcessor2[string] 的兩個方法,同時底層類型是 struct{ Data interface{} }。所以實現了接口 DataProcessor2[string]
type JsonProcessor struct {
    Data interface{}
}

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

}

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

}

// 錯誤。DataProcessor2[string]是一般接口不能用於創建變量
var processor DataProcessor2[string]

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

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

    PrintString()
}

// 錯誤,帶方法的一般接口不能作爲類型並集的成員(參考6.5 接口定義的種種限制規則
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 }  // 正確
    }
        
    type _ interface {
        interface{ ~int } | MyInt // 也正確
    }
        
    type _ interface {
        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}  // 理所當然,這樣也是不行的
    }
  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] 
    }
        
    type Bad[T any] interface {
        ~int | ~string | DataProcessor[T]  // 也不行
    }
  6. 總結


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

泛型並不取代 Go1.18 之前用接口 + 反射實現的動態類型,在下面情景的時候非常適合使用泛型:當你需要針對不同類型書寫同樣的邏輯,使用泛型來簡化代碼是最好的 (比如你想寫個隊列,寫個鏈表、棧、堆之類的數據結構)

參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://segmentfault.com/a/1190000041634906