Go 部落格

關於型別推導,您想知道的一切——以及更多

Robert Griesemer
2023 年 10 月 9 日

這是我在 2023 年聖迭戈 GopherCon 大會上關於型別推導的演講的部落格版本,為清晰起見略有擴充套件和編輯。

什麼是型別推導?

維基百科對型別推導的定義如下:

型別推導是指在編譯時自動推斷表示式型別(部分或全部)的能力。編譯器通常能夠在沒有顯式型別註解的情況下推斷出變數的型別或函式的型別簽名。

這裡的關鍵短語是“自動推斷……表示式的型別”。Go 從一開始就支援基本形式的型別推導。

const x = expr  // the type of x is the type of expr
var x = expr
x := expr

這些宣告中沒有給出顯式型別,因此等號 (=) 和 := 左側的常量和變數 x 的型別就是右側相應初始化表示式的型別。我們說這些型別是從它們的初始化表示式(的型別)推導出來的。隨著 Go 1.18 中泛型的引入,Go 的型別推導能力得到了顯著擴充套件。

為什麼要型別推導?

在非泛型 Go 程式碼中,省略型別效果最明顯的是短變數宣告。這種宣告將型別推導和一點語法糖(可以省略 var 關鍵字的能力)結合成一個非常簡潔的語句。考慮以下對映變數宣告:

var m map[string]int = map[string]int{}

m := map[string]int{}

省略 := 左側的型別可以消除重複,同時提高可讀性。

泛型 Go 程式碼有可能顯著增加程式碼中出現的型別數量:如果沒有型別推導,每個泛型函式和型別例項化都需要型別引數。能夠省略它們變得更加重要。考慮使用來自新 slices 包的以下兩個函式:

package slices
func BinarySearch[S ~[]E, E cmp.Ordered](x S, target E) (int, bool)
func Sort[S ~[]E, E cmp.Ordered](x S)

如果沒有型別推導,呼叫 BinarySearchSort 需要顯式的型別引數。

type List []int
var list List
slices.Sort[List, int](list)
index, found := slices.BinarySearch[List, int](list, 42)

我們不希望在每次這樣的泛型函式呼叫中都重複 [List, int]。有了型別推導,程式碼簡化為:

type List []int
var list List
slices.Sort(list)
index, found := slices.BinarySearch(list, 42)

這樣既乾淨又緊湊。事實上,它看起來與非泛型程式碼完全相同,而型別推導使得這一切成為可能。

重要的是,型別推導是一種可選機制:如果型別引數使程式碼更清晰,那麼請隨意寫出來。

型別推導是一種型別模式匹配

推導會比較型別模式,其中型別模式是包含型別引數的型別。出於稍後顯而易見的原因,型別引數有時也稱為型別變數。型別模式匹配允許我們推斷需要放入這些型別變數中的型別。讓我們看一個簡短的例子:

// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)

type List []int
var list List
slices.Sort(list)

Sort 函式呼叫將 list 變數作為函式引數傳遞給 slices.Sort 的引數 x。因此,list 的型別(即 List)必須與 x 的型別(即型別引數 S)匹配。如果 S 的型別是 List,則此賦值有效。實際上,賦值規則很複雜,但目前假設型別必須相同就足夠了。

一旦我們推斷出 S 的型別,我們就可以檢視 S型別約束。它說(因為有波浪號 ~ 符號)S底層型別必須是切片 []ES 的底層型別是 []int,因此 []int 必須與 []E 匹配,這樣我們就可以得出結論 E 必須是 int。我們已經找到了 SE 的型別,使得對應的型別匹配。推導成功!

這是一個更復雜的場景,其中有許多型別引數:來自 slices.EqualFuncS1S2E1E2,以及泛型函式 equalE1E2。本地函式 fooequal 函式作為引數呼叫 slices.EqualFunc

// From the slices package
// func EqualFunc[S1 ~[]E1, S2 ~[]E2, E1, E2 any](s1 S1, s2 S2, eq func(E1, E2) bool) bool

// Local code
func equal[E1, E2 comparable](E1, E2) bool { … }

func foo(list1 []int, list2 []float64) {
    …
    if slices.EqualFunc(list1, list2, equal) {
        …
    }
    …
}

這是一個型別推導真正大放異彩的例子,因為我們可以省略多達六個型別引數,每個型別引數一個。型別模式匹配方法仍然有效,但我們可以看到它很快就會變得複雜,因為型別關係的數量正在激增。我們需要一種系統的方法來確定哪些型別引數和哪些型別涉及哪些模式。

從略有不同的角度看待型別推導會有所幫助。

型別方程

我們可以將型別推導重新表述為解決型別方程的問題。解決方程是我們從高中代數中都熟悉的事情。幸運的是,解決型別方程是一個更簡單的問題,我們很快就會看到。

讓我們再次看看我們之前的例子:

// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)

type List []int
var list List
slices.Sort(list)

如果可以解決以下型別方程,則推導成功。這裡 代表相同under(S) 代表 S底層型別

S ≡ List        // find S such that S ≡ List is true
under(S) ≡ []E  // find E such that under(S) ≡ []E is true

型別引數是方程中的變數。解決方程意味著為這些變數(型別引數)找到值(型別引數),以便方程成立。這種觀點使得型別推導問題更易於處理,因為它為我們提供了一個正式的框架,允許我們寫下流入推導的資訊。

精確處理型別關係

到目前為止,我們只是談論型別必須相同。但對於實際的 Go 程式碼來說,這要求太嚴格了。在前面的例子中,S 不必與 List 相同,而是 List 必須可分配S。同樣,S 必須滿足其相應的型別約束。我們可以使用我們寫成 :≡ 的特定運算子來更精確地表述我們的型別方程。

S :≡ List         // List is assignable to S
S ∈ ~[]E          // S satisfies constraint ~[]E
E ∈ cmp.Ordered   // E satisfies constraint cmp.Ordered

通常,我們可以說型別方程有三種形式:兩種型別必須相同,一種型別必須可分配給另一種型別,或者一種型別必須滿足型別約束。

X ≡ Y             // X and Y must be identical
X :≡ Y            // Y is assignable to X
X ∈ Y             // X satisfies constraint Y

(注意:在 GopherCon 演講中,我們使用了符號 A 表示 :≡,使用 C 表示 。我們認為 :≡ 更清晰地喚起了賦值關係;而 直接表達了型別引數所代表的型別必須是其約束的型別集中的一個元素。)

型別方程的來源

在泛型函式呼叫中,我們可能有顯式的型別引數,儘管大多數時候我們希望它們可以被推匯出來。通常我們還有普通的函式引數。每個顯式型別引數都會貢獻一個(平凡的)型別方程:型別引數必須與型別引數相同,因為程式碼就是這樣說的。每個普通函式引數都會貢獻另一個型別方程:函式引數必須可分配給其相應的函式引數。最後,每個型別約束也透過約束滿足約束的型別來提供型別方程。

總而言之,這會產生 n 個型別引數和 m 個型別方程。與基本高中代數不同,對於型別方程來說,nm 不需要相同就可以解決。例如,下面的單個方程允許我們推斷兩個型別引數的型別引數:

map[K]V ≡ map[int]string  // K ➞ int, V ➞ string (n = 2, m = 1)

讓我們逐一檢視這些型別方程的來源:

1. 型別引數的型別方程

對於每個型別引數宣告:

func f[…, P constraint, …]…

和顯式提供的型別引數:

f[…, A, …]…

我們得到型別方程:

P ≡ A

我們可以將此方程平凡地解出 PP 必須是 A,我們寫成 P ➞ A。換句話說,這裡沒有什麼可做的。為了完整起見,我們仍然可以寫下相應的型別方程,但在這種情況下,Go 編譯器只需將型別引數替換為其型別引數,然後這些型別引數就不復存在了,我們可以忽略它們。

2. 賦值的型別方程

對於傳遞給函式引數 p 的每個函式引數 x

f(…, x, …)

其中 px 包含型別引數,x 的型別必須可分配給引數 p 的型別。我們可以用方程表示:

𝑻(p) :≡ 𝑻(x)

其中 𝑻(x) 表示“x 的型別”。如果 px 都不包含型別引數,則沒有要解決的型別變數:如果賦值是有效的 Go 程式碼,則方程為真,如果程式碼無效,則為假。因此,型別推導只考慮包含所涉及函式(或函式)的型別引數的型別。

從 Go 1.21 開始,未例項化的或部分例項化的函式(但不是函式呼叫)也可以分配給函式型別的變數,如下所示:

// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)

var intSort func([]int) = slices.Sort

與引數傳遞類似,此類賦值會導致相應的型別方程。對於這個例子,它將是:

𝑻(intSort) :≡ 𝑻(slices.Sort)

或簡化為:

func([]int) :≡ func(S)

以及來自 slices.Sort 的約束(見下文)的方程。

3. 約束的型別方程

最後,對於我們想要推斷型別引數的每個型別引數 P,我們可以從其約束中提取型別方程,因為型別引數必須滿足約束。給定宣告:

func f[…, P constraint, …]…

我們可以寫下方程:

P ∈ constraint

這裡, 表示“必須滿足約束”,這(幾乎)等同於作為約束型別集中的一個型別元素。我們稍後將看到,一些約束(例如 any)是無用的,或者由於實現限制目前無法使用。在這種情況下,推導會簡單地忽略相應的方程。

型別引數和方程可能來自多個函式

在 Go 1.18 中,推導的型別引數必須全部來自同一個函式。具體來說,不可能將泛型、未例項化或部分例項化的函式作為函式引數傳遞,或將其分配給(函式型別的)變數。

如前所述,在 Go 1.21 中,型別推導也適用於這些情況。例如,泛型函式:

func myEq[P comparable](x, y P) bool { return x == y }

可以分配給函式型別的變數:

var strEq func(x, y string) bool = myEq  // same as using myEq[string]

myEq 沒有被完全例項化,型別推導會推斷出 P 的型別引數必須是 string

此外,泛型函式可以作為引數未例項化或部分例項化的傳遞給另一個,可能是泛型的函式:

// From the slices package
// func CompactFunc[S ~[]E, E any](s S, eq func(E, E) bool) S

type List []int
var list List
result := slices.CompactFunc(list, myEq)  // same as using slices.CompactFunc[List, int](list, myEq[int])

在最後一個例子中,型別推導確定了 CompactFuncmyEq 的型別引數。更一般地說,可能需要推斷任意多個函式的型別引數。當涉及多個函式時,型別方程也可能來自或涉及多個函式。在 CompactFunc 示例中,我們最終得到三個型別引數和五個型別方程:

Type parameters and constraints:
    S ~[]E
    E any
    P comparable

Explicit type arguments:
    none

Type equations:
    S :≡ List
    func(E, E) bool :≡ func(P, P) bool
    S ∈ ~[]E
    E ∈ any
    P ∈ comparable

Solution:
    S ➞ List
    E ➞ int
    P ➞ int

繫結與自由型別引數

此時,我們對型別方程的各種來源有了更清晰的理解,但我們還沒有明確說明要為哪些型別引數解決方程。讓我們看另一個例子。在下面的程式碼中,sortedPrint 的函式體呼叫 slices.Sort 來進行排序。sortedPrintslices.Sort 都是泛型函式,因為它們都聲明瞭型別引數。

// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)

// sortedPrint prints the elements of the provided list in sorted order.
func sortedPrint[F any](list []F) {
    slices.Sort(list)  // 𝑻(list) is []F
    …                  // print list
}

我們要推斷 slices.Sort 呼叫的型別引數。將 list 傳遞給 slices.Sort 的引數 x 會產生方程:

𝑻(x) :≡ 𝑻(list)

這與以下方程相同:

S :≡ []F

在這個方程中有兩個型別引數,SF。我們需要為哪個解決型別方程?因為被呼叫的函式是 Sort,我們關心它的型別引數 S,而不是型別引數 F。我們說 S繫結Sort,因為它由 Sort 宣告。在這個方程中,S 是相關的型別變數。相比之下,F 繫結到(由)sortedPrint 宣告。我們說 F 相對於 Sort自由的。它有自己的、已經給定的型別。該型別是 F,無論它是什麼(在例項化時確定)。在這個方程中,F 已經給出,它是一個型別常量

在解決型別方程時,我們總是為我們正在呼叫的函式(或在泛型函式賦值的情況下進行賦值)繫結的型別引數求解。

解決型別方程

現在我們已經確定瞭如何收集相關的型別引數和型別方程,缺失的部分當然是允許我們解決方程的演算法。在經歷了各種示例之後,可能已經很明顯,解決 X ≡ Y 僅僅意味著遞迴地將型別 XY 與對方進行比較,並在過程中為可能出現在 XY 中的型別引數確定合適的型別引數。目標是使型別 XY相同。這個匹配過程稱為合一

我們知道型別相同的規則是如何比較型別的。由於繫結型別引數充當型別變數的角色,我們需要指定它們如何與其他型別匹配。規則如下:

  • 如果型別引數 P 具有已推斷的型別,則 P 代表該型別。
  • 如果型別引數 P 沒有已推斷的型別,並且與另一個型別 T 匹配,則將 P 設定為該型別:P ➞ T。我們說型別 T 是為 P 推斷的。
  • 如果 P 與另一個型別引數 Q 匹配,並且 PQ 都還沒有推斷的型別,則 PQ統一

統一兩個型別引數意味著將它們組合在一起,使得它們都表示相同的型別引數值:如果其中一個 PQ 與型別 T 匹配,則 PQ 會同時設定為 T(通常,任意數量的型別引數可以這樣統一)。

最後,如果兩個型別 XY 不同,則無法使方程成立,並且求解失敗。

統一型別以實現型別相同

幾個具體的例子應該能使這個演算法變得清晰。考慮包含三個繫結型別引數 ABC 的兩個型別 XY,它們都出現在型別方程 X ≡ Y 中。目標是為型別引數解決此方程;即,為它們找到合適的型別引數,以便 XY 相同,從而使方程成立。

X: map[A]struct{i int; s []B}
Y: map[string]struct{i C; s []byte}

合一透過遞迴地比較 XY 的結構來進行,從頂部開始。僅檢視兩種型別的結構,我們得到:

map[…]… ≡ map[…]…

其中 代表我們在此步驟中忽略的相應對映鍵和值型別。由於兩邊都是對映,因此到目前為止型別是相同的。合一遞迴地進行,首先是鍵型別,對於 X 對映是 A,對於 Y 對映是 string。相應的鍵型別必須相同,由此我們可以立即推斷出 A 的型別引數必須是 string

A ≡ string => A ➞ string

繼續對映的元素型別,我們得到:

struct{i int; s []B} ≡ struct{i C; s []byte}

兩邊都是結構體,因此合一繼續進行結構體欄位。如果它們的順序相同,名稱相同,並且型別相同,則它們是相同的。第一對欄位是 i inti C。名稱匹配,因為 int 必須與 C 合一,因此:

int ≡ C => C ➞ int

這種遞迴型別匹配會一直持續到兩種型別的樹結構被完全遍歷,或者出現衝突。在此示例中,最終我們得到:

[]B ≡ []byte => B ≡ byte => B ➞ byte

一切正常,合一推斷出型別引數:

A ➞ string
B ➞ byte
C ➞ int

統一具有不同結構的型別

現在,讓我們考慮上一個例子的一個細微變化:這裡的 XY 沒有相同的型別結構。當遞迴比較型別樹時,合一仍然成功地推斷出 A 的型別引數。但是對映的值型別不同,合一失敗。

X: map[A]struct{i int; s []B}
Y: map[string]bool

XY 都是對映型別,因此合一像之前一樣遞迴進行,從鍵型別開始。我們得到:

A ≡ string => A ➞ string

也像之前一樣。但是當我們繼續進行對映的值型別時,我們得到:

struct{…} ≡ bool

struct 型別與 bool 不匹配;我們有不同的型別,合一(因此型別推導)失敗。

統一具有衝突型別引數的型別

另一種衝突出現在不同型別匹配同一型別引數時。這裡我們再次有一個初始示例的版本,但現在型別引數 A 出現在 X 中兩次,而 C 出現在 Y 中兩次。

X: map[A]struct{i int; s []A}
Y: map[string]struct{i C; s []C}

遞迴型別合一起初工作正常,我們得到以下型別引數和型別的配對:

A   ≡ string => A ➞ string  // map key type
int ≡ C      => C ➞ int     // first struct field type

當我們處理第二個結構體欄位型別時,我們得到:

[]A ≡ []C => A ≡ C

由於 AC 都推斷出了型別引數,因此它們代表這些型別引數,分別是 stringint。這些是不同的型別,因此 AC 無法匹配。合一(因此型別推導)失敗。

其他型別關係

合一解決了形式為 X ≡ Y 的型別方程,目標是型別相同。但 X :≡ YX ∈ Y 呢?

一些觀察結果對我們有幫助:型別推導的唯一目的是查詢被省略的型別引數的型別。型別推導之後總是伴隨著型別或函式例項化,它會檢查每個型別引數是否確實滿足其各自的型別約束。最後,在泛型函式呼叫的情況下,編譯器還會檢查函式引數是否可分配給其相應的函式引數。所有這些步驟都必須成功,程式碼才有效。

如果型別推導不夠精確,它可能會推斷出一個(不正確的)型別引數,而實際上不存在該型別。如果是這種情況,那麼例項化或引數傳遞都會失敗。無論哪種方式,編譯器都會產生錯誤訊息。只是錯誤訊息可能略有不同。

這個見解允許我們在 :≡ 這兩種型別關係上稍微寬鬆一些。特別是,它允許我們簡化它們,使它們可以像 一樣處理。簡化的目標是從型別方程中提取儘可能多的型別資訊,從而在精確實現可能失敗的地方推斷型別引數,因為我們可以。

簡化 X :≡ Y

Go 的可分配性規則相當複雜,但大多數時候我們實際上可以透過型別相同或其細微變體來完成。只要我們找到潛在的型別引數,我們就很高興,這正是因為型別推導之後仍然是型別例項化和函式呼叫。如果推導找到了一個本不該找到的型別引數,它將在稍後被捕獲。因此,在匹配可分配性時,我們對合一演算法進行以下調整:

  • 當命名(已定義)型別與型別字面量匹配時,將比較它們的底層型別。
  • 比較通道型別時,會忽略通道方向。

此外,忽略賦值方向:X :≡ Y 被視為 Y :≡ X

這些調整僅適用於型別結構的頂層:例如,根據 Go 的可分配性規則,命名對映型別可以分配給未命名的對映型別,但鍵和元素型別仍然必須相同。有了這些更改,可分配性的合一就成為型別相同的合一(一個細微的)變體。以下示例說明了這一點。

讓我們假設我們將前面定義的 List 型別(定義為 type List []int)的值傳遞給型別為 []E 的函式引數,其中 E 是繫結型別引數(即 E 由正在呼叫的泛型函式宣告)。這會導致型別方程 []E :≡ List。嘗試統一這兩種型別需要比較 []EList。這兩種型別不相同,並且如果沒有對合一工作方式的任何更改,它將失敗。但是因為我們正在為可分配性進行合一,所以這種初始匹配不需要完全精確。繼續使用命名型別 List 的底層型別沒有害處:在最壞的情況下,我們可能會推斷出一個不正確的型別引數,但這將在稍後檢查賦值時導致錯誤。在最好的情況下,我們找到了一個有用且正確的型別引數。在我們的示例中,不精確合一成功,我們正確地推斷出 int 作為 E

簡化 X ∈ Y

能夠簡化約束滿足關係甚至更重要,因為約束可能非常複雜。

同樣,約束檢查是在例項化時進行的,因此這裡的目標是儘可能地幫助型別推導。這些通常是我們知道型別引數結構的情況;例如,我們知道它必須是一個切片型別,並且我們關心切片的元素型別。例如,形式為 [P ~[]E] 的型別引數列表告訴我們,無論 P 是什麼,它的底層型別都必須是 []E 的形式。這些正是約束具有核心型別的情況。

因此,如果我們有一個形式為

P ∈ constraint               // or
P ∈ ~constraint

並且如果存在 core(constraint)(或 core(~constraint),分別),則方程可以簡化為:

P        ≡ core(constraint)
under(P) ≡ core(~constraint)  // respectively

在所有其他情況下,涉及約束的型別方程將被忽略。

展開推導的型別

如果合一成功,它會產生一個從型別引數到推斷型別引數的對映。但是合一本身並不能確保推斷的型別不包含繫結型別引數。為了說明這一點,請考慮下面的泛型函式 g,它與型別為 int 的單個引數 x 一起呼叫:

func g[A any, B []C, C *A](x A) { … }

var x int
g(x)

A 的型別約束是 any,它沒有核心型別,所以我們忽略它。其餘型別約束具有核心型別,分別是 []C*A。連同傳遞給 g 的引數,經過微小簡化後,型別方程是:

    A :≡ int
    B ≡ []C
    C ≡ *A

由於每個方程都將一個型別引數與一個非型別引數型別進行對比,因此合一幾乎沒有什麼工作要做,並且立即推斷出:

    A ➞ int
    B ➞ []C
    C ➞ *A

但這會在推斷的型別中留下型別引數 AC,這是沒有幫助的。就像在高中代數中一樣,一旦一個方程為變數 x 求解,我們就需要將 x 替換為其值,遍及剩餘的方程。在我們的示例中,在第一步中,[]C 中的 C 被替換為 C 的推斷型別(“值”),即 *A,我們得到:

    A ➞ int
    B ➞ []*A    // substituted *A for C
    C ➞ *A

再進行兩個步驟,我們將推斷型別 []*A*A 中的 A 替換為 A 的推斷型別,即 int

    A ➞ int
    B ➞ []*int  // substituted int for A
    C ➞ *int    // substituted int for A

現在推導才完成。就像在高中代數中一樣,有時這行不通。可能會出現以下情況:

    X ➞ Y
    Y ➞ *X

在一輪替換後,我們得到:

    X ➞ *X

如果我們繼續下去,X 的推斷型別會不斷增長:

    X ➞ **X     // substituted *X for X
    X ➞ ***X    // substituted *X for X
    etc.

型別推導在擴充套件期間檢測到這種迴圈並報告錯誤(因此失敗)。

未型別化的常量

到現在為止,我們已經看到了型別推導如何透過合一解決型別方程,然後展開結果。但是如果沒有型別怎麼辦?如果函式引數是未型別化的常量怎麼辦?

另一個例子有助於闡明這種情況。讓我們考慮一個函式 foo,它接受任意數量的引數,所有這些引數必須具有相同的型別。foo 呼叫了各種未型別化的常量引數,包括型別為 int 的變數 x

func foo[P any](...P) {}

var x int
foo(x)         // P ➞ int, same as foo[int](x)
foo(x, 2.0)    // P ➞ int, 2.0 converts to int without loss of precision
foo(x, 2.1)    // P ➞ int, but parameter passing fails: 2.1 is not assignable to int

對於型別推導,型別化引數優先於未型別化引數。只有當它被賦值到的型別引數尚未推斷出型別時,才會考慮未型別化的常量進行推導。在前三個對 foo 的呼叫中,變數 x 確定了 P 的推斷型別:它是 x 的型別,即 int。在這種情況下,未型別化的常量被忽略,呼叫行為與 foo 被顯式例項化為 int 完全相同。

如果 foo 只用未型別化的常量引數呼叫,情況會更有趣。在這種情況下,型別推導會考慮未型別化常量的預設型別。快速回顧一下,Go 中可能的預設型別如下:

Example     Constant kind              Default type    Order

true        boolean constant           bool
42          integer constant           int             earlier in list
'x'         rune constant              rune               |
3.1416      floating-point constant    float64            v
-1i         complex constant           complex128      later in list
"gopher"    string constant            string

有了這些資訊,讓我們考慮函式呼叫:

foo(1, 2)    // P ➞ int (default type for 1 and 2)

未型別化的常量引數 12 都是整數常量,它們的預設型別是 int,因此推斷為 foo 的型別引數 Pint

如果不同的常量——比如未型別化的整數和浮點常量——競爭同一個型別變數,我們就有不同的預設型別。在 Go 1.21 之前,這被視為衝突並導致錯誤:

foo(1, 2.0)    // Go 1.20: inference error: default types int, float64 don't match

這種行為在使用上的人體工程學不佳,並且與未型別化常量在表示式中的行為不同。例如,Go 允許常量表達式 1 + 2.0;結果是浮點常量 3.0,預設型別為 float64

在 Go 1.21 中,行為相應地得到了更改。現在,如果多個未型別化的數字常量與同一個型別引數匹配,則會選擇預設型別,該型別在 intrunefloat64complex 列表中出現得更晚,這與常量表達式的規則相匹配:

foo(1, 2.0)    // Go 1.21: P ➞ float64 (larger default type of 1 and 2.0; behavior like in 1 + 2.0)

特殊情況

現在我們已經對型別推導有了大致的瞭解。但是有幾個重要的特殊情況值得注意。

引數順序依賴性

第一個與引數順序依賴性有關。我們希望從型別推導中獲得的一個重要屬性是,無論函式引數的順序(以及該函式每次呼叫的相應引數順序)如何,推斷出的型別都應相同。

讓我們重新審視我們的可變引數 foo 函式:為 P 推斷的型別應與我們傳遞引數 st 的順序無關(playground)。

func foo[P any](...P) (x P) {}

type T struct{}

func main() {
    var s struct{}
    var t T
    fmt.Printf("%T\n", foo(s, t))
    fmt.Printf("%T\n", foo(t, s)) // expect same result independent of parameter order
}

從對 foo 的呼叫中,我們可以提取相關的型別方程:

𝑻(x) :≡ 𝑻(s) => P :≡ struct{}    // equation 1
𝑻(x) :≡ 𝑻(t) => P :≡ T           // equation 2

不幸的是,:≡ 的簡化實現產生了順序依賴性:

如果合一開始處理方程 1,它會將 Pstruct 匹配;P 此時還沒有推斷出型別,因此合一推斷出 P ➞ struct{}。當合一在方程 2 中稍後看到型別 T 時,它會繼續使用 T 的底層型別,即 struct{}Punder(T) 合一時,合一(因此推導)成功。

反之,如果合一開始處理方程 2,它會將 PT 匹配;P 此時還沒有推斷出型別,因此合一推斷出 P ➞ T。當合一在方程 1 中看到 struct{} 時,它會繼續使用推斷為 P 的型別 T 的底層型別。該底層型別是 struct{},它與方程 1 中的 struct 匹配,合一(因此推導)成功。

因此,根據合一解決兩個型別方程的順序,推斷出的型別是 struct{}T。這當然是令人不滿意的:一個程式可能突然停止編譯,僅僅因為在程式碼重構或清理過程中引數的順序可能發生了變化。

恢復順序無關性

幸運的是,補救方法很簡單。在某些情況下,我們只需要進行一些小的更正。

具體來說,如果合一正在解決 P :≡ T 並且:

  • P 是一個型別引數,它已經推斷出了型別 AP ➞ A
  • A :≡ T 為真
  • T 是一個命名型別

那麼將 P 的推斷型別設定為 TP ➞ T

這可以確保 P 是命名型別(如果存在選擇),而不管命名型別在與 P 匹配的哪個點出現(即,無論型別方程以何種順序求解)。請注意,如果不同的命名型別與同一個型別引數匹配,我們總是會發生合一失敗,因為根據定義,不同的命名型別不相同。

由於我們對通道和介面進行了類似的簡化,它們也需要類似的特殊處理。例如,我們在為可分配性合一時會忽略通道方向,因此可能會推斷出定向或雙向通道,具體取決於引數順序。介面也出現類似問題。這裡我們不討論這些。

回到我們的示例,如果合一開始處理方程 1,它會像之前一樣推斷 P ➞ struct{}。當它繼續處理方程 2 時,就像之前一樣,合一成功,但現在我們有了恰好需要更正的條件:P 是一個已經有一個型別(struct{})的型別引數,struct{}struct{} :≡ T 為真(因為 struct{} ≡ under(T) 為真),並且 T 是一個命名型別。因此,合一進行更正並將 P ➞ T 設定為。結果,無論合一順序如何,兩種情況下的結果都是相同的(T)。

自遞迴函式

另一種在天真的推導實現中引起問題的場景是自遞迴函式。讓我們考慮一個泛型的階乘函式 fact,它被定義為也可以處理浮點引數(playground)。請注意,這不是伽馬函式的數學上正確的實現,它只是一個方便的例子。

func fact[P ~int | ~float64](n P) P {
    if n <= 1 {
        return 1
    }
    return fact(n-1) * n
}

關鍵不在於階乘函式本身,而在於 fact 使用型別與傳入引數 n 相同的引數 n-1 來呼叫自身。在此呼叫中,型別引數 P 同時是繫結和自由型別引數:它是繫結的,因為它由 fact(被呼叫的函式)宣告。但它也是自由的,因為它由包含該呼叫的函式宣告,該函式恰好也是 fact

將引數 n-1 傳遞給引數 n 的遞迴呼叫產生的方程將 P 與自身進行比較:

𝑻(n) :≡ 𝑻(n-1) => P :≡ P

合一在方程的兩邊看到相同的 P。合一成功,因為兩種型別相同,但沒有獲得任何資訊,P 仍然沒有推斷的型別。因此,型別推導失敗。

幸運的是,解決這個問題的技巧很簡單:在呼叫型別推導之前,並且僅供型別推導(臨時)使用,編譯器會重新命名涉及相應呼叫的所有函式簽名中的型別引數(但不是函式體)。這不會改變函式簽名的含義:無論型別引數的名稱是什麼,它們都表示相同的泛型函式。

在這個例子中,我們假設 fact 簽名中的 P 被重新命名為 Q。效果就像遞迴呼叫是透過一個 helper 函式間接完成的(playground):

func fact[P ~int | ~float64](n P) P {
    if n <= 1 {
        return 1
    }
    return helper(n-1) * n
}

func helper[Q ~int | ~float64](n Q) Q {
    return fact(n)
}

透過重新命名或使用 helper 函式,將引數 n-1 傳遞給 fact(或 helper 函式)的遞迴呼叫產生的方程變為:

𝑻(n) :≡ 𝑻(n-1) => Q :≡ P

這個方程有兩個型別引數:由被呼叫函式宣告的繫結型別引數 Q,以及由包含函式宣告的自由型別引數 P。這個型別方程對於 Q 被平凡地求解,結果是推斷 Q ➞ P,這當然是我們期望的,並且我們可以透過顯式例項化遞迴呼叫來驗證(playground)。

func fact[P ~int | ~float64](n P) P {
    if n <= 1 {
        return 1
    }
    return fact[P](n-1) * n
}

缺少什麼?

我們描述中明顯缺失的是泛型型別的型別推導:當前泛型型別必須始終顯式例項化。

這有幾個原因。首先,對於型別例項化,型別推導只有型別引數可以工作;與函式呼叫不同,沒有其他引數。因此,至少必須始終提供一個型別引數(除非在型別約束指定所有型別引數恰好有一個可能型別引數的病態情況)。因此,型別推導對於型別僅在完成部分例項化的型別時才有用,其中所有被省略的型別引數都可以從型別約束產生的方程中推匯出來;即,其中至少有兩個型別引數。我們認為這種情況並不常見。

其次,也是更相關的,型別引數允許一種全新的遞迴型別。考慮假設的型別:

type T[P T[P]] interface{ … }

其中 P 的約束是正在宣告的型別。結合具有多個可能相互引用以複雜遞迴方式引用彼此的型別引數的能力,型別推導變得更加複雜,目前我們並不完全理解所有這些含義。也就是說,我們認為檢測迴圈並在沒有此類迴圈的情況下繼續進行型別推導應該不難。

最後,有些情況下型別推導根本不夠強大,無法進行推導,通常是因為合一使用了某些簡化假設,例如本文前面描述的那些。這裡的主要例子是具有無核心型別的約束,但更復雜的方法可能仍然能夠推斷出型別資訊。

這些都是我們可能會在未來 Go 版本中看到增量改進的領域。重要的是,我們認為當前推導失敗的情況要麼很少見,要麼在生產程式碼中不重要,並且我們當前的實現涵蓋了絕大多數有用的程式碼場景。

不過,如果您遇到我認為型別推導應該工作或出錯的情況,請提交 issue!一如既往,Go 團隊很樂意聽到您的聲音,特別是當它有助於我們使 Go 變得更好時。

下一篇文章:Go 的十四年
上一篇文章:解構型別引數
部落格索引