Goのためのジェネリクス
原文は Ben Hoyt により に公開されました。 このブログを購読する
Goプログラミング言語は2009年に初めて公開され、1.0は2012年3月にリリースされた。1.0のリリース前から、一部の開発者はこの言語が単純すぎると批判しており、その理由のひとつが、型でパラメータ化されたユーザー定義のジェネリック型や関数が欠けていることだった。この欠如にもかかわらず、Goは広く使われており、世界中で100万〜200万人の開発者がいると推定されている。長年にわたり、何らかの形でジェネリクスを言語に追加しようという提案がいくつもなされてきたが、コア開発者のイアン・ランス・テイラーとロバート・グリーズマーが書いた最新の提案は、将来のバージョンのGoに含まれる可能性が高い。
背景
Goは静的型付け言語であり、型はソースコードで指定される(あるいはそこから推論される)かたちでコンパイラによって検査される。コンパイラは最適化されたマシンコードを生成するため、CPU負荷の高いコードは、バイトコードコンパイラを持ち仮想マシンで実行されるPythonやRubyのような言語に比べて、はるかに効率的だ。
ジェネリクスは「パラメータ化された型」や「パラメトリック多相」とも呼ばれ、任意のデータ型に対して動作するコードやデータ構造を記述する方法である。コードやデータ構造は、コードを複製することなく、それぞれ異なるデータ型を処理するためにインスタンス化できる。ソートや探索のような汎用アルゴリズムを書く場合や、木構造やスレッドセーフなマップといった型に依存しないデータ構造を構築する際に役立つ。たとえば、開発者はすべての整数型や浮動小数点型で動作する汎用的なmin()関数を書いたり、キー型と値型を対応づけ、文字列や整数、ユーザー定義型などで動作する二分木を作ったりできる。ジェネリクスを使えば、この種のコードを重複なしで記述でき、しかもコンパイラが型を静的に検査してくれる。
初期のJavaと同様に、Goにはユーザー定義のジェネリクスが備わっていない。GoのFAQが指摘するように、ジェネリクスは「いずれ追加される可能性は十分にある
」が、同時にそれを含めなかったのは意図的なトレードオフだったと説明されている。
ジェネリクスは便利だが、型システムや実行時に複雑さというコストを伴う。我々はまだ、その複雑さに見合う価値をもたらす設計を見つけていないが、検討は続けている。一方で、Goに組み込まれているマップやスライスに加え、空インターフェースを使ってコンテナを構築する機能(明示的なアンボックスを伴う)があれば、ジェネリクスで実現できることを、たとえ洗練されていなくても、多くの場合実現可能だ。
実際にこの言語を使っているユーザーが、ジェネリクスの欠如について声高に不満を述べない理由のひとつは、Goが組み込みのコンテナ型についてはジェネリクスを備えていることだ。具体的には、スライス(Goの可変長配列型)、マップ(ハッシュテーブル)、そしてチャネル(スレッドセーフな通信キュー)である。たとえば、ブログソフトウェアを書く開発者は、記事のリストや著者IDから著者情報へのマッピングを取得する関数を次のように書くかもしれない。
// takes ID, returns "slice of Article" (compiler checks types)
func GetLatestArticles(num int) []Article {
...
}
// takes "slice of int" of IDs, returns "map of int IDs to Author"
func GetAuthors(authorIDs []int) map[int]Author {
...
}len()やappend()のような組み込み関数は、これらのコンテナ型に対して動作するが、開発者がこれらの汎用的な組み込み関数と同等のものを独自に定義する方法はない。多くのGo開発者が証言するように、型でパラメータ化された可変長配列やマップが組み込みで用意されているだけでも、ユーザー定義のジェネリック型がなくても大いに役立つ。
さらに、Goはジェネリクスの代わりや、その欠如を回避する手段としてしばしば使われる2つの機能、すなわちインターフェースとクロージャをサポートしている。たとえば、Goでのソートはsort.Interface型を使って行われ、このインターフェースは3つのメソッドを要求する。
type Interface interface {
Len() int // length of this collection
Less(i, j int) bool // true if i'th element < j'th element
Swap(i, j int) // swap i'th and j'th elements
}ユーザー定義のコレクションがこのインターフェースを実装していれば、標準ライブラリのsort.Sort()関数を使ってソートできる。Go 1.8でsort.Slice()が追加されて以来、開発者はソート用インターフェース全体を実装する代わりに、この関数を使って「小なりクロージャ」を渡すことができる。たとえば次のようになる。
// declare a struct for names and ages and a slice of those structs with four entries
people := []struct {
Name string
Age int
}{
{"Gopher", 7},
{"Alice", 55},
{"Vera", 24},
{"Bob", 75},
}
// sort people using the "less-than closure" specified in the call
sort.Slice(
people,
func(i, j int) bool { // i and j are the two slice indices
return people[i].Name < people[j].Name
},
)Goのジェネリクス不足を回避するほかの方法として、interface{}(「空インターフェース」)を使うコンテナ型を作る方法もある。これはコレクションに挿入されるすべての値を事実上ボックス化し、実行時の型アサーションを必要とするため、特に効率的でも型安全でもない。しかし動作はするし、sync.Mapのような標準ライブラリの一部の型でもこの手法が使われている。
中には、複雑さを招きすぎるという理由で、Goにはそもそもジェネリクスを追加すべきではないと主張する開発者さえいる。たとえば、グレッグ・ホールは「Goには決してジェネリクスが追加されないこと、あるいは追加されるとしても、設計者がJavaのジェネリクスやC++のテンプレートで私が見てきたような複雑さや困難を回避する何らかの方法を見つけてくれること
」を望んでいる。
Goチームはこの複雑さの問題を真剣に受け止めている。コア開発者であるラス・コックスは、2009年の記事「The Generic Dilemma」で次のように述べている。
ジェネリクスには3つの基本的なアプローチがあるように思える。
- (Cのアプローチ)省く。プログラマの速度は落ちる。しかし言語に複雑さは加わらない。
- (C++のアプローチ)コンパイル時の特殊化やマクロ展開。コンパイルが遅くなる。大量のコードが生成され、その多くは冗長で、重複したコピーを取り除くには優れたリンカが必要になる。[…]
- (Javaのアプローチ)すべてを暗黙的にボックス化する。実行が遅くなる。[…]
ジェネリクスのジレンマとはこうだ。遅いプログラマ、遅いコンパイラと肥大化したバイナリ、遅い実行時間のいずれが欲しいか?
それでも、多くのGo開発者がジェネリクスを求めており、Goらしい方法でそれを追加する最善の方法について、長年にわたり膨大な議論がなされてきた。何人かの開発者は、自身のGo使用経験に基づく「経験報告」の中で思慮深い根拠を示している。公式Goブログにおけるテイラーの記事「Why Generics?」では、ジェネリクスがGoにもたらすものが詳述され、Goチームがジェネリクス追加にあたって従っているガイドラインが示されている。
最も重要なのは、今日のGoがシンプルな言語であるということだ。Goのプログラムは通常、明確で理解しやすい。この分野を探求してきた長い過程の大部分は、その明確さとシンプルさを保ちながらジェネリクスをどう追加するかを理解しようとすることに費やされてきた。既存の言語によくなじみ、まったく別物にしてしまわないような仕組みを見つける必要がある。
これらのガイドラインは、Goにおけるあらゆるジェネリクスの実装に当てはまるはずだ。これが、今日皆さんに残したい最も重要なメッセージだ。ジェネリクスは言語に大きな恩恵をもたらし得るが、Goが依然としてGoらしく感じられる場合にのみ、やる価値がある。
最近の提案
とりわけテイラーは、Goへのジェネリクス追加というテーマで精力的に活動しており、6つもの提案を書いている。2010年から2013年にかけて書かれた最初の4つは、彼のドキュメント「Go should have generics」の末尾に列挙されている。それらについて、彼は「すべてが何らかの点で欠陥がある
」と述べている。2019年7月には前述のブログ記事「Why Generics?」を投稿し、そこでテイラーとグリーズマーが「コントラクト」に基づくジェネリクスの一形態について書いた長大な2019年の提案へのリンクが示された。そのほぼ1年後の2020年6月に、テイラーとグリーズマーはコントラクトの追加を避けた現在の提案を公開した。テイラーの言葉を借りれば、
以前のドラフト設計のジェネリクスでは、コントラクトと呼ばれる新しい言語構造を使って制約を実装していた。型リストはインターフェース型ではなく、コントラクトにのみ現れていた。しかし、多くの人がコントラクトとインターフェース型の違いを理解するのに苦労した。また、コントラクトは対応するインターフェースの集合として表現できることも判明し、したがってコントラクトがなくても表現力は失われないことがわかった。我々は、インターフェース型だけを使うようにアプローチを単純化することにした。
コントラクトの削除は、一部にはフィリップ・ワドラーとその共同研究者による2020年5月の論文「Featherweight Go [PDF]」(動画プレゼンテーション)の成果に基づいている。ワドラーはHaskellの設計に貢献した型理論家であり、2004年にJavaへのジェネリクス追加にも関わった。Goの生みの親の一人であるロブ・パイクは、ワドラーに対し、Goの将来のバージョンのために「ポリモーフィズムを正しく実現する手助け(あるいは『正しい』とは何かを明らかにすること)に興味はないか
」と尋ねたが、この論文はその依頼に対する回答である。
2020年の提案では、関数と型にオプションの型パラメータを追加することが提案されており、それぞれ汎用アルゴリズムと汎用コンテナ型を可能にする。この提案の下で汎用関数がどのように見えるか、例を次に示す。
// Stringify calls the String method on each element of s,
// and returns the results.
func Stringify(type T Stringer)(s []T) []string {
var ret []string
for _, v := range s {
ret = append(ret, v.String())
}
return ret
}
// Stringer is a type constraint that requires the type argument to have
// a String method and permits the generic function to call String.
// The String method should return a string representation of the value.
type Stringer interface {
String() string
}型パラメータはT(任意の名前)で、関数名の後に追加される括弧の中に、Stringerという制約とともにtype T Stringerとして指定される。関数への実際の実引数は2つ目の括弧内、s []Tにある。このような関数を書くことは現在のGoでは不可能だ。Goでは、具象型のスライスを、インターフェース型(たとえばStringer)のスライスを受け取る関数に渡すことが許されていないからだ。
汎用関数に加え、新しい提案では型のパラメータ化もサポートしており、二分木やグラフデータ構造などの型安全なコレクションを可能にする。汎用的なVector型がどのようになるか、例を次に示す。
// Vector is a name for a slice of any element type.
type Vector(type T) []T
// Push adds a value to the end of a vector.
func (v *Vector(T)) Push(x T) {
*v = append(*v, x)
}
// v is a Vector of Authors
var v Vector(Author)
v.Push(Author{Name: "Ben Hoyt"})Goは演算子のオーバーロードをサポートしておらず、演算子をメソッドとして定義することもないため、インターフェース制約を使って型が(たとえば)<演算子をサポートしなければならないことを指定する方法がない。提案では、これは「型リスト」と呼ばれる新機能を使って行われ、その例を以下に示す。
// Ordered is a type constraint that matches any ordered type.
// An ordered type is one that supports the <, <=, >, and >= operators.
type Ordered interface {
type int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64,
string
}実際には、Orderedのような一般的な制約をあらかじめ定義したconstraintsパッケージが標準ライブラリに追加される可能性が高い。型リストにより、開発者は組み込み演算子を使う汎用関数を書くことができる。
// Smallest returns the smallest element in a slice of "Ordered" values.
func Smallest(type T Ordered)(s []T) T {
r := s[0]
for _, v := range s[1:] {
if v < r { // works due to the "Ordered" constraint
r = v
}
}
return r
}型リストとして記述できない唯一の制約は、==と!=演算子に関する制約である。Goでは構造体、配列、インターフェース型を等価比較できるためだ。これを解決するために、提案では等価演算子を可能にする組み込みのcomparable制約の追加が示唆されている。これはたとえば、スライスや配列内で値のインデックスを探す関数のような場合に有用だ。
// Index returns the index of x in s, or -1 if not found.
func Index(type T comparable)(s []T, x T) int {
for i, v := range s {
// v and x are type T, which has the comparable
// constraint, so we can use == here.
if v == x {
return i
}
}
return -1
}テイラーとグリーズマーは、この提案で規定されたGoコードを通常のGoコードに変換する実験用のツール(go2goブランチ上)を開発しており、開発者は今日でもジェネリックなコードをコンパイルして実行できる。この提案に基づいて書かれたコードをオンラインで共有・実行できるGo playgroundのバージョンさえ存在する。たとえば、上記のStringify()関数の動作例がこちらにある。
Goチームは開発者に対し、ジェネリクスの実験用ツールを使って自身の問題を解決してみること、そして次の質問に対する詳細なフィードバックを送ることを求めている。
第一に、ジェネリックなコードは理にかなっているか。Goらしく感じられるか。人々はどのような驚きに遭遇するか。エラーメッセージは有用か。
第二に、多くの人がGoにはジェネリクスが必要だと言ってきたことは分かっているが、それが正確に何を意味するのかは必ずしも分かっていない。このドラフト設計は、その問題に有用なかたちで対処しているか。「Goにジェネリクスがあれば解決できるのに」と思わせるような問題があるとき、このツールを使ってその問題を解決できるか。
議論
最新の提案が公開されて以来、主要なgolang-nutsメーリングリストや、Hacker News、reddit.com/r/golangのスレッドで、ジェネリクスについて多くの公開議論が行われてきた。
パイクが昨年述べた [YouTube]ように、「構文は問題ではない、少なくともまだ」だが、メーリングリスト上の多くのスレッドでは直ちに構文への批判がなされた。確かにこの構文は unusual であり、すでに括弧が多いことで知られるGo(たとえば、Goのメソッド定義ではレシーバの型に1組、メソッドの引数にもう1組の括弧を使う)に、さらに丸括弧を1組追加することになる。提案では、山括弧ではなく丸括弧を選んだ理由の説明によって、構文に関する不毛な議論を未然に防ごうとしている。
関数内のコード、たとえばv := F<T>を解析する際、<を見た時点で、それが型のインスタンス化なのか<演算子を使った式なのかは曖昧だ。これを解決するには実質的に無制限の先読みが必要になる。一般的に、我々はGoのパーサを効率的に保つよう努めている。
メーリングリストの回答者の大半は、C++やJava、C#のように山括弧を使うことを提案しており、たとえばList(T)の代わりにList<T>とする案だ。テイラーは新しい提案の意味論が理にかなっているかどうかにずっと関心があるが、これらの構文に関するスレッドのそれぞれに、次のような内容で辛抱強く返信している。
代替案を心配する前に、提案されている構文で実際のコードがどう見えるか見てみましょう。ありがとう。
これが何度も繰り返されたため、メーリングリストの寄稿者の一人であるタイラー・コンプトンが、構文関連のスレッドすべてについて有用なリストをまとめた。
ジェネリクスは、複数の型のために繰り返される型や関数をなくすのに役立つ。たとえばsortパッケージにおけるsort.Ints、sort.Float64s、sort.Stringsなどだ。Hacker News上のコメントで、カイル・コンロイは標準ライブラリのさまざまなsql.Null*型に対する「4行での置き換え
」を示した。
type Null(type T) struct {
Val T
Valid bool // Valid is true if Val is not NULL
}メーリングリストの寄稿者であるピー・ジャイは、型を構造体のみに制限する方法がないかと疑問を呈したが、テイラーはそれは不可能だと指摘し、「ジェネリクスですべての問題が解決するわけではない
」と述べた。ロバート・エンゲルスは、いずれにせよこのケースではreflectパッケージが依然として必要になると述べた。
あるスレッドでは、「i3dmaster」がカスタムのマップ型についていくつか質問し、テイラーは「カスタムのコンテナ型はlen()やrangeをサポートする予定はない
」と明確にした。コレクション型の作成者はこの特別な構文を利用できず、独自のLen()メソッドや、コレクションを反復処理する独自の方法を定義する必要がある。
Goのコアコントリビューターであるブライアン・ミルズは、多くのスレッドで洞察に富んだ返信を投稿している。彼はまた、ジェネリクスに関する実験からのさまざまなメモやコード例をまとめた自身のリポジトリも作成しており、その中にはなぜ型リストを理想的ではないと考えるかについての説明も含まれている。このリポジトリには、提案されたジェネリクスを使ってappend()組み込み関数を再実装しようとするさまざまな試みも含まれている。
タイムライン
最近のブログ記事で、テイラーとグリーズマーは、言語へのジェネリクス追加は迅速なプロセスにはならないことを明確にしている。彼らはそれを正しく行い、コミュニティからのフィードバックを考慮したいと考えている。
我々はGoコミュニティから集めたフィードバックを使って、今後の進め方を決める。ドラフト設計が好評で、大幅な変更が必要なければ、次のステップは正式な言語変更提案となる。期待値を示しておくと、もし全員が設計ドラフトに完全に満足し、さらなる調整が不要であれば、ジェネリクスがGoに追加される最も早いタイミングは、2021年8月に予定されているGo 1.17リリースとなるだろう。もちろん実際には予期せぬ問題が起こり得るため、これは楽観的なタイムラインであり、確定的な予測はできない。
筆者自身の推測では、この規模の機能にとって2021年8月(あと1年余り)は楽観的すぎる。フィードバックを募り、設計を繰り返し改善し、現在のGo-to-Goトランスレータを使うのではなく本番対応の形でジェネリクスを実装するには、かなりの時間がかかるだろう。しかし、これまでの提案の数やフィードバックの量を考えれば、ジェネリクスはいつ導入されても、きっと多用され(そして願わくば悪用は少なく)使われる機能になるはずだ。
記事をランダムに読む
コメント
ログインしてコメントする