Go 部落格

你想知道的關於型別推斷的一切 - 以及更多

Robert Griesemer
2023年10月9日

本文是根據我在聖迭戈 GopherCon 2023 大會上關於型別推斷的演講整理的部落格版本,內容略有擴充套件並進行了編輯,以便更清晰地闡述。

什麼是型別推斷?

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

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

這裡的關鍵短語是“自動推匯出……表示式的型別”。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 關鍵字的能力——結合成一個非常緊湊的語句。考慮以下 map 變數宣告

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.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

繫結型別引數 vs 自由型別引數

至此,我們對各種型別方程的來源有了更清晰的理解,但對於應該為哪些型別引數求解方程還不夠精確。讓我們考慮另一個例子。在下面的程式碼中,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 相同。這種匹配過程稱為合一(unification)

型別相同的規則告訴我們如何比較型別。由於繫結型別引數扮演型別變數的角色,我們需要指定它們如何與其他型別進行匹配。規則如下

  • 如果型別引數 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[…]…

其中 代表我們在此步驟忽略的各自的 map 鍵和值型別。由於兩側都是 map,到目前為止型別是相同的。合一遞迴地進行,首先比較鍵型別,X 的 map 鍵型別是 AY 的 map 鍵型別是 string。相應的鍵型別必須相同,由此我們可以立即推斷出 A 的型別實參必須是 string

A ≡ string => A ➞ string

繼續比較 map 元素型別,我們得到

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

兩側都是 struct,所以合一繼續比較 struct 欄位。如果它們的順序相同、名稱相同且型別相同,則它們是相同的。第一個欄位對是 i inti C。名稱匹配,並且因為 int 必須與 C 合一,所以

int ≡ C => C ➞ int

這種遞迴型別匹配持續進行,直到兩個型別的樹結構完全遍歷,或者出現衝突。在這個例子中,最終我們得到

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

一切順利,合一推斷出型別實參

A ➞ string
B ➞ byte
C ➞ int

對結構不同的型別進行合一

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

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

XY 都是 map 型別,所以合一像之前一樣遞迴進行,從鍵型別開始。我們得到

A ≡ string => A ➞ string

同樣像之前一樣。但當我們繼續處理 map 的值型別時,我們有

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

當我們比較第二個 struct 欄位型別時,我們有

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

由於 AC 都推斷出了型別實參,它們代表這些型別實參,分別是 stringint。這些是不同的型別,所以 AC 不可能匹配。合一(因此型別推斷)失敗。

其他型別關係

合一求解形如 X ≡ Y 的型別方程,其目標是型別相同。但是對於 X :≡ YX ∈ Y 呢?

這裡有一些觀察可以幫助我們:型別推斷的工作僅僅是找到被省略的型別實參的型別。型別推斷之後總是跟著型別或函式例項化,它檢查每個型別實參是否確實滿足其相應的型別約束。最後,在泛型函式呼叫的情況下,編譯器還會檢查函式實參是否可賦值給其相應的函式引數。所有這些步驟都必須成功,程式碼才是有效的。

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

這個洞察使我們能夠對型別關係 :≡ 稍微放寬要求。具體來說,它允許我們簡化它們,使得它們可以幾乎與 同等對待。簡化的目標是從型別方程中提取儘可能多的型別資訊,從而在精確實現可能失敗的情況下推斷出型別實參,因為我們可以這樣做。

簡化 X :≡ Y

Go 的可賦值性規則相當複雜,但大多數情況下,我們實際上可以用型別相同或其微小變體來處理。只要我們找到潛在的型別實參,我們就很高興,正是因為型別推斷之後仍然有型別例項化和函式呼叫。如果推斷找到了不應該存在的型別實參,它稍後會被捕獲。因此,在匹配可賦值性時,我們對合一演算法進行以下調整

  • 當命名(定義)型別與型別字面量匹配時,改為比較它們的底層型別。
  • 比較 channel 型別時,忽略 channel 方向。

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

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

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

簡化 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。在這種情況下,無型別常量會被型別推斷忽略,並且這些呼叫的行為與顯式例項化 fooint 完全相同。

如果只用無型別常量實參呼叫 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 中,該行為進行了相應更改。現在,如果多個無型別數值常量與同一個型別引數匹配,則選擇在 int, rune, float64, complex 列表中出現較晚的預設型別,這與常量表達式的規則相匹配。

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

特殊情況

至此,我們已經大致瞭解了型別推斷。但還有一些重要的特殊情況需要注意。

引數順序依賴性

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

讓我們重新考慮可變引數函式 foo:無論我們傳遞實參 st 的順序如何,為 P 推斷出的型別都應該相同(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{} :≡ T 為真(因為 struct{} ≡ under(T) 為真),並且 T 是一個命名型別。因此,統一過程進行修正,並將 P ➞ T。結果,無論統一順序如何,兩種情況下結果都是相同的 (T)。

自遞迴函式

另一個在推斷的樸素實現中引起問題的場景是自遞迴函式。讓我們考慮一個泛型階乘函式 fact,其定義使其也適用於浮點引數(playground)。請注意,這不是伽馬函式(gamma function)的數學上正確實現,它僅僅是一個方便的例子。

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

這裡的重點不是階乘函式本身,而是 fact 透過傳入引數 n-1 呼叫自身,其型別 P 與傳入引數 n 的型別相同。在這種呼叫中,型別引數 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 十四年
上一篇文章:解構型別引數
部落格索引