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。它接受一個型別為 E 的切片引數 s,並返回一個相同型別的切片。這個簽名對於熟悉 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)

編譯器告訴我們不能將型別實參 MySlice 用於型別引數 S,因為 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 MySliceMySlice2 的底層型別是 []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 約束為 comparable,這是 map 鍵型別所必需的。我們可以根據需要約束元件型別。

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

這表示 WithStrings 的引數必須是一個切片型別,其元素型別具有 String 方法。

由於所有 Go 型別都可以由元件型別構建而成,因此我們總是可以使用型別引數來解構這些型別並根據需要對其進行約束。

下一篇文章:關於型別推斷你想知道的一切——以及更多
上一篇文章:修復 Go 1.22 中的 For 迴圈
部落格索引