Goブログ
型推論についてあなたが知りたかったことすべて - そして少しばかりそれ以上のこと
これはサンディエゴで開催された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
キーワードを省略する機能)を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)
これはよりクリーンでコンパクトです。実際、非ジェネリックコードとまったく同じように見え、型推論がこれを可能にします。
重要なのは、型推論はオプションのメカニズムであるということです。型引数によってコードが明確になる場合は、ぜひ書き出してください。
型推論は型パターンマッチングの一種
推論は型パターンを比較します。ここで、型パターンは型パラメーターを含む型です。少しすると明らかになる理由から、型パラメーターは型変数とも呼ばれます。型パターンマッチングにより、これらの型変数に格納する必要のある型を推論できます。簡単な例を考えてみましょう
// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)
type List []int
var list List
slices.Sort(list)
Sort
関数呼び出しは、slices.Sort
のパラメーターx
の関数引数としてlist
変数を渡します。したがって、List
であるlist
の型は、型パラメーターS
であるx
の型と一致する必要があります。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
を呼び出します
// 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) {
…
}
…
}
これは、各型パラメーターに1つずつ、6つの型引数を潜在的に省略できるため、型推論が真価を発揮する例です。型パターンマッチングのアプローチは引き続き機能しますが、型の関係の数が増殖するため、すぐに複雑になる可能性があることがわかります。どの型パラメーターとどの型がどのパターンに関与するかを決定するための体系的なアプローチが必要です。
型推論を少し異なる観点から見てみると役立ちます。
型方程式
型推論を型方程式を解く問題として再構成することができます。方程式を解くことは、私たち全員が高校の代数でよく知っていることです。幸いなことに、型方程式を解くことは、すぐにわかるように、より簡単な問題です。
以前の例をもう一度見てみましょう
// 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
一般に、型方程式には3つの形式があると言えます。2つの型は同一である必要があり、1つの型は別の型に代入可能である必要があり、または1つの型は型制約を満たす必要があります
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
個の型方程式が生成されます。基本的な高校代数とは対照的に、型方程式を解くことが可能なためには、n
とm
が同じである必要はありません。たとえば、以下の方程式を使用すると、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以降、インスタンス化されていない関数または部分的にインスタンス化された関数(ただし、関数呼び出しではない)も、次のように関数型の変数に代入できます
// 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
からの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 // 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])
最後の例では、型推論によって CompactFunc
と myEq
の型引数が決定されます。より一般的には、任意の数の関数からの型パラメータを推論する必要がある場合があります。複数の関数が関与する場合、型方程式は複数の関数からのもの、または複数の関数が関与するものになる可能性があります。CompactFunc
の例では、最終的に 3 つの型パラメータと 5 つの型方程式が得られます。
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
を呼び出しています。sortedPrint
と slices.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
の呼び出しに対する型引数を推論したいと考えています。slices.Sort
のパラメータ x
に list
を渡すと、次の方程式が発生します。
𝑻(x) :≡ 𝑻(list)
これは次と同じです。
S :≡ []F
この方程式には、2 つの型パラメータ S
と F
があります。どの方程式を解く必要があるでしょうか? 呼び出された関数は Sort
なので、型パラメータ F
ではなく、Sort
の型パラメータ S
が重要になります。S
は Sort
によって宣言されているため、Sort
に束縛されていると言います。S
はこの方程式で関連する型変数です。対照的に、F
は sortedPrint
に束縛 (宣言) されています。F
は Sort
に対して自由であると言います。それ自体に、すでに与えられている型があります。その型は 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 // map key type
int ≡ C => C ➞ int // first struct field type
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
は呼び出されているジェネリック関数によって宣言されます)。これにより、型方程式 []E :≡ List
が生成されます。これら 2 つの型を統合しようとすると、[]E
と List
を比較する必要があります。これら 2 つの型は同一ではなく、統合の動作を変更しないと失敗します。ただし、代入可能性のために統合しているため、この初期の一致は正確である必要はありません。名前付き型 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
その他のすべての場合、制約を含む型方程式は無視されます。
推論された型の展開
ユニフィケーションが成功すると、型パラメータから推論された型引数へのマッピングが生成されます。しかし、ユニフィケーションだけでは、推論された型に束縛された型パラメータが含まれていないことを保証できません。なぜそうなるのかを理解するために、単一のint
型の引数x
で呼び出される以下のジェネリック関数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
について解かれたら、残りのすべての方程式でx
をその値に置き換える必要があります。この例では、最初のステップで、[]C
のC
が、C
の推論された型(「値」)である*A
で置き換えられ、以下のように到達します。
A ➞ int
B ➞ []*A // substituted *A for C
C ➞ *A
さらに2つのステップで、推論された型[]*A
と*A
のA
を、A
の推論された型であるint
で置き換えます。
A ➞ int
B ➞ []*int // substituted int for A
C ➞ *int // substituted int for A
これで初めて推論が完了します。そして、高校の代数のように、これがうまくいかない場合があります。次のような状況に陥る可能性があります。
X ➞ Y
Y ➞ *X
1回の置換後、以下のようになります。
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
の最初の3回の呼び出しでは、変数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)
型指定のない定数引数1
と2
はどちらも整数定数であり、それらのデフォルト型は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では、それに応じて動作が変更されました。現在、複数の型指定のない数値定数が同じ型パラメータに対してマッチングされる場合、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)
特殊な状況
これで、型推論の大まかな概要がわかりました。しかし、いくつか注目に値する重要な特殊な状況があります。
パラメータ順序の依存関係
最初の1つは、パラメータ順序の依存関係に関係します。型推論から私たちが望む重要な特性は、関数パラメータの順序(およびその関数の各呼び出しでの対応する引数の順序)に関係なく、同じ型が推論されることです。
可変長引数を持つfoo
関数を再度検討してみましょう。P
に推論される型は、引数s
とt
を渡す順序に関係なく同じである必要があります(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から開始する場合、P
をstruct
とマッチングします。P
にはまだ推論された型がないため、ユニフィケーションはP ➞ struct{}
を推論します。ユニフィケーションが後で方程式2で型T
を認識すると、struct{}
であるT
の基本型で処理を進めます。P
とunder(T)
がユニファイされ、ユニフィケーション、したがって推論が成功します。
逆に、ユニフィケーションが方程式2から開始する場合、P
をT
とマッチングします。P
にはまだ推論された型がないため、ユニフィケーションはP ➞ T
を推論します。ユニフィケーションが後で方程式1でstruct{}
を認識すると、P
に推論された型T
の基本型で処理を進めます。その基本型はstruct{}
であり、方程式1のstruct
と一致し、ユニフィケーション、したがって推論が成功します。
結果として、ユニフィケーションが2つの型方程式を解決する順序に応じて、推論される型はstruct{}
またはT
のいずれかになります。これはもちろん不満足です。コードのリファクタリングやクリーンアップ中に引数がシャッフルされただけで、プログラムが突然コンパイルを停止する可能性があるためです。
順序の独立性の回復
幸いなことに、解決策は非常に簡単です。いくつかの状況では、わずかな修正が必要です。
具体的には、ユニフィケーションがP :≡ T
を解決しており、
P
は、すでに型A
を推論している型パラメータです:P ➞ A
A :≡ T
は真ですT
は名前付き型です
次に、P
の推論された型をT
に設定します:P ➞ T
これにより、P
は、P
とのマッチングで名前付き型がどの時点で現れたか(つまり、型方程式がどの順序で解決されたか)に関係なく、選択肢がある場合は常に名前付き型であることが保証されます。異なる名前付き型が同じ型パラメータと一致する場合、異なる名前付き型は定義上同一ではないため、常にユニフィケーションの失敗が発生することに注意してください。
チャネルとインターフェースについても同様の簡略化を行ったため、同様の特別な処理も必要です。たとえば、割り当て可能性のためにユニファイする際にチャネルの方向を無視すると、結果として、引数の順序に応じて、方向指定されたチャネルまたは双方向のチャネルが推論される可能性があります。同様の問題は、インターフェースでも発生します。ここではこれらについて議論しません。
例に戻ると、ユニフィケーションが方程式1から開始する場合、以前と同様にP ➞ struct{}
を推論します。方程式2に進むと、以前と同様に、ユニフィケーションは成功しますが、ここで、修正を求める条件がまさに適用されます。P
は、すでに型(struct{}
)を持っている型パラメータであり、struct{}
、struct{} :≡ T
は真であり(struct{} ≡ under(T)
が真であるため)、T
は名前付き型です。したがって、ユニフィケーションは修正を行い、P ➞ T
を設定します。その結果、ユニフィケーションの順序に関係なく、結果はどちらの場合も同じ(T
)になります。
自己再帰関数
推論のナイーブな実装で問題を引き起こすもう1つのシナリオは、自己再帰関数です。浮動小数点引数でも機能するように定義されたジェネリック階乗関数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をさらに良くするのに役立つ場合はなおさらです。