Go 部落格

所有可比較型別

Robert Griesemer
2023 年 2 月 17 日

我們在 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
}

定義了所有底層型別為 intstring,並且還實現了 io.WriterWrite 方法的型別集合。

這種泛化介面不能用作變數型別。但由於它們描述的是型別集合,因此它們被用作型別約束,而型別約束就是型別的集合。例如,我們可以編寫一個泛型 min 函式

func min[P interface{ ~int64 | ~float64 }](x, y P) P

它接受任何 int64float64 引數。(當然,更實際的實現會使用一個約束來列舉所有支援 < 運算子的基本型別。)

順便說一句,由於列舉沒有方法的顯式型別很常見,一點點 語法糖 允許我們 省略外部的 interface{},從而形成更緊湊、更地道的寫法

func min[P ~int64 | ~float64](x, y P) P { … }

有了新的型別集合觀點,我們還需要一種新的方式來解釋 實現 介面意味著什麼。我們說(非介面)型別 T 實現了介面 I,當且僅當 T 是介面型別集合中的一個元素。如果 T 本身是一個介面,它描述了一個型別集合。該集合中的每一個型別也必須在 I 的型別集合中,否則 T 將包含未實現 I 的型別。因此,如果 T 是一個介面,當且僅當 T 的型別集合是 I 的型別集合的子集時,它才實現了介面 I

現在我們已經具備了理解約束滿足的所有要素。正如我們之前所見,型別約束描述了型別引數可接受的型別實參集合。當型別實參位於約束介面描述的集合中時,該型別實參滿足相應的型別引數約束。這是另一種說法,即型別實參實現了約束。在 Go 1.18 和 Go 1.19 中,約束滿足意味著約束實現。正如我們稍後將看到的那樣,在 Go 1.20 中,約束滿足不再完全等同於約束實現。

型別引數值的操作

型別約束不僅指定了型別引數可接受的型別實參,它還決定了對型別引數值可以進行的操作。正如我們所期望的,如果一個約束定義了一個方法,例如 Write,那麼可以在相應型別引數的值上呼叫 Write 方法。更一般地說,如果約束定義的型別集合中的所有型別都支援某個操作,例如 +*,那麼相應的型別引數值也可以使用該操作。

例如,以上面的 min 函式為例,在函式體中,任何 int64float64 型別支援的操作都可以在型別引數 P 的值上執行。這包括所有基本算術運算,以及 < 等比較操作。但不包括 &| 等位運算,因為這些操作未定義在 float64 值上。

可比較型別

與其他一元和二元操作不同,== 不僅定義在有限的一組 預宣告型別 上,還定義在無限多樣的型別上,包括陣列、結構體和介面。不可能在約束中列舉所有這些型別。如果我們關注的型別不僅僅是預宣告型別,我們需要一種不同的機制來表達型別引數必須支援 ==(當然還有 !=)。

我們透過 Go 1.18 引入的預宣告型別 comparable 解決了這個問題。comparable 是一個介面型別,其型別集合是可比較型別的無限集合,並且可以在我們需要型別實參支援 == 時用作約束。

然而,comparable 包含的型別集合與 Go 規範定義的全部 可比較型別 集合並不相同。根據定義,介面(包括 comparable)指定的型別集合不包含介面本身(或任何其他介面)。因此,像 any 這樣的介面不包含在 comparable 中,即使所有介面都支援 ==。這是怎麼回事?

介面(以及包含它們的複合型別)的比較可能會在執行時發生 panic:當動態型別(儲存在介面變數中的實際值的型別)不可比較時,就會發生這種情況。考慮我們最初的 lookupTable 示例:它接受任意值作為鍵。但是如果我們嘗試使用不支援 == 的鍵(例如切片值)輸入值,就會發生執行時 panic

lookupTable[[]int{}] = "slice"  // PANIC: runtime error: hash of unhashable type []int

相比之下,comparable 只包含編譯器保證在使用 == 時不會發生 panic 的型別。我們將這些型別稱為嚴格可比較型別

大多數時候,這正是我們想要的:令人欣慰的是,如果運算元受 comparable 約束,泛型函式中的 == 不會發生 panic,這符合我們的直覺。

不幸的是,comparable 的這個定義以及約束滿足的規則阻止了我們編寫有用的泛型程式碼,例如前面展示的 genericLookupTable 型別:要使 any 成為可接受的型別實參,any 必須滿足(並因此實現)comparable。但是 any 的型別集合比 comparable 的型別集合更大(不是子集),因此 any 未實現 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 值在使用 == 時永遠不會 panic,因此 int 實現了 comparable(情況 1)。另一方面,使用 P 例項化 f 是不允許的:P 的型別集合由其約束 any 定義,而 any 代表所有可能型別的集合。該集合包含根本不可比較的型別。因此,P 未實現 comparable,因此不能用於例項化 f(情況 2)。最後,使用型別 any(而不是受 any 約束的型別引數)也不起作用,原因完全相同(情況 3)。

然而,我們確實希望在這種情況下能夠使用型別 any 作為型別實參。擺脫這種困境的唯一方法是改變語言。但如何改變呢?

介面實現 vs 約束滿足

如前所述,約束滿足即介面實現:型別實參 T 滿足約束 C,當且僅當 T 實現了 C。這是有道理的:T 必須在 C 期望的型別集合中,這正是介面實現的定義。

但這同時也是問題所在,因為它阻止我們將非嚴格可比較型別用作 comparable 的型別實參。

因此,對於 Go 1.20,在公開討論了近一年的眾多替代方案(參見上面提到的問題)之後,我們決定僅針對這種情況引入一個例外。為了避免不一致,我們沒有改變 comparable 的含義,而是區分了與將值傳遞給變數相關的介面實現,以及與將型別實參傳遞給型別引數相關的約束滿足。一旦分離,我們可以為這些概念分別賦予(略微)不同的規則,這正是我們在提案 #56548 中所做的。

好訊息是,這個例外在 規範 中的範圍相當有限。約束滿足與介面實現幾乎相同,只是有一個例外

型別 T 滿足約束 C,當且僅當

  • T 實現了 C;或
  • C 可以寫成 interface{ comparable; E } 的形式,其中 E 是一個基本介面,並且 T可比較的 並實現了 E

第二個要點就是這個例外。撇開規範的繁瑣形式不談,這個例外是這樣說的:期望嚴格可比較型別(可能還有方法 E 等其他要求)的約束 C,會被任何支援 == 的型別實參 T 滿足(並且 T 也實現了 E 中的方法,如果存在的話)。或者更簡短地說:支援 == 的型別也滿足 comparable(即使它可能並未實現它)。

我們可以立即看到這個改動是向後相容的:在 Go 1.20 之前,約束滿足與介面實現相同,我們仍然保留該規則(第 1 個要點)。所有依賴該規則的程式碼繼續按以前的方式工作。只有當該規則失敗時,我們才需要考慮例外情況。

讓我們重新審視之前的示例

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 型別做個例外,這基本上是出於歷史原因:我們一直都能夠比較非嚴格可比較的型別。

後果和補救措施

我們 gopher 們感到自豪的是,特定語言的行為可以透過語言規範中闡述的一套相當緊湊的規則來解釋和歸納。多年來,我們一直在完善這些規則,儘可能地使其更簡單、更通用。我們也一直在小心地保持規則的正交性,時刻警惕意外和不幸的後果。爭議透過查閱規範解決,而不是透過規定。這是我們自 Go 誕生以來一直追求的目標。

精心設計的型別系統,新增例外絕非易事,必然伴隨後果!

那麼問題出在哪裡?有一個明顯的(儘管是輕微的)缺點,還有一個不太明顯(但更嚴重)的缺點。顯然,現在我們的約束滿足規則更復雜了,可以說不如以前優雅。這不太可能對我們的日常工作產生重大影響。

但我們確實為這個例外付出了代價:在 Go 1.20 中,依賴 comparable 的泛型函式不再是靜態型別安全的。如果對 comparable 型別引數的運算元應用 ==!=,它們可能會發生 panic,即使宣告表明它們是嚴格可比較的。一個不可比較的值可能透過一個非嚴格可比較的型別實參偷偷溜過多個泛型函式或型別,並導致 panic。在 Go 1.20 中,我們現在可以宣告

var lookupTable genericLookupTable[any, string]

而不會出現編譯時錯誤,但如果我們在此例中使用了非嚴格可比較的鍵型別,就會發生執行時 panic,就像使用內建的 map 型別一樣。我們為了執行時檢查而放棄了靜態型別安全。

在某些情況下,這可能還不夠,我們希望強制執行嚴格可比較性。以下觀察使我們能夠做到這一點,至少是有限的形式:型別引數不會從我們新增到約束滿足規則中的例外中受益。例如,在我們之前的示例中,函式 g 中的型別引數 Pany 約束(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 整合測試的程式碼覆蓋率
上一篇文章:效能分析引導最佳化預覽
部落格索引