🐙

Goのジェネリクスについて

2024/03/31に公開

今回はGoのジェネリクスについて書いていきます。

ジェネリクスとは

ジェネリクスとは、プログラミングにおける型を変数のように扱い、一つの関数で複数の型を処理できるようにする仕組みのことです。ジェネリクスを使用することで、型安全性を保ちつつ、コードの再利用性を高めることができます。Goにはバージョン1.18から導入されました。

使用例

以下に簡単なジェネリクスの使用例を書いてみます。スライスを受け取って、そのスライスを出力するコードです。ジェネリクスを使わない場合だと、それぞれの型に合わせて関数を定義する必要があります。

package main

import "fmt"

func getArrayInt(items []int) {
	fmt.Println(items)
}

func getArrayString(items []string) {
	fmt.Println(items)
}

func main() {
	getArrayInt([]int{1, 2, 3}) // [1, 2, 3]
	getArrayString([]string{"apple", "banana"}) // ["apple", "banana"]
}

これをジェネリクスを使うことで一つの関数にまとめることができます。

package main

import "fmt"
// ジェネリクスを使用して関数を一つにまとめる
func getArray[T any](items []T) {
	fmt.Println(items)
}

func main() {
	getArray([]string{"apple", "banana"}) // ["apple", "banana"]
	getArray([]int{1, 2, 3}) // [1, 2, 3]
}

このように、ジェネリクスを使うと型ごとに同じようなコードを複数書く必要がなくなり、コードの量を減らして保守性を高めることができます

ここからはジェネリクスの中身について詳しく見ていこうと思います。

型パラメータ

型パラメータはジェネリクスを使用する際に、具体的な型の代わりに指定されるパラメータのことです。使用例のコード例で言うと[T any]の部分のTのことで、例の場合では型パラメータTはany型(全ての型)を満たす型であるという型制約を定義しています(型制約については後述します)。型パラメータに実際に渡される具体的な型を型引数と呼びます。

型引数

型パラメータに実際に渡される具体的な型を型引数と呼びます。この型引数ですが、基本的には省略することが可能です。上の例で確認してみましょう。

func main() {
	getArray([]string{"apple", "banana"})
	getArray([]int{1, 2, 3})

	// 型引数が省略されていないコード
	// getArray[string]([]string{"apple", "banana"})
	// getArray[int]([]int{1, 2, 3})
}

このgetArray関数を使用する箇所でそれぞれ[string][int]の型引数が省略されています。これはGoの型推論の機能によるもので、基本的には型を明示しなくてもコンテキストから自動で適切な型を判断してくれます。今回のコードはgetArray関数に[]string{"apple", "banana"}[]int{1, 2, 3}という引数を渡しているため型推論が可能となり、型引数は省略されています。ケースとしては少ないと思いますが、引数がなく型推論を行えない場合は型引数を指定する必要があります

型制約

型制約は型パラメータに対して許可される操作やメソッドを制限するために使用されるインターフェースです。[T any]anyが型制約にあたります。ちなみにここで使用しているanyはGo 1.18から導入されたinterface{}のエイリアスで、型パラメータが何の型でも良い場合に使用されます。任意のインターフェースを定義した上で型制約として使用する場合、以下のような形でコードを書くことができます。

package main

import "fmt"
// 型制約として使用するインターフェースを定義
type Adder[T any] interface {
	Add(a T) T
}
// int型のラッパーであるInt型を定義
type Int int
// Int型にAddメソッドを実装することでAdderインターフェースの要件を満たす
func (i Int) Add(a Int) Int {
	return i + a
}
// ジェネリクスを使用した関数の実装。[T Adder[T]]の部分で型パラメータTを宣言。
// TはAdderインターフェースを満たす必要があるという型制約を付与する。
func add[T Adder[T]](a, b T) T {
	return a.Add(b)
}
// Int型はAdderインターフェースを満たしているため、add関数内で使用できる。
func main() {
	fmt.Println(add(Int(1), Int(2))) // 3が出力される。
}

型制約としてanyが使用できないパターン

先ほど型制約にanyを使用していましたが、anyを型制約として使用できないケースもあります。以下のコードはマップのキーがユニークでなければならない性質を利用して、与えられたスライスから重複した要素を削除していますが、ここのUnique関数の型パラメータにanyを型制約として付与することはできません。これはコード内でマップのキーをユニークに保つために、型パラメータの要素を比較する必要があるためです。

package main

import (
	"fmt"
)

// Uniqueは、与えられたスライスから重複する要素を削除する。
// この関数は、型Tが比較可能(comparable)であることを要求するためanyは使用できない。
func Unique[T comparable](slice []T) []T {
	uniqueElements := make([]T, 0)
	seen := make(map[T]bool)
	for _, element := range slice {
		if _, ok := seen[element]; !ok {
			seen[element] = true
			uniqueElements = append(uniqueElements, element)
		}
	}
	return uniqueElements
}

func main() {
	numbers := []int{1, 2, 3, 2, 1}
	uniqueNumbers := Unique(numbers)
	fmt.Println(uniqueNumbers) // 出力: [1 2 3]

	strings := []string{"apple", "banana", "apple", "cherry"}
	uniqueStrings := Unique(strings)
	fmt.Println(uniqueStrings) // 出力: ["apple" "banana" "cherry"]
}

なぜこのようなことが発生するのかというと、Goの型には比較可能な型と不可能な型が存在するからです。そのため比較が必要な関数に全ての型を許容するanyの型制約をつけると不整合が生じてしまうのです。このようなケースではcomparableを使用することができます。comparableはGo1.18でジェネリクスと一緒に導入された比較可能な型が宣言された型制約です。comparableを利用することで簡単に比較可能な型だけを型制約に含めることができます。

独自に定義した型を型制約に含める

いくつかの型を型制約に含めたい場合、以下のようにインターフェースで指定したい型のリストを作成することで実現可能です。

package main

import "fmt"
// ここで型制約
type Number interface {
	int | float64
}

func Add[T Number](x, y T) T {
	return x + y
}

func main() {
	fmt.Println(Add(1, 2))
	fmt.Println(Add(1.2, 3.4))
}

しかしこのままだと、型制約に含まれる型を基に新たな型を作成した場合、新しい型は型制約に含まれなくなってしまいます。

func Add[T Number](x, y T) T {
	return x + y
}

type MyInt int

func main() {
	fmt.Println(Add(1, 2))
	fmt.Println(Add(1.2, 3.4))
	var a, b MyInt = 1, 2
    // MyIntが型制約に含まれないためエラーになる
	fmt.Println(Add[MyInt](a, b))
}

こんな時、もちろん型制約に直接MyInt型を追加してもよいのですが、Goには型制約に含まれる型を基に作成された型を型制約に含めることができる機能が備わっています。この機能はインターフェースを定義する際に、型名の前に~をつけることで使用することができます。

type Number interface {
	~int | ~float64
}

こうすることで型制約に含まれる型を基に作成された型を型制約に含めることが可能になります。

型パラメータを宣言できないケース

Goにおけるメソッドは関数の一種ですが、メソッド宣言時に新たな型パラメータを宣言することはできません。以下のようなスライスに渡された要素の数を構造体に格納するコードを書いたとします。

package main

import (
	"fmt"
)

type Count struct {
	count []int
}

func (c *Count) WriteInt(i []int) (int, error) {
	c.count = append(c.count, len(i))
	return len(i), nil
}

func (c *Count) WriteString(s []string) (int, error) {
	c.count = append(c.count, len(s))
	return len(s), nil
}

func main() {
	myCount := Count{}
	myCount.WriteInt([]int{1, 2, 3})
	myCount.WriteString([]string{"hoge", "huga"})
	fmt.Println(myCount.count)
}

ここで[]intに対するメソッドと[]stringに対するメソッドを共通化するために以下のようなコードを書いても、メソッド宣言時に新たな型パラメータを宣言することができず、コンパイルエラーになってしまいます。

// コンパイルエラーになる
func (c *Count) Write[T []int | []string](t T) (int, error) {
	c.count = append(c.count, len(t))
	return len(t), nil
}

そのため、このメソッドの処理を共通化したい場合は、以下のように関数として切り出してジェネリクスを利用する必要があります。

func write[T []int | []string](c *Count, t T) (int, error) {
	c.count = append(c.count, len(t))
	return len(t), nil
}

func (c *Count) WriteInt(i []int) (int, error) {
	return write(c, i)
}

func (c *Count) WriteString(s []string) (int, error) {
	return write(c, s)
}

最後に

今回はGoのジェネリクスの基本とその周辺知識について簡単にまとめてみました。正直まだあまり使いこなせていないのですが、使えそうなケースがあれば積極的に導入していきたいです。最後までご覧いただき、ありがとうございました。

参考

https://zenn.dev/nobishii/articles/type_param_intro

Discussion