The Go Blog

型推論について知りたかったことすべて - そしてもう少し

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パッケージの次の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`関数呼び出しは、`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`を呼び出します。

// 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つの型が同一である必要がある場合、ある型が別の型に代入可能である必要がある場合、またはある型が型制約を満たす必要がある場合です。

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)

ここで、`T(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`呼び出しの型引数を推論したいと考えています。`list`を`slices.Sort`のパラメータ`x`に渡すと、次の方程式が発生します。

𝑻(x) :≡ 𝑻(list)

これは次と同じです。

S :≡ []F

この方程式には、`S`と`F`という2つの型パラメータがあります。どちらの型パラメータについて型方程式を解く必要があるのでしょうか?呼び出される関数は`Sort`であるため、その型パラメータ`S`に関心があり、型パラメータ`F`には関心がありません。`S`は`Sort`によって宣言されているため、`Sort`に*バインドされている*と言います。`S`はこの方程式における関連する型変数です。対照的に、`F`は`sortedPrint`にバインドされています(`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  // 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

それ以外のすべての場合、制約を含む型方程式は無視されます。

推論された型の展開

単一化が成功すると、型パラメータから推論された型引数へのマッピングが生成されます。しかし、単一化だけでは、推論された型に束縛された型パラメータが含まれていないことを保証できません。その理由を見るために、以下のジェネリック関数`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

しかし、推論された型の中に型パラメータ`A`と`C`が残っており、これは役に立ちません。高校代数と同じように、ある方程式が変数`x`について解かれたら、残りのすべての方程式で`x`をその値で置き換える必要があります。この例では、最初のステップで、`[]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

一度の置換の後、次のようになります。

    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

型推論では、型付き引数が型なし引数よりも優先されます。型なし定数は、それが割り当てられる型パラメータがまだ推論された型を持っていない場合にのみ推論の対象となります。これら最初の3つの`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)

型なし定数引数`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)

特殊な状況

これで、型推論の全体像がわかりました。しかし、いくつか注目に値する重要な特殊な状況があります。

パラメータの順序依存性

最初のものは、パラメータの順序依存性に関するものです。型推論に期待する重要な特性は、関数のパラメータの順序(およびその関数の各呼び出しにおける対応する引数の順序)に関係なく、同じ型が推論されることです。

可変長引数関数`foo`を再検討しましょう。`P`に推論される型は、引数`s`と`t`を渡す順序に関係なく同じであるべきです(プレイグラウンド)。

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`を見ると、`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 ➞ A`
  • `A :≡ T`が真である。
  • `T`が名前付き型である。

その場合、`P`に推論された型を`T`に設定する。`P ➞ T`

これにより、`P`が名前付き型である選択肢がある場合、名前付き型が`P`との一致にどの時点で現れたか(つまり、型方程式がどの順序で解かれたか)に関係なく、`P`がその名前付き型であることが保証されます。異なる名前付き型が同じ型パラメータと一致する場合、定義上、異なる名前付き型は同一ではないため、常に単一化の失敗となります。

チャネルとインターフェースについても同様の簡略化を行ったため、同様の特別な処理が必要です。たとえば、代入可能性のために単一化する際にチャネルの方向を無視するため、引数の順序によって、指向性または双方向性チャネルを推論する可能性があります。同様の問題はインターフェースでも発生します。これらについてはここでは説明しません。

例に戻りましょう。単一化が方程式1から開始すると、以前と同様に`P ➞ struct{}`を推論します。方程式2に進むと、以前と同様に単一化は成功しますが、ここで修正を必要とする条件が正確に発生します。`P`は既に型(`struct{}`)を持つ型パラメータであり、`struct{}`は`struct{}`です。`struct{} :≡ T`は真(`struct{} ≡ under(T)`が真であるため)であり、`T`は名前付き型です。したがって、単一化は修正を行い、`P ➞ T`を設定します。結果として、単一化の順序に関係なく、両方のケースで結果は同じ(`T`)になります。

自己再帰関数

推論の単純な実装で問題を引き起こすもう1つのシナリオは、自己再帰関数です。浮動小数点引数でも機能するように定義されたジェネリックな階乗関数`fact`を考えてみましょう(プレイグラウンド)。これはガンマ関数の数学的に正しい実装ではなく、単に便利な例であることに注意してください。

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`関数を介して間接的に行われたかのようです(プレイグラウンド)。

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`の推論をもたらします。これはもちろん私たちが期待するものであり、再帰呼び出しを明示的にインスタンス化することによって検証できます(プレイグラウンド)。

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

何が欠けているのか?

説明から conspicuously 欠けているのは、ジェネリック型の型推論です。現在、ジェネリック型は常に明示的にインスタンス化する必要があります。

これにはいくつかの理由があります。まず、型のインスタンス化の場合、型推論は型引数のみを扱う必要があります。関数呼び出しの場合のように、他の引数はありません。結果として、常に少なくとも1つの型引数を提供する必要があります(すべての型パラメータに対して制約が正確に1つの可能な型引数を指定するような病理的なケースを除いて)。したがって、型の型推論は、省略されたすべての型引数が型制約から導かれる方程式から推論できる、つまり少なくとも2つの型パラメータがある場合にのみ、部分的にインスタンス化された型を完成させるのに役立ちます。これはあまり一般的なシナリオではないと私たちは考えています。

第二に、より重要なことですが、型パラメータは全く新しい種類の再帰型を可能にします。仮想的な型を考えてみましょう。

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

`P`の制約は、宣言されている型である場合です。複数の型パラメータが複雑な再帰的な方法で互いを参照できる機能と組み合わせると、型推論ははるかに複雑になり、現時点ではそのすべての影響を完全に理解しているわけではありません。そうは言っても、そのようなサイクルが存在しない場合、サイクルを検出し、型推論を進めることはそれほど難しくないと考えています。

最後に、型推論が推論を行うのに十分な強さではない状況があります。これは通常、単一化がこの投稿で以前に説明したような特定の簡略化された仮定で機能するためです。ここでの主な例は、コア型を持たない制約ですが、より洗練されたアプローチであれば、とにかく型情報を推論できるかもしれません。

これらはすべて、将来のGoリリースで段階的な改善が見られる可能性のある分野です。重要なことに、推論が現在失敗するケースは、本番コードでは稀であるか重要ではないと考えており、現在の実装はすべての有用なコードシナリオの大部分をカバーしていると考えています。

ただし、型推論が機能すべきである、または誤って機能したと思われる状況に遭遇した場合は、問題を提出してください!いつものように、Goチームは皆様からのご意見を eagerly お待ちしております。特に、それがGoをさらに改善するのに役立つ場合は尚更です。

次の記事:Goの14年間
前の記事:型パラメータの分解
ブログインデックス