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
等地圖,這與 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
是介面,則它實作介面 I
,條件是 T
的類型集合是 I
的類型集合的子集合。
現在我們已具備理解約束滿足所需的所有成分。如我們先前所見,類型約束描述類型參數可接受的參數類型集合。類型參數滿足其對應的類型參數約束的條件是,如果類型參數在約束介面所描述的集合中。這是一種表示類型參數實作約束的另一種方式。在 Go 1.18 和 Go 1.19 中,約束滿足表示約束實作。正如我們稍後會看到的,在 Go 1.20 中,約束滿足不再完全等同於約束實作。
類型參數值的操作
類型約束不僅指定哪些類型參數可接受類型參數,也決定可對類型參數值執行哪些操作。正如我們所預期的那樣,如果約束定義一個方法(例如 Write
),則可以對應類型參數的值呼叫 Write
方法。更一般地來說,類型參數的對應值允許執行約束定義的類型集合中所有類型支援的作業,例如 +
或 *
。
例如,給定 min
示例,在函數本體中,int64
和 float64
類型支援的任何操作都允許在類型參數 P
的值上進行。這包括所有基本算術運算,但也包括 <
等比較。但它不包括位元運算,例如 &
或 |
,因為這些運算未定義於 float64
值。
可比較類型
與其他一元和二元運算相反,==
不僅定義於有限的 預先宣告類型 集合,而且定義於各種各樣的類型,包括陣列、結構和介面。無法在約束中列舉所有這些類型。如果我們關心超過預先宣告的類型,我們需要不同的機制來表示類型參數必須支援 ==
(當然是 !=
)。
我們透過在 Go 1.18 中引入的預先宣告類型 comparable
來解決這個問題。comparable
是一個介面類型,其類型集合是可比較類型無限集合,可以在需要類型引數支援 ==
時用作約束。
然而,comparable
包含的類型集合與 Go 規格定義的所有 可比較類型 集合不同。 根據結構,介面指定的類型集合(包含 comparable
)不包含介面本身(或任何其他介面)。因此,即使所有介面都支援 ==
,comparable
中也不包含類似於 any
的介面。為什麼?
比較介面(和包含它們的複合類型)可能會在執行時引發異常:這會在動態類型(儲存在介面變數中的實際值類型)不可比較的時發生。考慮我們的原始 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
需要一個 strictly comparable 的類型參數。顯然,使用 int
來說明 f
是沒問題的:int
值在 ==
上永遠不會發生錯誤,因此 int
實作了 comparable
(案例 1)。另一方面,使用 P
來說明 f
是不允許的:P
的類型集合由其約束 any
定義,而 any
表示所有可能類型的集合。這個集合包含根本無法比較的類型。因此,P
沒有實作 comparable
,因此不能用來說明 f
(案例 2)。最後,使用類型 any
(而不是受 any
約束的類型參數)也不能運作,因為遇到的問題完全相同(案例 3)。
然而,我們確實希望能夠在這種情況下使用類型 any
作為類型參數。解決這個兩難境地的唯一方法是改變程式語言。但是怎麼改?
介面實作與約束滿足
如前所述,約束滿足是介面實作:當某個類型參數 T
實作 C
時,它就滿足了約束 C
。這是合乎邏輯的:T
必須在 C
預期中類型集合內,這正是介面實作的定義。
但這也是問題所在,因為它讓我們無法將非 strictly comparable 的類型作為 comparable
的類型參數。
因此,在公開討論各種備選方案近一年後(請參閱上述問題),我們決定針對此案例引入例外情況,以適用於 Go 1.20。為了避免不一致,我們沒有改變 comparable
的意義,而是區分了與傳遞值到變數相關的 介面實作 以及與傳遞類型參數到類型參數相關的 約束滿足。區分完畢後,我們可以針對每個概念(稍微)設定不同的規則,這正是我們在提案 #56548 中所做的。
好消息是這個例外情況在規範中是相當區域化的。約束滿足在介面的執行方面幾乎是一樣的,只差一點點
一個型別
T
滿足一個約束C
如果
T
實作C
;或C
可以寫成interface{ comparable; E }
的形式,其中,E
是基本介面,且T
是 可比較 並實作E
。
第二個項目符號是例外情況。在不討論規範中的過多形式主義的情況下,例外情況所說的如下:一個約束 C
期望 strictly 可比較的型別 (可能還包括方法 E
等其他需求) 由任何型別引數 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
型別(與 spec 規則中的型別 T
對應)的值搭配使用,且因為約束 comparable
(對應於規則中的約束 C
)可以寫成 interface{ comparable; E }
,其中,E
在這個範例中只是一個空介面(情況 3)。
有趣的是,P
仍然不滿足 comparable
(情況 2)。原因是 P
是由 any
約束的一個型別參數(它 不是 any
)。運算 ==
不適用於 P
的型別集合中的所有型別,因此無法在 P
上使用;它不是 可比較型別。因此,例外情況並不適用。但這很好:我們確實希望了解嚴格比較需求的 comparable
在大部分時間內得到強制執行。我們只針對那些支援 ==
的 Go 型別需要一個例外情況,基本上是出於歷史原因:我們一向都能比較非 strictly 可比較的型別。
後果和救濟措施
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
}
最後的觀察
有趣的是,直到 Go 1.18 發行前兩個月為止,編譯器實現約束滿足的方式與我們現在在 Go 1.20 中所用的方法完全相同。但因為當時約束滿足表示介面實作,所以我們有一個與語言規格不一致的實作。我們在 問題 #50646 中獲知此項事實。我們已經非常接近發行時間,必須快速做出決定。在缺乏令人滿意的解決方案下,讓實作與規格一致似乎是最安全的作法。時隔一年後,有了充裕的時間來思考不同的方法,我們現在看來,我們先前的實作正是我們一開始便想要的實作。我們已經繞了一大圈。
一如往常,如果您發現任何部分的運作方式未達預期,請透過 https://go.dev.org.tw/issue/new 提出問題。
感謝您!
下一篇文章: Go 整合測試的程式碼涵蓋率
前一篇文章: 設定檔導向最佳化的預覽
部落格索引