はじめに
2022年3月にリリースされたGo1.
timeパッケージ
日付と時刻の操作を扱うtimeパッケージでは内部的にジェネリクスが利用されています。JSONのシリアライズを行うMarshalJSONへのバリデーションの改善とジェネリクスの導入により、9%以上の高速化が成されました。では実装を見てみましょう。
この例では、ジェネリクスを用いてatoi
関数を実装することで、[]byte
型とstring
型の両方を引数に取る柔軟性をもたせています。これによって、この関数を利用する際に、いくつかのメリットが得られます。
- コードの簡潔性と可読性の向上:ジェネリクスを使用することで、
[]byte
型とstring
型用に別々の関数を実装する必要がなくなり、コードの量が減りました。これにより、同じ目的を達成するために書かれた複数のコードが統合されて簡潔になり、メンテナンスも用意になりました。 - パフォーマンスの向上:
[]byte
型とstring
型を両方サポートするため、入力を変換する必要がなくなりました。これにより、冗長なコピーの必要がなくなりました。
このように、ジェネリクスを利用した柔軟な実装によって恩恵を得られます。特に[]byte
型とstring
型の両方を引数に取るパターンはGoで文字列を扱う際には頻繁に発生するので参考になりそうです。
メソッドではジェネリクス(型パラメータ)は使えない
しかし、注意点もあります。先ほど学んだパターンを利用して、例えば*strings.
型のメソッドを共通化しようとすると、ジェネリクス
ここで*strings.
型の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.atomic.
型がリリースされました。これは、標準ライブラリで公開された型atomic.
型は、型*T
のポインターに対してアトミックな操作を提供しています。
この実装では、_ [0]*T
という要素が構造体に含まれています。この要素はGo1.
この要素によって、Pointer[T]
型からPointer[U]
型への不正な型変換が防がれています。_ [0]*T
は、実際にメモリを消費しないダミーの要素であり、型変換の制約を提供するためだけに存在しています。また、_ [0]T
ではなく_ [0]*T
としているのは、再帰的な定義と判定されてしまうケースを防ぐためです
slicesパッケージとmapsパッケージ
執筆時の最新リリースであるGo1.slices
パッケージおよびmaps
パッケージはGo1.
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] // 副作用に注意
この例では、次のような操作が行われています。
slices.
関数によって、スライスに指定した要素が含まれているかをチェックしています。Contains slices.
関数によって、スライスから重複する要素を削除しています。Compact
同様の操作を行う関数を独自で実装したことがある読者の方も多いでしょう。そのため、このような機能が標準ライブラリに追加されていくのは良い流れだと感じます。
ここで注意すべき点は、元のスライスが変更されてしまっているということです。CompactやReplaceなどの操作には副作用があります。これらの操作を使用する際には、元のスライスが変更される可能性に注意し、必要に応じてスライスのコピーを作成してから操作を行うことが望ましいです。
標準ライブラリにおけるジェネリクス移行の難しさ
標準パッケージにおいても、math.
などのfloat64
のみを引数に取る関数や、interface{}(=any)
を利用して実装されている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
と
このような問題があるために、ジェネリクスへの移行は容易ではありません。ジェネリクスを使って実装されたv2
パッケージを用意することや、関数であればジェネリクス用の接尾子をつけた関数を用意するなどの様々な可能性が議論されています。
おわりに
本記事では、Goの標準ライブラリにおけるジェネリクスの利用例と、既存の実装にジェネリクスを導入する際の課題について説明しました。執筆時点ではslicesパッケージとmapsパッケージがmasterブランチでは標準ライブラリとして追加されています。今後もジェネリクスを用いた実装が徐々に増えていくでしょう。
ジェネリクスは、interface{}
型any型
)
ここで紹介した事例を参考に、是非ジェネリクスを活用したGoプログラムの開発に取り組んでみてください。