型推論について知りたかったすべてのこと、そしてもう少し
Everything You Always Wanted to Know About Type Inference - And a Little Bit More by Robert Griesemer
本記事は、私がGopherCon 2023(サンディエゴ開催)で行った型推論についての講演をブログ向けに書き起こしたもので、内容を多少拡張し、わかりやすくなるよう手を加えています。
型推論とは何か
Wikipediaでは型推論を次のように定義しています。
型推論とは、コンパイル時に式の型を部分的に、あるいは完全に自動で導き出す機能のことです。多くの場合、コンパイラは明示的な型注釈が与えられていなくても、変数の型や関数の型シグネチャを推論できます。
ここで重要なのは「式の型を……自動で導き出す」という部分です。Goは当初から基本的な形の型推論をサポートしていました。
const x = expr // xの型はexprの型
var x = expr
x := expr
これらの宣言では明示的な型が与えられていないため、= や := の左辺にある定数や変数 x の型は、右辺に置かれたそれぞれの初期化式の型になります。これを、型は初期化式の型から「推論される」と言います。Go 1.18でジェネリクスが導入されたことで、Goの型推論の能力は大幅に拡張されました。
なぜ型推論が必要か
ジェネリクスを使わない通常のGoのコードでは、型を省略する効果は短縮変数宣言においてもっとも顕著に現れます。この宣言は、型推論と、var キーワードを省略できるというちょっとした糖衣構文とを組み合わせて、非常にコンパクトな1文にまとめたものです。次のマップ変数の宣言を考えてみましょう。
var m map[string]int = map[string]int{}
対して
m := map[string]int{}
:= の左辺の型を省略することで、重複がなくなると同時に可読性も高まります。
ジェネリクスを使ったGoのコードでは、コード中に現れる型の数が大幅に増える可能性があります。型推論がなければ、ジェネリック関数やジェネリック型を具体化するたびに型引数が必要になります。そのため、型引数を省略できることはより一層重要になります。新しいslicesパッケージにある次の2つの関数を使う場合を考えてみましょう。
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)
こちらのほうがすっきりしていてコンパクトです。実際、これはジェネリクスを使っていないコードとまったく同じに見えますが、それを可能にしているのが型推論です。
重要なのは、型推論はあくまで任意の仕組みだということです。型引数を書いたほうがコードが明確になるのであれば、遠慮なく書いてかまいません。
型推論は型パターンマッチングの一種
型推論は、型パターン同士を比較します。ここでの型パターンとは、型パラメータを含む型のことです。この後すぐに明らかになる理由から、型パラメータは_型変数_と呼ばれることもあります。型パターンのマッチングによって、これらの型変数に入れるべき型を推論できます。簡単な例を見てみましょう。
// slicesパッケージより
// 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 の基底型は []E というスライスでなければならないとわかります。S の基底型は []int なので、[]int は []E と一致しなければならず、そこから E は int でなければならないと結論づけられます。こうして、対応する型同士が一致するように S と E の型を見つけられました。推論は成功です!
次はもっと複雑な例で、多くの型パラメータが登場します。slices.EqualFunc からの S1、S2、E1、E2、そしてジェネリック関数 equal からの E1 と E2 です。ローカル関数 foo は equal 関数を引数として slices.EqualFunc を呼び出します。
// slicesパッケージより
// func EqualFunc[S1 ~[]E1, S2 ~[]E2, E1, E2 any](s1 S1, s2 S2, eq func(E1, E2) bool) bool
// ローカルのコード
func equal[E1, E2 comparable](E1, E2) bool { … }
func foo(list1 []int, list2 []float64) {
…
if slices.EqualFunc(list1, list2, equal) {
…
}
…
}
これは、型パラメータの数だけ、つまり最大6個の型引数を省略できるという、型推論がまさに真価を発揮する例です。型パターンのマッチングという考え方自体はここでも通用しますが、型同士の関係が増えていくにつれてすぐに複雑になっていくことがわかります。どの型パラメータとどの型が、どのパターンに関わっているのかを見極めるための体系的な手法が必要です。
型推論を少し違う角度から眺めてみると、理解の助けになります。
型方程式
型推論は、型方程式を解く問題として捉え直せます。方程式を解くというのは、誰もが高校の数学で慣れ親しんできたはずです。幸い、型方程式を解く問題は、これから見ていくようにもっと単純なものです。
先ほどの例をもう一度見てみましょう。
// slicesパッケージより
// func Sort[S ~[]E, E cmp.Ordered](x S)
type List []int
var list List
slices.Sort(list)
次の型方程式が解ければ推論は成功します。ここで ≡ は「同一である」を、under(S) は S の基底型を表します。
S ≡ List // S ≡ List が真になるようなSを見つける
under(S) ≡ []E // under(S) ≡ []E が真になるようなEを見つける
方程式の中の変数にあたるのが型パラメータです。方程式を解くとは、方程式が真になるように、これらの変数(型パラメータ)に対する値(型引数)を見つけることを意味します。このように捉えることで、推論に流れ込む情報を書き下せる形式的な枠組みが手に入り、型推論の問題がより扱いやすくなります。
型の関係を正確に扱う
ここまでは単純に、型は同一でなければならないという話をしてきました。しかし実際のGoのコードにとって、それは強すぎる要件です。先ほどの例では、S は List と同一である必要はなく、むしろ List が S に代入可能でなければなりません。同様に、S は対応する型制約を満たさなければなりません。:≡ と ∈ と書く専用の演算子を使うことで、型方程式をより正確に定式化できます。
S :≡ List // ListはSに代入可能
S ∈ ~[]E // Sは制約 ~[]E を満たす
E ∈ cmp.Ordered // Eは制約 cmp.Ordered を満たす
一般に、型方程式には3つの形があると言えます。2つの型が同一でなければならない、ある型が別の型に代入可能でなければならない、あるいはある型が型制約を満たさなければならない、の3つです。
X ≡ Y // XとYは同一でなければならない
X :≡ Y // YはXに代入可能
X ∈ Y // Xは制約Yを満たす
(補足: GopherConの講演では、:≡ の代わりに ≡A、∈ の代わりに ≡C という記号を使っていました。:≡ のほうが代入の関係をより明確に想起させ、∈ は型パラメータが表す型がその制約の型集合の要素でなければならないことを直接的に表していると私たちは考えています。)
型方程式の由来
ジェネリック関数の呼び出しでは、明示的な型引数が与えられることもありますが、多くの場合は推論できることを期待します。また、通常の関数引数もあるのが普通です。明示的な型引数はそれぞれ、(自明な)型方程式を1つもたらします。コードにそう書いてある以上、その型パラメータは型引数と同一でなければならない、というものです。通常の関数引数もそれぞれ別の型方程式をもたらします。その関数引数は対応する関数パラメータに代入可能でなければならない、というものです。そして最後に、それぞれの型制約も、どのような型がその制約を満たすかを制限することで型方程式をもたらします。
これらを合わせると、n 個の型パラメータと m 個の型方程式が生まれます。基本的な高校数学の代数とは違い、型方程式が解けるために n と m が一致している必要はありません。たとえば、次のたった1つの方程式から、2つの型パラメータの型引数を推論できます。
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. 代入による型方程式
関数パラメータ p に渡される関数引数 x それぞれについて
f(…, x, …)
p または x が型パラメータを含む場合、x の型は p の型に代入可能でなければなりません。これは次の方程式で表せます。
𝑻(p) :≡ 𝑻(x)
ここで 𝑻(x) は「x の型」を意味します。p にも x にも型パラメータが含まれていなければ、解くべき型変数は存在しません。この方程式は、代入が有効なGoのコードであれば真になり、無効なコードであれば偽になるだけです。このため、型推論は関与する関数(複数の場合も含む)の型パラメータを含む型のみを考慮します。
Go 1.21以降では、(関数呼び出しではなく)具体化されていない、あるいは部分的にしか具体化されていない関数を、次のように関数型の変数に代入することもできるようになりました。
// slicesパッケージより
// func Sort[S ~[]E, E cmp.Ordered](x S)
var intSort func([]int) = slices.Sort
パラメータの受け渡しと同様に、このような代入も対応する型方程式をもたらします。この例では次のようになります。
𝑻(intSort) :≡ 𝑻(slices.Sort)
簡略化すると
func([]int) :≡ func(S)
これに加えて、slices.Sort の S と E の制約に関する方程式も一緒に得られます(後述)。
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 // myEq[string] を使うのと同じ
myEq が完全に具体化されていなくても、型推論によって P の型引数は string でなければならないと推論されます。
さらに、ジェネリック関数は、具体化されていない、あるいは部分的にしか具体化されていない状態のまま、別の(それ自身もジェネリックかもしれない)関数への引数として使うこともできます。
// slicesパッケージより
// 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) // slices.CompactFunc[List, int](list, myEq[int]) を使うのと同じ
最後の例では、型推論によって CompactFunc と myEq の型引数が決定されています。より一般には、任意の数の関数に由来する型パラメータを推論する必要が生じることがあります。複数の関数が関与する場合、型方程式もまた複数の関数に由来し、あるいはそれらに関わることがあります。CompactFunc の例では、最終的に3個の型パラメータと5個の型方程式が得られます。
型パラメータと制約:
S ~[]E
E any
P comparable
明示的な型引数:
なし
型方程式:
S :≡ List
func(E, E) bool :≡ func(P, P) bool
S ∈ ~[]E
E ∈ any
P ∈ comparable
解:
S ➞ List
E ➞ int
P ➞ int
束縛された型パラメータと自由な型パラメータ
ここまでで型方程式のさまざまな由来について理解が深まりましたが、その方程式をどの型パラメータについて解くのかは、まだあまり厳密に説明していませんでした。別の例を考えてみましょう。次のコードでは、sortedPrint の関数本体がソート処理のために slices.Sort を呼び出しています。sortedPrint と slices.Sort はどちらも型パラメータを宣言しているため、両方ともジェネリック関数です。
// slicesパッケージより
// func Sort[S ~[]E, E cmp.Ordered](x S)
// sortedPrintは与えられたリストの要素をソートして表示する。
func sortedPrint[F any](list []F) {
slices.Sort(list) // 𝑻(list) は []F
… // listを表示する
}
ここでは slices.Sort の呼び出しに対する型引数を推論したいとします。list を slices.Sort のパラメータ x に渡すことで、次の方程式が生まれます。
𝑻(x) :≡ 𝑻(list)
これは次と同じです。
S :≡ []F
この方程式には S と F という2つの型パラメータがあります。どちらについて型方程式を解く必要があるのでしょうか。呼び出されている関数は Sort なので、私たちが気にするのはその型パラメータである S であって、F ではありません。S は Sort によって宣言されているため、S は Sort に_束縛_されていると言います。この方程式で意味を持つ型変数は S です。対照的に、F は sortedPrint に束縛(宣言)されています。F は Sort に対して_自由_であると言います。F はすでに与えられた、それ自身の型を持っています。その型が何であるかは(具体化の時点で決まる)F そのものです。この方程式において、F はすでに与えられている、いわば_型定数_です。
型方程式を解くときには、私たちが呼び出そうとしている関数(ジェネリック関数の代入の場合は代入先の関数)に束縛された型パラメータについて、常に解を求めます。
型方程式を解く
関連する型パラメータと型方程式を集める方法が確立できたところで、残るピースはもちろん、その方程式を解くためのアルゴリズムです。ここまでのさまざまな例から、X ≡ Y を解くとは、単に型 X と Y を再帰的に比較し、その過程で X と Y の中に現れうる型パラメータに適切な型引数を決定していくことにほかならない、ということがおそらく明らかになったでしょう。目標は型 X と Y を同一にすることです。このマッチングの処理は単一化と呼ばれます。
型の同一性のルールは、型をどのように比較するかを教えてくれます。束縛された型パラメータは型変数の役割を果たすため、それらが他の型とどのようにマッチングされるかを規定する必要があります。ルールは次の通りです。
- 型パラメータ
Pに推論済みの型があれば、Pはその型を表す。 - 型パラメータ
Pに推論済みの型がなく、別の型Tとマッチングされた場合、Pはその型に設定される:P ➞ T。このとき、型TはPに対して推論されたと言う。 Pが別の型パラメータQとマッチングされ、PにもQにもまだ推論済みの型がない場合、PとQは単一化される。
2つの型パラメータの単一化とは、それらが結び付けられ、以後は両方とも同じ型パラメータの値を表すようになることを意味します。P または Q のどちらか一方が型 T とマッチングされると、P と Q の両方が同時に T に設定されます(一般に、任意の数の型パラメータをこの方法で単一化できます)。
最後に、2つの型 X と Y が異なる場合、その方程式を真にすることはできず、解くことに失敗します。
型の同一性のための単一化
いくつか具体的な例を見ればこのアルゴリズムが明らかになるはずです。3つの束縛された型パラメータ A、B、C を含む2つの型 X と Y を考えます。これらはすべて型方程式 X ≡ Y に現れます。目標はこの方程式を型パラメータについて解くこと、つまり X と Y が同一になり方程式が真になるように、それぞれに適切な型引数を見つけることです。
X: map[A]struct{i int; s []B}
Y: map[string]struct{i C; s []byte}
単一化は、頂点から始めて X と Y の構造を再帰的に比較することで進みます。2つの型の構造だけに注目すると、次のようになります。
map[…]… ≡ map[…]…
… は、この段階では無視しているそれぞれのマップのキー型と値型を表しています。両辺ともマップなので、ここまでは型は一致しています。単一化は再帰的に進み、まずキー型同士、つまり X のマップの A と Y のマップの string を比較します。対応するキー型は同一でなければならないため、そこから A の型引数は string でなければならないとただちに推論できます。
A ≡ string => A ➞ string
続いてマップの値型に進むと、次に行き着きます。
struct{i int; s []B} ≡ struct{i C; s []byte}
両辺とも構造体なので、単一化は構造体のフィールドについて進みます。フィールドが同じ順序で、同じ名前を持ち、かつ型が同一であれば、構造体は同一とみなされます。最初のフィールドの組は i int と i C です。名前は一致しており、int は C と単一化されなければならないため、次のようになります。
int ≡ C => C ➞ int
この再帰的な型のマッチングは、2つの型の木構造がすべて走査されるか、矛盾が現れるまで続きます。この例では、最終的に次のようになります。
[]B ≡ []byte => B ≡ byte => B ➞ byte
すべてがうまくいき、単一化によって次の型引数が推論されます。
A ➞ string
B ➞ byte
C ➞ int
構造の異なる型の単一化
今度は先ほどの例を少し変えたものを考えてみましょう。ここでは X と Y は同じ型構造を持っていません。型の木構造を再帰的に比較していくと、A の型引数の推論には単一化は成功しますが、マップの値型が異なっているため単一化は失敗します。
X: map[A]struct{i int; s []B}
Y: map[string]bool
X と Y はどちらもマップ型なので、単一化は先ほどと同様にキー型から再帰的に進みます。次に行き着きます。
A ≡ string => A ➞ string
これも先ほどと同じです。しかしマップの値型に進むと、次のようになります。
struct{…} ≡ bool
struct 型は bool と一致しません。異なる型同士であるため単一化(したがって型推論)は失敗します。
型引数が矛盾する型の単一化
別の種類の矛盾は、異なる型が同じ型パラメータに対してマッチングされたときに生じます。ここでも最初の例の別バージョンを考えますが、今度は型パラメータ A が X の中に2回、C が Y の中に2回現れます。
X: map[A]struct{i int; s []A}
Y: map[string]struct{i C; s []C}
再帰的な型の単一化は最初のうちはうまくいき、型パラメータと型の次のような組が得られます。
A ≡ string => A ➞ string // マップのキー型
int ≡ C => C ➞ int // 構造体の最初のフィールド型
構造体の2番めのフィールド型に着くと、次のようになります。
[]A ≡ []C => A ≡ C
A にも C にもすでに型引数が推論されているため、両者はそれぞれの型引数、すなわち string と int を表します。これらは異なる型なので、A と C は決して一致し得ません。単一化、したがって型推論は失敗します。
その他の型の関係
単一化は、型の同一性を目標とする X ≡ Y という形の型方程式を解きます。では X :≡ Y や X ∈ Y はどうでしょうか。
ここでは、いくつかの観察が助けになります。型推論の仕事は、省略された型引数の型を見つけることだけです。型推論の後には必ず型や関数の具体化が続き、そこで各型引数が実際にそれぞれの型制約を満たしているかが確認されます。さらに、ジェネリック関数の呼び出しの場合、コンパイラは関数引数が対応する関数パラメータに代入可能かどうかも確認します。コードが有効であるためには、これらすべての段階が成功しなければなりません。
型推論が十分に正確でない場合、本来型が存在しないところに(誤った)型引数を推論してしまうことがあります。その場合、具体化か引数の受け渡しのいずれかが失敗します。いずれにせよコンパイラはエラーメッセージを出します。ただ、そのエラーメッセージが多少異なるものになるだけです。
この気づきによって、:≡ と ∈ という型の関係については、多少大まかに扱う余地が生まれます。具体的には、これらを単純化して、ほとんど ≡ と同じように扱えるようにできます。この単純化の狙いは、型方程式からできる限り多くの型情報を引き出すことです。つまり、それが可能である以上、厳密な実装では失敗してしまうようなケースでも型引数を推論できるようにすることです。
X :≡ Y の単純化
Goの代入可能性のルールはかなり複雑ですが、実際にはほとんどの場合、型の同一性かそれをわずかに変えたものだけで済ませられます。型推論の後には型の具体化と関数呼び出しが必ず続くからこそ、可能性のある型引数さえ見つかればそれで十分なのです。推論が本来見つけるべきでない型引数を見つけてしまっても、それは後で捕捉されます。そこで、代入可能性についてマッチングする際には、単一化のアルゴリズムに次の調整を加えます。
- 名前付き(定義された)型がリテラル型とマッチングされる場合、代わりに両者の基底型が比較される。
- チャンネル型を比較する際には、チャンネルの方向は無視される。
さらに、代入の向きは無視されます。X :≡ Y は Y :≡ X と同じように扱われます。
これらの調整は型構造の最上位でのみ適用されます。たとえば、Goの代入可能性のルールによれば、名前付きのマップ型は名前のないマップ型に代入できますが、キー型と要素型は依然として同一でなければなりません。この変更によって、代入可能性のための単一化は、型の同一性のための単一化を(わずかに)変えたものになります。次の例でこれを説明します。
先ほどの List 型(type List []int として定義)の値を、E が束縛された型パラメータである(つまり、呼び出されているジェネリック関数によって宣言されている)型 []E の関数パラメータに渡す場合を考えます。これは型方程式 []E :≡ List をもたらします。この2つの型を単一化しようとすると、[]E と List を比較する必要がありますが、この2つは同一ではなく、単一化の動作を何も変えなければ失敗してしまいます。しかし、ここでは代入可能性のための単一化を行っているため、この最初のマッチングは厳密である必要はありません。名前付き型 List の基底型に進んでも問題はありません。最悪の場合、誤った型引数を推論してしまうこともありますが、それは後で代入がチェックされる際にエラーとして検出されます。最良の場合には、役に立つ正しい型引数が見つかります。この例では、厳密ではない単一化は成功し、E に対して正しく int が推論されます。
X ∈ Y の単純化
制約の充足関係を単純化できることは、制約が非常に複雑になり得ることを考えるといっそう重要です。
ここでもやはり、制約の充足は具体化の時点でチェックされるため、ここでの狙いは、可能な範囲で型推論を手助けすることです。これは典型的には、型パラメータの構造がわかっている状況です。たとえば、それがスライス型でなければならないとわかっていて、そのスライスの要素型を知りたい場合です。たとえば [P ~[]E] という形の型パラメータリストは、P が何であれ、その基底型は []E という形でなければならないことを教えてくれます。これはまさに、その制約がコア型を持つ状況です。
したがって、次の形の方程式があり、
P ∈ constraint // あるいは
P ∈ ~constraint
core(constraint)(それぞれ core(~constraint))が存在するならば、この方程式は次のように単純化できます。
P ≡ core(constraint)
under(P) ≡ core(~constraint) // それぞれの場合
それ以外のすべての場合、制約を含む型方程式は無視されます。
推論された型の展開
単一化が成功すると、型パラメータから推論された型引数へのマッピングが得られます。しかし、単一化だけでは、推論された型に束縛された型パラメータが含まれていないことまでは保証されません。なぜそうなるのかを見るために、int 型の引数 x 1つだけで呼び出される、次のジェネリック関数 g を考えてみましょう。
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
しかしこれでは、推論された型の中に型パラメータ A と C が残ってしまい、役に立ちません。高校数学の代数と同じように、ある変数 x について方程式を1つ解いたら、残りの方程式全体でその x を求めた値に置き換える必要があります。この例では、まず []C の中の C を、C について推論された型(「値」)である *A に置き換えると、次のようになります。
A ➞ int
B ➞ []*A // CをA*で置換
C ➞ *A
さらに2段階の置き換えを行い、推論された型 []*A と *A の中の A を、A について推論された型である int に置き換えます。
A ➞ int
B ➞ []*int // Aをintで置換
C ➞ *int // Aをintで置換
ここでようやく推論が完了します。そして高校数学の代数と同じように、これがうまくいかないこともあります。次のような状況に行き着くこともあり得ます。
X ➞ Y
Y ➞ *X
1回置換を行うと、次のようになります。
X ➞ *X
これを続けていくと、X に対して推論される型はどんどん大きくなっていきます。
X ➞ **X // XをX*で置換
X ➞ ***X // XをX*で置換
// 以下同様
型推論は、展開の過程でこのような循環を検出し、エラーを報告します(つまり失敗します)。
無型定数
ここまでで、型推論が単一化によって型方程式を解き、続いてその結果を展開することでどのように機能するかを見てきました。しかし、もし型がまったくなかったらどうなるでしょうか。関数の引数が無型定数だったらどうなるでしょうか。
別の例がこの状況を理解する助けになります。任意の個数の引数を取り、それらすべてが同じ型でなければならない関数 foo を考えます。foo は、int 型の変数 x を含むさまざまな無型定数の引数とともに呼び出されます。
func foo[P any](...P) {}
var x int
foo(x) // P ➞ int, foo[int](x) と同じ
foo(x, 2.0) // P ➞ int, 2.0は精度を失わずにintに変換できる
foo(x, 2.1) // P ➞ int だが、引数の受け渡しは失敗する: 2.1はintに代入できない
型推論では、型付きの引数が無型の引数よりも優先されます。無型定数が推論の対象として考慮されるのは、それが割り当てられる型パラメータにまだ推論済みの型がない場合だけです。foo へのこれら最初の3回の呼び出しでは、変数 x が P の推論される型を決定します。それは x の型である int です。この場合、無型定数は型推論において無視され、これらの呼び出しは foo が明示的に int で具体化されたのとまったく同じように振る舞います。
foo が無型定数の引数だけで呼び出されると、話はもう少し興味深くなります。この場合、型推論は無型定数のデフォルト型を考慮します。簡単なおさらいとして、Goで取り得るデフォルト型を挙げておきます。
例 定数の種類 デフォルト型 順序
true 真偽値定数 bool
42 整数定数 int リストの先頭側
'x' rune定数 rune |
3.1416 浮動小数点定数 float64 v
-1i 複素数定数 complex128 リストの末尾側
"gopher" 文字列定数 string
この情報をふまえて、次の関数呼び出しを考えてみましょう。
foo(1, 2) // P ➞ int(1と2のデフォルト型)
無型定数の引数 1 と 2 はどちらも整数定数で、そのデフォルト型は int です。したがって foo の型パラメータ P に対して推論されるのは int です。
もし異なる種類の定数(たとえば無型の整数定数と浮動小数点定数)が同じ型変数を奪い合う場合、デフォルト型が異なることになります。Go 1.21より前は、これは矛盾とみなされエラーになっていました。
foo(1, 2.0) // Go 1.20: 推論エラー: デフォルト型のintとfloat64が一致しない
この挙動は使い勝手があまりよくなく、式の中での無型定数の挙動とも異なっていました。たとえば、Goでは定数式 1 + 2.0 が許されており、その結果はデフォルト型が float64 である浮動小数点定数 3.0 になります。
Go 1.21ではこれに合わせて挙動が変更されました。現在では、複数の無型の数値定数が同じ型パラメータに対してマッチングされた場合、int、rune、float64、complex というリストのより後方に現れるデフォルト型が選ばれます。これは定数式のルールに一致するものです。
foo(1, 2.0) // Go 1.21: P ➞ float64(1と2.0のうち大きいほうのデフォルト型。1 + 2.0 と同じ挙動)
特殊な状況
ここまでで型推論の全体像はつかめました。しかし、注目に値する重要な特殊状況がいくつかあります。
パラメータの順序への依存
1つめは、パラメータの順序への依存に関するものです。型推論に求めたい重要な性質の1つは、関数パラメータの順序(およびその関数の各呼び出しにおける対応する引数の順序)にかかわらず、同じ型が推論されることです。
可変長引数を取る foo 関数をもう一度考えてみましょう。引数 s と t を渡す順序にかかわらず、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)) // パラメータの順序にかかわらず同じ結果になるはず
}
foo の呼び出しから、関連する型方程式を取り出せます。
𝑻(x) :≡ 𝑻(s) => P :≡ struct{} // 方程式1
𝑻(x) :≡ 𝑻(t) => P :≡ T // 方程式2
残念ながら、:≡ の単純化された実装は順序への依存を生んでしまいます。
単一化が方程式1から始まる場合、P は struct{} とマッチングされます。P にはまだ推論済みの型がないため、単一化は P ➞ struct{} と推論します。その後、方程式2で型 T を見ると、単一化は T の基底型である struct{} に進み、P と under(T) が単一化され、単一化、したがって推論は成功します。
逆に、単一化が方程式2から始まる場合、P は T とマッチングされます。P にはまだ推論済みの型がないため、単一化は P ➞ T と推論します。その後、方程式1で struct{} を見ると、単一化は P に対して推論された型 T の基底型に進みます。その基底型は struct{} であり、これは方程式1の struct と一致するため、単一化、したがって推論は成功します。
その結果、単一化が2つの型方程式を解く順序によって、推論される型は struct{} になったり T になったりします。これはもちろん満足のいくものではありません。コードのリファクタリングや整理のときに引数の並び順が入れ替わっただけで、プログラムが突然コンパイルできなくなってしまうことがあり得るからです。
順序への非依存性の回復
幸い、この対処法はかなり単純です。必要なのは、いくつかの状況でのちょっとした補正だけです。
具体的には、単一化が P :≡ T を解いていて、かつ
Pがすでに型Aを推論している型パラメータである:P ➞ AA :≡ Tが真であるTが名前付き型である
という条件を満たす場合、P に対して推論された型を T に設定します: P ➞ T
これにより、選択の余地がある場合には、その名前付き型が P とのマッチングの中でどの時点で現れたか(つまり、型方程式がどの順序で解かれたか)にかかわらず、P は必ずその名前付き型になります。なお、異なる名前付き型が同じ型パラメータに対してマッチングされた場合には、異なる名前付き型は定義上同一ではないため、常に単一化は失敗することに注意してください。
チャンネルやインターフェースについても同様の単純化を行っているため、それらにも同様の特別な処理が必要です。たとえば、代入可能性のための単一化ではチャンネルの方向を無視しているため、その結果、引数の順序によって単方向のチャンネルが推論されたり双方向のチャンネルが推論されたりすることがあります。インターフェースについても同様の問題が起こります。ここではこれらについては触れません。
先ほどの例に戻ると、単一化が方程式1から始まる場合、これまでと同じように P ➞ struct{} と推論されます。方程式2に進むと、これもこれまでと同じように単一化は成功しますが、ここでちょうど補正が必要になる条件がそろっています。P はすでに型(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 と同じ型 P を持つ引数 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
この方程式には2つの型パラメータがあります。呼び出されている関数によって宣言された束縛された型パラメータ 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
}
何が足りないか
ここまでの説明で目立って欠けているのが、ジェネリック_型_に対する型推論です。現状、ジェネリック型は常に明示的に具体化しなければなりません。
これにはいくつか理由があります。まず第一に、型の具体化においては、型推論が扱えるのは型引数だけであり、関数呼び出しの場合のような他の引数はありません。その結果、(すべての型パラメータに対して型制約がちょうど1つの型引数しか許さないような病的なケースを除いて)少なくとも1つの型引数を常に与える必要があります。したがって、型に対する型推論が役立つのは、省略されたすべての型引数を型制約から生じる方程式から推論できる、部分的に具体化された型を完成させる場合、つまり少なくとも2つの型パラメータがある場合に限られます。これはあまり一般的なシナリオではないと私たちは考えています。
第二に、こちらのほうがより本質的な理由ですが、型パラメータによってまったく新しい種類の再帰的な型が可能になるということがあります。次のような仮定の型を考えてみましょう。
type T[P T[P]] interface{ … }
ここでは P の制約が、いままさに宣言されている型そのものになっています。複数の型パラメータが複雑な再帰的な形で互いを参照し合える能力と組み合わさると、型推論ははるかに複雑になり、私たちも現時点でそのすべての含意を完全には理解できていません。とはいえ、循環を検出して、そのような循環が存在しない場合には型推論を進める、ということ自体はそれほど難しくないはずだと考えています。
最後に、単一化はこの記事で先に説明したようないくつかの単純化した仮定のもとで動作しているため、型推論だけでは単純に推論を行うだけの力がない状況もあります。ここでの代表例は、コア型を持たない制約です。もっと洗練されたアプローチを使えば、そういった場合でも型情報を推論できるかもしれません。
これらはいずれも、今後のGoのリリースで段階的な改善が見られるかもしれない領域です。重要なのは、現状の実装で推論が失敗するケースは、まれであるか、あるいは実運用のコードにおいてはさほど重要でないかのいずれかであり、現在の実装が有用なコードのシナリオの大部分をカバーしていると私たちが考えている、ということです。
とはいえ、型推論が本来うまくいくはずだ、あるいはおかしな挙動をしていると思われる状況に出くわしたら、ぜひイシューを報告してください! いつものことですが、Goチームは皆さんからの声を、特にそれがGoをさらによくする助けになるときには、大歓迎です。
By Robert Griesemer