【Golang】ジェネリクスのメリデメを比較してみる【1.18】
はじめに
Calendar for QualiArts | Advent Calendar 2021 - Qiita、20日目担当の鈴木光です
今回はGo言語 1.18について導入されるジェネリクスについてのお話をさせていただきます
Go言語 1.18 Betaの公開
先日Go言語 1.18のBetaが公開され、ついにジェネリクスが本格的に使えるようになってきたと話題になりました
go.dev
Go言語は言語仕様にジェネリクスが入っていなかったため、多くの人々はそれを自動生成で賄ってきました
そこでジェネリクスが入ることでどのように実際の開発が変わるのかを見ていきたいと思うのですが、ジェネリクスとは何かや具体的な記法などは多くの方が詳しく解説してくださっているので、今回は具体的にどう変わるのかということに対してスポットを当てていこうと思います
Go言語におけるジェネリクス
とはいえGo言語しか知らない方向けに簡単に説明させていただきます
ジェネリクスとは型パラメータを用いて抽象的なコードを実装することで、特定の型に依存することなく型安全性を維持しながら汎用的なコードを実装する手法のことです
例えばJava言語にはArrayListというリストを表現するための可変長配列のクラスがあります
これは
public class ArrayList<E>
として定義されており、実際には
List list = new ArrayList<String>();
のように使います。ArrayListはListインタフェースを実装しており、型に関わらずadd, remove, sortなどListインタフェースに定義されたあらゆる操作を行うことが可能です
Go言語ではこれを組み込み関数と気合で解決してきましたが、ジェネリクスによって気合が不要になり人類は楽をできるというわけです
実際にGo言語でジェネリクスを使ってみたい方はdevブランチモードのGo Playgroundで簡単に試せます
go.dev
また、こちらがジェネリクスのチュートリアルになります
go.dev
従来の手法による実装
前置きはこのくらいにして実際にどうなるのかを見ていきましょう。ただその前に従来の自動生成による実装を考えてみます
まず皆さんは何をソースに自動生成を行っているでしょうか。私のプロジェクトでは数千ファイルをProtocol Buffersによる定義から出力しているのですが、これはマイナーな手法だと思うので今回はソースをGo言語の構造体で定義しているとしましょう。実運用を考えるとGORMのようなORMのModelからリフレクションなどを用いて情報を取得し、関連コードを生成するのが一般的でしょうか
type User struct { ID uint Name string } type Users []*User
この構造体に対して以下のようなMap, Filter, Findのようなコレクション操作を行いたい場合、どのような実装があるかを考えてみます
func (u Users) Map (f func(u *User) Users) {} func (u Users) Filter (f func(u *User) bool) {} func (u Users) Find (f func(u *User) *User) {}
色々手法はあると思いますが一例を上げてみます
- User構造体とUsers型の定義をuser.goに定義する
- 同一パッケージ内にUsers型を拡張するメソッドを定義したuser_gen.goのようなファイルを生成する
都度実装しても良いのですがそれは面倒です。しかしGo言語にはジェネリクスがありません。そこでメソッドによる構造体拡張を用いることで全てのModel構造体に対してコレクションメソッドを自動生成するという手法です
問題点
さて、この手法にはいくつか問題点があります
まずは自動生成の手間です。新しいテーブルを追加するたび、あるいはコレクション操作が必要になるたびにこの自動生成を実行しなければなりません
次に自動生成コードのメンテナンスです。自動生成するためのスクリプト、テンプレートをメンテナンスしていかなければなりません。ジェネリクスを用いた場合でも当然そのコードはメンテナンスしなければならないのですが、自動生成の場合一つのツールのメンテナンスとなります。メンテナンスコストはジェネリクスのものよりは重くなりがちです
ジェネリクスを導入することによるメリット・デメリット
メリット
ジェネリクスを導入することでその抽象コードのメンテナンスだけで済みますが、型解決のためコンパイル速度は15%ほど遅くなります
Because of changes in the compiler related to supporting generics, the Go 1.18 compile speed can be roughly 15% slower than the Go 1.17 compile speed. The execution time of the compiled code is not affected. We intend to improve the speed of the compiler in Go 1.19.
https://go.dev/blog/go1.18beta1 より
しかし、これは実際には問題とならないケースもあるでしょう。なぜなら大量のコードを生成していた場合、そのコードを読み込むための時間がかかるためです。リフレクションが存在する限り未参照のコードを削除するというようなことはできませんし、そもそも未参照というのを解決するためにもファイルの読み込みが必要だからです
また、大量のファイルが生成されることによるデメリットはいくつか考えられます。セキュリティソフトの起動フックになったり、そもそも自動生成自体がとても長い時間かかったり、IDEのインデックス再構築を待つ時間があったりとコードのメンテナンス以外のデメリットも存在します
デメリット
コンパイル速度についてもそうですが、ジェネリクスによる実装は複雑になりがちだからです。自動生成コードには全てが記述されていますし、Protocol Buffersなどを使う場合はそもそもProtocol Buffers側でそのような抽象化がサポートされいないため、そのあたりの自動生成コードはそのまま使えます
また、自動生成にしかできないこともあります。 sqlboiler が良い例ですが、リレーションされているコードを静的に呼び出すことなど、モノによって実装があるなしが変わるようなものには当然ジェネリクスは使えません。入力された型パラメータによって戻り値が変化するような実装を現在のジェネリクス仕様では表現することができないためです
さらに、専用の関数を直接呼び出すほうが早いということもありえます。以下のコードをご覧ください。なお、実行はできませんのでご了承ください
実行してみたい方は 1.18 beta1系 のDockerコンテナがDocker Hubにアップロードされているので簡単に試すことが可能です。今回は golang:1.18beta1-alpine3.15
イメージを用いてベンチマークを取ってみました。私のPCでの実行結果は次のようなものになっています
/app # go test -bench . -benchmem goos: linux goarch: amd64 pkg: example.com cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz BenchmarkCast-8 11541190 102.3 ns/op 35 B/op 2 allocs/op BenchmarkGenerics-8 11655067 109.7 ns/op 35 B/op 2 allocs/op PASS ok example.com 2.687s /app #
ほぼ変わりませんが、若干ジェネリクス用の実装のほうが遅いことがわかります。重要なのは直接変換関数を呼び出しているか、動的に値の型を取得してtype switchしているかです。型ではなく値の型ということがキモです。本来は型情報だけで最適なswitchへ変換するようコンパイル時に判定できそうですが、現状は cannot use type switch on type parameter value
というエラーが出ており、この記法はまだ未対応なのでこのような結果となっています。これについては下記issueのとおり最適化を待つしか無いようです
https://github.com/golang/go/issues/45380#issuecomment-851580844
まとめ
今回はジェネリクスが導入されることによるメリット、デメリットを比較してみました。この春から始まるプロジェクトについては最初からジェネリクスありきの設計にすることでより柔軟なコードを実装することが出来ると思います。しかし世の中には既に運用中だったり開発中だったり色々なフェーズのプロジェクトが存在し、ジェネリクスを導入するかどうかは一つの大きな決断になると思います。この記事がそのような人々やプロジェクトの参考になれば幸いです