はじめに
Go言語のジェネリクスは、Go 1.18から導入され、異なるデータ型に対応可能な汎用的な関数や型を作成することができます。他の言語におけるジェネリクスと同様、Goのジェネリクスもまた、型パラメータを用いて抽象化されたコードを書くことができます。たとえば、C#やJavaでは長年ジェネリクスが利用されており、コレクションライブラリなどでその力を発揮していますし、TypeScriptのような言語では、ジェネリクスを使ってより柔軟かつ型安全なコードを書くことができます。
少しサンプルコードを参考として使い方を紹介しようかと思います。
考え方
ジェネリクスがよくわからん。という人のために少し説明すると、「型に柔軟性を持たせる仕組み」です。プログラミングするとき、色んなデータ型がありますよね。整数や文字列、あるいはもっと複雑なカスタム型まで。でも、多くの場合、異なる型であっても「同じような処理」をしたい時があります。そこで登場するのがジェネリクスです。簡単に言うと、ジェネリクスを使うと「この関数は、どんな型にも対応するよ」と宣言できるんです。例えば、配列の要素を逆順にする関数を作るとき、整数の配列にも、文字列の配列にも、ほかの何かの配列にも使える一つの関数を作ることができるわけです。
この「どんな型にも対応する」というのがポイントで、ジェネリクスを使うと、特定の型を指定する代わりに「型パラメータ」というものを使って、関数や型を定義します。実際に関数を呼び出すときには、「この関数、今回は整数の配列で使うよ」とか「文字列の配列で使うね」と指定するわけです。
スライスの最初の要素を返す関数
任意の型 T
のスライスを受け取り、スライスの最初の要素を返します。返す値は2つあり、一つ目がスライスの最初の要素、二つ目がその要素が存在するかどうかを表すブール値です。
func First[T any](s []T) (T, bool) {
if len(s) > 0 {
return s[0], true
}
var zero T
return zero, false
}
この、[T any]
という部分がそれを示しています。T
は型パラメータで、any
は「どんな型でも良い」という意味です。つまり、この関数は int
のスライス、string
のスライス、あるいはユーザー定義型のスライスなど、どんな型のスライスにも対応しています。
var zero T
の部分は、T
型の変数 zero
を宣言し、その型のゼロ値(デフォルト値)で初期化しています。Go言語では、変数を宣言した時に初期値を明示的に指定しない場合、その型のゼロ値が自動的に割り当てられます。
各型のゼロ値は以下の通りです:
- 数値型(
int
、float64
など)のゼロ値は0
。 - ブーリアン型(
bool
)のゼロ値はfalse
。 - 文字列型(
string
)のゼロ値は空文字列(""
)。 - ポインタ、スライス、マップ、チャネル、関数、インターフェースなどの参照型のゼロ値は
nil
。
したがって、var zero T
というコード行では、T
というジェネリック型パラメータに対して、その型に応じたゼロ値を持つ変数zero
を宣言しています。例えば、T
がint
型であれば、zero
の値は0
になりますし、T
がstring
型であれば、zero
の値は空文字列(""
)になります。
この zero
変数は、関数 First
でスライス s
が空(len(s) == 0
)の場合に、ゼロ値を返すために使用されます。このようにして、First
関数はスライスが空の場合にも、適切な型のゼロ値を返すことができるようになっています。これにより、関数がジェネリックであるにもかかわらず、型安全性を保ちながら、あらゆる型のスライスに対応することが可能になります。
任意の型のスライスを受け取り、その要素を逆順にする関数
任意の型 T
のスライスを受け取り、その要素の順序を逆にして返します。
func Reverse[T any](s []T) []T {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
return s
}
この関数は、スライス s
の最初の要素と最後の要素を交換し、次に2番目の要素と最後から2番目の要素を交換する、という処理を繰り返します。この交換は、スライスの中央に達するまで続けられます。for
ループの条件 i < j
が、この「中央に達するまで」という処理を制御しています。
T any
という部分がジェネリクスの型パラメータ定義で、これにより Reverse
関数は int
型のスライス、string
型のスライス、さらにはユーザー定義型のスライスなど、任意の型のスライスに対応することができます。これは、ジェネリクスを使うことで、一つの関数で多様な型を扱えるようになるというジェネリクスの強力な利点を示しています。
マップエントリのフィルタリング: キーが偶数の要素だけを抽出するジェネリック関数
この関数は、マップのキーと値の両方に対して条件を適用し、その条件を満たすエントリだけを含む新しいマップを返します。
package main
import "fmt"
// FilterMap関数は、マップmと、キーと値のペアに対する条件を表す関数predicateを引数に取ります。
// この関数は、predicateがtrueを返すすべてのエントリで構成される新しいマップを返します。
func FilterMap[K comparable, V any](m map[K]V, predicate func(K, V) bool) map[K]V {
result := make(map[K]V)
for k, v := range m {
if predicate(k, v) {
result[k] = v
}
}
return result
}
func main() {
// 整数をキーとし、文字列を値とするマップの例
m := map[int]string{
1: "a",
2: "b",
3: "c",
4: "d",
}
// マップからキーが偶数のエントリだけを抽出する
filtered := FilterMap(m, func(k int, v string) bool {
return k%2 == 0
})
fmt.Println(filtered) // 出力: map[2:b 4:d]
}
このコード例では、FilterMap
はジェネリクスを利用して、任意の型 K
のキーと V
の値を持つマップに対して動作するように設計されています。K
には comparable
制約があり、これはキーとして使用できる型が比較可能である必要があることを意味します(マップのキーとして使用するためには、この制約が必要です)。
predicate
関数は、マップの各エントリ(キーと値のペア)に対して適用され、この関数が true
を返すエントリのみが結果のマップに含まれます。これにより、非常に柔軟なフィルタリング処理が可能になり、様々な条件でマップのエントリを選択的に抽出できます。
終わり
というわけで、Go言語のジェネリクスについて、いくつかのサンプルコードを元に最初の基本的な例から、もう少し複雑なフィルタリングの例まで、ジェネリクスがどんなに便利かが分かったかと思います。
ジェネリクスは、一見するとちょっと難しそうに見えるかもしれませんが、慣れてくると本当に便利なツールです。型安全を保ちつつ、コードの重複を減らし、様々なデータ型で同じロジックを再利用できるので、よりクリーンで読みやすいコードを書くことができます。
これを機会に色々と調べるのも楽しいかもしれません。
参考