Go言語のジェネリクスの使い方をQueueとSetの実装を例に解説
Go言語にも導入されたジェネリクス(Generics)の使い方を、基本的な文法を実例を使って解説します。実例としては、ジェネリクスに対応したQueue(キュー)とSet(集合型)です。ジェネリクスがGo言語に導入されたのは、比較的最近ですので、まだ慣れていない人も多いと思います。本記事では、QueueとSetの実装を通じて、実際にどのように使うのかを詳しく解説します。
ジェネリクスとは
簡単に言うと、ジェネリクス(Generics)は、型をパラメータ化する機能です。この機能がある言語では、特定のデータ型に依存しないコードを書くことができます。
例えば、a+bを行う関数add
を作成したい場合、a, bが整数の場合は、以下のような関数addint
を作成します。
func addInt(a, b int) int {
return a+b
}
addint()
は、入力が整数のため、浮動小数点の計算う場合は別途関数を用意する必要があります。例えば、以下のようなaddFloat64
関数を用意します。
func addFloat64(a, b float64) float64 {
return a+b
}
ジェネリクスを使えば、特定の型に依存しないコードを書くことができんるので、この2つをまとめて1のadd()
関数として定義することができます。
ジェネリクスは、多くのプログラミング言語に導入されている機能ですが、Go言語ではサポートされていませんでした。このため、引数の型が違う入力に対応するためには、引数の型違いの関数を用意する必要がありました。
Go言語もバージョン1.18よりジェネリクスが導入され、使えるようになりました。ここでは、Go言語でのジェネリクスの記述方法を、サンプルコードを交えて解説します。
Go言語でジェネリクスが導入されていなかった主な理由は「シンプルさと明快さの追求」です。Go言語の設計思想の1つは、シンプルさを重視することです。ジェネリクスは言語の複雑性を増し、コードの読みやすさを低下させる可能性があります。そのため、ジェネリクスを導入する場合には、この原則とのバランスを考慮する必要がありました。
導入までに、コミュニティや開発者間で結構な議論があったようです。2016年くらいからジェネリクスの提案が始まり、2022年にリリースされたGo 1.18でやっと導入されました。かなり慎重にデザインされ、導入されたことがわかります。
ジェネリクスと整合性が高いのは、キューやヒープといった汎用的なアルゴリズムだと思います。ということで、この記事では、QueueとSetを実装例に選びました。
ジェネリクスの基本的な使い方
使い方(1)
簡単なジェネリスクの使い方として、整数型と浮動小数点、文字列をサポートしたAdd関数を定義してみます(文字列の場合は、2つの文字列を連結する操作とする)。
Add関数の例
Add
関数の定義は以下のようになります。
func Add[T int | float64 | string](a, b T) T {
return a + b
}
[T int | float64 | string]
の部分が関数が受け入れる型の指定です。関数宣言のこの部分は型パラメータの定義で、この関数がint
, float64
, string
の3つを受け付けることを示しています。
T
を定義してたので、関数の引数や戻り値、内部ではT
が入力された型として利用できます。
この関数は、T
型の変数a, b
を受け取り、a+b
した結果を返す関数となります。
Print関数の例
別の例として、引数としてどのような型で受け付けるPrint
関数を定義してみます。この関数は、内部でfmt.Println
を呼び出しています(fmt.Println
自体が様々な型を入力として受け付けるので、この関数は動作としては意味ないですがサンプルとして理解ください)。
any
を用いることで様々な型を受け取ることが可能になります(内部的にはinterface{}
と同じ)。
func Print[T any](n T) {
fmt.Println(n)
}
定義したAdd, Print関数の呼び出し方
この2つの関数を利用する場合は以下のようになります(コメント部は実行結果)
型推論が可能な場合は、引数として渡された変数の型を推論し、適切な処理が行われます。
なお、Print[int]
のように、[]
で直接型を指定することも可能です。
func main() {
Print(Add(1, 2))
// 3
Print(Add(1.2, 2))
// 3.2
Print(Add("a", "b"))
// ab
Print[int](10)
// 10
}
使い方(2)
typeを使って型に名前をつける例
type
を使って型に名前をつけておくことも可能です。
下記の例では、num
という型をint
またはfloat64
として定義し、Sub[T num]
と言う形で定義した型を利用して関数を宣言しています。
type num interface {
int | float64
}
func Sub[T num](a, b T) T {
return a - b
}
このように、type
を使うことで、コードをシンプルにすることが可能です。
キュー(Queue)を作ってみる
ジェネリクスを使って、いろいろな型を格納可能なキューを実装してみます。
Queueのコード
Vector
という型を[]T
として定義しています。それぞれの関数は以下の処理を行います。
Queue
関数:キューを初期化Push
関数:キューに値を入力Pop
関数:キューから値を取り出し
各関数の記述は、ジェネリクスを使っていることを除けば、Go言語でよく見かける記述なので説明は省略します。
type Vector[T any] []T
func Queue[T any]() *Vector[T] {
ret := make(Vector[T], 0)
return &ret
}
func (v *Vector[T]) Push(x T) {
*v = append(*v, x)
}
func (v *Vector[T]) Pop() T {
ret := (*v)[0]
*v = (*v)[1:]
return ret
}
Queueを呼び出す例(main関数)
これを使った例が以下になります。int
、float64
はもとより、定義した構造体Pair
もキューで取り扱うことができます。
type Pair struct {
a, b int
}
func main() {
// int
{
q := Queue[int]()
for i := 0; i < 10; i++ {
q.Push(i)
}
fmt.Println(q)
for len(*q) != 0 {
v := q.Pop()
fmt.Println(v, q)
}
}
// float64
{
q := Queue[float64]()
for i := 0; i < 10; i++ {
q.Push(float64(i) / 10)
}
fmt.Println(q)
for len(*q) != 0 {
v := q.Pop()
fmt.Println(v, q)
}
}
// Pair
{
q := Queue[Pair]()
for i := 0; i < 10; i++ {
q.Push(Pair{i, i * 10})
}
fmt.Println(q)
for len(*q) != 0 {
v := q.Pop()
fmt.Println(v, q)
}
}
}
Setを実装してみる
Set
をジェネリクスを使って実装してみます。
Setの実装
ここでは、any
ではなく、comparable
を使いました。comparable
を指定すると、型として指定できるのは、==
による値の比較ができる型限定になります。
それぞれの関数は以下の処理を行います。
Set
関数:Setを新規に作成するAdd
関数:Setに値を追加するIncludes
関数:値がSetに含まれているかどうかを返すRemove
関数:値をSetから削除する
内部では、map[T]bool
と、マップ形式で管理しています。
type _Set[T comparable] map[T]bool
func Set[T comparable](xs ...T) _Set[T] {
s := make(_Set[T])
for _, xs := range xs {
s.Add(xs)
}
return s
}
func (s _Set[T]) Add(x T) {
s[x] = true
}
func (s _Set[T]) Includes(x T) bool {
return s[x]
}
func (s _Set[T]) Remove(x T) {
delete(s, x)
}
Setを呼び出す例(main関数)
以下が利用例です。
func main() {
s := Set(1, 2, 3, 3, 4)
fmt.Println(s)
s.Add(5)
fmt.Println(s.Includes(3))
s.Remove(3)
fmt.Println(s.Includes(3))
}
上記の例では、Setを整数(int
)で利用してますが、ジェネリクスを使っているのでfloat64
やstring
型でも同じように利用することが可能です。
このように、1つの関数を様々な型で使えるとことがジェネリクスの利点です
comparable
制約を満たす型であれば、型が構造体であっても問題なく動作します。ここが便利なところです。
まとめ
ジェネリクスの使い方について解説しました。個人的にはC++でもテンプレートをあまり使わない派なので、ジェネリクスを利用することは少ないと思います。
ただ、ライブラリがジェネリクス対応に切り替わって行けば、かなり便利になるのではないかと感じています。