Go ジェネリクスの基礎を学ぶ

Go 1.18 でジェネリクスが導入されてからこれまで雰囲気でジェネリクスを使用してきました。

ここらでちゃんと仕様を体に覚え込ませたいと思い立ち、公式の文書を読みつつ手を動かした記録を記事として残すことにしました。

少しずつ理解を深めていく経緯を記録していく記事となるため、誤った情報を発信してしまう形になることもあるかもしれません。もしも内容に間違いがあれば、記事のコメントXのTweetなどで教えていただけると幸いです。

本記事では下記ページをベースに学んだことを記していきます。

Tutorial: Getting started with generics - The Go Programming Language

ジェネリクスの何が嬉しいか

ジェネリクスを使用することで、呼び出し元から提供される任意の型で動作するような関数や型を定義することができます。

ジェネリクスにより汎用的な関数、型を定義することで似たような処理の重複を排除された保守性の高いコードに繋がりそうです。

ジェネリクスを使わない実装

まずはジェネリクスを使わない実装を行います。

後に同じ結果を得られる処理をジェネリクスを用いて実装し両者を比較することで、ジェネリクスの利点を感じることができますね。

package main

import "fmt"

func avgInts(x []int64) int64 {
    var res int64
    for _, v := range x {
        res += v
    }
    return res / int64(len(x))
}

func avgFloats(x []float64) float64 {
    var res float64
    for _, v := range x {
        res += v
    }
    return res / float64(len(x))
}

func main() {
    fmt.Println(avgInts([]int64{1, 2, 3}))
    fmt.Println(avgFloats([]float64{1.2, 2.2, 3.2}))
}
2
2.2

avgIntsとavgFloatsは引数、戻り値の型が違うだけで計算ロジックは同じです。

ジェネリクスを使った実装

ジェネリクスを用いることで、前項で定義した二つの関数を一つにまとめることが可能です。

ここでは int64 または float64 を受け取られる関数を定義します。

複数の型を受け取られるように、型パラメータを宣言した関数を定義します。

型パラメータには型制約があり、型制約により呼び出し元が型引数として渡せる値の型は制限されます。

型引数の型が制約を違反している場合、コンパイルは失敗します。

型パラメーターは、関数が型パラメータに対して行う全ての操作をサポートする必要があり、制約に数値型が含まれている型パラメーターに対して文字列操作を行なった場合、コンパイルは失敗します。

具体的な例を挙げてみます。

下記のコードは一つの型パラメーターT、型パラメーターをT使用するT型の引数vを宣言しています。戻り値の型はT型となっています。

型パラメーターTは、制約として int64 と float64 のいずれかの型であることを指定します。どちらの型も型引数として許可されます。

func avg[T int64 | float64](x []T) T {
    var res T
    for _, v := range x {
        res += v
    }
    return res / T(len(x))
}

呼び出し元では関数引数に加えて型引数を渡して関数を呼び出します。

型引数として呼び出す関数の型パラメーターを置き換える型を明示的に指定し、コンパイラーにどの型を使用するかを伝えます。

コードを実行するために、コンパイラは型パラメーターを呼び出し先に指定された具象型に置き換えます。

fmt.Println(avg[int64]([]int64{1, 2, 3}))
fmt.Println(avg[float64]([]float64{1.2, 2.2, 3.2}))

使用する型をコンパイラが推測できる場合、型引数は省略可能です。

常に可能なわけではなく、例えば型パラメーターが宣言されているが関数引数のない関数を呼び出す場合には、型引数の指定は必須となります。

fmt.Println(avg([]int64{1, 2, 3}))
fmt.Println(avg([]float64{1.2, 2.2, 3.2}))

型制約はインターフェースとして宣言することが可能です。これにより型制約の再利用性が高まります。

インターフェースとして宣言すると、型制約はインタフェースを実装している任意の型を許可します。例えば A() というメソッドを持つ型制約インターフェースを宣言し型パラメーターとして使用した場合、型引数は A() というメソッドを持つ型である必要があります。

型制約インターフェースは特定の型を参照することもできます。

下記のような型制約インタフェースを宣言することで、型パラメーターの制約を int64 | float64 と記述する代わりに num を使用できます。

type num interface {
    int64 | float64
}

func avg[T num](x []T) T {
    var res T
    for _, v := range x {
        res += v
    }
    return res / T(len(x))
}

ここまでの学んだことを踏まえて実装した完成系は下記です。

package main

import "fmt"

type num interface {
    int64 | float64
}

func avg[T num](x []T) T {
    var res T
    for _, v := range x {
        res += v
    }
    return res / T(len(x))
}

func main() {
    fmt.Println(avg([]int64{1, 2, 3}))
    fmt.Println(avg([]float64{1.2, 2.2, 3.2}))
}

go.dev

最後に

Go のジェネリクスの基礎の基礎を改めて学びました。

正直今回の内容は理解して使っていたつもりですが、人にわかりやすく説明できるかと言われると自信を持ってYESとは言えないくらいの理解度だったので、記事にすることで理解を言語化できて良かったです。