つきなみGo

Goの標準ライブラリに学ぶジェネリクス

はじめに

2022年3月にリリースされたGo1.18ではジェネリクス(型パラメータ)が導入されました。長年楽しみされてきた機能で、少しずつGoの標準ライブラリでも使用され始めています。一方でリリース時に少し試してはみたものの、使いどころ所が難しいと思った読者の方も多いのではないでしょうか。この記事ではGoの標準ライブラリにおける利用例を紐解きながらジェネリクスへの理解を深めていきます。

timeパッケージ

日付と時刻の操作を扱うtimeパッケージでは内部的にジェネリクスが利用されています。JSONのシリアライズを行うMarshalJSONへのバリデーションの改善とジェネリクスの導入により、9%以上の高速化が成されました。では実装を見てみましょう。

timeパッケージのformat.goより
func atoi[bytes []byte | string](s bytes) (x int, err error) {
	neg := false
	if len(s) > 0 && (s[0] == '-' || s[0] == '+') {
		neg = s[0] == '-'
		s = s[1:]
	}
	q, rem, err := leadingInt(s)
	x = int(q)
	if err != nil || len(rem) > 0 {
		return 0, atoiError
	}
	if neg {
		x = -x
	}
	return x, nil
}

この例では、ジェネリクスを用いてatoi関数を実装することで、[]byte型とstring型の両方を引数に取る柔軟性をもたせています。これによって、この関数を利用する際に、いくつかのメリットが得られます。

  1. コードの簡潔性と可読性の向上:ジェネリクスを使用することで、[]byte型とstring型用に別々の関数を実装する必要がなくなり、コードの量が減りました。これにより、同じ目的を達成するために書かれた複数のコードが統合されて簡潔になり、メンテナンスも用意になりました。
  2. パフォーマンスの向上[]byte型とstring型を両方サポートするため、入力を変換する必要がなくなりました。これにより、冗長なコピーの必要がなくなりました。

このように、ジェネリクスを利用した柔軟な実装によって恩恵を得られます。特に[]byte型とstring型の両方を引数に取るパターンはGoで文字列を扱う際には頻繁に発生するので参考になりそうです。

メソッドではジェネリクス(型パラメータ)は使えない

しかし、注意点もあります。先ほど学んだパターンを利用して、例えば*strings.Builder型のメソッドを共通化しようとすると、ジェネリクス(型パラメータ)をメソッドに利用できない制約が存在します。これは、Goの現行の仕様によるものです。

stringsパッケージのbuilder.goより
type Builder struct {
	addr *Builder // of receiver, to detect copies by value
	buf  []byte
}

func (b *Builder) Write(p []byte) (int, error) {
	b.copyCheck()
	b.buf = append(b.buf, p...)
	return len(p), nil
}

func (b *Builder) WriteString(s string) (int, error) {
	b.copyCheck()
	b.buf = append(b.buf, s...)
	return len(s), nil
}

ここで*strings.Builder型のWriteメソッドおよびWriteStringメソッドを共通化しようとすると、次のようなコードを想像すると思いますがこれはコンパイルエラーとなります。

func (b *Builder) Write[bytes []byte | string](p bytes) (int, error) {
	b.copyCheck()
	b.buf = append(b.buf, p...)
	return len(p), nil
}

メソッドにおいてジェネリクスが利用できないことはわかりました。代わりに、ジェネリクスを活用して、WriteメソッドとWriteStringメソッドに共通する処理を関数に切り出すことは可能です。たとえば次のようになります。

// writeCommonは、WriteとWriteStringに共通する処理を行うジェネリクス関数
func writeCommon[bytes []byte | string](b *Builder, p bytes) (int, error) {
	b.copyCheck()
	b.buf = append(b.buf, p...)
	return len(p), nil
}

func (b *Builder) Write(p []byte) (int, error) {
	return writeCommon(b, p)
}

func (b *Builder) WriteString(s string) (int, error) {
	return writeCommon(b, s)
}

この実装ではWriteメソッドとWriteStringメソッドがジェネリクス関数writeCommonを呼び出すことで、共通の処理を行います。この方法でコードの重複を減らし、保守性を向上させらることができます。

atomic.Pointer型

Go1.19では、ジェネリクスを利用したatomic.Pointer型がリリースされました。これは、標準ライブラリで公開された型(および関数)における初めてのジェネリクスの利用例です。atomic.Pointer型は、型*Tのポインターに対してアトミックな操作を提供しています。

sync/atomicパッケージのtype.goより
// A Pointer is an atomic pointer of type *T. The zero value is a nil *T.
type Pointer[T any] struct {
	_ [0]*T
	_ noCopy
	v unsafe.Pointer
}

この実装では、_ [0]*Tという要素が構造体に含まれています。この要素はGo1.19の最初のリリースには含まれませんでしたが、Go1.19.4において追加されました。

この要素によって、Pointer[T]型からPointer[U]型への不正な型変換が防がれています。_ [0]*Tは、実際にメモリを消費しないダミーの要素であり、型変換の制約を提供するためだけに存在しています。また、_ [0]Tではなく_ [0]*Tとしているのは、再帰的な定義と判定されてしまうケースを防ぐためですより深く知りたい方はこの議論が参考になります⁠。

slicesパッケージとmapsパッケージ

執筆時の最新リリースであるGo1.20には含まれていませんが、slicesパッケージおよびmapsパッケージはGo1.21で標準ライブラリ追加される予定です。これらのパッケージでは、ジェネリクスを活用してさまざまな型のスライスやマップに対する便利な操作を提供しています。ここでは具体的な例を示してみます。

import (
	"fmt"
	"golang.org/x/exp/slices" // Go1.21では”import slices”で利用できる
)

func main() {
	s := []int{1, 1, 2, 3}
	contains2 := slices.Contains[int](s, 2)
	fmt.Println("Slice contains 2:", contains2)

	duplicatesRemoved := slices.Compact[[]int](s)
	fmt.Println("Slice with duplicates removed:", duplicatesRemoved)
	fmt.Println("Original Slice after Compact:", s)
}

// 実行結果
Slice contains 2: true
Slice with duplicates removed: [1 2 3]
Original Slice after Compact: [1 2 3 3] // 副作用に注意

この例では、次のような操作が行われています。

  1. slices.Contains関数によって、スライスに指定した要素が含まれているかをチェックしています。
  2. slices.Compact関数によって、スライスから重複する要素を削除しています。

同様の操作を行う関数を独自で実装したことがある読者の方も多いでしょう。そのため、このような機能が標準ライブラリに追加されていくのは良い流れだと感じます。

ここで注意すべき点は、元のスライスが変更されてしまっているということです。CompactやReplaceなどの操作には副作用があります。これらの操作を使用する際には、元のスライスが変更される可能性に注意し、必要に応じてスライスのコピーを作成してから操作を行うことが望ましいです。

標準ライブラリにおけるジェネリクス移行の難しさ

標準パッケージにおいても、math.Abs/Min/Maxなどのfloat64のみを引数に取る関数や、interface{}(=any)を利用して実装されているlist.Listをジェネリクスを利用して実装し直すための議論が行われています。list.Listを例に、現在の実装の問題点とジェネリクスを利用した場合の利点を確認してみます。

現行のlist.Listは、interface{}any型)を利用して実装されています。次の例では、任意の型を受け取って動作しますが、使用方法によっては不正な動作を引き起こす可能性があります。

import (
	"container/list"
	"fmt"
)

func main() {
	l := list.New()
	l.PushBack(1)
	l.PushBack("string")

	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Println(e.Value)
	}
}
// 実行結果
1
string

この例では、整数と文字列が同じリストに格納されています。これは型安全性に問題があります。

次のようにジェネリクスを利用してリストを再定義することで、リスト内のすべての要素が同じ型であることが保証されます。

type List[T any] struct { ... } // 要素は省略

新しいList[T]の定義では、すべての要素が同じ型であることがコンパイラによって保証されます。したがって、最初のintとstringを混ぜた利用例はコンパイルエラーになります。新しいList[T]の定義を導入した場合、以前はコンパイルできていた1⁠string⁠をPushするようなコードがコンパイルできなくなります。これは、型安全性を確保する一方で既存のコードとの後方互換性が維持されないことを意味します。

このような問題があるために、ジェネリクスへの移行は容易ではありません。ジェネリクスを使って実装されたv2パッケージを用意することや、関数であればジェネリクス用の接尾子をつけた関数を用意するなどの様々な可能性が議論されています。

おわりに

本記事では、Goの標準ライブラリにおけるジェネリクスの利用例と、既存の実装にジェネリクスを導入する際の課題について説明しました。執筆時点ではslicesパッケージとmapsパッケージがmasterブランチでは標準ライブラリとして追加されています。今後もジェネリクスを用いた実装が徐々に増えていくでしょう。

ジェネリクスは、interface{}any型の代わりに利用することで型安全性を向上させるだけでなく、コードの再利用性や可読性も向上させるため、Goプログラマにとって有益な機能です。ただし、既存の実装、特に標準ライブラリにジェネリクスを導入する際には、後方互換性の問題を考慮する必要があります。また、ジェネリクスが一般にどのような場面で役に立つかについて知りたい場合には、When To Use Genericsというガイドラインが参考になります。

ここで紹介した事例を参考に、是非ジェネリクスを活用したGoプログラムの開発に取り組んでみてください。

おすすめ記事

記事・ニュース一覧