Go 部落格

泛型簡介

Robert Griesemer 和 Ian Lance Taylor
2022 年 3 月 22 日

引言

本文基於我們在 GopherCon 2021 上的演講

Go 1.18 版本增加了對泛型的支援。泛型是我們自首次開源以來對 Go 所做的最大改變。在本文中,我們將介紹這些新的語言特性。我們不會嘗試涵蓋所有細節,但會涵蓋所有要點。有關更詳細、更長的描述,包括許多示例,請參閱 提案文件。有關語言更改的更精確描述,請參閱 更新後的語言規範。(請注意,實際的 1.18 實現對提案文件允許的某些內容施加了一些限制;規範應該是準確的。未來的版本可能會取消其中一些限制。)

泛型是一種編寫獨立於所使用的特定型別的程式碼的方式。現在可以編寫函式和型別,使其可以使用一組型別中的任何一個。

泛型為語言增加了三項重要功能

  1. 函式和型別的型別引數。
  2. 將介面型別定義為型別集,包括沒有方法的型別。
  3. 型別推斷,允許在許多情況下省略呼叫函式時使用的型別引數。

型別引數

函式和型別現在允許具有型別引數。型別引數列表看起來像一個普通的引數列表,只不過它使用方括號而不是圓括號。

為了展示這是如何工作的,讓我們從基本的非泛型浮點數 Min 函式開始

func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

透過新增型別引數列表,我們可以使此函式成為泛型函式——使其適用於不同型別的函式。在此示例中,我們添加了一個包含單個型別引數 T 的型別引數列表,並將 float64 的所有用法替換為 T

import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

現在可以透過編寫如下呼叫來為該函式提供型別引數

x := GMin[int](2, 3)

GMin 提供型別引數,在此例中為 int,稱為 *例項化*。例項化分兩步進行。首先,編譯器將所有型別引數替換為其在泛型函式或型別中的相應型別引數。其次,編譯器驗證每個型別引數是否滿足相應的約束。我們很快就會講到這意味著什麼,但如果第二步失敗,例項化就會失敗,程式也是無效的。

成功例項化後,我們就得到了一個可以像其他任何函式一樣呼叫的非泛型函式。例如,在像這樣的程式碼中

fmin := GMin[float64]
m := fmin(2.71, 3.14)

例項化 GMin[float64] 會產生有效地是我們最初的浮點數 Min 函式,我們可以在函式呼叫中使用它。

型別引數也可以與型別一起使用。

type Tree[T interface{}] struct {
    left, right *Tree[T]
    value       T
}

func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

var stringTree Tree[string]

這裡的泛型型別 Tree 儲存型別引數 T 的值。泛型型別可以有方法,比如這個示例中的 Lookup。為了使用泛型型別,它必須被例項化;Tree[string] 就是一個用型別引數 string 例項化 Tree 的例子。

型別集

讓我們更深入地研究可以用來例項化型別引數的型別引數。

普通函式有一個值引數的型別;該型別定義了一組值。例如,如果我們有一個像上面非泛型函式 Min 那樣的 float64 型別,允許的引數值集就是 float64 型別可以表示的浮點數值集。

同樣,型別引數列表為每個型別引數都有一個型別。因為型別引數本身就是一種型別,所以型別引數的型別定義了型別的集合。這種元型別稱為 *型別約束*。

在泛型 GMin 中,型別約束是從 constraints 包匯入的。Ordered 約束描述了所有具有可排序值的型別集,換句話說,可以用 < 運算子(或 <=、> 等)進行比較的型別集。該約束確保只有具有可排序值的型別才能傳遞給 GMin。這也意味著在 GMin 函式體內,該型別引數的值可以使用 < 運算子進行比較。

在 Go 中,型別約束必須是介面。也就是說,介面型別可以用作值型別,也可以用作元型別。介面定義方法,因此我們顯然可以表達需要存在某些方法的型別約束。但 constraints.Ordered 也是一種介面型別,而 < 運算子不是方法。

為了讓這奏效,我們以一種新的方式來看待介面。

直到最近,Go 規範都說介面定義了一個方法集,它大致是介面中列出的方法集。任何實現所有這些方法的型別都實現了該介面。

但另一種看待方式是說介面定義了一個型別集,即實現這些方法的型別。從這個角度來看,任何屬於介面型別集的型別都實現了該介面。

這兩種觀點 leads to the same outcome: 對於每組方法,我們都可以想象出實現這些方法的相應型別集,這就是介面定義的型別集。

然而,對我們來說,型別集觀點比方法集觀點有優勢:我們可以顯式地向集合中新增型別,從而以新的方式控制型別集。

我們擴充套件了介面型別的語法來實現這一點。例如,interface{ int|string|bool } 定義了包含 intstringbool 型別的型別集。

換句話說,這個介面只能由 intstringbool 滿足。

現在讓我們看看 constraints.Ordered 的實際定義

type Ordered interface {
    Integer|Float|~string
}

此宣告表示 Ordered 介面是所有整數、浮點數和字串型別的集合。豎線表示型別的並集(在此情況下是型別集的並集)。IntegerFloatconstraints 包中類似定義的介面型別。請注意,Ordered 介面沒有定義任何方法。

對於型別約束,我們通常不關心特定型別,例如 string;我們關心所有字串型別。這就是 ~ 符號的作用。表示式 ~string 表示其底層型別為 string 的所有型別的集合。這包括 string 型別本身以及所有使用 type MyString string 等定義宣告的型別。

當然,我們仍然希望在介面中指定方法,並且我們希望向後相容。在 Go 1.18 中,介面可以包含方法和嵌入的介面,就像以前一樣,但它也可以嵌入非介面型別、聯合型別和底層型別集。

當用作型別約束時,介面定義的型別集精確地指定了允許作為相應型別引數的型別引數的型別。在泛型函式體中,如果運算元的型別是具有約束 C 的型別引數 P,則允許的操作是如果它們被 C 的型別集中的所有型別允許(目前有一些實現限制,但普通程式碼不太可能遇到它們)。

用作約束的介面可以命名(如 Ordered),或者它們可以是內聯在型別引數列表中的字面介面。例如

[S interface{~[]E}, E interface{}]

這裡 S 必須是一個切片型別,其元素型別可以是任何型別。

因為這是一個常見情況,所以對於約束位置的介面,可以省略外圍的 interface{},我們可以簡單地寫

[S ~[]E, E interface{}]

因為空介面在型別引數列表和普通的 Go 程式碼中都很常見,所以 Go 1.18 引入了一個新的預宣告識別符號 any,作為空介面型別的別名。有了它,我們就得到了這段慣用的程式碼

[S ~[]E, E any]

作為型別集介面是一個強大的新機制,並且是使 Go 中的型別約束正常工作的關鍵。目前,使用新語法形式的介面只能用作約束。但我們可以很容易地想象出顯式型別約束的介面可能在通用場景中有用。

型別推斷

最後一個重要的語言新特性是型別推斷。在某些方面,這是對語言最複雜的更改,但它很重要,因為它允許人們在編寫呼叫泛型函式的程式碼時使用自然的風格。

函式引數型別推斷

有了型別引數,就需要傳遞型別引數,這可能會導致程式碼冗長。回到我們泛型的 GMin 函式

func GMin[T constraints.Ordered](x, y T) T { ... }

型別引數 T 用於指定普通非型別引數 xy 的型別。如前所述,可以使用顯式型別引數呼叫

var a, b, m float64

m = GMin[float64](a, b) // explicit type argument

在許多情況下,編譯器可以從普通引數推斷出 T 的型別引數。這使得程式碼更短,同時保持清晰。

var a, b, m float64

m = GMin(a, b) // no type argument

這是透過將引數 ab 的型別與引數 xy 的型別進行匹配來實現的。

這種從函式引數的型別推斷型別引數的推斷稱為 *函式引數型別推斷*。

函式引數型別推斷僅適用於用在函式引數中的型別引數,而不適用於僅用在函式結果或僅在函式體中的型別引數。例如,它不適用於像 MakeT[T any]() T 這樣僅將 T 用於結果的函式。

約束型別推斷

該語言支援另一種型別的推斷,即 *約束型別推斷*。為了說明這一點,讓我們從這個縮放整數切片的示例開始

// Scale returns a copy of s with each element multiplied by c.
// This implementation has a problem, as we will see.
func Scale[E constraints.Integer](s []E, c E) []E {
    r := make([]E, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

這是一個適用於任何整數型別切片的泛型函式。

現在假設我們有一個多維 Point 型別,其中每個 Point 只是一個整數列表,表示點的座標。自然,這種型別會有一些方法。

type Point []int32

func (p Point) String() string {
    // Details not important.
}

有時我們想縮放 Point。由於 Point 只是一個整數切片,我們可以使用我們之前編寫的 Scale 函式

// ScaleAndPrint doubles a Point and prints it.
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // DOES NOT COMPILE
}

不幸的是,這無法編譯,會報錯,例如 r.String undefined (type []int32 has no field or method String)

問題在於 Scale 函式返回的型別是 []E,其中 E 是引數切片的元素型別。當我們用 Point 型別的值呼叫 Scale 時,其底層型別為 []int32,我們得到一個 []int32 型別的值,而不是 Point 型別。這遵循泛型程式碼的編寫方式,但這不是我們想要的。

為了解決這個問題,我們必須修改 Scale 函式,為切片型別使用型別引數。

// Scale returns a copy of s with each element multiplied by c.
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
    r := make(S, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

我們引入了一個新的型別引數 S,它是切片引數的型別。我們對其進行了約束,使其底層型別為 S 而不是 []E,並且結果型別現在是 S。由於 E 被約束為整數,其效果與之前相同:第一個引數必須是某種整數型別的切片。函式體中唯一的改變是,現在我們在呼叫 make 時傳遞 S,而不是 []E

如果用普通切片呼叫,新函式的作用與之前相同,但如果用 Point 型別呼叫,我們現在將得到一個 Point 型別的值。這就是我們想要的。有了這個版本的 Scale,之前的 ScaleAndPrint 函式將按預期編譯和執行。

但問一下是合理的:為什麼在呼叫 Scale 時可以不傳遞顯式型別引數?也就是說,為什麼我們可以寫 Scale(p, 2),而不需要寫 Scale[Point, int32](p, 2)?我們的新 Scale 函式有兩個型別引數 SE。在呼叫 Scale 時不傳遞任何型別引數,函式引數型別推斷(如上所述)允許編譯器推斷出 S 的型別引數是 Point。但該函式還有一個型別引數 E,它是乘法因子 c 的型別。相應的函式引數是 2,因為 2 是一個 *無型別* 常量,函式引數型別推斷無法推斷出 E 的正確型別(最多可能推斷出 2 的預設型別 int,這可能是錯誤的)。相反,編譯器推斷出 E 的型別引數是切片元素型別的過程稱為 *約束型別推斷*。

約束型別推斷從型別引數約束推匯出型別引數。當一個型別引數的約束定義依賴於另一個型別引數時,就會用到它。當其中一個型別引數的型別引數已知時,約束被用來推斷另一個的型別引數。

這種情況通常發生在使用 ~*type* 形式的約束,其中 type 是用其他型別引數書寫的。我們在 Scale 示例中看到了這一點。S~[]E,即 ~ 後跟一個用其他型別引數書寫的型別 []E。如果我們知道 S 的型別引數,我們就可以推斷出 E 的型別引數。S 是一個切片型別,而 E 是該切片的元素型別。

這只是對約束型別推斷的介紹。有關詳細資訊,請參閱 提案文件語言規範

型別推斷的實際應用

型別推斷工作的確切細節很複雜,但使用它並不複雜:型別推斷要麼成功,要麼失敗。如果成功,則可以省略型別引數,呼叫泛型函式與呼叫普通函式沒有區別。如果型別推斷失敗,編譯器將給出錯誤訊息,在這種情況下,我們只需提供必要的型別引數。

在向語言新增型別推斷時,我們試圖在推斷能力和複雜性之間取得平衡。我們希望確保編譯器推斷型別時,這些型別絕不會令人驚訝。我們試圖謹慎地寧願失敗而不能推斷出型別,而不是錯誤地推斷出型別。我們可能還沒有完全做到這一點,並且可能會在未來的版本中繼續改進它。其效果是,更多的程式可以編寫而無需顯式型別引數。今天不需要型別引數的程式明天也一樣不需要。

結論

泛型是 1.18 版本中的一項重要新語言特性。這些新的語言更改需要大量的程式碼,這些程式碼在生產環境中尚未經過大量測試。只有當更多人編寫和使用泛型程式碼時,這種情況才會發生。我們相信這項功能得到了很好的實現並且質量很高。然而,與 Go 的大多數方面不同,我們無法用實際經驗來支援這一信念。因此,雖然我們鼓勵在有意義的地方使用泛型,但在生產環境中部署泛型程式碼時,請謹慎使用。

撇開這些謹慎,我們很高興能夠使用泛型,並希望它們能提高 Go 程式設計師的生產力。

下一篇文章: Go 如何抵禦供應鏈攻擊
上一篇文章: Go 1.18 已釋出!
部落格索引