フューチャー技術ブログ

Go 1.18集中連載 ジェネリクス

Go 1.18のリリースが迫っているため、最近恒例でやっている新機能を何人かで紹介する集中連載を行います。ただ、Go 1.18は機能が大きく、ベータが長くなっており、当初2月予定だったのが、3月リリース見込みとなっています。

Date Title Author
2/9 (水) ジェネリクス(この記事です) 澁川喜規
2/10(木) net/http, wasm 真野隼記さん
2/14(月) fuzzing 伊藤真彦さん
2/15(火) net/netip 宮永崇史さん
2/16(水) Workspaceモード 辻大志郎さん
2/17(木) debug/buildinfo 多賀聡一朗さん

ジェネリクス

Go 1.18で入る機能で注目度が高い新機能がジェネリクスです。Goに対する批判的な言葉としてよく使われるものが「ジェネリクスがない」というものでした。Goを長く使っている人は「ジェネリクスなんていらん」と言い続けてきたし、個人的にもいまだになくてもいいと思っているのですが、大きな変更であるし、これから影響もいろいろある領域ですので学ぶ必要はあるでしょう。自分で書かなくても、書かれたコードを読むこともあるでしょうし。

ジェネリクスのチュートリアルが追加されています。

https://go.dev/doc/tutorial/generics

mattnさんの動画もあります。

文法的には基底型を表すチルダ演算子が増えたりいろいろ変更が入っています。

とはいえ、constraintsパッケージが1.18に入る予定だったのが、一旦準標準パッケージに格下げされたり、まだまだいろいろ変更が入っています。

また、今回のアップデートでは言語の変更が大きいので標準ライブラリの変更はなるべく減らそう、という提案も行われています。

そのため、ジェネリクスの文法は入るものの、標準ライブラリでそれを活かしたものが入るのは1.19以降ですね。現状、準標準ライブラリで提供されているのは次の3つのパッケージです。

  • golang.org/x/exp/constraints
    constraints.Ordered など、型パラメータの制約で使えそうな事前定義済みの型がいろいろ定義されているパッケージ。
  • golang.org/x/exp/slices
    コピー、挿入、削除、重複の削除、ソート、バイナリサーチなどを提供する汎用のスライス処理の関数群。
  • golang.org/x/exp/maps
    コピー、空にする、値の一覧取得、キーの一覧取得など、汎用のマップ処理の関数群

また、現状の実装ではいくつか制限もあります。2つは1.19に向けて改善していく旨がリリースノートに書かれています。

  • ジェネリックな関数やメソッド内部で型宣言が扱えない
  • パラメータ型の引数を組み込み関数のreal()/imag()/complex()といった複素数関数に渡せない

次の制限は解決するかどうかは明言されていません。

  • 型パラメータや型パラメータへのポインタを構造体の無名フィールドに埋め込めない。インタフェース型にも型パラメータを埋め込めない
  • 2つ以上の項を持つ要素ユニオン要素に、メソッドセットを持つインタフェース型を入れられない

ジェネリクスでできること・できないこと

いろいろ大きな変更となっていますが、Goのジェネリクスでできるのは主に2つです。

  • 型チェックを実行時からコンパイル時にフォワードできる(ものがある)
  • キャストを減らせる

挙動としてはC++のテンプレート的に、型パラメータを設定したタイミングで、その型が設定されたバージョンのコードが生成されているようですので、interface{}で毎回キャストしたりしていたのよりも高速に実行されそうです。

ジェネリクスは以前ではリフレクションで行っていたようなことを一部コンパイル時にできるようにしてくれます。また、以前は汎用型のinterface{}で扱っていて、毎回キャストしていたようなケースでキャストしないで書けます。

とはいえ、その構造体のフィールド一覧を取り出すとか、そういうのはあいかわらずリフレクションが必要です。

また、Goのジェネリクスは型違いによりロジックを最適化する方法はありません。16ビットの時はSIMDで8個ずつ、32ビットだと4個ずつ計算といったように、型の種別ごとに実行されるプログラムを変えることはできません。

また、Goの場合はオーバーロードもなく、定数も入れられず、記述力としてはかなりシンプルです。C++では型パラメータがより柔軟でオーバーロードもありますし、型パラメータが可変長だったり、型だけでなく整数なども入れられ、再帰を使って柔軟なデータ構造を実現したりできます。C++のテンプレート機能は、コンパイル時に計算を行ったり、うっかりチューリング完全になってしまったことが知られていますが、Goの場合はC++と違って地獄門が開いたりはしてませんので、安心してください。

ジェネリクスとの付き合い方・アプリ編

では実際、ジェネリクスを業務にどのように入れていくか、というところは問題ですが、この手の話はアプリとライブラリで様子が変わってきます。

まずアプリケーションですが、これは他から利用されるコードではないため、自分の中で完結すればOKです。なのでコンパイラのバージョンが上げられる状況であれば使えるでしょう。ただし、GAEのStandard Environmentだったり、GCPのCloud RunとかCloud Functionsとかでソースコードをクラウドビルドする場合はランタイムのバージョンが環境次第だったりするでしょうし(Cloud Runは自分でローカルビルドしたイメージも使えますが)、そこだけが問題ですね。

具体的に何に使っていくかですが、ゆるーく型チェックを行うのと、キャストを減らすぐらいの使い道でしょう。

緩く、というと、例えば整数型を受け取るときに、intを受け取るだけではなく、int8, int16, int32, int64も受け取れるようにするとか、今までのGoではintのみメソッド提供して利用者がキャストするか、個別に関数を用意するか、interface{}で雑に扱うかの3択だったところに、「intっぽいものは全部受け取るよ」という柔軟な選択肢が追加で提供できますね。

それ以外だと、「ポインタのみ受け取りたい、インスタンスは受け取らない」みたいなケースで、ポインタを強制するみたいなのもできますね。デコード系の処理でよく問題になるやつ。

package main

import (
"encoding/json"
"fmt"
)

func JsonDecode[T any](data []byte, input *T) error {
return json.Unmarshal(data, input)
}

func main() {
type Favorite struct {
Food string
}
var f Favorite
JsonDecode([]byte(`{"food":"中本"}`), &f) // ←ポインタではなくインスタンスを渡すとコンパイルエラー
fmt.Println(f.Food)
}

あとはデータベースのNullBoolとかその手のコード内のイディオムとかはどんどん使って行ってもいいかと思います。

オレオレカッコいいコンテナクラスとか、壮大なプログラミングモデルを変えうるような機構とかを入れてしまいたくなるかもしれませんが、JavaScriptでプリミティブな構造を大きく変えるようなライブラリが入った結果、そのコードを読み解くのにそのライブラリの知識も追加で必要になって、他の人から手が出ない、みたいなことがあったので(数社の共同開発案件で、突然、コードフリーズの日に今まで使ってなかったimmutable.jsを使ったコードがPRで送られてきてコードレビューで苦戦した)、Go流から大きく外れるようなものは慎重にやった方が良いですね。世間の流行とうっかり距離が離れてしまうと負債化してしまいますし。

もちろん、RxGoみたいなのとか、関数型のリスト処理を行ライブラリがジェネリクス対応になって多くのコミュニティの支持を得られて広まっていく、というのは当然あると思いますが、エコシステム全体がアップデートされていくには数年単位でかかると思うので、まあのんびりやればいいかと思います。

ジェネリクスとの付き合い方・ライブラリ編

ライブラリを実装する人は、ジェネリクスを使ってしまうとGo 1.18以上でしか使えないものになってしまいます。Node.jsもfsパッケージのasync版を追加したときにパッケージを分けていたし、Goでもそうすべき気がします。

  • github.com/yourname/mylibrary/v2
  • github.com/yourname/mylibrary/v2/generics

で、半年後に1.19がリリースされ、1.17のサポートが切れたタイミングでバージョンをあげて後方互換性を切りつつ、ジェネリクスの方をデフォルトにする、という感じでしょうか。

  • github.com/yourname/mylibrary/v3

ジェネリクスのチュートリアルには「まずジェネリクスでない実装を作ってからジェネリクス化せよ」とありますが、その前にまるごとコピーする工程を入れるだけなのでまあ、手法としてはこれでいけるかと思います。

ジェネリクスに向けたイディオムの整備

Goに限らず、どの言語でも「よくある命名規則やルール」などがあります。Goでは、Append()関数があれば1つ目のものが追加先で返り値に変更済みのものが入るとか、Copy()はコピー先、コピー元の順序の引数になっているとかです。ジェネリクスに関するこのようなイディオムも今後、徐々にコンセンサスが取れてくると思います。

今の所のサンプルとかで見えてくる命名規則はこんな感じですね。

  • 型パラメータ名として、型が1つだけならTを使う。
  • 型パラメータ名として、複数ある場合は1文字変数的な命名で大文字にして使う

mapsパッケージだとこんな感じですね。

func Equal[M1, M2 constraints.Map[K, V], K, V comparable](m1 M1, m2 M2) bool

holdになっているプロポーザルでは、型パラメータを取るファクトリー関数としてPoolOf()が提案されています。

https://github.com/golang/go/issues/47657

そういうジェネリクスならではのイディオムが整備されると、ジェネリクスを使ったコードの意図が伝わりやすくなるので、今後その手のイディオムが充実していくといいですね。