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キーワードを省略する機能)を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)

型推論がないと、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関数呼び出しは、slices.Sortのパラメーターxの関数引数としてlist変数を渡します。したがって、Listであるlistの型は、型パラメーターSであるxの型と一致する必要があります。Sの型がListである場合、この代入は有効になります。実際には、代入のルールは複雑ですが、今のところは型が同一であると想定しておけば十分です。

Sの型を推論したら、S型制約を見ることができます。チルダ~記号のため、S基になる型はスライス[]Eである必要があると示されています。Sの基になる型は[]intであるため、[]int[]Eと一致する必要があり、それによりEintであると結論付けることができます。対応する型が一致するようなSEの型を見つけることができました。推論が成功しました!

ここでは、slices.EqualFuncからのS1S2E1、およびE2、およびジェネリック関数equalからのE1E2という、多くの型パラメーターがある、より複雑なシナリオを示します。ローカル関数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コードでは、それは強すぎる要件です。前の例では、SListと同一である必要はなく、むしろListS代入可能である必要があります。同様に、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個の型方程式が生成されます。基本的な高校代数とは対照的に、型方程式を解くことが可能なためには、nmが同じである必要はありません。たとえば、以下の方程式を使用すると、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に対して自明に解くことができます。PAである必要があり、P ➞ Aと記述します。言い換えれば、ここでは何もすることがありません。完全を期すためにそれぞれの型方程式を書き出すこともできますが、この場合、Goコンパイラーは型引数を型パラメーター全体に代入するだけで、それらの型パラメーターは消えてしまい、それらを忘れることができます。

2. 代入からの型方程式

関数パラメーターpに渡される各関数引数xの場合

f(…, x, …)

pまたはxに型パラメーターが含まれている場合、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.SortからのSEの制約の方程式(下記参照)と合わせて。

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 の例では、最終的に 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 を呼び出しています。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 の呼び出しに対する型引数を推論したいと考えています。slices.Sort のパラメータ xlist を渡すと、次の方程式が発生します。

𝑻(x) :≡ 𝑻(list)

これは次と同じです。

S :≡ []F

この方程式には、2 つの型パラメータ SF があります。どの方程式を解く必要があるでしょうか? 呼び出された関数は Sort なので、型パラメータ F ではなく、Sort の型パラメータ S が重要になります。SSort によって宣言されているため、Sort束縛されていると言います。S はこの方程式で関連する型変数です。対照的に、FsortedPrint に束縛 (宣言) されています。FSort に対して自由であると言います。それ自体に、すでに与えられている型があります。その型は F であり、それが何であれ (インスタンス化時に決定されます)。この方程式では、F はすでに与えられており、型定数です。

型方程式を解くときは、常に呼び出している関数 (またはジェネリック関数の代入の場合には代入) に束縛されている型パラメータを解きます。

型方程式を解く

関連する型パラメータと型方程式を収集する方法を確立した今、不足しているのは、もちろん方程式を解くことを可能にするアルゴリズムです。さまざまな例の後、X ≡ Y を解くということは、単に型 XY を互いに再帰的に比較し、その過程で XY に出現する可能性のある型パラメータに適した型引数を決定することを意味することが明らかになったでしょう。目標は、型 XY同一にすることです。このマッチングプロセスは、ユニフィケーションと呼ばれます。

型同一性のルールは、型の比較方法を教えてくれます。束縛された型パラメータは型変数の役割を果たすため、他の型とのマッチング方法を指定する必要があります。ルールは次のとおりです。

  • 型パラメータ P に推論された型がある場合、P はその型を表します。
  • 型パラメータ P に推論された型がなく、別の型 T と照合された場合、P はその型に設定されます: P ➞ T。型 TP に対して推論されたと言います。
  • P が別の型パラメータ Q と照合され、PQ のどちらにも推論された型がない場合、PQ統合されます。

2 つの型パラメータの統合とは、それらが結合されて、今後同じ型パラメータ値を表すことを意味します。P または Q のいずれかが型 T と照合された場合、PQ の両方が同時に T に設定されます (一般に、任意の数の型パラメータをこの方法で統合できます)。

最後に、2 つの型 XY が異なる場合、方程式を真にすることはできず、解くのに失敗します。

型同一性のための型の統合

いくつかの具体的な例で、このアルゴリズムを明確に説明する必要があります。3 つの束縛された型パラメータ AB、および C を含む 2 つの型 XY を考え、すべて型方程式 X ≡ Y に出現します。目標は、型パラメータに対してこの方程式を解くことです。つまり、XY が同一になり、方程式が真になるような適切な型引数を見つけることです。

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

統合は、上から始めて、XY の構造を再帰的に比較することによって進みます。2 つの型の構造を単純に見ると、次のようになります。

map[…]… ≡ map[…]…

は、このステップでは無視しているそれぞれのマップのキーと値の型を表しています。両側にマップがあるため、これまでのところ型は同一です。統合は再帰的に進行し、最初に X マップの AY マップの string であるキーの型から進みます。対応するキーの型は同一である必要があり、そこから、A の型引数が string でなければならないとすぐに推論できます。

A ≡ string => A ➞ string

マップ要素の型を続行すると、次のようになります。

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

両側は構造体であるため、統合は構造体のフィールドに進みます。それらが同じ順序で、同じ名前で、同じ型である場合、それらは同一です。最初のフィールドペアは i inti C です。名前が一致し、intC と統合する必要があるため、次のようになります。

int ≡ C => C ➞ int

この再帰的な型マッチングは、2 つの型のツリー構造が完全に走査されるか、競合が発生するまで続きます。この例では、最終的に次のようになります。

[]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 に 2 回、CY に 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

AC の両方に推論された型引数があるため、それらの型引数 (それぞれ stringint) を表します。これらは異なる型であるため、AC が一致する可能性はありません。統合、したがって型推論は失敗します。

その他の型の関係

統合は、目標が型同一性である形式 X ≡ Y の型方程式を解きます。しかし、X :≡ Y または X ∈ Y はどうでしょうか。

いくつかの観察結果がここで役に立ちます。型推論の役割は、省略された型引数の型を見つけることのみです。型推論の後には、常に各型引数がそれぞれの型制約を実際に満たしていることを確認する型または関数のインスタンス化が続きます。最後に、ジェネリック関数の呼び出しの場合、コンパイラは関数引数が対応する関数パラメータに代入可能であることも確認します。コードが有効であるためには、これらのすべてのステップが成功する必要があります。

型推論が十分に正確でない場合、型が存在しない (誤った) 型引数を推論する可能性があります。その場合、インスタンス化または引数渡しが失敗します。いずれにせよ、コンパイラはエラーメッセージを生成します。エラーメッセージがわずかに異なるだけです。

この洞察により、型関係 :≡ および を少し緩く扱うことができます。具体的には、 とほとんど同じように扱うことができるように、それらを単純化できます。単純化の目標は、型方程式から可能な限り多くの型情報を抽出し、それによって正確な実装が失敗する可能性のある場所で型引数を推論することです。なぜなら、私たちはそうすることができるからです。

X :≡ Y の単純化

Go の代入規則は非常に複雑ですが、ほとんどの場合、型同一性またはそのわずかなバリエーションで実際にやり過ごすことができます。潜在的な型引数が見つかれば、型推論の後には型インスタンス化と関数呼び出しが続くため、問題ありません。推論が本来ない場所で型引数を見つけた場合、後でキャッチされます。したがって、代入可能性のマッチングを行うときは、統合アルゴリズムに次の調整を行います。

  • 名前付き (定義済み) 型が型リテラルと照合される場合、その基になる型が代わりに比較されます。
  • チャネル型を比較する場合、チャネルの方向は無視されます。

さらに、代入方向は無視されます。X :≡ YY :≡ X のように扱われます。

これらの調整は、型構造の最上位レベルでのみ適用されます。たとえば、Go の代入規則に従って、名前付きのマップ型は名前のないマップ型に代入できますが、キーと要素の型は同一である必要があります。これらの変更により、代入可能性のための統合は、型同一性のための統合の (わずかな) バリエーションになります。次の例でこれを説明します。

以前の List 型 (type List []int として定義) の値を、型 []E の関数パラメータに渡すと仮定します。ここで、E は束縛された型パラメータです (つまり、E は呼び出されているジェネリック関数によって宣言されます)。これにより、型方程式 []E :≡ List が生成されます。これら 2 つの型を統合しようとすると、[]EList を比較する必要があります。これら 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

しかし、これでは、推論された型に型パラメータACが残ってしまい、役に立ちません。高校の代数のように、方程式が変数xについて解かれたら、残りのすべての方程式でxをその値に置き換える必要があります。この例では、最初のステップで、[]CCが、Cの推論された型(「値」)である*Aで置き換えられ、以下のように到達します。

    A ➞ int
    B ➞ []*A    // substituted *A for C
    C ➞ *A

さらに2つのステップで、推論された型[]*A*AAを、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回の呼び出しでは、変数xPの推論された型を決定します。それは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の型パラメータ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)

特殊な状況

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

パラメータ順序の依存関係

最初の1つは、パラメータ順序の依存関係に関係します。型推論から私たちが望む重要な特性は、関数パラメータの順序(およびその関数の各呼び出しでの対応する引数の順序)に関係なく、同じ型が推論されることです。

可変長引数を持つfoo関数を再度検討してみましょう。Pに推論される型は、引数stを渡す順序に関係なく同じである必要があります(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を認識すると、struct{}であるTの基本型で処理を進めます。Punder(T)がユニファイされ、ユニフィケーション、したがって推論が成功します。

逆に、ユニフィケーションが方程式2から開始する場合、PTとマッチングします。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のシグネチャのPQに名前変更されたと仮定しましょう。その効果は、再帰呼び出しが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-1fact(またはそれぞれ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をさらに良くするのに役立つ場合はなおさらです。

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