Go 泛型那些事:能做什么,不能做什么,如何改變慣用模式
閱讀本文大概需要 20?分鐘。
大家好,我是 polarisxu。
前兩天分享了《Learning Go》最后一章作者重寫的 PDF,今天有 gopher 翻譯成了中文,經(jīng)過譯者授權發(fā)布。
譯者:tearon,譯者首發(fā)地址:https://blog.csdn.net/tearon/article/details/124960440。
盡管對功能的重視程度較低,但 Go 并不是一種靜止的、一成不變的編程語言。
新的功能是在經(jīng)過大量的討論和實驗后慢慢采用的。自最初的 Go 1.0 發(fā)布以來,定義符合 Go 語言習慣的模式已經(jīng)發(fā)生了重大變化。首先是在 Go 1.7 中采用了 context。隨后在 Go 1.11 中采用了 modules,在 Go 1.13 中采用了 error wrapping。
下一個重大變化已經(jīng)到來。Go 的 1.18 版本包括了類型參數(shù)的實現(xiàn),也就是俗稱的泛型。在本章中,我們將探討人們?yōu)槭裁葱枰盒停珿o 的泛型實現(xiàn)可以做什么,不能做什么,以及它們?nèi)绾胃淖儜T用模式。
泛型減少重復代碼并提高類型安全性
Go 是一種靜態(tài)類型編程語言,這意味著在編譯時會檢查變量和參數(shù)的類型。內(nèi)置類型(maps、slices、channels)和內(nèi)置函數(shù)(如 len、cap 或 make)能夠接收和返回不同具體類型的值,但在 Go 1.18 之前,用戶定義的 Go 類型和函數(shù)不能如此。
如果你熟悉動態(tài)類型語言,即在代碼運行前不對類型進行安全檢查,你可能不明白泛型有什么大驚小怪的,而且你可能有點不清楚它們是什么。如果你將它們視為 “類型參數(shù)”,會有所幫助。我們習慣于編寫接收參數(shù)的函數(shù),這些參數(shù)的值在函數(shù)被調(diào)用時被指定。在這段代碼中,我們指定 Min 函數(shù)接收兩個 float64 類型的參數(shù)并返回一個 float64:
func?Min(v1,?v2?float64)?float64?{
????if?v1?????????return?v1
????}
????return?v2
}
然而,在某些情況下,編寫函數(shù)或結構體時,參數(shù)或字段的具體類型在使用前是不被指定的,這時泛型就顯得尤為重要了。
泛型類型的使用場景很容易理解。在第 133 頁(指《Learning Go》這本圖書)的 “Code Your Methods for nil Instances” 中,我們看了 ints 的二叉樹。如果我們想為 strings 或 float64s 建立一個二叉樹,并且希望類型安全,有幾種選擇。第一種可能性是為每個類型寫一個自定義的樹,但有那么多重復的代碼很冗長且容易出錯。
在 Go 沒有加入泛型前,避免重復代碼的唯一方法是修改我們的 Tree 實現(xiàn),使其使用一個接口來指定如何對值進行排序。接口如下所示:
type?Orderable?interface?{
????//?Order?returns:
????//?a?value?0?when?the?Orderable?is?less?than?the?supplied?value,
????//?a?value?>?0?when?the?Orderable?is?greater?than?the?supplied?value,
????//?and?0?when?the?two?values?are?equal.
????Order(interface{})?int
}
現(xiàn)在我們有了 Orderable,我們可以修改 Tree 實現(xiàn)來支持它:
type?Tree?struct?{
????val?Orderable
????left,?right?*Tree
}
func?(t?*Tree)?Insert(val?Orderable)?*Tree?{
????if?t?==?nil?{
????????return?&Tree{val:?val}
????}
????switch?comp?:=?val.Order(t.val);?{
????case?comp?0:
????????t.left?=?t.left.Insert(val)
????case?comp?>?0:
????????t.right?=?t.right.Insert(val)
????}
????return?t
}
有了 OrderableInt 類型,我們就可以插入 int 值了:
type?OrderableInt?int
func?(oi?OrderableInt)?Order(val?interface{})?int?{
????return?int(oi?-?val.(OrderableInt))
}
func?main()?{
????var?it?*Tree
????it?=?it.Insert(OrderableInt(5))
????it?=?it.Insert(OrderableInt(3))
????//?etc...
}
雖然這段代碼可以正常工作,但它不允許編譯器驗證插入到我們數(shù)據(jù)結構中的值是否都相同。如果我們還有一個 OrderableString 類型:
type?OrderableString?string
func?(os?OrderableString)?Order(val?interface{})?int?{
????return?strings.Compare(string(os),?val.(string))
}
以下代碼可以編譯:
var?it?*Tree
it?=?it.Insert(OrderableInt(5))
it?=?it.Insert(OrderableString("nope"))
Order 函數(shù)使用 interface{} 來表示傳入的值。這降低了 Go 的主要優(yōu)勢之一,即編譯時類型安全檢查。當我們試圖將 OrderableString 插入已經(jīng)包含 OrderableInt 樹中的代碼時,編譯器接受了該代碼,然而,程序在運行時卻出現(xiàn)了 panic:
panic:?interface?conversion:?interface?{}?is?main.OrderableInt,?not?string
現(xiàn)在 Go 已經(jīng)加入了泛型,有一種方法可以為多種類型的數(shù)據(jù)結構實現(xiàn)一次,并在編譯時檢測不兼容的數(shù)據(jù)。我們將在稍后看到如何正確使用它們。
雖然沒有泛型的數(shù)據(jù)結構很不方便,但真正的限制在于編寫函數(shù)。由于泛型最初不是語言的一部分,因此在 Go 的標準庫中做出了幾個實現(xiàn)決策。例如,Go 沒有編寫多個函數(shù)來處理不同的數(shù)字類型,而是使用 float64 參數(shù)來實現(xiàn) math.Max、math.Min 和 math.Mod 等函數(shù),這些參數(shù)的范圍大到足以準確表示幾乎所有其他數(shù)字類型(例外情況是 int、int64 或 uint,其值大于 2^53 - 1 或小于 -2^53 - 1)。
還有其他一些情況,沒有泛型是不可能的。你不能為一個由接口指定的變量創(chuàng)建一個新的實例,你也不能指定兩個具有相同接口類型的參數(shù)也具有相同的具體類型。如果沒有泛型,你就不能寫一個函數(shù)來處理任何類型的切片,而不需要借助反射,并放棄一些性能和編譯時的類型安全(這就是 sort.Slice 的工作原理)。這意味著從過往的情況來看,對切片進行操作的函數(shù)會對每種類型的切片進行重復實現(xiàn)。
2017 年,我寫了一篇名為 "Closures Are the Generics for Go[1]" 的博文,探討了使用閉包來解決其中一些問題。然而,閉包方法有幾個缺點。它的可讀性要差得多,迫使數(shù)值逃逸到堆中,而且在許多常見的情況下根本不起作用。
其結果是,許多常見的算法,如 map、reduce 和 filter,最終都要為不同的類型重新實現(xiàn)。雖然簡單的算法很容易復制,但許多(如果不是大多數(shù))軟件工程師發(fā)現(xiàn),僅僅因為編譯器不夠聰明而自動重復代碼是令人厭惡的。
介紹 Go 泛型
自 Go 首次發(fā)布以來,就一直有很多人呼吁將泛型加入到該語言中。Go 的開發(fā)負責人 Russ Cox 在 2009 年寫了一篇博文,解釋了為什么最初沒有包含泛型。Go 強調(diào)快速的編譯器、可讀的代碼和良好的執(zhí)行時間,而他們所知道的泛型實現(xiàn)都不能讓他們包含這三者。經(jīng)過對這個問題研究了十年后,Go 團隊有了一個可行的方法,在 Type Parameters Proposal[2] (類型參數(shù)提案)中進行了概述。
我們將通過觀察堆棧來了解泛型在 Go 中是如何工作的。如果你沒有計算機科學背景,堆棧是一種數(shù)據(jù)類型,其中的值是按照后進先出(LIFO)的順序添加和刪除的。它就像一堆待洗的碗碟;先放的碗碟在底部,你只有洗完后來添加的碗碟才能拿到它們。讓我們看看如何使用泛型來制作一個堆棧:
type?Stack[T?any]?struct?{
????vals?[]T
}
func?(s?*Stack[T])?Push(val?T)?{
????s.vals?=?append(s.vals,?val)
}
func?(s?*Stack[T])?Pop()?(T,?bool)?{
????if?len(s.vals)?==?0?{
????????var?zero?T
????????return?zero,?false
????}
????top?:=?s.vals[len(s.vals)-1]
????s.vals?=?s.vals[:len(s.vals)-1]
????return?top,?true
}
有幾點需要注意。首先,我們在類型聲明后面有 [T any],類型參數(shù)被放在括號里。它們的寫法和變量參數(shù)一樣,類型名稱在前,類型約束在后。你可以為類型參數(shù)選擇任何名字,但習慣上使用大寫字母來表示它們。Go 使用接口來指定哪些類型可以使用。如果任何類型都可以使用,則使用新的 universe 塊標識符 any 來指定,它完全等同于 interface{}(從 Go 1.18 及以后的版本開始,你可以在你的代碼中使用 interface{} 的任何地方使用 any,但要注意你的代碼將不能在 1.18 以前的 Go 版本中編譯。)在 Stack 聲明中,我們聲明 vals 的類型為 []T。
接下來,我們看一下我們的方法聲明。就像我們在 vals 聲明中使用 T 一樣。我們在這里也這么做。我們還在接收器部分用 Stack[T] 來指代類型,而不是 Stack。
最后,泛型使零值處理變得有些有趣。在 Pop 中,我們不能直接返回 nil,因為這不是一個有效的值類型,比如 int。為泛型獲得零值的最簡單的方法是用 var 聲明一個變量并返回它。因為根據(jù)定義,如果沒有分配其他值,var 總是將其變量初始化為零值。
使用泛型類型與使用非泛型類型非常相似:
func?main()?{
????var?intStack?Stack[int]
????intStack.Push(10)
????intStack.Push(20)
????intStack.Push(30)
????v,?ok?:=?intStack.Pop()
????fmt.Println(v,?ok)
}
唯一的區(qū)別是,當我們聲明我們的變量時,我們包括了我們想在堆棧中使用的類型,在這個例子中是 int。如果你試圖把一個 string 推到我們的堆棧中,編譯器會捕獲它。添加這一行:
intStack.Push("nope")
產(chǎn)生編譯器錯誤:
cannot?use?"nope"?(untyped?string?constant)?as?int?value
?in?argument?to?intStack.Push
你可以在 The Go Playground[3] 上試試我們的泛型堆棧。讓我們給我們的堆棧添加另一個方法來告訴我們堆棧是否包含一個值:
func?(s?Stack[T])?Contains(val?T)?bool?{
????for?_,?v?:=?range?s.vals?{
????????if?v?==?val?{
????????????return?true
????????}
????}
????return?false
}
不幸的是,這并不能編譯。它給出了錯誤:
invalid?operation:?v?==?val?(type?parameter?T?is?not?comparable?with?==)
就像 interface{} 什么都不說一樣,any 也一樣。我們只能存儲 any 類型的值并檢索它們。要使用==,我們需要一個不同的類型。由于幾乎所有的 Go 類型都可以用 == 和 != 來比較,所以在 universe 塊中定義了一個新的內(nèi)置接口,叫做 comparable。如果我們改變我們對 Stack 的定義,使用 comparable:
type?Stack[T?comparable]?struct?{
????vals?[]T
}
我們就可以使用我們的新方法:
func?main()?{
????var?s?Stack[int]
????s.Push(10)
????s.Push(20)
????s.Push(30)
????fmt.Println(s.Contains(10))?//?true
????fmt.Println(s.Contains(5))??//?false
}
你也可以試試這個更新的堆棧[4]。
稍后,我們將看到如何制作一個泛型二叉樹。在這之前,我們要介紹一些額外的概念:泛型函數(shù),泛型如何與接口一起工作,以及類型約束。
泛型函數(shù)抽象算法
正如我們所暗示的那樣,你也可以編寫泛型函數(shù)。前面我們提到,如果沒有泛型,就很難寫出適用于所有類型的 map、reduce 和 filter 實現(xiàn)。泛型使之變得簡單。下面是類型參數(shù)提案中的實現(xiàn):
//?Map?turns?a?[]T1?to?a?[]T2?using?a?mapping?function.
//?This?function?has?two?type?parameters,?T1?and?T2.
//?This?works?with?slices?of?any?type.
func?Map[T1,?T2?any](s?[]T1,?f?func(T1?"T1,?T2?any")?T2)?[]T2?{
????r?:=?make([]T2,?len(s))
????for?i,?v?:=?range?s?{
????????r[i]?=?f(v)
????}
????return?r
}
//?Reduce?reduces?a?[]T1?to?a?single?value?using?a?reduction?function.
func?Reduce[T1,?T2?any](s?[]T1,?initializer?T2,?f?func(T2,?T1?"T1,?T2?any")?T2)?T2?{
????r?:=?initializer
????for?_,?v?:=?range?s?{
????????r?=?f(r,?v)
????}
????return?r
}
//?Filter?filters?values?from?a?slice?using?a?filter?function.
//?It?returns?a?new?slice?with?only?the?elements?of?s
//?for?which?f?returned?true.
func?Filter[T?any](s?[]T,?f?func(T?"T?any")?bool)?[]T?{
????var?r?[]T
????for?_,?v?:=?range?s?{
????????if?f(v)?{
????????????r?=?append(r,?v)
????????}
????}
????return?r
}
函數(shù)將其類型參數(shù)放在函數(shù)名之后,變量參數(shù)之前。Map 和 Reduce 有兩個類型參數(shù),都是 any 類型,而 Filter 有一個。當我們運行以下代碼時:
words?:=?[]string{"One",?"Potato",?"Two",?"Potato"}
filtered?:=?Filter(words,?func(s?string)?bool?{
????return?s?!=?"Potato"
})
fmt.Println(filtered)
lengths?:=?Map(filtered,?func(s?string)?int?{
????return?len(s)
})
fmt.Println(lengths)
sum?:=?Reduce(lengths,?0,?func(acc?int,?val?int)?int?{
????return?acc?+?val
})
fmt.Println(sum)
輸出如下:
[One?Two]
[3?3]
6
親自動手試試吧[5]。
泛型和接口
你可以使用任何接口作為類型約束,而不僅僅是 any 和 comparable。例如,假設你想做一個可以容納任意兩個相同類型的值的類型,只要這個類型實現(xiàn)了 fmt.Stringer。泛型使我們有可能在編譯時強制執(zhí)行這一點:
type?Pair[T?fmt.Stringer]?struct?{
????Val1?T
????Val2?T
}
你也可以創(chuàng)建有類型參數(shù)的接口。例如,這里有一個接口,其方法是與指定類型的值進行比較,并返回一個 float64。它還嵌入了 fmt.Stringer:
type?Differ[T?any]?interface?{
????fmt.Stringer
????Diff(T)?float64
}
我們將使用這兩種類型來創(chuàng)建一個比較函數(shù)。這個函數(shù)接收兩個具有 Differ 類型字段的 Pair 實例,并返回具有更接近值的 Pair。
func?FindCloser[T?Differ[T]](pair1,?pair2?Pair[T]?"T?Differ[T]")?Pair[T]?{
????d1?:=?pair1.Val1.Diff(pair1.Val2)
????d2?:=?pair2.Val1.Diff(pair2.Val2)
????if?d1?????????return?pair1
????}
????return?pair2
}
請注意,F(xiàn)indCloser 接收的是具有符合 Differ 接口的字段的 Pair 實例。Pair 要求它的字段都是相同的類型,并且類型符合 fmt.Stringer 接口;這個函數(shù)的選擇性更強。如果一個 Pair 實例中的字段不符合 Differ,編譯器會阻止你用 FindCloser 來使用這個 Pair 實例。我們現(xiàn)在定義了幾個符合 Differ 接口的類型:
type?Point2D?struct?{
????X,?Y?int
}
func?(p2?Point2D)?String()?string?{
????return?fmt.Sprintf("{%d,%d}",?p2.X,?p2.Y)
}
func?(p2?Point2D)?Diff(from?Point2D)?float64?{
????x?:=?p2.X?-?from.X
????y?:=?p2.Y?-?from.Y
????return?math.Sqrt(float64(x*x)?+?float64(y*y))
}
type?Point3D?struct?{
????X,?Y,?Z?int
}
func?(p3?Point3D)?String()?string?{
????return?fmt.Sprintf("{%d,%d,%d}",?p3.X,?p3.Y,?p3.Z)
}
func?(p3?Point3D)?Diff(from?Point3D)?float64?{
????x?:=?p3.X?-?from.X
????y?:=?p3.Y?-?from.Y
????z?:=?p3.Z?-?from.Z
????return?math.Sqrt(float64(x*x)?+?float64(y*y)?+?float64(z*z))
}
下面是使用這段代碼的樣子:
func?main()?{
????pair2Da?:=?Pair[Point2D]{Point2D{1,?1},?Point2D{5,?5}}
????pair2Db?:=?Pair[Point2D]{Point2D{10,?10},?Point2D{15,?5}}
????closer?:=?FindCloser(pair2Da,?pair2Db)
????fmt.Println(closer)
????pair3Da?:=?Pair[Point3D]{Point3D{1,?1,?10},?Point3D{5,?5,?0}}
????pair3Db?:=?Pair[Point3D]{Point3D{10,?10,?10},?Point3D{11,?5,?0}}
????closer2?:=?FindCloser(pair3Da,?pair3Db)
????fmt.Println(closer2)
}
在 The Go Playground[6] 上親自動手運行它。
使用類型約束來指定操作符
還有一件事,我們需要用泛型來表示:操作符。如果我們想寫一個 Min 的泛型,我們需要一種方法來指定我們可以使用比較運算符,比如 < 和 >。Go 泛型通過一個類型元素來實現(xiàn),該元素由一個或多個類型項束組成。由一個接口中的一個或多個類型約束組成:
type?BuiltInOrdered?interface?{
????string?|?int?|?int8?|?int16?|?int32?|?int64?|?float32?|?float64?|
????????uint?|?uint8?|?uint16?|?uint32?|?uint64?|?uintptr
}
我們已經(jīng)在第 146 頁的 "Embedding and Interfaces" 中看到了具有類型元素的接口。在那種情況下,我們嵌入了另一個接口,以表明包含接口的方法集包括嵌入接口的方法。在這里,我們列出了由 | 分隔的具體類型。這指定了哪些類型可以被分配給一個類型參數(shù),以及哪些運算符被支持。允許的操作符是那些對所有列出的類型都有效的操作符。在這種情況下,這些運算符是 ==、!=、>、<、>=、<=、和 +。請注意,在一個類型元素中帶有具體類型限界的接口只作為類型參數(shù)的邊界有效。使用它們作為變量、字段、返回值或參數(shù)的類型是一個編譯時錯誤。
現(xiàn)在我們可以編寫 Min 的泛型版本,并將其與內(nèi)置的 int 類型(或 BuiltInOrdered 中列出的任何其他類型)一起使用。
func?Min[T?BuiltInOrdered](v1,?v2?T?"T?BuiltInOrdered")?T?{
????if?v1?????????return?v1
????}
????return?v2
}
func?main()?{
????a?:=?10
????b?:=?20
????fmt.Println(Min(a,?b))
}
默認情況下,類型限界完全匹配。如果我們試圖用一個用戶定義的類型來使用 Min 而該類型的底層類型是 BuiltInOrdered 中列出的類型之一,我們會得到一個錯誤。這段代碼:
type?MyInt?int
var?myA?MyInt?=?10
var?myB?MyInt?=?20
fmt.Println(Min(myA,?myB))
產(chǎn)生錯誤:
MyInt?does?not?implement?BuiltInOrdered?(possibly?missing?~?for
int?in?constraint?BuiltInOrdered)
錯誤文本給出了如何解決這個問題的提示。如果你想讓一個類型限界對任何將該類型限界作為其底層類型的類型有效,在類型限界前加一個 ~。這就對 BuiltInOrdered 的定義改為:
type?BuiltInOrdered?interface?{
????~string?|?~int?|?~int8?|?~int16?|?~int32?|?~int64?|?~float32?|?~float64?|
????????~uint?|?~uint8?|?~uint16?|?~uint32?|?~uint64?|?~uintptr
}
您可以在 The Go Playground[7] 上看到這個 Min 函數(shù)。
在一個用于類型參數(shù)的接口中,同時擁有類型元素和方法元素是合法的。例如,你可以指定一個類型必須有一個 int 基本類型和一個 String() 字符串方法:
type?PrintableInt?interface?{
????~int
????String()?string
}
請注意,Go 會讓你聲明一個無法實際實例化的類型參數(shù)接口。如果我們在 PrintableInt 中使用 int 而不是 ~int,就不會有符合它的有效類型,因為 int 沒有方法。這可能看起來很糟糕,但編譯器還是會來救你。如果你聲明的類型或函數(shù)有一個不可能的類型參數(shù),任何試圖使用它的行為都會引起編譯器錯誤。假設我們聲明了這些類型:
type?ImpossiblePrintableInt?interface?{
????int
????String()?string
}
type?ImpossibleStruct[T?ImpossiblePrintableInt]?struct?{
????val?T
}
type?MyInt?int
func?(mi?MyInt)?String()?string?{
????return?fmt.Sprint(mi)
}
盡管我們不能實例化 ImpossibleStruct,編譯器對這些聲明都沒有問題。然而,一旦我們嘗試使用 ImpossibleStruct,編譯器就會報錯。這段代碼:
s?:=?ImpossibleStruct[int]{10}
s2?:=?ImpossibleStruct[MyInt]{10}
產(chǎn)生編譯時錯誤:
int?does?not?implement?ImpossiblePrintableInt?(missing?String?method)
MyInt?does?not?implement?ImpossiblePrintableInt?(possibly?missing?~?for
int?in?constraint?ImpossiblePrintableInt)
在 The Go Playground[8] 試試。
除了內(nèi)置的原始類型外,類型限界還可以是切片、映射、數(shù)組、通道、結構體,甚至是函數(shù)。
當你想確保一個類型參數(shù)有一個特定的底層類型和一個或多個方法時,它們是最有用的。
類型推斷和泛型
正如 Go 在使用 := 操作符時支持類型推斷一樣,它也支持類型推斷以簡化對泛型函數(shù)的調(diào)用。你可以在上面對 Map、Filter 和 reduce 的調(diào)用中看到這一點。在某些情況下,類型推斷是不可能的(例如,當一個類型參數(shù)只作為返回值使用時)。當這種情況發(fā)生時,所有的類型參數(shù)都必須被指定。這里有一段略顯愚蠢的代碼,演示了類型推斷不工作的情況:
type?Integer?interface?{
????int?|?int8?|?int16?|?int32?|?int64?|?uint?|?uint8?|?uint16?|?uint32?|?uint64
}
func?Convert[T1,?T2?Integer](in?T1?"T1,?T2?Integer")?T2?{
????return?T2(in)
}
func?main()?{
????var?a?int?=?10
????b?:=?Convert[int,?int64](a?"int,?int64")?//?can't?infer?the?return?type
????fmt.Println(b)
}
在 The Go Playground[9] 上試試。
類型元素限制常量
類型元素還指定了哪些常量可以被分配給泛型類型的變量。像操作符一樣,這些常量需要對類型元素中的所有類型限界有效。在類型元素中,沒有常量可以被分配給 BuiltInOrdered 中列出的每個類型。因此你不能把常量分配給該泛型類型的變量。如果你使用 Integer 接口,下面的代碼將不能編譯,因為你不能把 1,000 分配給一個 8 位的整型:
//?INVALID!
func?PlusOneThousand[T?Integer](in?T?"T?Integer")?T?{
????return?in?+?1_000
}
然而,這是有效的:
//?VALID
func?PlusOneHundred[T?Integer](in?T?"T?Integer")?T?{
????return?in?+?100
}
將泛型函數(shù)與泛型數(shù)據(jù)結構相結合
讓我們回到二叉樹示例,看看如何把我們所學到的一切結合起來,做成一個適用于任何具體類型的單一樹。秘訣在于認識到我們的樹需要的是一個單一的泛型函數(shù),用來比較兩個值并告訴我們它們的順序:
type?OrderableFunc?[T?any]?func(t1,?t2?T)?int
現(xiàn)在我們有了 OrderableFunc,我們可以稍微修改我們的 Tree 實現(xiàn)。首先,我們要把它分成兩種類型,Tree 和 Node:
type?Tree[T?any]?struct?{
????f?OrderableFunc[T]
????root?*Node[T]
}
type?Node[T?any]?struct?{
????val?T
????left,?right?*Node[T]
}
我們用一個構造函數(shù)來構造一個新的 Tree:
func?NewTree[T?any](f?OrderableFunc[T]?"T?any")?*Tree[T]?{
????return?&Tree[T]{
????????f:?f,
????}
}
Tree 的方法非常簡單,因為它們只是調(diào)用 Node 來完成所有實際工作:
func?(t?*Tree[T])?Add(v?T)?{
????t.root?=?t.root.Add(t.f,?v)
}
func?(t?*Tree[T])?Contains(v?T)?bool?{
????return?t.root.Contains(t.f,?v)
}
Node 上的 Add 和 Contains 方法與我們之前看到的非常相似。唯一的區(qū)別是,我們用來排序元素的函數(shù)被傳入:
func?(n?*Node[T])?Add(f?OrderableFunc[T],?v?T)?*Node[T]?{
????if?n?==?nil?{
????????return?&Node[T]{val:?v}
????}
????switch?r?:=?f(v,?n.val);?{
????case?r?<=?-1:
????????n.left?=?n.left.Add(f,?v)
????case?r?>=?1:
????????n.right?=?n.right.Add(f,?v)
????}
????return?n
}
func?(n?*Node[T])?Contains(f?OrderableFunc[T],?v?T)?bool?{
????if?n?==?nil?{
????????return?false
????}
????switch?r?:=?f(v,?n.val);?{
????case?r?<=?-1:
????????return?n.left.Contains(f,?v)
????case?r?>=?1:
????????return?n.right.Contains(f,?v)
????}
????return?true
}
現(xiàn)在我們需要一個匹配 OrderedFunc 定義的函數(shù)。通過利用 BuiltInOrdered,我們可以編寫一個支持任何原始類型的函數(shù):
func?BuiltInOrderable[T?BuiltInOrdered](t1,?t2?T?"T?BuiltInOrdered")?int?{
????if?t1?????????return?-1
????}
????if?t1?>?t2?{
????????return?1
????}
????return?0
}
當我們將 BuiltInOrderable 與我們的 Tree 一起使用時,它看起來像這樣:
t1?:=?NewTree(BuiltInOrderable[int])
t1.Add(10)
t1.Add(30)
t1.Add(15)
fmt.Println(t1.Contains(15))
fmt.Println(t1.Contains(40))
對于結構體,我們有兩種選擇。我們可以寫一個函數(shù):
type?Person?struct?{
????Name?string
????Age?int
}
func?OrderPeople(p1,?p2?Person)?int?{
????out?:=?strings.Compare(p1.Name,?p2.Name)
????if?out?==?0?{
????????out?=?p1.Age?-?p2.Age
????}
????return?out
}
然后我們可以在創(chuàng)建樹的時候把這個函數(shù)傳進去:
func?(p?Person)Order(other?Person)?int?{
????out?:=?strings.Compare(p.Name,?other.Name)
????if?out?==?0?{
????????out?=?p.Age?-?other.Age
????}
????return?out
}
然后我們使用它:
t3?:=?NewTree(Person.Order)
t3.Add(Person{"Bob",?30})
t3.Add(Person{"Maria",?35})
t3.Add(Person{"Bob",?50})
fmt.Println(t3.Contains(Person{"Bob",?30}))
fmt.Println(t3.Contains(Person{"Fred",?25}))
您可以在 The Go Playground[10] 上找到這棵樹的代碼。
遺漏的功能特性
Go 仍然是一種小而專注的語言,Go 的泛型實現(xiàn)并不包括其他語言的泛型實現(xiàn)中的許多功能。以下是 Go 泛型的初始實現(xiàn)中沒有的一些功能。
雖然我們可以建立一個同時適用于用戶定義類型和內(nèi)置類型的單一樹,但是像 Python、Ruby 和 C++ 這樣的語言是以不同的方式解決的這個問題。它們包括運算符重載,這使得用戶定義的類型可以為運算符指定實現(xiàn)。Go 不會增加這一功能。這意味著你不能使用 range 來遍歷用戶定義的容器類型,也不能使用 [] 來對它們進行索引。
省去運算符重載是有充分理由的。首先,Go 中的運算符數(shù)量多得令人吃驚。Go 也沒有函數(shù)或方法重載,你需要一種方法來為不同的類型指定不同的操作符功能。此外,運算符重載會導致代碼更難理解,因為開發(fā)者為符號發(fā)明了巧妙的含義(在 C++中,<< 對某些類型來說意味著 “向左移位”,對其他類型來說意味著 “把右邊的值寫到左邊的值” )。這些都是 Go 試圖避免的可讀性問題。
另一個在最初的 Go 泛型實現(xiàn)中被遺漏的有用功能是方法上的附加類型參數(shù)。回顧 Map / Reduce / Filter 函數(shù),你可能會認為它們作為方法會很有用,就像這樣:
type?functionalSlice[T?any]?[]T
//?THIS?DOES?NOT?WORK
func?(fs?functionalSlice[T])?Map[E?any](f?func(T?"T])?Map[E?any")?E)?functionalSlice[E]?{
????out?:=?make(functionalSlice[E],?len(fs))
????for?i,?v?:=?range?fs?{
????????out[i]?=?f(v)
????}
????return?out
}
//?THIS?DOES?NOT?WORK
func?(fs?functionalSlice[T])?Reduce[E?any](start?E,?f?func(E,?T?"T])?Reduce[E?any")?E)?E?{
????out?:=?start
????for?_,?v?:=?range?fs?{
????????out?=?f(out,?v)
????}
????return?out
}
你可以這樣使用:
var?numStrings?=?functionalSlice[string]{"1",?"2",?"3"}
sum?:=?numStrings.Map(func(s?string)?int?{
????v,?_?:=?strconv.Atoi(s)
????return?v
}).Reduce(0,?func(acc?int,?cur?int)?int?{
????return?acc?+?cur
})
不幸的是,對于函數(shù)式編程的愛好者來說,這并不可行。你需要的不是將方法調(diào)用鏈接在一起,而是將函數(shù)調(diào)用嵌套起來,或者使用更易讀的方法,即一次一次地調(diào)用函數(shù),并將中間值分配給變量。類型參數(shù)提案詳細闡述了排除參數(shù)化方法的原因。
也沒有可變參數(shù)類型參數(shù)。在第 314 頁的 "Build Functions with Reflection to Automate Repetitive Tasks" 中,我們用反射寫了一個包裝函數(shù)來為任何現(xiàn)有函數(shù)計時。這些函數(shù)仍然必須通過反射來處理,因為無法用泛型來處理。任何時候你使用類型參數(shù),你都必須為你需要的每一種類型明確提供一個名稱,所以你不能用任意數(shù)量的不同類型的參數(shù)來表示一個函數(shù)。
Go 泛型中遺漏的其他功能特性更為深奧。這些包括:
特殊化:一個函數(shù)或方法除了泛型版本外,還可以重載一個或多個特定類型的版本。由于 Go 沒有重載功能,所以這個功能不在考慮之列。 局部化(Currying):允許你通過指定一些類型參數(shù),在另一個泛型函數(shù)或類型的基礎上部分實例化一個函數(shù)或類型。 元編程:允許你指定在編譯時運行的代碼來生成在運行時運行的代碼。
慣用 Go 和泛型
泛型的加入顯然改變了一些關于如何習慣性地使用 Go 的建議。使用 float64 來表示任何數(shù)字類型的做法將結束。你應該使用 any 而不是 interface{} 來表示數(shù)據(jù)結構或函數(shù)參數(shù)中未指定的類型。你可以只用一個函數(shù)處理不同的切片類型。但不要覺得有必要立即將你的所有代碼都切換到使用類型參數(shù)。隨著新的設計模式的發(fā)明和完善,你的舊代碼仍然可以工作。
現(xiàn)在判斷泛型對性能的長期影響還為時尚早。Go 1.18 中的編譯器比以前的版本要慢,但這有望在未來的版本中得到解決。已經(jīng)有一些關于當前運行時影響的研究。Vicent Marti 寫了一篇詳細的博文[11],他探討了泛型導致代碼變慢的情況以及解釋了為什么會這樣的實現(xiàn)細節(jié)。相反,Eli Bendersky 寫了一篇博文[12],表明泛型使排序算法更快。同樣,隨著泛型實現(xiàn)在 Go 的未來版本中逐漸成熟,預計運行時性能將得到改善。
一如既往,我們的目標是編寫足夠快的可維護程序來滿足你的需求。使用我們在第 285 頁 "Benchmarks" 中討論的基準和剖析工具來測量和改進。
進一步解鎖未來
Go 1.18 中泛型的最初發(fā)布是非常保守的。它在 universe 塊中加入了新的接口 any 和 comparable,但在標準庫中并沒有為支持泛型而改變 API。我們做了一種風格上的改變;標準庫中幾乎所有的 interface{} 的使用都被替換為 any。
未來版本的標準庫可能會包括新的接口定義來表示常見情況(如 Orderable),新的類型(如 set、tree 或有序 map),以及新的函數(shù)。在此期間,你可以隨意編寫你自己的,但考慮在標準庫更新后替換它們。
泛型可能是未來其他功能的基礎。一種可能性是 sum 類型。就像類型元素被用來指定可以替代類型參數(shù)的類型一樣,它們也可以被用于可變參數(shù)的接口。這將實現(xiàn)一些有趣的功能。今天,Go 在處理 JSON 常見業(yè)務時遇到了一個問題:一個字段可以是單個值,也可以是一個值的列表。即使有泛型,處理這種情況的唯一方法是使用 any 類型的字段。添加 sum 類型將允許你創(chuàng)建一個接口,指定一個字段可以是一個字符串,一個字符串的切片,而不是其他。然后,類型轉換可以完全枚舉每一種有效的類型,提高類型安全性。這種指定一組有界類型的能力允許許多現(xiàn)代語言(包括 Rust 和 Swift)使用 sum 類型來表示枚舉。鑒于 Go 目前枚舉功能的不足,這可能是一個有吸引力的解決方案,但這些想法需要時間來評估和探索。
結束語
在本章中,我們了解了泛型以及如何使用它們來簡化我們的代碼。Go 泛型還處于早期階段。看到他們?nèi)绾螏椭?Go 語言成長,同時又保持 Go 獨特的匠心精神,這將是令人興奮的。
我們已經(jīng)完成了對 Go 以及如何習慣使用它的旅程。就像任何畢業(yè)典禮一樣,是時候說幾句結束語了。讓我們回顧一下序言中所說的內(nèi)容。“寫得好,Go 很無聊…… 寫得好的 Go 程序往往是直截了當?shù)模袝r還有點重復”。我希望你現(xiàn)在能明白為什么這會造就更好的軟件工程。慣用 Go 的一套工具、實踐和模式,可以使跨時間和不斷變化的團隊維護軟件變得更加容易。這并不是說其他語言的文化不重視可維護性;只是這可能不是他們的最高優(yōu)先事項。相反,他們強調(diào)的是性能、新功能或簡明的語法等。這些權衡都有其存在的意義,但從長遠來看,我認為 Go 的重點是專注于打造能持久使用的軟件,Go 在這一點上會勝出。
我祝愿你在為未來 50 年的計算創(chuàng)造軟件時一切順利。
參考資料
Closures Are the Generics for Go: https://medium.com/capital-one-tech/closures-are-the-generics-for-go-cb32021fb5b5
[2]Type Parameters Proposal: https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md
[3]The Go Playground: https://go.dev/play/p/l5tpbYr55tz
[4]更新的堆棧: https://go.dev/play/p/Ep2_6Zftl5r
[5]試試吧: https://go.dev/play/p/MYYW3e7cpkX
[6]The Go Playground: https://go.dev/play/p/1_tlI22De7r
[7]The Go Playground: https://go.dev/play/p/DtSr07O_o1-
[8]The Go Playground: https://go.dev/play/p/MRSprnfhyeT
[9]The Go Playground: https://go.dev/play/p/bDffkpsewcj
[10]The Go Playground: https://go.dev/play/p/Caw23gcjnmF
[11]詳細的博文: https://planetscale.com/blog/generics-can-make-your-go-code-slower
[12]博文: https://eli.thegreenplace.net/2022/faster-sorting-with-go-generics
我是 polarisxu,北大碩士畢業(yè),曾在 360 等知名互聯(lián)網(wǎng)公司工作,10多年技術研發(fā)與架構經(jīng)驗!2012 年接觸 Go 語言并創(chuàng)建了 Go 語言中文網(wǎng)!著有《Go語言編程之旅》、開源圖書《Go語言標準庫》等。
堅持輸出技術(包括 Go、Rust 等技術)、職場心得和創(chuàng)業(yè)感悟!歡迎關注「polarisxu」一起成長!也歡迎加我微信好友交流:gopherstudio
