ジェネリクス入門

An Introduction To Generics by Robert Griesemer and Ian Lance Taylor

はじめに

この記事は、私たちがGopherCon 2021で行った講演をもとにしています。

講演動画(YouTube)

Go 1.18のリリースでジェネリクスのサポートが追加されました。 ジェネリクスは、最初のオープンソースリリース以来、Goに加えられた最大の変更です。 この記事では、この新しい言語機能を紹介します。 すべての詳細を網羅するわけではありませんが、重要なポイントはひととおり押さえます。 より詳細で長い説明、そして多くの例を見たい方は、提案文書を参照してください。 言語仕様の変更についてより正確な説明を見たい方は、更新された言語仕様を参照してください。 (実際の1.18の実装は提案文書が許容する内容の一部に制限を課していますが、仕様のほうは正確なはずです。今後のリリースでいくつかの制限が緩和されるかもしれません。)

ジェネリクスとは、使用する具体的な型に依存しないコードを書くための手段です。 関数や型は、複数の型からなる集合のいずれかを使えるように書けるようになりました。

ジェネリクスは、言語に3つの大きな要素を追加します。

  1. 関数や型に対する型パラメータ。
  2. メソッドを持たない型を含む、型の集合としてのインターフェース型の定義。
  3. 関数呼び出し時に多くの場合で型引数を省略できるようにする型推論。

型パラメータ

関数や型は、型パラメータを持てるようになりました。 型パラメータリストは通常のパラメータリストに似ていますが、丸括弧の代わりに角括弧を使う点が異なります。

これがどのように機能するかを示すために、まずは浮動小数点数向けの、ジェネリクスを使わない基本的なMin関数から始めましょう。

func Min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

型パラメータリストを追加することで、この関数をジェネリックにする、つまり異なる型に対して動作するようにできます。 この例では、Tという単一の型パラメータを持つ型パラメータリストを追加し、float64の使用箇所をTに置き換えます。

import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

これで、次のように書くことで型引数を指定してこの関数を呼び出せるようになりました。

x := GMin[int](2, 3)

GMinに型引数(この例ではint)を渡すことを インスタンス化 と呼びます。 インスタンス化は2つの手順で行われます。 まず、コンパイラはジェネリックな関数や型の全体にわたって、すべての型引数をそれぞれ対応する型パラメータに置き換えます。 次に、コンパイラは各型引数がそれぞれの制約を満たしているかを検証します。 この制約が何を意味するかについてはこのあと説明しますが、もしこの2番めの手順が失敗すると、インスタンス化は失敗し、そのプログラムは不正なものとなります。

インスタンス化に成功すると、他の関数と同じように呼び出せるジェネリックでない関数が得られます。 たとえば、次のようなコードでは、

fmin := GMin[float64]
m := fmin(2.71, 3.14)

インスタンス化GMin[float64]によって、事実上、私たちが最初に書いた浮動小数点数用のMin関数と同じものが生成され、それを関数呼び出しの中で使えます。

型パラメータは型でも使えます。

type Tree[T interface{}] struct {
    left, right *Tree[T]
    value       T
}

func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

var stringTree Tree[string]

ここでは、ジェネリック型Treeが型パラメータTの値を保持しています。 ジェネリック型は、この例のLookupのようにメソッドを持てます。 ジェネリック型を使うには、それをインスタンス化する必要があります。 Tree[string]は、Treeを型引数stringでインスタンス化する例です。

型集合

型パラメータをインスタンス化するのに使える型引数について、もう少し深く見ていきましょう。

通常の関数では、値パラメータそれぞれに型があり、その型は値の集合を定義します。 たとえば、先ほどのジェネリックでないMin関数のようにfloat64型がある場合、指定可能な引数の値の集合は、float64型で表現できる浮動小数点数の値の集合です。

同様に、型パラメータリストには型パラメータそれぞれに対応する型があります。 型パラメータ自体が型であることから、型パラメータの型は型の集合を定義します。 このメタ型は 型制約 と呼ばれます。

ジェネリックなGMinでは、型制約はconstraintsパッケージからインポートされています。 Ordered制約は、順序付け可能な値、言い換えると<演算子(あるいは<=>など)で比較可能な値を持つすべての型の集合を表します。 この制約によって、順序付け可能な値を持つ型だけがGMinに渡せることが保証されます。 またこれは、GMin関数の本体の中で、その型パラメータの値を<演算子を使った比較に使えることも意味します。

Goでは、型制約はインターフェースでなければなりません。 つまり、インターフェース型は値の型として使えるだけでなく、メタ型としても使えるのです。 インターフェースはメソッドを定義するので、特定のメソッドの存在を要求する型制約を表現できるのは明らかです。 しかし、constraints.Orderedもインターフェース型ですし、<演算子はメソッドではありません。

これを機能させるために、インターフェースを新しい視点で見てみましょう。

これまで、Go仕様ではインターフェースはメソッド集合、つまり大まかに言えばそのインターフェースに列挙されているメソッドの集合を定義するとされてきました。 それらすべてのメソッドを実装する型は、そのインターフェースを実装していることになります。

method-sets

しかし、これを見る別の方法として、インターフェースは型の集合、つまりそれらのメソッドを実装する型の集合を定義していると言うこともできます。 この見方をすると、インターフェースの型集合の要素であるどんな型も、そのインターフェースを実装していることになります。

type-sets

どちらの見方も同じ結論に行き着きます。それぞれのメソッドの集合に対して、それらのメソッドを実装する型の集合を思い描けます。それが、そのインターフェースによって定義される型の集合です。

とはいえ、私たちの目的にとっては、型集合という見方がメソッド集合という見方に対して優位な点があります。それは、明示的に型を集合に追加でき、それによって新しい方法で型集合を制御できるという点です。

これを実現するために、インターフェース型の構文を拡張しました。 たとえば、interface{ int|string|bool }は、intstringboolという型を含む型集合を定義します。

type-sets-2

別の言い方をすると、このインターフェースはintstringboolのいずれかによってのみ満たされる、ということです。

それでは、実際のconstraints.Orderedの定義を見てみましょう。

type Ordered interface {
    Integer|Float|~string
}

この宣言は、Orderedインターフェースがすべての整数型、浮動小数点型、および文字列型の集合であることを表しています。 縦棒は型(あるいはこの場合は型の集合)の和集合を表します。 IntegerFloatは、constraintsパッケージ内で同様に定義されているインターフェース型です。 Orderedインターフェースにはメソッドが1つも定義されていない点に注意してください。

型制約に関しては、通常、stringのような特定の型そのものは気にしません。私たちが関心を持つのは、すべての文字列型です。 それが~というトークンの役割です。 ~stringという式は、基底型がstringであるすべての型の集合を意味します。 これにはstring型そのものに加えて、type MyString stringのような定義によって宣言されたすべての型が含まれます。

もちろん、私たちは今でもインターフェースの中でメソッドを指定したいですし、後方互換性も保ちたいと思っています。 Go 1.18では、インターフェースは以前と同じようにメソッドや埋め込みインターフェースを含められますが、加えて非インターフェース型、和、そして基底型の集合も埋め込めるようになりました。

型制約として使われる場合、インターフェースによって定義される型集合は、対応する型パラメータに対して型引数として許可される型を正確に指定します。 ジェネリックな関数本体の中で、あるオペランドの型が制約Cを持つ型パラメータPである場合、Cの型集合に含まれるすべての型で許可されている操作であれば、その操作は許可されます(現時点ではいくつか実装上の制限がありますが、通常のコードでこれに遭遇することはまずないでしょう)。

制約として使われるインターフェースには、(Orderedのように)名前を付けることも、型パラメータリストにインラインで書かれたリテラルのインターフェースにすることもできます。 たとえば、

[S interface{~[]E}, E interface{}]

ここでSは要素型が任意の型でありうるスライス型でなければなりません。

これはよくあるケースなので、制約の位置にあるインターフェースについては、それを囲むinterface{}を省略でき、単に次のように書けます。

[S ~[]E, E interface{}]

空インターフェースは型パラメータリストの中でも、また実のところ通常のGoのコードの中でもよくあるので、Go 1.18では空インターフェース型のエイリアスとして、新しい定義済み識別子anyが導入されました。 これにより、次のようなイディオムに到達します。

[S ~[]E, E any]

型集合としてのインターフェースは強力な新しい仕組みであり、Goにおいて型制約を機能させるための鍵となるものです。 現時点では、この新しい構文形式を使うインターフェースは制約としてのみ使用できます。 しかし、明示的に型が制約されたインターフェースが一般的にも有用でありうることは、想像に難くありません。

型推論

最後の新しい主要な言語機能は型推論です。 これはある意味でこの言語に対する最も複雑な変更ですが、ジェネリックな関数を呼び出すコードを書く際に自然なスタイルを使えるようにしてくれるという点で重要です。

関数引数型推論

型パラメータが導入されたことで、型引数を渡す必要が生じ、コードが冗長になりえます。 ジェネリックなGMin関数に話を戻しましょう。

func GMin[T constraints.Ordered](x, y T) T { ... }

型パラメータTは、通常の型ではない引数xyの型を指定するために使われています。 先ほど見たように、これは明示的な型引数を使って次のように呼び出せます。

var a, b, m float64

m = GMin[float64](a, b) // 明示的な型引数

多くの場合、コンパイラは通常の引数からTの型引数を推論できます。 これによってコードは明快さを保ったまま短くなります。

var a, b, m float64

m = GMin(a, b) // 型引数なし

これは、引数abの型を、パラメータxyの型と照合することによって機能します。

この種の推論、つまり関数への引数の型から型引数を推論するものを、 関数引数型推論 と呼びます。

関数引数型推論は、関数のパラメータの中で使われている型パラメータに対してのみ機能し、関数の結果や関数本体の中でしか使われていない型パラメータに対しては機能しません。 たとえば、結果のためだけにTを使うMakeT[T any]() Tのような関数には適用されません。

制約型推論

この言語はもう1つの型推論、 制約型推論 をサポートしています。 これを説明するために、整数のスライスをスケーリングする次の例から始めましょう。

// Scaleはsの各要素をcで乗じたコピーを返す。
// この実装には、これから見るように問題がある。
func Scale[E constraints.Integer](s []E, c E) []E {
    r := make([]E, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

これは、任意の整数型のスライスに対して機能するジェネリックな関数です。

さて、複数の座標を持つPoint型があるとします。それぞれのPointは、その点の座標を表す整数の単純なリストです。 当然、この型にはいくつかのメソッドがあるでしょう。

type Point []int32

func (p Point) String() string {
    // 詳細は重要ではない。
}

Pointをスケーリングしたい場合があります。 Pointは単なる整数のスライスなので、先ほど書いたScale関数を使えます。

// ScaleAndPrintはPointを2倍にして表示する。
func ScaleAndPrint(p Point) {
    r := Scale(p, 2)
    fmt.Println(r.String()) // コンパイルできない
}

残念ながら、これはコンパイルできず、r.String undefined (type []int32 has no field or method String)というようなエラーで失敗します。

問題は、Scale関数が、引数のスライスの要素型であるEを使った[]E型の値を返すことです。 Point型の値でScaleを呼び出すと、その基底型は[]int32なので、Point型ではなく[]int32型の値が返ってきます。 これは、ジェネリックなコードが書かれている以上そうなるのですが、私たちが望んでいる動作ではありません。

これを修正するには、Scale関数を、スライスの型に対して型パラメータを使うように変更する必要があります。

// Scaleはsの各要素をcで乗じたコピーを返す。
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
    r := make(S, len(s))
    for i, v := range s {
        r[i] = v * c
    }
    return r
}

ここで、スライス引数の型となる新しい型パラメータSを導入しました。 Sの基底型が[]EではなくS自身になるように制約し、結果の型もSにしました。 Eは整数であるように制約されているので、効果としては以前と同じです。最初の引数は何らかの整数型のスライスでなければなりません。 関数本体への変更点は、makeを呼び出す際に[]EではなくSを渡すようになったことだけです。

この新しい関数は、単純なスライスを渡して呼び出した場合には以前と同じように振る舞いますが、Point型を渡して呼び出した場合にはPoint型の値が返ってくるようになりました。 これこそが私たちが望んでいたことです。 この新しいバージョンのScaleを使えば、先ほどのScaleAndPrint関数はコンパイルでき、期待どおりに動作します。

しかし、明示的な型引数を渡さずにScaleを呼び出すコードを書けるのはなぜでしょうか。 つまり、なぜScale[Point, int32](p, 2)と書く必要がなく、Scale(p, 2)と型引数なしで書けるのでしょうか。 新しいScale関数にはSEという2つの型パラメータがあります。 型引数を渡さずにScaleを呼び出す場合、先ほど説明した関数引数型推論によって、コンパイラはSの型引数がPointであると推論できます。 しかし、この関数には乗算係数cの型である型パラメータEもあります。 対応する関数の引数は2ですが、2型なしの 定数なので、関数引数型推論ではEの正しい型を推論できません(せいぜい2のデフォルト型であるintを推論できるだけで、これは誤りです)。 そこで代わりに、コンパイラがEの型引数をスライスの要素型であると推論する仕組みを、 制約型推論 と呼びます。

制約型推論は、型パラメータの制約から型引数を導き出します。 これは、ある型パラメータの制約が別の型パラメータを使って定義されている場合に使われます。 それらの型パラメータのうち一方の型引数がわかっているとき、その制約を使ってもう一方の型引数を推論します。

これが適用される典型的なケースは、いずれかの制約が、他の型パラメータを使って書かれたある型に対して~ という形を使っている場合です。 これはScaleの例で見たとおりです。 S~[]Eであり、これは別の型パラメータを使って書かれた型[]Eの前に~が付いたものです。 Sの型引数がわかっていれば、Eの型引数を推論できます。 Sはスライス型であり、Eはそのスライスの要素型です。

これは制約型推論の入門にすぎません。 詳細まですべて知りたい方は、提案文書言語仕様を参照してください。

実践における型推論

型推論の仕組みの正確な詳細は複雑ですが、使うこと自体は複雑ではありません。型推論は成功するか失敗するかのどちらかです。 成功すれば型引数を省略でき、ジェネリックな関数の呼び出しは通常の関数の呼び出しと変わらないように見えます。 型推論が失敗した場合、コンパイラはエラーメッセージを出すので、その場合は必要な型引数を指定するだけです。

型推論を言語に追加するにあたって、私たちは推論の力と複雑さのバランスを取ろうとしました。 コンパイラが型を推論するとき、その型が決して意外なものにならないようにしたいと考えています。 私たちは、誤った型を推論してしまう側に倒れるよりは、型の推論に失敗する側に倒れるよう注意深く努めてきました。 おそらく完璧にはできていませんし、今後のリリースでも改良を続けていくことになるでしょう。 その結果として、より多くのプログラムが明示的な型引数なしで書けるようになるはずです。 今日、型引数を必要としないプログラムは、明日も必要としないままです。

結論

ジェネリクスは1.18における大きな新しい言語機能です。 これらの新しい言語の変更には大量の新しいコードが必要となり、それらは本番環境での大規模なテストをまだ経ていません。 それは、より多くの人々がジェネリックなコードを書き、使うようになって初めて実現します。 私たちは、この機能がうまく実装されており、品質も高いと信じています。 しかし、Goの他のほとんどの側面とは異なり、私たちはその信念を実世界での経験で裏付けることはまだできていません。 そのため、意味のある場面ではジェネリクスの利用を推奨する一方で、本番環境でジェネリックなコードを使う際には適切な注意を払ってください。

その注意点はさておき、私たちはジェネリクスが利用可能になったことに興奮していますし、これによってGoプログラマの生産性がより高まることを願っています。

By Robert Griesemer and Ian Lance Taylor