Golang 泛型入門指南
Go 語言中的泛型是指一種語言特性,允許創建可以處理不同類型的函數、數據結構和接口。換句話說,泛型使得可以創建不受特定類型或數據結構限制的代碼。如果我們此前有使用 Java 或者 C++ 的經驗,那麼會很好理解。
在 Go 語言引入泛型之前,開發人員必須編寫多個函數來處理不同類型的數據。這種方法通常很繁瑣,並導致代碼重複。有了泛型,開發人員可以編寫更簡潔和可重用的代碼,可以處理不同類型的數據。
Go 語言中的泛型是在 2021 年 2 月發佈的 1.18 版本中引入的。Go 語言中的泛型實現是基於類型參數的概念。類型參數是傳遞給函數或數據結構的類型的佔位符,使它們能夠處理不同類型的數據。
Go 中的泛型是什麼?
泛型是一種代碼,允許我們通過改變函數類型來在各種函數中使用它們。泛型的創建是爲了使代碼獨立於類型和函數。
泛型的主要目的是通過添加更少的代碼行來實現更大的靈活性。
爲了更好地理解,看下面的例子。我們創建一個打印任何類型參數的函數,就像這樣:
func Print(s[] string) {
for _, v := range s {
fmt.Print(v)
}
}
現在,我們突然希望打印一個整數,所以我們相應地改變了代碼。
func Print(s[] int) {
for _, v := range s {
fmt.Print(v)
}
}
但是每次像這樣更改代碼可能看起來令人生畏,這就是泛型發揮作用的地方。通過將任何類型分配給其泛型形式,我們可以將相同的代碼用於不同的函數。看一下這個:
func Print[T any](s[] T) {
for _, v := range s {
fmt.Print(v)
}
}
在這裏,我們將 "T"
定義爲 any
類型。這個任意類型允許我們在同一個函數中解析不同類型的變量。S
是相應的變量,它是 T
類型的一個切片。現在,調用該方法,我們可以在同一個函數中打印一個字符串和一個整數。
func main() {
str := []string{"Hello", "Again Hello"}
intArray := []int{1, 2, 3}
Print(str)
Print(intArray)
}
Go 中的泛型是如何工作的?
Go 中的泛型是使用類型參數實現的,它允許創建可以在不同類型上操作的泛型函數和數據結構,而無需顯式類型轉換。
考慮以下示例,其中類型參數 “T”
是使用 “any”
關鍵字定義的,該關鍵字指定該函數可以與任何類型一起使用。
func Swap[T any](a, b * T) {
*a, *b = *b, *a
}
函數體然後執行傳入的兩個指針指向的值的簡單交換。
當函數被調用時,編譯器爲與函數一起使用的類型生成特定版本的函數。例如,如果函數被用於兩個整數指針,編譯器會生成一個操作整數的函數版本。
類型參數是什麼?
在 Go 中,類型參數是使用方括號括起的類型參數列表來指定的,緊跟在函數、數據結構或接口名稱之後。類型參數由單個大寫字母或一系列大寫字母表示,並用尖括號括起來。
類型參數用於在 Go 中創建通用函數、數據結構和接口。類型參數是在編譯時確定的類型的佔位符。
// 這裏的 T 是類型參數,any 是類型約束;
// 這裏表示 T 可以是任何類型。
func Print[T any](s []T) {
for _, v := range s {
fmt.Print(v)
}
}
使用:
func main() {
str := []string{"Hello", "Again Hello"}
intArray := []int{1, 2, 3}
Print(str)
Print(intArray)
}
例如,考慮上面的示例,顯式了使用類型參數的函數聲明。在這個函數中,類型參數由大寫字母 "T"
表示。"any"
關鍵字表示函數可以使用任何類型。當調用此函數時,類型參數將被替換爲傳遞給函數的實際類型。
類型參數使得在 Go 語言中可以創建更通用和可重用的代碼,因爲它允許函數和數據結構可以處理不同類型的數據。
在泛型中使用類型參數
在上面的例子中,我們看到了如何在同一個函數下結合多種類型的變量。
在這個例子中,使用 "any"
關鍵字聲明瞭一個帶有類型參數 "T"
的函數。"any"
關鍵字表示該函數可以處理任何類型。該函數以類型 "T"
的切片作爲參數,並打印其內容。
T
是類型參數,any
是類型約束;這裏表示T
可以是任何類型。
要使用此功能,您可以使用下面給出的任何類型的切片來調用它:
intSlice := []int{
1, 2, 3, 4, 5,
}
stringSlice := []string{
"apple", "banana", "cherry",
}
Print(intSlice) // prints 1 2 3 4 5
Print(stringSlice) // prints apple banana cherry
在這個例子中,Print
函數被調用時使用了整數切片和字符串切片。類型參數 "T"
被實際傳遞給函數的參數類型所替換。
您還可以使用類型參數在 Go 中創建通用數據結構和接口。以下是一個使用類型參數的通用數據結構示例:
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() T {
if len(s.items) == 0 {
panic("stack is empty")
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}
-
在這裏,使用
“any”
關鍵字聲明瞭帶有類型參數“T”
的棧數據結構。 -
Push
方法接受類型爲"T"
的項目作爲參數,並將其添加到棧中。 -
Pop
方法從棧頂返回一個類型爲"T"
的項目。
要使用這種數據結構,您可以創建任何類型的棧:
intStack := &Stack[int]{}
stringStack := &Stack[string]{}
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
stringStack.Push("apple")
stringStack.Push("banana")
stringStack.Push("cherry")
fmt.Println(intStack.Pop()) // prints 3
fmt.Println(stringStack.Pop()) // prints cherry
在這個例子中,創建了兩個棧,一個是 int
類型,另一個是 string
類型。類型參數 “T”
被替換爲創建棧的實際類型。
類型約束
泛型中的類型約束定義了可以與泛型函數或數據結構一起使用的類型集合。類型約束允許編譯器強制執行類型安全,並確保只有兼容的類型與泛型結構一起使用。
類型約束使用 "interface"
關鍵字指定,後跟接口的名稱和類型必須實現的方法。例如,考慮以下使用類型約束的通用函數:
func Equal[T comparable](a, b T) T {
if a == b {
return a
}
return b
}
在這個例子中,類型參數 "T"
受到 "comparable"
接口的約束,該接口要求類型可以進行 ==
或 !=
比較。這確保了函數只能被支持比較的類型調用。
comparable
是一個內置接口,用於將泛型類型參數限制爲僅支持比較運算符(!= ,和 ==)的類型。
comparable
接口是由 Go 語言規範隱式定義的,並不需要在代碼中顯式定義。這意味着任何支持比較運算符的類型都可以作爲 Equal
函數的類型參數,而無需額外聲明 comparable
接口。
類型約束也可以是用戶定義的接口,它允許對可以與通用函數或數據結構一起使用的類型進行更具體的約束。例如,考慮以下用戶定義的接口:
type Number interface {
Add(other Number) Number
Sub(other Number) Number
Mul(other Number) Number
Div(other Number) Number
}
該接口定義了一組方法,一個類型必須實現這些方法才能被視爲 “Number”
。使用該接口作爲類型約束的泛型函數或數據結構只能與實現了這些方法的類型一起使用,確保類型安全和兼容性。
Go 中的泛型類型約束提供了一種確保類型安全並限制可以與泛型結構一起使用的類型集的方法,同時仍然允許泛型提供的靈活性和可重用性。
在 Golang 中使用泛型的示例
這裏有一些在 Go 中使用泛型的例子:
通用函數
該函數接受任何類型 T
的切片和類型 T
的值,並返回該值在切片中的索引。類型參數中的 any
關鍵字指定可以使用任何類型。
func findIndex[T any](slice []T, value T) int {
for i, v := range slice {
if reflect.DeepEqual(v, value) {
return i
}
}
return -1
}
通用類型
這定義了一個通用的棧類型,可以保存任何類型 T
的元素。關鍵字 any
指定任何類型都可以用作元素類型。
type Stack[T any] []T
func (s *Stack[T]) Push(value T) {
*s = append(*s, value)
}
func (s *Stack[T]) Pop() T {
if len(*s) == 0 {
panic("Stack is empty")
}
value := (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
return value
}
類型參數的約束
這定義了對類型參數 T
的類型約束,要求其實現 Equatable
接口。這允許 findIndex
函數使用 Equals
方法來比較類型 T 的值。
type Equatable interface {
Equals(other interface{}) bool
}
func findIndex[T Equatable](slice []T, value T) int {
for i, v := range slice {
if v.Equals(value) {
return i
}
}
return -1
}
支持多種數據類型的加法
讓我們編寫一個函數 SumGenerics
,它對各種數值類型進行加法操作,比如 int
,int16
,int32
,int64
,int8
,float32
和 float64
。
func SumGenerics[T int | int16 | int32 | int64 | int8 | float32 | float64](a, b T) T {
return a + b
}
func main() {
sumInt := SumGenerics[int](2, 3) // returns 5
sumFloat := SumGenerics[float32](2.5, 3.5) // returns 6.0
sumInt64 := SumGenerics[int64](10, 20) // returns 30
println(sumInt, sumFloat, sumInt64)
}
在上面的代碼中,我們可以看到,在調用泛型函數時通過在方括號 []
中指定類型參數,我們可以對不同的數值類型執行加法操作。類型約束確保只有指定的類型 [T int, int16, int32, int64, int8, float32, or float64]
可以用作類型參數。
map 中的泛型
map
的泛型需要兩種類型,一個 key
類型和一個 value
類型。值類型沒有任何限制,但鍵類型應該始終滿足 comparable
約束。
// keys 返回一個 map 的所有 key
// m 參數是使用了 K 和 V 泛型的 map
// K 是使用了 comparable 約束的泛型,也就是說 K 必須支持 != 和 == 操作
// V 是使用了 any 約束的泛型,也就是說 V 可以是任意類型
func keys[K comparable, V any](m map[K]V) []K {
// 創建一個長度爲 map 長度的 K 類型的 slice
key := make([]K, len(m))
i := 0
for k, _ := range m {
key[i] = k
i++
}
return key
}
結構體中的泛型
Go 允許使用類型參數定義 struct
。語法類似於泛型函數。類型參數可用於結構體上的方法和數據成員。
// T 是類型參數,使用了 any 約束
type MyStruct[T any] struct {
inner T
}
// 在 struct 方法中不允許使用新的類型參數
func (m *MyStruct[T]) Get() T {
return m.inner
}
func (m *MyStruct[T]) Set(v T) {
m.inner = v
}
在結構體方法中不允許定義新的類型參數,但在結構體定義中定義的類型參數可以在方法中使用。
多個泛型參數
泛型可以嵌套在其他類型中。在函數或結構中定義的類型參數可以傳遞給具有類型參數的任何其他類型。
// 擁有兩個泛型類型的泛型 struct
type Entries[K comparable, V any] struct {
Key K
Value V
}
// entries 函數返回一個 Entries 的 slice,代表了傳入的 map 的所有 key 和 value
// K 和 V 是泛型類型參數,K 有 comparable 約束,V 沒有約束
func entries[K comparable, V any](m map[K]V) []*Entries[K, V] {
// 創建一個 Entries 類型的 slice,傳入 K 和 V 類型參數
e := make([]*Entries[K, V], len(m))
i := 0
for k, v := range m {
// 定義一個 Entries 類型的變量
newEntry := new(Entries[K, V])
newEntry.Key = k
newEntry.Value = v
e[i] = newEntry
i++
}
return e
}
我們可以通過逗號分隔多個類型參數來實現多個泛型參數。
類型並集
我們知道,在以往的 interface
定義中,往往都是隻包含了方法定義的,如下面這樣:
type Stringer interface {
String() string
}
而現在,我們還可以在 interface
中定義多個類型,如下面這樣:
type Number interface {
int | int8
}
這種帶有類型的 interface
可以幫助我們寫出更加簡潔的泛型代碼,因爲它可以用一個 intreface
來表示多個不同的相似類型。但是這種帶有類型的接口,不能用於定義變量,只能用於泛型的類型約束中。
在上面的泛型加法實現中,我們使用了 [T int | int16 | int32 | int64 | int8 | float32 | float64]
這種方式來給 T
定義了一個約束, 但是這種方式並不是很優雅,我們可以將約束定義爲一個 interface
,然後將 interface
作爲約束。
我們稱通過
|
連接的多個類型的interface
爲類型並集。
type Number interface {
int | int8 | int16 | int32 | int64 | float32 | float64
}
使用 Number
來作爲泛型的約束:
// T 可以是任意 int 或 float 類型
// T 只能是支持算術運算的類型
func Min[T Number](x, y T) T {
if x < y {
return x
}
return y
}
使用多種類型的聯合允許執行這些類型支持的常見操作,並編寫適用於聯合中所有類型的代碼。
這些只是一些示例,說明了在 Go 中如何使用泛型來編寫更靈活、可重用的代碼。
類型交集
類似的,還有一種類型交集的概念,它是通過在 interface
中寫多行類型來實現的:每一行定義了一種或多種類型的並集。
type AllInt interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}
type Uint interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}
在上面的代碼中,AllInt
是一個類型並集,它包含了所有整數類型。Uint
是一個類型並集,它包含了所有無符號整數類型。
下面是一個使用類型交集的例子:
// 取 AllInt 和 Uint 的交集
// 也就是:~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
type Int interface {
AllInt
Uint
}
其實它的最終的結果等同於:
type Int interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}
除此之外,如果其中不同行之間沒有任何交集,那麼它們的交集就是空集。在現實中可能意義不大。
泛型接口和泛型結構體
在 Go 中,struct
和 interface
都可以使用泛型。
例如,在下面的代碼片段中,類型參數 T
的任何值只支持 String
方法 - 您可以使用 len()
或對其進行任何其他操作。
// Stringer 是一個約束
type Stringer interface {
String() string
}
// T 需要實現 Stringer 接口,T 只能執行 Stringer 接口中定義的操作
func stringer[T Stringer](s T) string {
return s.String()
}
再比如,下面的例子中,是一個使用了泛型的 struct
:
type Person[T int] struct {
age T
}
func (p Person[T]) Age() T {
return p.age
}
使用這個 struct
:
var p Person[int]
p.age = 10
fmt.Println(p.Age()) // 10
使用 ~ 指定底層類型
在 Go 中,定義了一個 cmp.Ordered
接口:
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}
這個聲明表示 Ordered
是所有整數、浮點數、和字符串類型的集合。
對於類型約束,我們通常不關心特定類型,比如 string
,我們對所有字符串類型感興趣,所以我們使用 ~string
來表示所有字符串類型的集合。~string
表達式表示所有底層類型爲 string
的類型的集合,這包括類型 string
本身以及所有使用如 type MyString string
聲明定義的類型。
下面是一個錯誤的例子:
type Slice[T int] struct {
}
var s1 Slice[int] // 正確
type MyInt int
// 錯誤。MyInt 類型底層類型是 int 但並不是 int 類型,不符合 Slice[T] 的類型約束
var s2 Slice[MyInt]
正確的做法是,將 Slice
的類型約束脩改爲 ~int
:
// T 的底層類型是 int 即可,不一定是 int 類型
type Slice[T ~int] struct {
}
var s1 Slice[int] // 正確
type MyInt int
// 錯誤。MyInt 類型底層類型是 int 但並不是 int 類型,不符合 Slice[T] 的類型約束
var s2 Slice[MyInt]
使用 ~
有個限制:
-
~
後面的類型不能爲接口 -
~
後面的類型必須爲基礎類型
比如,下面是一個錯誤的例子:
// 錯誤:Invalid use of ~ ('cmp.Ordered' is an interface)
type Ab[T ~cmp.Ordered] struct {
}
泛型的限制
儘管 Go 語言中的泛型帶來了許多好處和新的可能性,但它們的實現仍然存在一些限制和挑戰。以下是 Go 語言中泛型的一些主要限制:
-
性能:在 Go 語言中,泛型的一個主要問題是對性能的潛在影響。引入泛型後,Go 編譯器需要在編譯時爲不同類型生成代碼,這可能導致更大的二進制文件和更慢的編譯時間。
-
類型約束:Go 語言的泛型實現依賴於類型約束來確保類型安全。然而,這些約束可能會限制可以與泛型函數和數據結構一起使用的類型。
-
語法複雜性:聲明和使用泛型函數和數據結構的語法可能會很複雜,尤其對於初學者來說難以理解。
-
錯誤消息:Go 編譯器生成的與泛型相關的問題的錯誤消息可能難以理解,使得調試和故障排除更具挑戰性。
-
代碼可讀性:在 Go 中,泛型有時會使代碼變得不太易讀,更難理解,特別是在大量使用類型約束和類型參數的情況下。
-
無法進行切換:當您想要從一個基礎泛型類型切換到另一個時,使用泛型是不可能的。唯一的方法是使用接口,並在運行時運行類型切換函數。
總結
泛型爲創建通用接口、結構體和函數提供了一種強大而簡單的方法。
它們可以減少冗餘信息,並且至少在某些情況下,提供了一種比反射更優越的替代方案。
當然,長時間以來,泛型受到激烈反對的主要原因是它們可能使代碼更難閱讀和解析,這似乎與 Go 語言的簡潔性相悖。 鑑於此,本文也不會介紹太多複雜的泛型用法,上面提到的這些用法應該可以覆蓋 90% 以上的使用場景了,因爲複雜的代碼必然會犧牲不少代碼的可維護性。
另一方面,泛型是語言中的一個很好且必要的補充,如果明智地使用並且在有意義的地方使用的話。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/D14p4LmN1lNihgXGgKCqbQ