文章目錄
- 一、環境
- 二、沒有泛型的Go
- 三、泛型的優點
- 四、理解泛型
- (一)泛型函數(Generic function)
- 1)定義
- 2)調用
- (二)類型約束(Type constraint)
- 1)接口與約束
- 2)結構體類型約束
- 3)類型近似(Type approximations)
- (三)泛型類型(Generic type)
- 1)泛型切片
- 2)泛型結構體
- 3)泛型接口
- (五)一些錯誤示例
- 1)聯合約束中的類型元素限制
- 2)一般接口只能用于泛型的類型約束
- 五、參閱
一、環境
Go 1.20.2
二、沒有泛型的Go
假設現在我們需要寫一個函數,實現:
1)輸入一個切片參數,切片類型可以是[]int
或[]float64
,然后將所有元素相加的“和”返回
2)如果是int
切片,返回int
類型;如果是float64
切片,返回float64
類型
當然,最簡單的方法是寫兩個函數SumSliceInt(s []int)
、SumSliceFloat64(s []float64)
來分別支持不同類型的切片,但是這樣會導致大部分代碼重復冗余,不是很優雅。那么有沒有辦法只寫一個函數呢?
我們知道,在Go中所有的類型都實現了interface{}
接口,所以如果想讓一個變量支持多種數據類型,我們可以將這個變量聲明為interface{}
類型,例如var slice interface{}
,然后使用類型斷言(.(type)
)來判斷這個變量的類型。
interface{} + 類型斷言:
// any是inerface{}的別名,兩者是完全相同的:type any = interface{}
func SumSlice(slice any) (any, error) {switch s := slice.(type) {case []int:sum := 0for _, v := range s {sum += v}return sum, nilcase []float64:sum := float64(0)for _, v := range s {sum += v}return sum, nildefault:return nil, fmt.Errorf("unsupported slice type: %T", slice)}
}
從上述代碼可見,雖然使用interface{}
類型可以實現在同一個函數內支持兩種不同切片類型,但是每個case
塊內的代碼仍然是高度相似和重復的,代碼冗余的問題沒有得到根本的解決。
三、泛型的優點
幸運的是,在Go 1.18之后開始支持了泛型(Generics),我們可以使用泛型來解決這個問題:
func SumSlice[T interface{ int | float64 }](slice []T) T {var sum T = 0for _, v := range slice {sum += v}return sum
}
是不是簡潔了很多?而且,泛型相比interface{}
還有以下優勢:
- 可復用性:提高了代碼的可復用性,減少代碼冗余。
- 類型安全性:泛型在編譯時就會進行類型安全檢查,可以確保編譯出來的代碼就是類型安全的;而
interface{}
是在運行時才進行類型判斷,如果編寫的代碼在類型判斷上有bug或缺漏,就會導致Go在運行過程中報錯。 - 性能:不同類型的數據在賦值給
interface{}
變量時,會有一個隱式的裝箱操作,從interface{}
取數據時也會有一個隱式的拆箱操作,而泛型就不存在裝箱拆箱過程,沒有額外的性能開銷。
四、理解泛型
(一)泛型函數(Generic function)
1)定義
編寫一個函數,輸入a
、b
兩個泛型參數,返回它們的和:
// T的名字可以更改,改成K、V、MM之類的都可以,只是一般比較常用的是T
// 這是一個不完整的錯誤例子
func Sum(a, b T) T {return a + b
}
大寫字母T
的名字叫類型形參(Type parameter),代表a
、b
參數是泛型,可以接受多種類型,但具體可以接受哪些類型呢?在上面的定義中并沒有給出這部分信息,要知道,并不是所有的類型都可以相加的,因此這里就引出了約束的概念,我們需要對T
可以接受的類型范圍作出約束:
// 正確例子
func Sum[T interface{ int | float64 }](a, b T) T {return a + b
}
中括號[]
之間的空間用于定義類型形參,支持定義一個或多個
T
:類型形參的名字interface{ int | float64 }
:對T
的類型約束(Type Constraint),必須是一個接口,約束T
只可以是int
或float64
為了簡化寫法,類型約束中的interface{}
在某些情況下是可以省略的,所以可以簡寫成:
func Sum[T int | float64](a, b T) T {return a + b
}
interface{}
不能省略的一些情況:
// 當接口中包含方法時,不能省略
func Contains[T interface{ Equal() bool }](num T) {
}
可以定義多個類型形參:
func Add[T int, E float64](a T, b E) E {return E(a) + b
}
2)調用
以上面的Sum
泛型函數為例,完整的調用寫法為:
Sum[int](1, 2)
Sum[float64](1.1, 2.2)
[]
之間的內容稱為類型實參(Type argument),是函數定義中的類型形參T
的實際值,例如傳int
過去,那么T
的實際值就是int
。
類型形參確定為具體類型的過程稱為實例化(Instantiations),可以簡單理解為將函數定義中的T
替換為具體類型:
泛型函數實例化后,就可以像普通函數那樣調用了。
但大多數時候,編譯器都可以自動推導出該具體類型,無需我們主動告知,這個功能叫函數實參類型推導(Function argument type inference)。所以可以簡寫成:
// 簡寫,跟調用普通函數一樣的寫法
Sum(1, 2)
Sum(1.1, 2.2)
需要注意的是,在調用這個函數時,a
、b
兩個參數的類型必須一致,要么兩個都是int
,要么都是float64
,不能一個是int
一個是float64
:
Sum(1, 2.3) // 編譯會報錯
什么時候不能簡寫?
// 當類型形參T僅用在返回值,沒有用在函數參數列表時
func Foo[T int | float64]() T {return 1
}
Foo() // 報錯:cannot infer T
Foo[int]() // OK
Foo[float64]() // OK
(二)類型約束(Type constraint)
1)接口與約束
Go 使用interface
定義類型約束。我們知道,在引入泛型之前,interface
中只可以聲明一組未實現的方法,或者內嵌其它interface
,例如:
// 普通接口
type Driver interface {SetName(name string) (int, error)GetName() string
}// 內嵌接口
type ReaderStringer interface {io.Readerfmt.Stringer
}
接口里的所有方法稱之為方法集(Method set)。
引入泛型之后,interface
里面可以聲明的元素豐富了很多,可以是任何 Go 類型,除了方法、接口以外,還可以是基本類型,甚至struct
結構體都可以,接口里的這些元素稱為類型集(Type set):
// 基本類型約束
type MyInt interface {int
}// 結構體類型約束
type Point interface {struct{ X, Y int }
}// 內嵌其它約束
type MyNumber interface {MyInt
}// 聯合(Unions)類型約束,不同類型元素之間是“或”的關系
// 如果元素是一個接口,這個接口不能包含任何方法!
type MyFloat interface {float32 | float64
}
有了豐富的類型集支持,我們就可以更加方便的使用接口對類型形參T
的類型作出約束,既可以約束為基本類型(int
、float32
、string
…),也可以約束它必須實現一組方法,靈活性大大增加。
因此前面的Sum
函數還可以改寫成:
// 原始例子:
// func Sum[T int | float64](a, b T) T {
// return a + b
// }type MyNumber interface {int | float64
}func Sum[T MyNumber](a, b T) T {return a + b
}
2)結構體類型約束
Go 還允許我們使用復合類型字面量來定義約束。例如,我們可以定義一個約束,類型元素是一個具有特定結構的struct
:
type Point interface {struct{ X, Y int }
}
然而,需要注意的是,雖然我們可以編寫受此類結構體類型約束的泛型函數,但在當前版本的 Go 中,函數無法訪問結構體的字段,例如:
func GetX[T Point](p T) int {return p.X // p.X undefined (type T has no field or method X)
}
3)類型近似(Type approximations)
我們知道,在Go中可以創建新的類型,例如:
type MyString string
MyString
是一個新的類型,底層類型是string
。
在類型約束中,有時候我們可能并不關心上層類型,只要底層類型符合要求就可以,這時候就可以使用類型近似符號:~
。
// 創建新類型
type MyString string// 定義類型約束
type AnyStr interface {~string
}// 定義泛型函數
func Foo[T AnyStr](param T) T {return param
}func main() {var p1 string = "aaa"var p2 MyString = "bbb"Foo(p1)Foo(p2) // 雖然p2是MyString類型,但也可以通過泛型函數的類型約束檢查
}
需要注意的是,類型近似中的類型,必須是底層類型,而且不能是接口類型:
type MyInt inttype I0 interface {~MyInt // 錯誤! MyInt不是底層類型, int才是~error // 錯誤! error是接口
}
(三)泛型類型(Generic type)
1)泛型切片
假設現在有一個IntSlice
類型:
type IntSlice []intvar s1 IntSlice = []int{1, 2, 3} // 正常
var s2 IntSlice = []string{"a", "b", "c"} // 報錯,因為IntSlice底層類型是[]int,字符串無法賦值
很顯然,因為類型不一致,s2
是無法賦值的,如果想要支持其它類型,需要定義新類型:
type StringSlice []string
type Float32Slice []float32
type Float64Slice []float64
// ...
但是這樣做的問題也顯而易見,它們結構都是一樣的,只是元素類型不同就需要重新定義這么多新類型,導致代碼復雜度增加。
這時候就可以用泛型類型來解決這個問題:
// 只需定義一種新類型,就可以同時支持[]int/[]string/[]float32多種切片類型
// 新類型的名字叫 MySlice[T]
type MySlice[T int|string|float32] []T
類型定義中帶 類型形參 的類型,稱之為泛型類型(Generic type)
泛型切片的初始化:
var s1 MySlice[int] = MySlice[int]{1, 2, 3}
var s2 MySlice[string] = MySlice[string]{"a", "b", "c"}
s3 := MySlice[string]{"a", "b", "c"} // 簡寫
其它一些例子:
// 泛型Map
type MyMap[K int | string, V any] map[K]Vvar m1 MyMap[string, int] = MyMap[string, int]{"a": 1, "b": 2} // 完整寫法
m2 := MyMap[int, string]{1: "a", 2: "b"} // 簡寫// 泛型通道
type MyChan[T int | float32] chan Tvar c1 MyChan[int] = make(MyChan[int]) // 完整寫法
c2 := make(MyChan[float32]) // 簡寫
2)泛型結構體
假設現在要創建一個struct
結構體,里面含有一個data
泛型屬性,類型是一個int
或float64
的切片:
type List[T int | float64] struct {data []T
}
給這個結構體增加一個Sum
方法,用于對切片求和:
func (l *List[T]) Sum() T {var sum Tfor _, v := range l.data {sum += v}return sum
}
實例化結構體,并調用Sum
方法:
// var list *List[int] = &List[int]{data: []int{1, 2, 3}} // 完整寫法
list := &List[int]{data: []int{1, 2, 3}}
sum := list.Sum()
fmt.Println(sum) // 輸出:6
3)泛型接口
泛型也可以用在接口上:
type Human[T float32] interface {GetWeight() T
}
假設現在有兩個結構體,它們都有GetWeight()
方法,哪個結構體實現了上面Human[T]
接口?
// 結構體1
type Person1 struct {Name string
}
func (p Person1) GetWeight() float32 {return 66.6
}// 結構體2
type Person2 struct {Name string
}
func (p Person2) GetWeight() int {return 66
}
注意觀察兩個GetWeight()
方法的返回值類型,因為我們在Human[T]
接口中約束了T
的類型只能是float32
,而只有Person1
結構體的返回值類型符合約束,所以實際上只有Person1
結構體實現了Human[T]
接口。
p1 := Person1{Name: "Tim"}
var iface1 Human[float32] = p1 // 正常,因為Person1實現了接口,所以可以賦值成功p2 := Person2{Name: "Tim"}
var iface2 Human[float32] = p2 // 報錯,因為Person2沒有實現接口
(五)一些錯誤示例
下面列出一些錯誤使用泛型的例子。
1)聯合約束中的類型元素限制
聯合約束中的類型元素不能是包含方法的接口:
// 錯誤
type ReaderStringer interface {io.Reader | fmt.Stringer // 錯誤,io.Reader和fmt.Stringer是包含方法的接口
}// 正確
type MyInt interface {int
}
type MyFloat interface {float32
}
type MyNumber interface {MyInt | MyFloat // 正確,MyInt和MyFloat接口里面沒有包含方法
}
聯合約束中的類型元素不能含有comparable
接口:
type Number interface {comparable | int // 含有comparable,報錯
}
2)一般接口只能用于泛型的類型約束
先解釋下相關概念,引入泛型后,Go的接口分為兩種類型:
- 基本接口(Basic interface)
只包含方法的接口,稱為基本接口,其實就是引入泛型之前的那種傳統接口。 - 一般接口(General interface)
由于引入泛型后,接口可以定義的元素大大豐富,如果一個接口里含有除了方法以外的元素,那么這個接口就稱為一般接口。
一般接口只能用于泛型的類型約束,不能用于變量、函數參數、返回值的類型聲明,而基本接口則沒有此限制:
type NoMethods interface {int
}// 錯誤,不能用于函數參數列表、返回值
func Foo(param NoMethods) NoMethods {return param
}// 錯誤,不能用來聲明變量的類型
var param NoMethods// 正確
func Foo[T NoMethods](param T) T {return param
}
五、參閱
- Go泛型全面講解:一篇講清泛型的全部
- Golang泛型
- An Introduction To Generics