Type parameters in Go

December 25, 2020

この記事は、Go Advent Calendar 2020の25日目の記事です。

なぜジェネリクスが必要なのか

Goでは長年ジェネリクスを加えてほしいという要望が多く寄せられており、さまざまなデザインが検討されてきました。ジェネリクスの必要性は以下のようなコードを型に依存せずに記述することができます。

func PrintInts(s []int) {
    for _, v := range s {
        fmt.Print(v)
    }
}

func PrintStrings(s []string) {
    for _, v := range s {
        fmt.Print(v)
    }
}

Type Parameters - Draft Designで提案されているデザインでは、以下のように記述できるようになります。

func Print[T any](s []T) {
    for _, v := range s {
        fmt.Print(v)
    }
}

Tは型パラメタと呼ばれるもので、関数を使用する際に具体的な型が引数として渡されます。そのため、Print関数は任意の型のスライスを引数として受け取ることが可能です。

本稿では、Type Parameters - Draft Designで提案されている型パラメタについて簡単に解説します。このドラフトデザインには、大まかに挙げると以下のようなことが記載されています。

  • 型パラメタの導入
  • 型引数とインスタンス化
  • インタフェースによる型パラメタへの制約
  • インタフェースにおける型リスト

go2goで型パラメタを試す

ドラフトデザインを読んだだけでは、いまいちピンとこないかもしれません。そこで、Goチームはコミュニティからフィードバックを得やすくするために、go2goというコマンドラインツールを提供しています。

go2goは型パラメタを含むコードをリリースされているGoのコンパイラでビルドできる形に変換します。go2goは、goパッケージを型パラメタに対応させることにより、静的解析のしくみを使ってコードの変換を行っています。

go2goは、Goのツールチェインをdev.go2goブランチに切り替えてGoのコンパイラをビルドし、そのコンパイラを使ってソースコードをビルドすることで使用できるようになります。

自分でビルドするのは手間なので、The go2go Playgroundを用いると簡単です。The Go Playgroundのように、Web上で型パラメタを試すことができます。

コマンドラインで試したい場合は、以下のように筆者の開発しているgpコマンドの-go2オプションを用いると便利です。

# 実行
$ gp run -go2 main.go
# フォーマット
$ gp format -go2 main.go
# 共有URLの生成
$ gp share -go2 main.go
# ソースコードのダウンロード
$ gp dl -go2 https://go2goplay.golang.org/p/b220S_n3wut

ジェネリックな関数の定義

パッケージスコープで定義される関数には、型パラメタを設けることができます。ここではこのような関数をここではジェネリックな関数と呼びます。以下のように、関数名の後ろの[]の間に型パラメタのリストを記述します。T anyは型パラメタTが任意の型を受け付けることを意味します。

型パラメタは、使用する際に型引数を指定することによりインスタンス化します。例えば、以下のコードではPrint[string](...)のように型引数にstringを指定することにより、型パラメタTstring型でインスタンス化しています。

func Print[T any](s []T) {
    for _, v := range s {
        fmt.Print(v)
    }
}

func main() {
    Print[string]([]string{"Hello, ", "playground\n"})
}

型引数stringでインスタンス化されたPrint関数では、型パラメタTはすべてstring型として扱われます。そのためPrint関数の引数s[]string型、変数vstring型になります。

型推論

実引数の型によって型引数を推論できる場合があります。そのような場合は以下のように型引数を省略することが可能です。

func main() {
    Print([]string{"Hello, ", "playground\n"})
    Print([]int{1, 2, 3})
}

1つめのPrint関数の呼び出しは、型引数をstring型として推論され、2つめではint型として推論されます。型推論は2パスのアルゴリズムによって実現され、1つめのパスでは型なしの定数を無視して推論が行われます。その後、型が決定していない型パラメタがあれば、2つめのパスとして型なしの定数をデフォルトの型として扱い推論を行います。

ジェネリックな型

型定義においても型パラメタを用いれます。例えば、以下のように任意の型のスライスを規定型とした型Listを定義できます。

// 型Tのリスト
type List[T any] []T
// int型でインスタンス化する
var ns List[int] = List[int]{10, 20, 30}

// 型エイリアスを使ってインスタンス化する
type IntList = List[int]
ms := IntList{100, 200, 300}

型パラメタを用いたジェネリックな型は型引数によってインスタンス化することによって使用できます。なお、型エイリアスを用いてインスタンス化することもできます。また、ジェネリックな型のインスタンス化では、型推論を行うことは執筆時のドラフトデザインではできないようでした。

インタフェースによる制約

ジェネリックな関数や型において、型引数としてどんな型でも指定できると不便です。型パラメタに制約を加えられる方が利便性が高くなるでしょう。型パラメタの制約は、以下のようにインタフェースによって行えます。

type Hex int
func (h Hex) String() string {
    return fmt.Sprintf("%x", int(h))
}

func PrintStringers[T fmt.Stringer](s []T) {
    for _, v := range s {
        fmt.Printf("0x%s\n", v.String())
    }
}

func main() {
    PrintStringers([]Hex{100, 200})
}

PrintStringers関数は、型引数としてfmt.Stringerインタフェースを実装した型しか指定できません。型パラメタへの制約は、T fmt.Stringerのように型パラメタの右隣にインタフェースを記述して設けます。制約を課さない場合は、新しい組み込みのanyインタフェースを指定すると良いでしょう。

以前のドラフトデザインでは、constraintsというキーワードを用いて制約を定義していました。しかし、新しいドラフトデザインでは、インタフェースを制約として用いることができるようになりました。

なお、Hex型をPrintStringers関数の型引数として指定した場合、引数は[]fmt.Stringer型ではなく、[]Hex型であることに注意してください。この点が、引数にインタフェースを用いる場合と大きく異なります。

インタフェースにおける型リスト

従来のインタフェースを制約として用いると、メソッドによる制約しか課すことができません。しかし、演算子を使ってジェネリックな処理を記述したい場合に困ります。そこでドラフトデザインでは、新しくインタフェースに型リストを記述できるように提案されています。Goには演算子をオーバーロードする機能がないため、シンプルかつ最低限の要件を満たす方法でしょう。

型リストは、以下のようにインタフェースの定義において、typeキーワードを用いてカンマ区切りで型を記述します。

type addable interface {
    type int, int8, int16, int32, int64, uint, uint8,
    uint16, uint32, uint64, uintptr,
    float32, float64, complex64, complex128, string
}

func sum[T addable](x []T) T {
    var total T
    for _, v := range x {
        total += v
    }
    return total
}

func main() {
    fmt.Println(sum([]int{1, 2, 3}))
    fmt.Println(sum([]string{"Hello, ", "World"}))
}

addableインタフェースは、int型、int8型など羅列された型のいずれか、またはその型を規定型に持つ型のみを型引数として指定できる制約として働きます。つまり、加算演算が可能な型のみに絞っているため、関数sumにおいて型パラメタTの変数totalと変数x+による演算ができます。

型リストは、メソッドリストと併記できます。併記した場合は、どちらの制約も満たす場合のみ型引数として指定できます。現在のドラフトデザインでは、制約として使用する場合のみ型リストを記述を許可していますが、将来的には通常のインタフェースとしても使用できるようになるかもしれません。

これからどうなっていくのか

ドラフトデザインで提案されている型パラメタが採用された場合、比較可能なことを表すcomparableインタフェースのような組み込みインタフェースが追加されたり、標準パッケージへ変更が加わることでしょう。例えば、bytesパッケージとstringsパッケージをジェネリックにしたslicesパッケージや、便利な制約が定義されたconstraintsパッケージ、containerパッケージやsync.Map型、sync/atomic.Value型のジェネリック化、mathパッケージのMin関数やMax関数のジェネリック化、sortパッケージのジェネリック化などが考えられます。

例えば、comparableインタフェースが加わると、以下のように任意のマップのキーを取得するような関数が簡単に定義できます。待望の機能です。

func Keys[K comparable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

func main() {
    keys := Keys[int, string](map[int]string{1: "one", 2: "two"})
    fmt.Println(keys)
}

型パラメタは、まだドラフトデザインによる提案です。コミュニティから十分なフィードバックが得られれば、正式なプロポーザルとして提案され、承認されるといよいよ実装されます。Goの公式ブログによると、最速でGo 1.18で導入されるかもしれません。2022年の2月には全世界のGopherが型パラメタを用いてジェネリックなコードを書いてると思うと楽しみです。

お知らせ

本稿では、Goに入るかもしれない型パラメタについて解説しました。このようなワクワクするようなGoの話をもっとできる場があると嬉しいですよね。

2020年は、新型コロナウィルスにより大きなカンファレンスがいくつも中止になりました。Goコミュニティでは、Go Conference in 仙台がオンラインとオフラインのハイブリッド版で開催し、成功しました。この成功を受けて、Go Conference運営チームでは、来年、2021年4月24日にGo Conference 2021 Springを完全オンラインで開催することをキメました。

去年の有料化に引き続き、初めての試みなので運営メンバーも完全に手探りです。コミュニティのみなさまの力添えをいただくことも増えるのではないかと思います。日程も場合によっては変更あったり、トラック数やセッションのやり方も大きく変わるかもしれません。ボランティアスタッフ、登壇者、スポンサーなどさまざまな形でご協力いただければ嬉しいです。

カンファレンスは素晴らしいセッションあってこそですので、まずはCfPをオープンします。ひとまず、2021年2月28日までオープンする予定なので、年末年始の空いてる時間にぜひネタを考えていただければと思います!運営チームもオンラインカンファレンスならではの工夫を色々考えていきます!それでは良いお年を!!