Go 部落格

您一直想知道的關於類型推論的所有事,再深入一點

Robert Griesemer
2023 年 10 月 9 日

這是我在聖地牙哥舉行的 GopherCon 2023 上關於類型推論而發表之演講的部落格版本,已稍作修改並編輯以加強清晰度。

什麼是類型推論?

Wikipedia 定義類型推論如下

類型推論是在編譯期間自動推論出表達式的類型(部分或完整)。編譯器通常可以在沒有明確類型註解的情況下,推論出變數的類型或函式的類型簽章。

此處的關鍵字句是「自動推論…表達式的類型」。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)

如果沒有類型推論,呼叫 'BinarySearch' 和 'Sort' 會需要明確的類型引數

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 變數作為引數 x 的函數引數,用於 slices.Sort。因此,list 的類型 (List),一定會和變數 x 的類型相符 (類型參數 S)。如果 S 為類型 List,則這項指定變得有效。實際上,指定項目的規則很複雜,但目前假設類型必須相同已足夠。

一旦推論出 S 的型別,我們便可以檢視 S型別限制。它表示(因為有波浪號 ~ 符號)S底層型別 必須是 []E 切片。S 的底層型別是 []int,因此 []int 必須符合 []E,如此我們便可以推論出 E 必須為 int。我們已經找出 SE 的型別,使得對應的型別相符。推論成功!

以下是一個較為複雜的範例,其中我們有很多型別參數:slices.EqualFuncS1S2E1E2,以及泛函數 equalE1E2。區域函數 foo 使用 equal 函數作為參數呼叫 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

對於 P 我們可以輕易地解出這個方程式:P 必須是 A,我們寫作 P ➞ A。換句話說,這裡沒有什麼好做的。我們仍然可以把對應的型別方程式寫下來以求完整,但在這種情況下,Go 編譯器會簡單地把型別參數替換成其型別引數,然後那些型別參數就消失了,我們可以忘記它們。

2. 來自指定命令的型別方程式

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

f(…, x, …)

其中 px 包含型別參數時,x 的型別必須可以指定給參數 p 的型別。我們可以使用方程式來表示這個情況

𝑻(p) :≡ 𝑻(x)

其中 𝑻(x) 表示「x 的類型」。如果 px 皆未包含類型參數,則不存在要解的類型變數:等式為 True,是因為指定為有效的 Go 程式碼,否則則為 False,因為程式碼無效。由於此原因,類型推論僅考慮包含所涉及函式(或函式)類型參數的類型。

從 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.SortSE 的限制的等式(詳見下文)。

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.Sortx 參數會產生方程式

𝑻(x) :≡ 𝑻(list)

等於

S :≡ []F

在這個方程式裡,我們有兩個型別參數,SF。我們需要解出哪一個型別方程式?因為呼叫的函式是 Sort,我們關心的是它宣告的型別參數 S,而不是 F。我們說 S 是「繫結」到 Sort,因為 Sort 宣告了 SS 是這個方程式中相關的型別變數。相較之下,F 是繫結到(宣告於)sortedPrint。我們說 F 關於 Sort 是「自由的」。它有它自己的型別,而且已經給定了。這個型別是 F,不管它是什麼(在實體化時會決定)。在這個方程式裡,F 已經給定了,它是一個「型別常數」。

當我們解型別方程式時,我們總是解算繫結到我們呼叫函式(或在泛函賦予部分的情況下)的型別參數。

解型別方程式

我們已經確定了如何收集相關的型別參數和型別方程式,現在缺少的部分當然就是允許我們解這些方程式的演算法。在看了各種範例之後,你可能會很明顯地看出來解 X ≡ Y 就表示要遞迴地比較型別 XY,並且在這個過程中,決定 XY 中可能會出現的型別參數的適當型別引數。目標是讓型別 XY「相同」。這個配對過程會被稱為 統一化

型別等同 的規則告訴我們要如何比較型別。因為繫結型別參數代表型別變數,所以我們需要說明如何將它們與其他型別配對。規則如下

  • 如果型別參數 P 有推論的型別,P 代表那個型別。
  • 如果類型參數 P 沒有推論類型,並且比對另一個類型 TP 會設定為該類型:P ➞ T。我們說類型 T 已經針對 P 推論出來。
  • 如果 P 比對另一個類型參數 Q,且 PQ 都還沒有推論出類型,則 PQ統一

兩個類型參數統一表示它們會合併在一起,從此它們都會表示相同的類型參數值:如果 PQ 其中一個比對類型 T,則 PQ 會同時設定為 T(一般來說,任何數量類型參數都可以這樣統一)。

最後,如果兩個類型 XY 不同,則無法得到正確方程式,求解就會失敗。

統一類型以取得類型識別

幾個具體範例可以讓這個演算法清晰易懂。考慮兩個類型 XY,包含三個已繫結類型參數 ABC,全部都出現在類型方程式 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 不同;我們的類型不同,統一(於是類型推論)失敗。

統一有衝突類型引數的類型

當不同的類型與相同的類型參數匹配時,會出現另一種衝突。以下又是我們最初範例的版本,但現在類型參數 AX 中出現兩次,而 CY 中出現兩次。

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 的基礎類型不會發生問題:最糟的情況下我們可能會推論出錯誤的類型引數,但當指派被檢查時,它會導致稍後出錯。最好的情況下,我們會找到有用而且正確的類型引數。在我們的範例中,不精確統一會成功,我們正確地推論出 intE

簡化 X ∈ Y

能夠簡化限制滿足關係更加重要,因為限制會非常複雜。

再次強調,約束滿足一事,檢查於實體化時刻,因此,目標為幫助型別推論,據其所能。這些通常是已知型別參數結構的狀況;例如已知它必定是切片型別,且我們關注切片的元素型別。例如,型別參數清單[P ~[]E]告訴我們,不論P為何,其底層型別必定為[]E的型式。這些情況正是約束擁有核心型別時的情況。

因此,假設我們有如下形式的等式

P ∈ constraint               // or
P ∈ ~constraint

而且如果core(constraint)(或core(~constraint))存在,則該等式可簡化為

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

在所有其他情況下,涉及約束的型別等式將會略過。

擴展已推論型別

倘若統一成功,則會產生從型別參數至推論型別引數的對應。但是,統一無法確保推論型別將會無約束型別參數。為了瞭解為何如此,請考量下方泛用函式g,該函式呼叫單一參數x,而該參數的型別為int

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 的型別參數 P 推論出 int

如果不同的常數(例如未打型別的整數常數和浮點常數)競爭同一個型別變數,我們就會看到不同的預設型別。在 Go 1.21 之前,會將這視為衝突而導致錯誤

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

這種行為使用起來並不是非常符合人體工學,而且與未打型別的常數在表示式中的行為不同。例如,Go 允許常數表示式 1 + 2.0;結果是預設型別為 float64 的浮點常數 3.0

在 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 的順序為何,都保持相同(遊樂場)。

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 是已推测出类型 A 的类型参数:P ➞ A
  • A :≡ T 为 true
  • T 为已命名类型

然后将 P 的推测类型设置为 TP ➞ T

这可确保 P 在有选择时为已命名类型,无论已命名类型在与 P 匹配时出现在何处(即,无论按什么顺序解决类型方程式)。请注意,如果不同的已命名类型与同一个类型参数匹配,则我们始终会遇到统一失败,因为根据定义不同的已命名类型不相同。

由于我们对通道和接口进行了类似的简化,因此它们也需要类似的特殊处理。例如,我们在针对可赋值性进行统一时会忽略通道方向,因此可能会根据参数顺序推断出单向或双向通道。接口会出现类似的问题。我们这里不予讨论这些问题。

回到我們的範例,如果統一從等式 1 開始,它會推論 P ➞ struct{},與之前相同。當它以等式 2 繼續進行,與之前相同,統一會成功,但現在我們有確切的條件需要更正:P 是一個類型參數,已經有一個類型 (struct{}),struct{}, struct{} :≡ T 為真 (因為 struct{} ≡ under(T) 為真),而且 T 是一個已命名類型。因此,統一會進行更正,並設定 P ➞ T。結果是,不論統一順序為何,兩個案例的結果都是相同的 (T)。

自我遞迴函數

另一個會在推理天真實作中造成問題的場景是自我遞迴函數。我們來考量一個泛用階乘函數 fact,定義成也可以用於浮點數參數 (Playground 的連結:playground)。請注意,這並不是 gamma 函數 數學上正確的實作,但這只是一個便利的範例。

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

這邊的重點不是在於階乘函數,而是 fact 會使用參數 n-1 來呼叫它自己,而這個參數與輸入參數 n 具有相同的類型 P。在這個呼叫中,類型參數 P 同時是一個約束和一個自由類型參數:它是約束的,因為它是由我們遞迴呼叫的函數 fact 宣告的。但它也是自由的,因為它是由包含這個函數呼叫的函數所宣告的,而這個函數碰巧也是 fact

將參數 n-1 傳遞給參數 n 所產生的等式使 P 自相矛盾

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

統一會在等式的兩邊看到相同的 P。由於這兩種類型完全相同,統一會成功,但沒有獲得任何資訊,而 P 仍然沒有推論出的類型。因此,類型推理會失敗。

幸運的是,解決這個問題的技巧很簡單:在呼叫類型推理之前,並且只供類型推理(暫時)使用,編譯器會將牽涉到相關呼叫的所有函數簽章(而非主體)中的類型參數重新命名。這不會改變函數簽章的意義:不論類型參數的名稱為何,它們表示相同的泛用函數。

為了這個範例,我們假設 fact 簽章中的 P 已經重新命名為 Q。影響就像是用一個 helper 函數間接執行遞迴呼叫一樣 (Playground 的連結: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,這當然是我們預期的,而且我們可以透過明確實例化遞迴呼叫來驗證(遊樂場

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 版本中可能會看到逐漸改善的領域。重要的是,我們認為目前推論失敗的情況在實際程式碼中不是罕見就是不重要,而且我們目前的實作涵蓋了所有有用的程式碼場景的大部分。

話雖如此,如果您遇到您認為型別推論應會運作或出錯的情況,請提交問題!一如往常,Go 團隊樂於收到您的回饋,特別是有助於我們讓 Go 變得更好的回饋。

下一篇:Go 十四年
上一篇:釐清型別參數
部落格索引