Go 1.18で実装されたジェネリクスを使ってみる

JavaScriptを有効にしてください

概要

Go言語はバージョン1.18で、長らくコミュニティから要望されていたジェネリクスを導入しました。
ジェネリクスを使用すると、より柔軟にコードを書くことができ、複数の型にわたって再利用可能な関数やデータ構造を定義できるようになります。
これにより、コードの重複が減り、より清潔で管理しやすいコードベースを実現できます。

この記事では、ジェネリクスがGo言語にもたらす利点と、その使い方について詳しく掘り下げていきます。
基本的なジェネリクス関数の定義から始め、型制約のカスタマイズ、~トークンの使用方法、さらにはmapを用いた高度なジェネリクス関数の例まで、幅広くカバーします。
Go 1.18のこの新機能を最大限に活用するための知識を提供することで、読者がより効果的にGoを書けるようになることを目指しています。

ジェネリクスとは

ジェネリクスとは一つの関数で複数の型に対応できるようにGo 1.18で追加された新しい言語仕様です。

Go 1.17までは一つの関数に付き一つの型にしか対応できませんでしたが、

Go-1.17
1
2
3
4
5
6
7
8
//int64型しか対応できない…
func SumInt(a, b int64) int64 {
	return a + b
}
//float64型しか対応できない…
func SumFloat(a, b float64) float64 {
	return a + b
}

Go 1.18で追加されたジェネリクスを使用すると、
下記のようにint64型またはfloat64型という風に複数の型を指定できるようになります。

Go-1.18
1
2
3
4
//int64型またはfloat64型が指定できる!
func SumIntOrFloat[T int64 | float64](a, b T) T {
	return a + b
}

型がTという定義に置き換えられて、引数と戻り値の型がTになっていますね。

このように1.17までは複数の型に対応するため型毎に関数を書く必要がありましたが、1.18からジェネリクスを使用することによって、一つの関数で複数の型に対応可能となります。

では早速使い方をみていきましょう。

ジェネリクスの使い方

まずは非ジェネリクス関数を定義して、それをジェネリクス関数に置き換えることで違いをみていきましょう。

非ジェネリクス関数を定義

int64型とfloat64型を変数として渡すとそれぞれ加算した結果を返す簡単な関数を定義します。

go
1
2
3
4
5
6
7
8
//int64型の足し算
func SumInt(a, b int64) int64 {
	return a + b
}
//float64型の足し算
func SumFloat(a, b float64) float64 {
	return a + b
}

こちらは見慣れた非ジェネリクスの関数です。
呼び出して使ってみましょう。

go
1
2
3
4
5
6
var i1, i2 int64 = 3, 8
var f1, f2 float64 = 3.5, 8.8

//非ジェネリクス関数
fmt.Println(SumInt(i1, i2))
fmt.Println(SumFloat(f1, f2))
実行結果
1
2
11
12.3

それぞれのデータ型に対して、対応した関数を呼び出して処理しています。

では次はジェネリクス関数を定義して置き換えてみましょう。

ジェネリクス関数を定義

下記のように関数名の後にブラケットを記述しその中にデータ型を定義するとジェネリクス関数になります。

go
1
2
3
func SumIntOrFloat[T int64 | float64](a, b T) T {
	return a + b
}

このブラケット内に記述したデータ型のことを型引数と言います。

最初に型引数の名称を定義して(今回はT)関数内で使用するデータ型を「|」で繋いで記述していきます。
|」はOR演算で使用していると思うので理解しやすいと思います。

では、ジェネリクス関数を呼び出して使ってみましょう。

go
1
2
3
//ジェネリクス関数
fmt.Println(SumIntOrFloat[int64](i1, i2))
fmt.Println(SumIntOrFloat[float64](f1, f2))
実行結果
1
2
11
12.3

呼び出すときはブラケット内に使用するデータ型を記述します。

ブラケット内にデータ型を記述して呼び出していますが、今回の場合コンパイラが引数から型を推測することができるので、下記のように型引数を省略することができます。

go
1
2
3
//型を省略
fmt.Println(SumIntOrFloat(i1, i2))
fmt.Println(SumIntOrFloat(f1, f2))

VSCodeなどのエディタを使用していると下記画像のように「これは省略して書けるよ!」といった補足が出るので省略できるケースに慣れていきましょう。
unnecessary type argumentsinfertypeargs(不要な型引数)
不要な型引数(unnecessary type arguments infertypeargs)と言われていますね。

型制約を定義する

ところでジェネリクス関数で型引数を記述しましたが、データ型が多くなってくると毎回書くのはめんどくさいですよね。
そこでtype 〇〇 interfaceを使用して下記のように型制約を定義することができます。

go
1
2
3
type Number interface {
    int64 | float64
}

このNumberint64float64だけでしか使用できない型制約です。

この型制約を使用して先ほどのジェネリクス関数を書き換えると下記のようになります。

go
1
2
3
func SumNumber[T Number](a, b T) T {
	return a + b
}

int64float64Numberという名称に置き換わってとてもスッキリしました。

このように型制約を自分で定義してもいいですが、constraintsという制約パッケージ が用意されていますので、こちらで使えるものがないか確認しておきましょう。

以上がジェネリクス関数の基本的な使い方になります。

次は型制約につける~トークンについてみていきましょう。

~トークンの役割

自分で定義した型を関数に渡そうとすると、予期しないエラーに遭遇することがあります。
たとえば、MyInt64MyFloat64を使って数値の合計を計算する関数に渡すと、Goはこれらが期待するNumber型ではないと判断してしまうのです。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

// 数値型の制約を定義
type Number interface {
	int | float64
}

// 数値の合計を計算する関数
func Sum[T Number](a, b T) T {
	return a + b
}

type MyFloat64 float64

func main() {
	var a, b MyFloat64 = 1.1, 2.2
	// エラー: MyFloat64 does not satisfy Number (possibly missing ~ for float64 in Number)
	// MyFloat64 は Number に適合しない
	fmt.Println(Sum(a, b))
}

この問題を解決するためには、~トークンを型制約に使うことができます。
~トークンを使うと、基本型に基づいて定義された型も受け入れるようになります。
つまり、int64float64を基にした型が関数に受け入れられるようになるのです。

以下のように型制約を定義することで、MyInt64MyFloat64も含めた数値型を関数の引数として受け入れることができるようになります。

go
1
2
3
type TildeTokenNumber interface {
    ~int64 | ~float64
}

そして、この型制約を使った関数であれば、自分で定義した型の変数を引数として使うことができます。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

// 数値型の制約を定義(~ を使う)
type TildeTokenNumber interface {
	~int | ~float64
}

// 数値の合計を計算する関数
func Sum[T TildeTokenNumber](a, b T) T {
	return a + b
}

type MyFloat64 float64

func main() {
	var a, b MyFloat64 = 1.1, 2.2
	// エラーなし
	fmt.Println(Sum(a, b))
}

Go言語で型制約を使うとき、~トークンを利用することで、基本型をベースにしたカスタム型も許可できるようになります。
これにより、より柔軟に関数を設計することが可能になります。

次は少し複雑な例として引数にmapを使用したジェネリクス関数を見ていきましょう。

mapとジェネリクス関数

mapはキーと値のペアを格納するデータ構造で、キーは一意でなければなりません。ジェネリクス関数を使って、mapの値の合計を計算する関数を作ることができます。

関数の例

ここでの関数は、int64型またはfloat64型の値を持つmapを引数に取り、その値の合計を計算して返します。

go
1
2
3
4
5
6
7
8
// int64またはfloat64の合計を計算して返却
func SumIntsOrFloats[K comparable, V Number](m map[K]V) V {
    var sum V
    for _, value := range m {
        sum += value
    }
    return sum
}
  • Kmapのキーの型で、comparable(比較可能な)条件を満たす必要があります。これは==!=で比較できる型を意味します。
  • VNumberという型制約を使っています。これはint64float64のような数値型に限定されます。

comparable について

comparableはGo 1.18で導入された新しい概念で、==!=で比較可能な型を指します。mapのキーには、このcomparableな型が必要です。

使用例

以下の例では、string型のキーとint64型またはfloat64型の値を持つmapを関数に渡しています。関数はこれらの値の合計を計算し、結果を返します。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import "fmt"

type Number interface {
	int64 | float64
}

// int64またはfloat64の合計を計算して返却
func SumIntsOrFloats[K comparable, V Number](m map[K]V) V {
	var sum V
	for _, value := range m {
		sum += value
	}
	return sum
}

func main() {
	ints := map[string]int64{"first": 3, "second": 8}
	floats := map[string]float64{"first": 3.5, "second": 8.8}

	// ジェネリクス関数の明示的な型指定を使って呼び出し
	fmt.Println(SumIntsOrFloats[string, int64](ints))     // 出力: 11
	fmt.Println(SumIntsOrFloats[string, float64](floats)) // 出力: 12.3

	// 型引数の推測を利用した呼び出し(省略形)
	fmt.Println(SumIntsOrFloats(ints))   // 出力: 11
	fmt.Println(SumIntsOrFloats(floats)) // 出力: 12.3
}

この関数は、キーの型がcomparable(比較可能)であり、値が数値型(Number型制約によって定義)である任意のmapに対して動作します。
ジェネリクスを使うことで、様々な型のmapに対応する汎用的な関数を簡単に作成することができます。

まとめ

Go 1.18の導入により、ジェネリクスがGoの言語機能として追加されたことは、開発者にとって大きな進歩です。
ジェネリクスを利用することで、一つの関数やデータ構造が複数の型に対応できるようになり、以前に比べてコードの重複を大幅に減らし、保守性と再利用性を高めることが可能になりました。

この記事では、ジェネリクスの基本的な使い方から始め、型制約の定義方法、~トークンの役割、さらにはmapを使用した複雑なジェネリクス関数の例まで、幅広いトピックをカバーしました。
これらの例を通じて、ジェネリクスがGo言語でどのように機能し、どのように利用されるべきかについての理解を深めることができました。

ジェネリクスは、単に型の柔軟性を高めるだけでなく、型安全性を保ちながらも、より表現力豊かで読みやすいコードを書くことを可能にします。
特に、型引数から型を推測する機能や、constraintsパッケージによる型制約の提供は、ジェネリクスをより手軽に使えるようにする重要な要素です。

今回紹介した内容は、ジェネリクスを使い始めるための基礎であり、Goでのジェネリクスの利用可能性はこれにとどまりません。
Go 1.18以降でジェネリクスを活用することで、より効率的で柔軟なプログラムの開発が期待されます。
開発者は、ジェネリクスを使うことで、より簡潔で再利用可能なコードを書くことができ、Go言語の強力な型システムのメリットを最大限に活用することができるようになるでしょう。

実行環境

bash
1
2
$ go version
go version go1.18 linux/amd64

スポンサーリンク

共有

もふもふ
著者
もふもふ
プログラマ。汎用系→ゲームエンジニア→Webエンジニア→QAエンジニア