ジェネリクスとは何か
ジェネリックプログラミング(generic programming)は、プログラミング言語の一つのスタイルまたはパラダイムです。ジェネリクスを使うことで、プログラマーは強い型付けのプログラミング言語でコードを書く際に、後で指定される型を使用でき、インスタンス化時にこれらの型をパラメータとして指定することができます。
ジェネリクスを使うと、さまざまな型に適用できるコードを書くことができ、各型ごとに同じロジックを繰り返して書く必要がなくなります。これにより、コードの再利用性、柔軟性、型安全性が向上します。
Go 言語においては、ジェネリクスは型パラメータによって実現されます。型パラメータは特殊なパラメータであり、任意の型を表すプレースホルダーです。関数、メソッド、型の定義で使用され、具体的な呼び出し時に実際の型に置き換えられます。
ジェネリクスがない場合
このような要件を考えてみましょう。2 つの int 型の引数を受け取り、そのうち値が小さい方を返す関数を実装します。要件は非常にシンプルなので、直感的に次のようなコードを書くことができます。
func Min(a, b int) int {
if a < b {
return a
}
return b
}
一見良さそうですが、この関数には制限があります。引数は int 型しか受け取れません。もし要件が拡張され、2 つの float64 型の引数を比較して、小さい方を返す必要が出てきた場合はどうでしょうか。
func Min(a, b int) int {
if a < b {
return a
}
return b
}
func MinFloat64(a, b float64) float64 {
if a < b {
return a
}
return b
}
皆さんはお気づきかもしれませんが、要件が拡張されるたびに、それに応じてコードも変更し、同じことを繰り返さなければなりません。ジェネリクスはまさにこのような問題を解決します。
import "golang.org/x/exp/constraints"
func Min[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
ジェネリクスの基本構文
// 関数定義
func F[T any](p T){...}
// 型定義
type M[T any] []T
// Constraintは具体的な型制約を表す。例: any, comparable
func F[T Constraint](p T){..}
// “~” は基底型(underlying type)の制約を表す
type E interface {
~string
}
// 特定の型のみを指定
type UnionElem interface {
int | int8 | int32 | int64
}
~記号
Go 言語のジェネリクスにおいて、「~」は基底型(underlying type)の制約を表します。
例えば、~int は基底型が int であるすべての型を受け入れます。これにはカスタム型も含まれます。もし MyInt というカスタム型の基底型が int であれば、この制約は MyInt 型も受け入れます。
type MyInt int
type Ints[T int | int32] []T
func main() {
a := Ints[int]{1, 2} // 正しい
b := Ints[MyInt]{1, 2} // コンパイルエラー
println(a)
println(b)
}
MyInt does not satisfy int | int32 (possibly missing ~ for int in int | int32)compilerInvalidTypeArg
以下のように修正すれば OK です。
type Ints[T ~int | ~int32] []T
型制約
- any:任意の型を受け入れる
- comparable:==や!=の操作をサポートする
- ordered:大小比較(> <)をサポートする
その他の型制約については https://pkg.go.dev/golang.org/x/exp/constraints を参照してください。
いつジェネリクスを使うべきか
ジェネリクスを使う場面
- 言語が定義するコンテナ型を操作するとき:スライス、マップ、チャネルなどのコンテナ型に対して操作を行う関数を書く場合で、要素型について特定の前提がないとき、型パラメータの利用が有用です。例えば、どんな型のマップでも全てのキーを返す関数など。
- 汎用データ構造:連結リストや二分木などの汎用データ構造について、型パラメータを使うことでより汎用的なデータ構造を作成できたり、型アサーションを回避して効率よくデータを格納し、構築時に完全な型チェックが可能になります。
- 汎用メソッドの実装:異なる型で同じ汎用メソッドを実装し、その実装が全く同じ場合、型パラメータの利用が有用です。例えば、任意のスライスタイプに対して sort.Interface を実装する汎用型など。
- 関数優先で:比較関数のようなものが必要な場合、メソッドよりも関数を優先して使用します。汎用データ型に対しても、できるだけ関数を使い、メソッドが必要な制約は避けるべきです。
// SliceFnはT型スライスに対してsort.Interfaceを実装
type SliceFn[T any] struct {
s []T
less func(T, T) bool
}
func (s SliceFn[T]) Len() int {
return len(s.s)
}
func (s SliceFn[T]) Swap(i, j int) {
s.s[i], s.s[j] = s.s[j], s.s[i]
}
func (s SliceFn[T]) Less(i, j int) bool {
return s.less(s.s[i], s.s[j])
}
// SortFnは比較関数を用いてスライスsをその場でソート
func SortFn[T any](s []T, less func(T, T) bool) {
sort.Sort(SliceFn[T]{s, less})
}
ジェネリクスを使わない場面
- インターフェース型の代用はしない:ある型の値のメソッドだけを呼び出す場合、型パラメータではなくインターフェース型を使うべきです。インターフェース型の関数を型パラメータの関数に変換するべきではありません。
- 型ごとに異なる実装が必要な場合:各型で実装が異なるメソッドが必要な場合は、型パラメータを使うのではなく、インターフェース型と異なる実装を用意すべきです。
- リフレクションの適切な利用:メソッドすら持たない型も含めて多様な型をサポートし、かつ型ごとに異なる操作が必要な場合はリフレクション(反射)を利用します。例として、encoding/json パッケージはリフレクションを利用しています。
簡単な指針
同じ内容のコードを何度も書いており、違いは使う型だけという場合、型パラメータの使用を検討しても良いでしょう。言い換えれば、全く同じコードを複数回書きそうだと気付くまでは、型パラメータの使用を避けるべきです。
余談
なぜ他言語で一般的な山括弧(<>)ではなく、角括弧([])を選んだのか。
https://github.com/golang/proposal/blob/master/design/15292/2013-12-type-params.md
Use angle brackets, as in Vector. This has the advantage of being familiar to C++ and Java programmers. Unfortunately, it means that f(true) can be parsed as either a call to function f or a comparison of f<T (an expression that tests whether f is less than T) with (true). While it may be possible to construct complex resolution rules, the Go syntax avoids that sort of ambiguity for good reason.
Vector のように山括弧(<>)を使うこともできます。これは C++や Java のプログラマーには馴染みがあるという利点があります。しかし、f(true)のような式が、関数 f の呼び出しなのか、あるいは f<T(f が T より小さいかを判定する式)と(true)との比較なのか、どちらにも解釈できてしまいます。複雑な解決ルールを構築することも可能かもしれませんが、Go の構文はそのような曖昧さを避けるために設計されています。
私たちはLeapcell、Goプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ