10 分鐘瞭解 Golang 泛型
泛型是 Golang 在 1.18 版本引入的強大工具,能夠幫助我們在合適的場合實現簡潔、可讀、可維護的代碼。原文: Go Generics: Everything You Need To Know[1]
導言
可能有人會覺得 Go 泛型很難,因此想要借鑑其他語言(比如 Java、NodeJS)的泛型實踐。事實上 Go 泛型很容易學,本文希望能幫助讀者更好的理解 Go 泛型。
👉注:本文不會將 Go 泛型與其他語言的泛型實現進行比較,但會幫助你理解 Go 泛型元素背後的上下文、結構及其原理。
前置條件
要編寫本文中的示例代碼,需要:
-
在計算機上安裝
Go 1.18+
-
對 Golang 結構、類型、函數和方法有最低限度的瞭解
概述
在 2020 年之前,Go 泛型既是風險也是機遇。
當 Go 泛型在 2009 年左右被首次提出時(當時該編程語言已經公開),該特性是 Go 語言的主要弱點之一(Go 團隊調查發現)。
此後,Go 團隊在 Go 草案設計中接受了許多泛型實現,並在 Go 1.18 版本 [2] 中首次引入了泛型。
Go 2020 調查顯示,自 Go 語言誕生以來,Go 社區一直要求引入泛型功能。
Go 開發人員(以及 Go 團隊成員)看到這一缺陷阻礙了 Go 語言的發展,同時,如果得到修復,Go 將具有更大的靈活性和性能。
什麼是程序設計中的泛型?
根據維基百科 [3] 的解釋,泛型編程是一種計算機編程風格,在這種編程風格中,算法的具體類型可以在以後指定。
簡單解釋一下:_泛型_是一種可以與多種類型結合使用的類型,_泛型函數_是一種可以與多種類型結合使用的函數。
☝️ 簡單提一下:儘管 "泛型" 在過去和現在都可以通過
interface{}
、反射包或代碼生成器在 Go 中實現,但還是要提一下在使用這三種方法之前需要仔細考慮。
爲了幫助我們以實用的方式理解和學習 Go 泛型,我們將在本文稍後部分提供示例代碼。
但要知道,既然 Go 泛型已經可用,就可以消除模板代碼,不必擔心向後兼容問題,同時還能編寫可重用、類型安全和可維護的代碼。
那麼...... 爲什麼需要 Go 泛型?
簡而言之,最多可提高 20% 性能。
根據 Go 博客的描述,Go 泛型爲 Go 語言增加了三個主要組件:
-
函數和類型的類型參數。
-
將接口類型定義爲類型集,包括沒有方法的類型。
-
類型推導,允許在調用函數時省略類型參數。
在 Go 1.18 之前沒有這種功能嗎?
從技術上講,早在 Go 泛型發佈之前,Go 就有一些處理 "泛型" 的方法:
-
使用 "泛型" 代碼生成器生成 Go 軟件包,如 https://github.com/cheekybits/genny[4]
-
使用帶有
switch
語句和類型轉換的接口 -
使用帶有參數驗證的反射軟件包
然而,與正式的 Go 泛型相比,這些方法還遠遠不夠,有如下缺點:
-
使用類型
switch
和轉換時性能較低 -
類型安全損耗:接口和反射不是類型安全的,這意味着代碼可能會傳遞任何類型,而這些類型在編譯過程中會被忽略,從而在運行時引起
panic
。 -
Go 項目構建更復雜,編譯時間更長
-
可能需要對調用代碼和函數代碼進行類型斷言
-
缺乏對自定義派生類型的支持
-
代碼可讀性差(使用反射時更明顯)
👉注:上述觀點並不意味着在 Go 編程中使用接口或反射包不好;它們還有其他用途,應該在合適的場景下應用。
巧合的是,上述幾點 ☝️ 使 Go 泛型適合處理目前在 Go 中的泛型實現,因爲:
-
類型安全 (運行時不會丟失類型,也不需要類型驗證、切換或轉換)
-
高性能
-
Go IDE 的支持
-
向後兼容 (使用 Go 1.18+ 重構後,舊版代碼仍可運行)
-
對自定義數據類型的高度支持
入門:使用 Go 泛型
在開始重構之前,我們藉助一個迷你 Go 程序來了解 Go 泛型使用的一些術語和邏輯。
作爲實操案例,我們將首先在不使用 Go 泛型的情況下解決 Leetcode 問題。然後,隨着我們對這一主題的瞭解加深,我們將使用 Go 泛型對其進行重構。
Leetcode 問題
有幾家公司在技術面試時都問過這個問題,我們對措辭稍作改動,但邏輯不變。Leetcode 鏈接爲:https://leetcode.com/problems/contains-duplicate[5]。
📌問題:給定一個整型(int 或 in32 或 int64)數組
nums
,如果任何值在數組中至少出現兩次,則返回true
;如果每個元素都不同,則返回false
。
現在,我們在不使用 Go 泛型的情況下解決這個問題。
進入開發目錄,創建一個新的 Go 項目目錄,名稱不限。我將其命名爲 leetcode1
。然後將目錄更改爲新創建的項目目錄。
按照慣例,我們在終端的項目根目錄下運行 go mod init github.com/username/leetcode1
,爲項目創建一個 Go 模塊。
❗️ 記住:不要忘記將 username 替換爲你自己的 Github 用戶名
接下來,創建 leetcode.go
文件並將下面的代碼複製進去:
package main
import "fmt"
type FilterInt map[int]bool
type FilterInt32 map[int32]bool
type FilterInt64 map[int64]bool
func main() {
data := []int{1, 3, 4, 4, 5, 8, 7, 3, 2} // sample array
data32 := []int32{1, 3, 4, 4, 5, 8, 7, 3, 2} // sample array
data64 := []int64{1, 3, 4, 4, 5, 8, 7, 3, 2} // sample array
fmt.Printf("Duplicate found %t\n", FindDuplicateInt(data))
fmt.Printf("Duplicate found %t\n", FindDuplicateInt32(data32))
fmt.Printf("Duplicate found %t\n", FindDuplicateInt64(data64))
}
func FindDuplicateInt(data []int) bool {
inArray := FilterInt{}
for _, datum := range data {
if inArray.has(datum) {
return true
}
inArray.add(datum)
}
return false
}
func FindDuplicateInt32(data []int32) bool {
inArray := FilterInt32{}
for _, datum := range data {
if inArray.has(datum) {
return true
}
inArray.add(datum)
}
return false
}
func FindDuplicateInt64(data []int64) bool {
inArray := FilterInt64{}
for _, datum := range data {
if inArray.has(datum) {
return true
}
inArray.add(datum)
}
return false
}
func (r FilterInt) add(datum int) {
r[datum] = true
}
func (r FilterInt32) add(datum int32) {
r[datum] = true
}
func (r FilterInt64) add(datum int64) {
r[datum] = true
}
func (r FilterInt) has(datum int) bool {
_, ok := r[datum]
return ok
}
func (r FilterInt32) has(datum int32) bool {
_, ok := r[datum]
return ok
}
func (r FilterInt64) has(datum int64) bool {
_, ok := r[datum]
return ok
}
再看一下 Leetcode 的問題,程序應該檢查輸入的數組(可以是 INT、INT32 或 INT64),並找出是否有重複數據,如果有則返回 true
,否則返回 false
,上面這段代碼就是完成這個任務的。
在第 10、11 和 12 行,分別提供了 int
、int32
和 int64
類型數據的示例數組。
在第 5、6 和 7 行,分別創建了關鍵字類型爲 int
、int32
和 int64
的 map
類型 FilterInt
、FilterInt32
和 FilterInt64
。
所有類型 map
的值都是布爾值,所有類型都有相同的 has
和 add
方法。從本質上講,add
方法將接受 datum
參數,並在 map
中創建值爲 true
的鍵。根據 map
是否包含作爲 datum
傳入的鍵,has
方法將返回 true
或 false
。
現在,第 18 行的函數 FindDuplicateInt
、第 29 行的函數 FindDuplicateInt32
和第 40 行的函數 FindDuplicateInt64
實現了相同的邏輯,即驗證所提供的數據中是否存在重複數據,如果發現重複數據,則返回 true
,否則返回 false
。
看看這些重複代碼。
有沒有讓你感到噁心🤕?
總之,如果我們在終端運行項目根目錄下的 go run leetcode.go
,就會編譯成功並運行。輸出結果應該與此類似:
Duplicate found true
Duplicate found true
Duplicate found true
如果我們要查找 float32
、float64
或字符串的重複內容,該怎麼辦?
我們可以爲每種類型編寫一個實現,爲不同類型明確編寫多個函數,或者使用接口,或者通過包生成 "泛型" 代碼。這就是 "泛型" 誕生的過程。
通過泛型,我們可以編寫泛型函數來替代多個函數,或使用帶有類型轉換的接口。
接下來我們用泛型來重構代碼,但首先需要熟悉一些術語和概念。
泛型基礎知識
1. 類型參數
上圖描述的是泛型函數 FindDuplicate
,T
是類型參數,any
是類型參數的約束條件(接下來將討論約束條件)。
類型參數就像一個抽象的數據層,通常用緊跟函數或類型名稱的方括號中的大寫字母(多爲字母 T)來表示。下面是一些例子:
...
// map type with type parameter T and constraint comparable
type Filter[T comparable] map[T]bool
...
...
// Function FindDuplicate with type parameter T and constraint any
func FindDuplicate[T any](data T "T any") bool {
// find duplicate code
}
...
2. 類型推導
泛型函數必須瞭解其支持的數據類型,才能正常運行。
🎯要點:泛型類型參數的約束條件是在編譯時由調用代碼確定的代表單一類型的一組類型。
進一步來說,類型參數的約束代表了一系列可允許的類型,但在編譯時,類型參數只代表一種類型,因爲 Go 是一種強類型的靜態檢查語言。
❗️提醒:由於 Go 是一種強類型的靜態語言,因此會在應用程序編譯期間而非運行時檢查類型。Go 泛型解決了這個問題。
類型由調用代碼類型推導提供,如果泛型類型參數的約束條件不允許使用該類型,代碼將無法編譯。
由於類型是通過約束知道的,因此在大多數情況下,編譯器可以在編譯時推斷出參數類型。
通過類型推導,可以避免從調用代碼中爲泛型函數或泛型類型實例化進行人工類型推導。
👉注意:如果編譯器無法推斷類型(即類型推導失敗),可以在實例化時或在調用代碼中手動指定類型。
下面是 FindDuplicate
泛型函數的一個很好的示例:
我們可以忽略調用代碼中的 [int]
,因爲編譯器會推斷出[int]
,但我更傾向於加入[int]
以提高代碼的可讀性。
3. 約束
在引入泛型之前,Go 接口用於定義方法集。然而,隨着泛型約束的引入,接口現在既可以定義類型集,也可以定義方法集。
約束是用於指定允許使用的泛型的接口,在上述 FindDuplicate
函數中使用了 any
約束。
❗️Pro 提示:除非必要,否則避免使用
any
接口約束。
在底層實現上,any
關鍵字只是一個空接口,這意味着可以用 interface{}
替換,編譯時不會出現任何錯誤。
上述接口約束允許使用 int
、int16
、int32
和 int64
類型。這些類型是約束聯合體,用管道符 |
分隔類型。
約束在以下幾個方面有好處:
-
通過類型參數定義了一組允許的類型
-
明確發現泛型函數的誤用
-
提高代碼可讀性
-
有助於編寫更具可維護性、可重用性和可測試性的代碼
☝️ 簡單提一下:使用約束時有一個小問題
請看下面的代碼:
package main
import "fmt"
type CustomType int16
func main() {
var value CustomType
value = 2
printValue(value)
}
func printValue[T int16](value T "T int16") {
fmt.Printf("Value %d", value)
}
在上面的代碼中,第 5 行定義了一個名爲 CustomType
的自定義類型,其基礎類型爲 int16
。
在第 8 行,聲明瞭一個以 CustomType
爲類型的變量,並在第 9 行爲其賦值。
然後,在第 10 行調用帶有值的 printValue
泛型函數。
...🤔
...🤔
你認爲代碼可以編譯運行嗎?
如果我們在終端執行 go run custom-generics.go
,就會出現這樣的錯誤。
./custom-type-generics.go:10:12: CustomType does not implement int16 (possibly missing ~ for int16 in constraint int16)
儘管自定義類型 CustomType
是 int16
類型,但 printValue
泛型函數的類型參數約束無法識別。
鑑於函數約束不允許使用該類型,這也是合理的。不過,可以修改 printValue
函數,使其接受我們的自定義類型。
現在,更新 printValue
函數如下:
func printValue[T int16 | CustomType](value T "T int16 | CustomType") {
fmt.Println(value)
}
使用管道操作符,我們將自定義類型 CustomType
添加到 printValue
泛型函數類型參數的約束中,現在有了一個聯合約束。
如果我們再次運行該程序,編譯和運行都不會出現任何錯誤。
但是,等等!爲什麼需要 int16
類型和 "int16
" 類型的約束聯合?
我們將在下一節介紹波浪線 ~
運算符。
4. 波浪線 (Tilde) 運算符和基礎類型
幸運的是,Go 1.18 通過波浪線運算符引入了底層類型,波浪線運算符允許約束支持底層類型。
在上一步代碼示例中,CustomType
類型的底層類型是 int16
。現在,我們使用 ~
波浪線更新 printValue
泛型函數類型參數的約束,如下所示:
func printValue[T ~int16](value T "T ~int16") {
fmt.Println(value)
}
新代碼應該是這樣的:
package main
import "fmt"
type CustomType int16
func main() {
var value CustomType
value = 2
printValue(value)
}
func printValue[T ~int16](value T "T ~int16") {
fmt.Printf("Value %d", value)
}
再次運行程序,應該可以成功編譯和運行。我們刪除了約束聯合,並在約束中的 int16
類型前用 ~
波浪線運算符替換了 CustomType
。
編譯器現在可以理解,CustomType
類型之所以可以使用,僅僅是因爲它的底層類型是 int16
。
💡 簡單來說,
~
告訴約束接受任何int16
類型以及任何以int16
作爲底層類型的類型。
下面是一個泛型約束接口示例,它也允許函數聲明:
type Number interface {
int | float32 | float64
IsEven() bool
}
不過,下一步還有更多東西要學。
5. 預定義約束
Go 團隊非常慷慨的爲我們提供了一個常用約束的預定義包,可在 golang.org/x/exp/constraints[6] 找到。
以下是預定義約束包中包含的約束示例:
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
type Integer interface {
Signed | Unsigned
}
type Float interface {
~float32 | ~float64
}
type Ordered interface {
Integer | Float | ~string
}
因此,我們可以更新之前示例中的 printValue
泛型函數,使其接受所有整數,具體方法如下。
func printValue[T Integer](value T "T Integer") {
fmt.Println(value)
}
❗️ 記住:不要忘記導入預定義約束包 golang.org/x/exp/constraints。
重構 Leetcode 示例
現在我們對泛型有了一些瞭解,接下來重構 FindDuplicate
程序,通過泛型在整數、浮點數和字符串類型的切片及其底層類型中查找是否有重複數據。
具體修改爲:
-
創建允許使用整數、浮點和字符串及其底層類型的接口約束
-
使用
go get
將約束包下載到項目中,在終端的 Leetcode 根目錄中執行如下指令:
go get -u golang.org/x/exp/constraints
- 添加到項目中後,在主函數上方創建名爲
AllowedData
的約束,如下所示:
type AllowedData interface {
constraints.Ordered
}
constraints.Ordered
是一種約束,允許任何使用支持比較運算符(如 ≤=≥===)的有序類型。
👉注:可以在泛型函數中使用
constraint.Ordered
,而無需創建新的接口約束。不過,爲了便於學習,我們還是創建了自己的約束AllowData
。
- 接下來,刪除類型 map 中的所有 FilterIntX 類型,創建一個名爲
Filter
的新類型,如下所示,該類型以T
爲類型參數,以AllowedData
爲約束條件:
type Filter[T AllowedData] map[T]bool
在泛型類型 Filter
前面,聲明瞭 T
類型參數,並指定 map
鍵只接受類型參數的約束 AllowedData
作爲鍵類型。
- 現在,刪除所有 FindDuplicateIntX 函數。然後使用 Go 泛型創建一個新的
FindDuplicate
函數,代碼如下:
func FindDuplicate[T AllowedData](data []T "T AllowedData") bool {
inArray := Filter[T]{}
for _, datum := range data {
if inArray.has(datum) {
return true
}
inArray.add(datum)
}
return false
}
FindDuplicate
函數是一個泛型函數,添加了類型參數 T
,並在函數名後面的方括號中指定了 AllowedData
約束,然後用類型參數 T
定義了切片類型的函數參數,並用類型參數 T
初始化了 inArray
。
👉注:在函數中聲明泛型參數時使用方括號。
- 接下來,更新
has
以及add
方法,如下所示。
func (r Filter[T]) add(datum T) {
r[datum] = true
}
func (r Filter[T]) has(datum T) bool {
_, ok := r[datum]
return ok
}
因爲我們在定義類型 Filter
時已經聲明瞭約束,因此方法中只包含類型參數。
最後,更新調用 FindDuplicateIntX 的調用代碼,使用新的泛型函數 FindDuplicate
,最終代碼如下:
package main
import (
"errors"
"fmt"
"golang.org/x/exp/constraints"
)
type Filter[T AllowedData] map[T]bool
type AllowedData interface {
constraints.Ordered
}
func main() {
data := []int{1, 3, 4, 4, 5, 8, 7, 3, 2} // sample array
data32 := []int32{1, 3, 4, 4, 5, 8, 7, 3, 2} // sample array
data64 := []int64{1, 3, 4, 4, 5, 8, 7, 3, 2} // sample array
fmt.Printf("Duplicate found %t\n", FindDuplicate(data))
fmt.Printf("Duplicate found %t\n", FindDuplicate(data32))
fmt.Printf("Duplicate found %t\n", FindDuplicate(data64))
}
func (r Filter[T]) add(datum T) {
r[datum] = true
}
func (r Filter[T]) has(datum T) bool {
_, ok := r[datum]
return ok
}
func FindDuplicate[T AllowedData](data []T "T AllowedData") bool {
inArray := Filter[T]{}
for _, datum := range data {
if inArray.has(datum) {
return true
}
inArray.add(datum)
}
return false
}
現在執行 go run main.go
,程序成功編譯並運行,預期輸出爲:
Duplicate found true
Duplicate found true
Duplicate found true
我們成功重構了代碼,卻沒有犯複製粘貼的錯誤。
6. 可比較(comparable)約束
可比較約束與相等運算符(即 == 和≠)相關聯。
這是在 Go 1.18 中引入的一個接口,由結構體、指針、接口、管道等類似類型實現。
👉注:Comparable 不用作任何變量的類型。
func Sort[K comparable, T Data](values map[K]T "K comparable, T Data") error {
for k, t := range values {
// code
}
return nil
}
7. 約束類型鏈和類型推導
- 類型鏈
允許一個已定義的類型參數與另一個類型參數複合的做法被稱爲類型鏈。當在泛型結構或函數中定義輔助類型時,這種方法就派上用場了。
示例:
- 約束類型推導
前面我們詳細介紹了類型推導,但與類型鏈無關,可以如下調用上圖中的函數:
c := Example(2)
由於 ~T
是類型參數 T
與任意約束條件的複合體,因此在調用 Example
函數時可以推斷出類型參數 U
。
👉注:2 是整數,是 T 的底層類型。
8. 多類型參數和約束
Go 泛型支持多類型參數,但有一個問題,我們看下面的另一個例子:
package main
import "fmt"
func main() {
printValues(1, 2, 3, "c")
}
func printValues[A, B any, C comparable](a, a1 A, b B, c C "A, B any, C comparable") {
fmt.Println(a, a1, b, c)
}
如果編譯併成功運行,預期輸出結果將是:
1 2 3 c
在函數方括號 [] 中,我們添加了多個類型參數。類型參數 A
和 B
共享同一個約束條件。在函數括號中,參數 a
和 a1
共享同一個類型參數 any
約束條件。
現在更新主函數,如下所示。
...
func main() {
printValues(1, 2.1, 3, "c")
}
...
發生了什麼?
我們將 2 的值從 2 改爲 2.1,如你所知,這會將 2 的數據類型從 int
改爲 float
。當我們再次運行程序時,編譯失敗:
/main.go:6:14: default type float64 of 2.1 does not match inferred type int for A
等等!我們到底有沒有聲明 int 類型?
原因就在這裏 -- 在編譯過程中,編譯器會根據函數括號中的類型參數約束進行推斷。可以看到,a
和 a1
共享同一個類型參數 A
,約束條件是 any
(允許所有類型)。
編譯器會根據調用代碼的變量類型進行推斷,並在編譯過程中使用函數括號中的類型參數約束來檢查類型。
可以看到,a
和 a1
具有相同的類型參數 A
,並帶有 any
約束。因此,a
和 a1
必須具有相同的類型,因爲它們在用於類型推導的函數括號中共享相同的類型參數。
儘管類型參數 A
和 B
共享同一個約束條件,但 b
在函數括號中是獨立的。
何時使用(或不使用)泛型
總之,請記住一點 -- 大多數用例並不需要 Go 泛型。不過,知道什麼時候需要也很有幫助,因爲這樣可以大大提高工作效率。
這裏有一些指導原則:
何時使用 Go 泛型
-
替換多個類型執行相同邏輯的重複代碼,或者替換處理切片、映射和管道等多個類型的重複代碼
-
在處理容器型數據結構(如鏈表、樹和堆)時
-
當代碼邏輯需要對多種類型進行排序、比較和 / 或打印時
何時不使用 Go 泛型
-
當 Go 泛型會讓代碼變得更復雜時
-
當指定函數參數類型時
-
當有可能濫用 Go 泛型時。避免使用 Go 泛型 / 類型參數,除非確定有使用多種類型的重複邏輯
-
當不同類型的實現不同時
-
使用 io.Reader 等讀取器時
侷限性
目前,匿名函數和閉包不支持類型參數。
Go 泛型的測試
由於 Go 泛型支持編寫多種類型的泛型代碼,測試用例將與函數支持的類型數量成正比增長。
結論
本文介紹了 Go 中的泛型、與之相關的新術語,以及如何在類型、函數、方法和結構體中使用泛型。
希望能對大家的學習 Go 有所幫助,但請不要濫用 Go 泛型。
收穫
-
如果使用得當,Go 泛型的功能會非常強大;但要謹慎,因爲能力越大,責任越大。
-
Go 泛型將提高代碼的靈活性和可重用性,同時保持向後兼容,從而爲 Go 語言增添價值。
-
它簡單易用,直接明瞭,學習週期短,練習有助於更好的理解 Go 泛型及其侷限性。
-
過度使用、借用其他語言的泛型實現以及誤解會導致 Go 社區出現反模式和複雜性,風險自擔。
你好,我是俞凡,在 Motorola 做過研發,現在在 Mavenir 做技術工作,對通信、網絡、後端架構、雲原生、DevOps、CICD、區塊鏈、AI 等技術始終保持着濃厚的興趣,平時喜歡閱讀、思考,相信持續學習、終身成長,歡迎一起交流學習。爲了方便大家以後能第一時間看到文章,請朋友們關注公衆號 "DeepNoMind",並設個星標吧,如果能一鍵三連 (轉發、點贊、在看),則能給我帶來更多的支持和動力,激勵我持續寫下去,和大家共同成長進步!
參考資料
[1]
Go Generics: Everything You Need To Know: https://medium.com/the-godev-corner/go-generics-everything-you-need-to-know-52dd3796d8a1
[2]
Go 1.18 版本: https://go.dev/blog/go1.18
[3]
泛型 - 維基百科: https://en.wikipedia.org/wiki/Generic_programming#:~:text=Generic%20programming%20is%20a%20style,specific%20types%20provided%20as%20parameters.
[4]
genny: https://github.com/cheekybits/genny,
[5]
Leetcode: contains duplicate: https://leetcode.com/problems/contains-duplicate/
[6]
golang.org/x/exp/constraints: https://golang.org/x/exp/constraints
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/TY3Nhx6BfD7yx9s8QEw8zg