The Go 部落格

解構類型參數

Ian Lance Taylor
2023 年 9 月 26 日

slices 套件函式簽章

slices.Clone 函式非常簡單:建立任何類型切片的副本。

func Clone[S ~[]E, E any](s S) S {
    return append(s[:0:0], s...)
}

這是可行的,因為附加到容量為零的切片會配置新的後援陣列。函式主體最後會比函式簽章短,部分原因是主體較短,但簽章很長也有關係。在本文中,我們將解釋為何簽章會以這種方式撰寫。

簡單的 Clone

我們將從撰寫簡單的通用 Clone 函式開始。這不是 slices 套件中的函式。我們想取得任何元素類型的切片,然後傳回新的切片。

func Clone1[E any](s []E) []E {
    // body omitted
}

一般函數 Clone1 有單一類型參數 E。它有一個單一參數 s,其為 E 類型的切片,且回傳同一類型的切片。這個簽章對任何熟悉 Go 中泛型的人來說都很直觀。

不過,有一個問題。具名稱的切片類型在 Go 中並不常見,但是人們確實使用它們。

// MySlice is a slice of strings with a special String method.
type MySlice []string

// String returns the printable version of a MySlice value.
func (s MySlice) String() string {
    return strings.Join(s, "+")
}

假設我們想要複製 MySlice,然後按照已排序的順序取得可列印的版本。

func PrintSorted(ms MySlice) string {
    c := Clone1(ms)
    slices.Sort(c)
    return c.String() // FAILS TO COMPILE
}

很不幸地,這無法運作。編譯器回報錯誤

c.String undefined (type []string has no field or method String)

如果我們以類型引數取代類型參數,手動建立 Clone1,就可以看出問題。

func InstantiatedClone1(s []string) []string

依照 Go 指派規則,我們可以將 MySlice 类型的變數傳遞給 []string 類型的參數,因此呼叫 Clone1 並沒有問題。但是 Clone1 會回傳 []string 类型的變數,而不是 MySlice 类型的變數。[]string 類型沒有 String 方法,因此編譯器回報錯誤。

彈性的 Clone

為了解決這個問題,我們必須撰寫 Clone 的版本來回傳與其引數相同的類型。如果我們可以做到這一點,那麼當我們使用 MySlice 类型的變數呼叫 Clone,它會回傳 MySlice 类型的結果。

我們知道它看起來應該像這樣。

func Clone2[S ?](s S) S // INVALID

這個 Clone2 函數會回傳與其引數類型相同的變數。

這裡我將約束寫成 ?,但這只是一個佔位符。為了讓它運作,我們需要撰寫一個可讓我們撰寫函數主體的約束。對於 Clone1,我們只要對元素類型使用 any 約束即可。對於 Clone2,這是不行的:我們想要要求 s 為切片類型。

因為我們知道要切片,所以 S 的約束必須為切片。我們不關心切片元素類型為何,因此我們姑且稱之為 E,就如同我們對 Clone1 所做的一樣。

func Clone3[S []E](s S) S // INVALID

這仍然是無效的,因為我們尚未宣告 EE 的類型引數可以是任何類型,這表示它本身也必須是類型參數。由於它可以是任何類型,因此它的約束為 any

func Clone4[S []E, E any](s S) S

這已經很接近了,而且至少可以編譯,但我們還沒有完全完成。如果我們編譯這個版本,當我們呼叫 Clone4(ms) 時,會出現錯誤。

MySlice does not satisfy []string (possibly missing ~ for []string in []string)

編譯器告訴我們,我們無法對類型參數 S 使用類型引數 MySlice,因為 MySlice 不符合 []E 約束。這是因為 []E 做為約束只允許切片類型文字,例如 []string。它不允許 MySlice 之類的名稱類型。

底層類型約束

就像錯誤訊息所提示的,答案是加上 ~

func Clone5[S ~[]E, E any](s S) S

再次說明,寫類型參數和約束 [S []E, E any] 表示,S 的類型參數可以是任何未命名切片類型,但不能是定義為切片文字的命名類型。寫 [S ~[]E, E any],加上 ~,表示 S 的類型參數可以是任何其基礎類型為切片類型的類型。

對於任何命名類型 type T1 T2T1 的基礎類型就是 T2 的基礎類型。已預先宣告類型(例如 int)或類型文字(例如 []string)的基礎類型就是它本身。如需具體細節,請參閱語言規範。在我們的範例中,MySlice 的基礎類型就是 []string

由於 MySlice 的基礎類型是切片,我們可以將類型為 MySlice 的參數傳遞給 Clone5。您可能已經注意到,Clone5 的簽章與 slices.Clone 的簽章相同。我們終於成功達到目標。

在繼續前進之前,我們來討論一下為什麼 Go 語法需要 ~。看起來好像我們永遠都希望允許傳遞 MySlice,那麼為什麼不將其設為預設值?或者,如果我們需要支援精確比對,為什麼不反過來,讓 []E 的約束允許命名類型,而 =[]E 的約束只允許切片類型文字呢?

要說明這一點,我們首先來看一下 [T ~MySlice] 這樣的類型參數清單沒有意義。這是因為 MySlice 不是任何其他類型的基礎類型。舉例來說,如果我們有一個像是 type MySlice2 MySlice 的定義,則 MySlice2 的基礎類型就是 []string,而不是 MySlice。因此,[T ~MySlice] 既不允許任何類型,或者是和 [T MySlice] 相同,且只和 MySlice 相符。不管怎樣,[T ~MySlice] 都是沒有用的。為了避免這種混淆,語言禁止 [T ~MySlice],而且編譯器會產生類似這樣的錯誤訊息

invalid use of ~ (underlying type of MySlice is []string)

如果 Go 不需要添加波浪號,使得 [S []E] 能夠和任何基礎類型為 []E 的類型相符,那麼我們就必須定義 [S MySlice] 的意義。

我們可以禁止使用 [S MySlice],或我們可以說 [S MySlice] 只符合 MySlice,但是這兩種方式都會遇到預設類型問題。預設類型例如 int,就是其自有的底層類型。我們想允許人們能夠撰寫可接受底層類型為 int 的任何類型引數的約束。在當今的語言中,他們可以透過撰寫 [T ~int] 來執行此操作。如果我們不需要波浪符號,我們仍然需要一種方式來說出「底層類型為 int 的任何類型」。最自然的方式是 [T int]。這表示 [T MySlice][T int] 表現會不同,雖然它們看起來非常類似。

我們或許可以說 [S MySlice] 符合底層類型為 MySlice 底層類型的任何類型,但這使得 [S MySlice] 變得不必要且令人困惑。

我們認為需要 ~ 符號比較好,並且在符合底層類型而非類型本身時非常清楚。

類型推論

現在我們已說明 slices.Clone 的簽章,讓我們看看實際使用 slices.Clone 如何透過類型推論得到簡化。請記住,Clone 的簽章為

func Clone[S ~[]E, E any](s S) S

呼叫 slices.Clone 會傳遞一個切片給參數 s。簡單的類型推論會讓編譯器推論型別參數 S 的類型引數是傳遞給 Clone 的切片類型。然後,類型推論功能強大到足以看出 E 的類型引數是傳遞給 S 的類型引數的元素類型。

這表示我們可以撰寫

    c := Clone(ms)

而不需要撰寫

    c := Clone[MySlice, string](ms)

如果我們參考 Clone 但未呼叫它,我們確實必須指定 S 的類型引數,因為編譯器沒有可供推論的任何內容。幸運的是,在這種情況下,類型推論能夠從 S 的引數推論 E 的類型引數,我們不必分別指定它。

也就是說,我們可以撰寫

    myClone := Clone[MySlice]

而不需要撰寫

    myClone := Clone[MySlice, string]

解構類型參數

我們在此處使用的通用技術,其中使用另一個類型參數 E 定義一個類型參數 S,是一種在通用函數簽章中解構類型的途徑。透過解構類型,我們可以命名所有類型的面向,也可以限制這些面向。

例如,以下是 maps.Clone 的簽章。

func Clone[M ~map[K]V, K comparable, V any](m M) M

如同 slices.Clone 一樣,我們使用 m 參數的類型參數,並使用兩個其他類型參數 KV 來解構類型。

maps.Clone 中,我們限制 K 為可比較類型的,就像映射鍵類型所需要的一樣。我們可以按照任何我們想要的方式限制元件類型。

func WithStrings[S ~[]E, E interface { String() string }](s S) (S, []string)

這表示 WithStrings 的引數必須是基礎類型的切片類型,其中元素類型具有 String 方法。

由於所有 Go 型別都可以透過組成類型建立,因此我們始終可以使用類型參數來解構這些類型,並根據需要限制它們。

下一篇文章: 所有您一直想知道的類型推論的事項 - 以及更多內容
上一篇文章: 在 Go 1.22 中修正 for 迴圈
部落格索引