Go 部落格
所有可比較型別
2 月 1 日,我們釋出了最新的 Go 版本 1.20,其中包含了一些語言更改。在這裡,我們將討論其中一項更改:預宣告的 comparable
型別約束現在由所有 可比較型別滿足。令人驚訝的是,在 Go 1.20 之前,一些可比較型別並不滿足 comparable
!
如果您感到困惑,那麼您來對地方了。考慮一個有效的 map 宣告
var lookupTable map[any]string
其中 map 的鍵型別是 any
(它是一種 可比較型別)。這在 Go 中執行良好。另一方面,在 Go 1.20 之前,看似等價的泛型 map 型別
type genericLookupTable[K comparable, V any] map[K]V
可以像普通 map 型別一樣使用,但在將 any
用作鍵型別時會產生編譯時錯誤
var lookupTable genericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)
從 Go 1.20 開始,此程式碼將可以正常編譯。
Go 1.20 之前的 comparable
行為尤其令人討厭,因為它阻止了我們編寫最初希望透過泛型實現的泛型庫。提議的 maps.Clone
函式
func Clone[M ~map[K]V, K comparable, V any](m M) M { … }
可以編寫,但不能用於像 lookupTable
這樣的 map,原因與我們的 genericLookupTable
不能以 any
作為鍵型別的原因相同。
在這篇博文中,我們希望闡明這背後的語言機制。為此,我們將從一些背景資訊開始。
型別引數和約束
Go 1.18 引入了泛型,並隨之引入了 型別引數 作為一種新的語言構造。
在普通函式中,引數的值範圍受其型別限制。類似地,在泛型函式(或型別)中,型別引數的類型範圍受其 型別約束 限制。因此,型別約束定義了允許作為型別引數的型別集合。
Go 1.18 還改變了我們看待介面的方式:雖然過去介面定義了一組方法,但現在介面定義了一個型別集。這種新的觀點是完全向後相容的:對於由介面定義的任何一組方法,我們可以想象實現這些方法的(無限)型別集合。例如,給定一個 io.Writer
介面,我們可以想象所有具有適當簽名的 Write
方法的型別的無限集合。所有這些型別都實現該介面,因為它們都具有必需的 Write
方法。
但新的型別集觀點比舊的方法集觀點更強大:我們可以顯式地描述一個型別集,而不僅僅是透過方法間接描述。這為我們提供了控制型別集的新方法。從 Go 1.18 開始,介面可以嵌入其他介面,也可以嵌入任何型別、型別聯合或具有相同 底層型別的無限型別集。然後將這些型別包含在 型別集計算中:聯合表示法 A|B
表示“型別 A
或型別 B
”,而 ~T
表示法代表“所有底層型別為 T
的型別”。例如,介面
interface {
~int | ~string
io.Writer
}
定義了所有底層型別為 int
或 string
,並且還實現了 io.Writer
的 Write
方法的型別集合。
這種廣義介面不能用作變數型別。但是,因為它們描述了型別集,所以它們被用作型別約束,即型別集。例如,我們可以編寫一個泛型 min
函式
func min[P interface{ ~int64 | ~float64 }](x, y P) P
它接受任何 int64
或 float64
引數。(當然,更現實的實現會使用一個列舉所有具有 <
運算子的基本型別的約束。)
順便說一句,因為列舉顯式型別而不帶方法很常見,一些 語法糖允許我們省略包圍的 interface{}
,從而得到簡潔且更慣用的
func min[P ~int64 | ~float64](x, y P) P { … }
使用新的型別集觀點,我們也需要一種新的方式來解釋實現介面的含義。我們說一個(非介面)型別 T
實現介面 I
當且僅當 T
是介面的型別集中的元素。如果 T
本身就是一個介面,它就描述了一個型別集。該集合中的每一個型別都必須也在 I
的型別集中,否則 T
將包含不實現 I
的型別。因此,如果 T
是一個介面,當 T
的型別集是 I
的型別集的子集時,T
就實現了介面 I
。
現在我們具備了理解約束滿足的所有要素。如前所述,型別約束描述了型別引數可接受的引數型別集。當型別引數屬於約束介面描述的集合時,它就滿足相應的型別引數約束。換句話說,型別引數實現了約束。在 Go 1.18 和 Go 1.19 中,約束滿足意味著約束實現。我們將在稍後看到,在 Go 1.20 中,約束滿足不再完全是約束實現。
對型別引數值的操作
型別約束不僅指定了型別引數可接受的型別引數,它還確定了對型別引數值可進行的操作。正如我們所料,如果一個約束定義了一個方法,比如 Write
,那麼就可以在相應型別引數的值上呼叫 Write
方法。更普遍地說,對於約束定義的型別集中的所有型別都支援的操作,例如 +
或 *
,在相應的型別引數值上是允許的。
例如,在 min
示例中,在函式體中,允許在型別引數 P
的值上執行 int64
和 float64
型別都支援的任何操作。這包括所有基本算術運算,以及比較運算子,如 <
。但它不包括按位運算,如 &
或 |
,因為這些運算在 float64
值上未定義。
可比較型別
與其他一元和二元運算子不同,==
不僅定義在有限的 預宣告型別集上,而且定義在無限的各種型別上,包括陣列、結構體和介面。在約束中不可能列舉所有這些型別。如果我們關心的是預宣告型別以外的型別,我們需要一種不同的機制來表達型別引數必須支援 ==
(當然還有 !=
)。
我們透過預宣告型別 comparable
來解決這個問題,該型別隨 Go 1.18 一起引入。comparable
是一種介面型別,其型別集是可比較型別的無限集,當我們需要型別引數支援 ==
時,可以使用它作為約束。
然而,comparable
所包含的型別集與 Go 規範定義的所有可比較型別的集合並不相同。根據構造,介面(包括 comparable
)指定的型別集不包含介面本身(或任何其他介面)。因此,像 any
這樣的介面不包含在 comparable
中,儘管所有介面都支援 ==
。這是怎麼回事?
介面(以及包含它們的複合型別的介面)的比較可能會在執行時發生恐慌:當動態型別,即介面變數中儲存的實際值的型別,不可比較時。考慮我們最初的 lookupTable
示例:它接受任意值作為鍵。但是,如果我們嘗試輸入一個鍵不支援 ==
的值,例如切片值,我們會得到一個執行時恐慌
lookupTable[[]int{}] = "slice" // PANIC: runtime error: hash of unhashable type []int
相反,comparable
只包含編譯器保證不會因 ==
而恐慌的型別。我們將這些型別稱為嚴格可比較型別。
大多數情況下,這正是我們想要的:知道泛型函式中的 ==
如果運算元由 comparable
約束,就不會恐慌,這是令人欣慰的,也是我們直觀期望的。
不幸的是,comparable
的這種定義以及約束滿足的規則阻止了我們編寫有用的泛型程式碼,例如前面顯示的 genericLookupTable
型別:為了使 any
成為可接受的引數型別,any
必須滿足(並因此實現)comparable
。但是 any
的型別集比 comparable
的型別集更大(不是子集),因此不實現 comparable
。
var lookupTable GenericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)
使用者很早就認識到了這個問題,並迅速提交了大量問題和提案(#51338、#52474、#52531、#52614、#52624、#53734 等)。顯然,這是一個我們需要解決的問題。
“顯而易見”的解決方案是將非嚴格可比較型別也包含在 comparable
型別集中。但這會導致與型別集模型不一致。考慮以下示例
func f[Q comparable]() { … }
func g[P any]() {
_ = f[int] // (1) ok: int implements comparable
_ = f[P] // (2) error: type parameter P does not implement comparable
_ = f[any] // (3) error: any does not implement comparable (Go 1.18, Go.19)
}
函式 f
需要一個嚴格可比較的型別引數。顯然,用 int
例項化 f
是可以的:int
值永遠不會因 ==
而恐慌,因此 int
實現 comparable
(情況 1)。另一方面,用 P
例項化 f
是不允許的:P
的型別集由其約束 any
定義,而 any
代表所有可能型別的集合。此集合包含根本不可比較的型別。因此,P
不實現 comparable
,因此不能用於例項化 f
(情況 2)。最後,使用型別 any
(而不是受 any
約束的型別引數)也不起作用,原因與此完全相同(情況 3)。
然而,我們確實希望在這種情況下使用型別 any
作為型別引數。擺脫這個困境的唯一方法是某種程度上改變語言。但是如何改變呢?
介面實現與約束滿足
如前所述,約束滿足就是介面實現:型別引數 T
滿足約束 C
當且僅當 T
實現 C
。這是有道理的:T
必須在 C
預期的型別集中,這正是介面實現的定義。
但這正是問題所在,因為它阻止了我們將非嚴格可比較型別用作 comparable
的型別引數。
因此,對於 Go 1.20,在公開討論了多種選擇(請參閱上述問題)近一年後,我們決定為這種情況引入一個例外。為了避免不一致,我們沒有改變 comparable
的含義,而是區分了介面實現(這與將值傳遞給變數有關)和約束滿足(這與將型別引數傳遞給型別引數有關)。一旦分開,我們就可以為每個概念(稍微)不同的規則,這正是我們透過提案 #56548 所做的。
好訊息是,這個例外在規範中相當區域性化。約束滿足仍然與介面實現幾乎相同,但有一個例外
型別
T
滿足約束C
當且僅當
T
實現C
;或C
可以寫成interface{ comparable; E }
的形式,其中E
是一個基本介面,並且T
是可比較的並實現E
。
第二點是例外。不深入研究規範的正式性,例外所說的如下:一個期望嚴格可比較型別的約束 C
(並且可能還有其他要求,如方法 E
)可以被任何支援 ==
的型別引數 T
(並且也實現了 E
中的方法(如果有))所滿足。或者更簡潔地說:支援 ==
的型別也滿足 comparable
(即使它可能不實現它)。
我們可以立即看出這個更改是向後相容的:在 Go 1.20 之前,約束滿足與介面實現相同,我們仍然保留該規則(第一點)。所有依賴於該規則的程式碼將繼續按原樣工作。只有當該規則失敗時,我們才需要考慮例外。
讓我們回顧一下之前的例子
func f[Q comparable]() { … }
func g[P any]() {
_ = f[int] // (1) ok: int satisfies comparable
_ = f[P] // (2) error: type parameter P does not satisfy comparable
_ = f[any] // (3) ok: satisfies comparable (Go 1.20)
}
現在,any
確實滿足(但沒有實現!)comparable
。為什麼?因為 Go 允許在型別為 any
的值上使用 ==
(這對應於規範規則中的型別 T
),並且因為約束 comparable
(對應於規則中的約束 C
)可以寫成 interface{ comparable; E }
,其中 E
在本例中只是空介面(情況 3)。
有趣的是,P
仍然不滿足 comparable
(情況 2)。原因是 P
是一個由 any
約束的型別引數(它不是 any
)。==
操作對於 P
型別集中的所有型別都不可用,因此對 P
也不可用;它不是可比較型別。因此,例外不適用。但這沒關係:我們確實希望知道 comparable
(嚴格可比較性要求)在大多數情況下都得到強制執行。我們只需要為支援 ==
的 Go 型別提供例外,這主要是出於歷史原因:我們一直能夠比較非嚴格可比較的型別。
後果和補救措施
我們 Gophers 以一個事實為傲,即特定於語言的行為可以透過相當簡潔的一組規則來解釋和簡化,這些規則都寫在語言規範中。多年來,我們不斷完善這些規則,並在可能的情況下使其更加簡單,並且通常更通用。我們還小心地保持規則的正交性,始終留意意外和不幸的後果。爭議透過查閱規範來解決,而不是透過命令。這就是我們從 Go 誕生之初就一直追求的目標。
在沒有後果的情況下,你不能輕易地向一個精心設計的型別系統中新增一個例外!
那麼問題出在哪裡?有一個明顯的(如果輕微的)缺點,和一個不太明顯(且更嚴重)的缺點。顯然,我們現在有一個更復雜的約束滿足規則,這可以說不如我們以前的規則優雅。這不太可能以任何重要的方式影響我們的日常工作。
但我們為這個例外付出了代價:在 Go 1.20 中,依賴 comparable
的泛型函式不再是靜態型別安全的。即使宣告說它們是嚴格可比較的,==
和 !=
操作在應用於 comparable
型別引數的運算元時也可能發生恐慌。一個不可比較的值可能會透過一個非嚴格可比較的型別引數,透過多個泛型函式或型別“溜走”,並導致恐慌。在 Go 1.20 中,我們現在可以宣告
var lookupTable genericLookupTable[any, string]
而不會出現編譯時錯誤,但如果我們在此處使用非嚴格可比較的鍵型別,我們將收到執行時恐慌,這與內建 map
型別的情況完全相同。我們為了執行時檢查而犧牲了靜態型別安全。
在某些情況下,這可能不夠好,我們希望強制執行嚴格可比較性。以下觀察結果使我們能夠做到這一點,至少是有限的形式:型別引數不受我們新增到約束滿足規則的例外的益處。例如,在我們之前的示例中,函式 g
中的型別引數 P
受 any
約束(它本身是可比較的,但不是嚴格可比較的),因此 P
不滿足 comparable
。我們可以利用這些知識來建立一個適用於給定型別 T
的編譯時斷言
type T struct { … }
我們想斷言 T
是嚴格可比較的。很容易寫成
// isComparable may be instantiated with any type that supports ==
// including types that are not strictly comparable because of the
// exception for constraint satisfaction.
func isComparable[_ comparable]() {}
// Tempting but not quite what we want: this declaration is also
// valid for types T that are not strictly comparable.
var _ = isComparable[T] // compile-time error if T does not support ==
虛擬(空白)變數宣告充當我們的“斷言”。但是,由於約束滿足規則中的例外,isComparable[T]
僅在 T
完全不可比較時才失敗;如果 T
支援 ==
,它將成功。我們可以透過使用 T
作為型別約束而不是型別引數來解決這個問題
func _[P T]() {
_ = isComparable[P] // P supports == only if T is strictly comparable
}
這裡有一個透過和一個失敗的 playground 示例來說明這個機制。
最終觀察
有趣的是,直到 Go 1.18 釋出前兩個月,編譯器實現的約束滿足與我們現在 Go 1.20 中的實現完全相同。但是,因為當時約束滿足意味著介面實現,所以我們的實現與語言規範不一致。我們透過#50646 問題意識到了這一點。我們離釋出非常近,必須迅速做出決定。在沒有令人信服的解決方案的情況下,使實現與規範保持一致似乎是最安全的。一年後,隨著有充足的時間考慮不同的方法,我們發現最初的實現正是我們想要的。我們兜了一個大圈。
一如既往,如果您發現任何內容不符合預期,請透過在https://golang.org.tw/issue/new 提交問題來告知我們。
謝謝!
下一篇文章: Go 整合測試的程式碼覆蓋率
上一篇文章:剖析驅動最佳化預覽
部落格索引