ジェネリックメソッド
Generic Methods by Mark Freeman
ジェネリクスはGoにとって大きな変化でした。言語に型パラメータを追加したことで、Gopherが書けるプログラムの種類が根本的に広がったのです。
Goでジェネリックな型や関数を表現できるようになりました。これらの構文により、特定の型に特化したデータ型や関数を用意する必要性が減り、プログラムの冗長さが減少し、そのような場面での言語の使い勝手が向上しました。
例えば、さまざまな種類の連結リストを1つの型定義にまとめられるようになりました。
// Go 1.18より前
type ListOfInts struct {
elem int
next *ListOfInts
}
type ListOfStrings struct {
elem string
next *ListOfStrings
}
// ...
// Go 1.18以降
type List[E any] struct {
elem E
next *List[E]
}
同様に、さまざまな種類の順序付きデータのソートも1つの関数として表現できるようになりました。
// Go 1.18より前
func SortInts(s []int) { /* ... */ }
func SortStrings(s []string) { /* ... */ }
// ...
// Go 1.18以降
func Sort[E cmp.Ordered](s []E) { /* ... */ }
しかし、メソッドにはこのような機能がもたらされませんでした。
ジェネリクスの提案では、ジェネリックなインターフェースメソッドは効率的に実装するのが難しく(この点は後述します)、そのためインターフェースではないメソッド、すなわち「具象」メソッドに型パラメータを追加することも意味がないだろうと結論づけていました。この見方では、メソッドは主にインターフェースを実装するための手段だと捉えられています。
Go 1.27はこれとは異なる見方を採用し、その結果としてGoにジェネリックメソッドを追加しました。この記事では、この見方の変化を説明し、この新機能のいくつかの使用例を示します。
整理のためのメソッド
メソッドを使うと、型を中心に機能を整理できます。例として、先ほどの連結リストを(いくつか便利な要素を加えて)もう一度考えてみましょう。
type List[E any] struct {
elem E
next *List[E]
}
func NewList[E any](elems ...E) List[E] { /* ... */ }
func (List[E]) String() string { /* ... */ }
この構造体を、たとえばstringのような別の型の値を保持するように変換したいとします。ToStringのようなメソッドがあれば十分です。
func (List[E]) ToString(f func(E) string) List[string] { /* ... */ }
「変換」関数fを渡すことで、ToStringを既存のルーチンを使ってカスタマイズできます。
例えば、List[int]に対してはstrconv.Itoaが使えるでしょう。
func main() {
fmt.Println(NewList(1, 2, 3).ToString(strconv.Itoa)) // [1 2 3]
}
List[[]byte]の場合は、(ユースケースによりますが)もっと多くの選択肢が考えられます。
func main() {
l := NewList([]byte("Hallo Welt"), []byte("Helló világ"))
fmt.Println(l).ToString(hex.EncodeToString) // [48616c6c6f2057656c74 48656c6cc3b32076696cc3a167]
fmt.Println(l).ToString(base64.StdEncoding.EncodeToString) // [SGFsbG8gV2VsdA== SGVsbMOzIHZpbMOhZw==]
}
Listをパラメータ化したことで元となる型は汎用化できましたが、変換先の型は適用する変換処理に依存するため汎用化できていない点に注意してください。変換先の型が1つだけならこれでも妥当かもしれませんが、複数ある場合はどうでしょうか。
Go 1.18では、これにジェネリック関数を使えました。
// Go 1.18以降
func MapList[E, R any](l List[E], f func(E) R) List[R] { /* ... */ }
しかし関数を使うと、MapListがパッケージスコープに置かれてしまうという欠点があります。多くのデータ型がマッピング操作をサポートしていると、パッケージ内が混雑してしまう可能性があります。さらに、連鎖呼び出しは「内側から外側へ」書かなければなりません。
func main() {
fmt.Println(MapList(MapList(NewList(0, 2, 4), add(2)), divideBy(2))) // [1 2 3]
}
Go 1.18ではジェネリックメソッドをサポートしていなかったため、回避策としてジェネリック関数が必要でした。この点はGo 1.27で改善されています。
// Go 1.27以降
func (List[E]) Map[R any](f func(E) R) List[R] { /* ... */ }
これはコンパクトで表現力があり、しかもローカルにスコープされています。さらに、連鎖呼び出しはより自然に左から右へと書けるため、可読性も向上しています。
func main() {
fmt.Println(NewList(0, 2, 4).Map(add(2)).Map(divideBy(2))) // [1 2 3]
}
「内側から外側へ」という形式のほうが好みだという方もいるでしょう。メソッド式を使えば、ジェネリックメソッドを含むあらゆるメソッドを、それに相当する関数に変換できます。そのため、望むのであれば関数呼び出しの構造も取り戻せます。
func main() {
f := List[int].Map[int]
fmt.Println(f(f(NewList(0, 2, 4), add(2)), divideBy(2))) // [1 2 3]
}
ジェネリックメソッドは、Goの他のジェネリクスと同様に、使用する前に、つまり呼び出したり関数に変換したりする前に、明示的または暗黙的にインスタンス化しなければなりません。
関心事を切り離す
メソッドを整理のための道具としても捉えるなら、Go 1.18での結論は過度に制限的に見えます。ジェネリックな具象メソッドは(ジェネリックなインターフェースメソッドがなければ)インターフェースの実装には役立ちませんが、コードの整理には依然として役立ちます。つまり、これらの関心事は切り離せるのです。
もう少し詳しく見るために、単純なインターフェースを考えてみましょう。
type I interface {
M()
}
type T struct{} // インターフェースではなく構造体
func (T) M[P any]() { /* ... */ } // ジェネリックな*具象*メソッド
ここで、T.MはPによってパラメータ化されています。T.Mをどのようにインスタンス化しても、I.Mと同一のメソッドシグネチャになります。
func main() {
T{}.M[int]()
}
T.M[int]とI.Mはどちらもfunc M()というシグネチャを持ちます。しかし、だからといってTがIを実装していることにはなりません。インターフェースの実装は、インスタンス化されているかどうかにかかわらず型が持つ性質であり、特定のメソッドが持つ性質ではないのです。なお、Tが宣言しているのはT.M[int]ではなく、そのジェネリックな、つまりインスタンス化されていない対応物であるT.Mです。
T.Mがインターフェースの実装に関与するには、I.M側にも対応するジェネリックメソッドが存在する必要があります。
type I interface {
M[P any]() // ジェネリックな*インターフェース*メソッド
}
type T struct{}
func (T) M[P any]() { /* ... */ }
Go 1.27の構文では、インターフェースメソッドが型パラメータを宣言することは許されていないため、このコードは書けません。
しかし、なぜGoにはジェネリックなインターフェースメソッドを持たせられないのでしょうか。それを理解するには、少し寄り道が必要です。
ジェネリックなインターフェースメソッドが抱える問題
インターフェース値とは、別の値を保持できる箱のようなものです。箱の中の値は、インターフェースを実装してさえいればどんな型でも構いません。つまり、その型に宣言されているメソッドは、インターフェースに宣言されているメソッドのスーパーセットになっているということです。
例えば、以下ではTがIを実装しています。
type I interface {
M()
}
type T struct{}
func (T) M() { /* ... */ }
インターフェース値に対してメソッドを呼び出せます。
func main() {
F(T{})
}
func F(i I) {
i.M() // T.Mによって「裏打ち」されている
}
上の例では、i.Mの呼び出しがT.Mへとルーティングされることは容易にわかります。しかし、より複雑なプログラムでは、インターフェース値のメソッド呼び出しとその呼び出し先候補との関係は、はるかにわかりにくくなります。この関係は通常パッケージをまたいで広がっており、一般には推論することが難しい、あるいは不可能な場合すらあります。
例として、パッケージの境界を導入してみましょう。
// -- package main --
import "p"
func main() {
p.F(T{})
}
type T struct{}
func (T) M() { /* ... */ }
// -- package p --
func F(i I) {
i.M()
}
type I interface {
M()
}
2つのパッケージは別々にコンパイルされるため、mainパッケージはp.FがT{}をどのように使うかを知りません。p.Fが呼び出す可能性のあるメソッドが確実に存在するようにするため、コンパイラは、ある型が宣言(またはインスタンス化)された時点で、その型が持つジェネリックでないすべてのメソッドのコードを生成します。こうすることで、p.FがT{}に対して何をしようとも、実行時に必要なコードが存在していることになります。
では、I.Mがジェネリックだったとしたらどうなるでしょうか。
// -- package main --
import "p"
func main() {
p.F(T{})
}
type T struct{}
func (T) M[P any]() { /* ... */ }
// -- package p --
func F(i I) {
i.M[int]()
}
type I interface {
M[P any]()
}
この場合もやはり、mainパッケージはp.FがT{}をどのように使うか、つまりT.Mをどうインスタンス化するかを知りません。p.F内でのT{}のあらゆる使われ方に対応するには、コンパイラはT.Mをあらゆる型引数の組み合わせでインスタンス化する必要が出てきます。
これは、コンパイラが型引数に基づいて各メソッドごとに専用のコードを生成するという、Goのインスタンス化に対するアプローチでは現実的ではありません。もしメソッドの引数が、つまりその制約インターフェースの値として渡される形で「ボックス化」されていれば、それぞれのインスタンス化は同じコードを共有できたでしょう。そうすればインスタンス化が過剰に増えることは避けられますが、その代償として、インスタンス化されたメソッドへの直接呼び出しであっても間接呼び出しのオーバーヘッドが発生してしまいます。
まとめ
Go 1.27は具象メソッドに型パラメータを導入しました。この機能は、より使い勝手がよく読みやすいコードを可能にするものとして、Goコミュニティから長らく望まれてきました。ジェネリックなインターフェースメソッドはサポートできませんでしたが、具象メソッドに型パラメータを認めることには、それでも十分な価値があると私たちは判断しました。
Goのジェネリックメソッドを楽しんでいただき、みなさんのプロジェクトで役立つ使い方を見つけていただければ幸いです。
By Mark Freeman