Go1.23の新機能!ざっくり理解するrange over function types!
本稿は、Go1.23で本格的に導入されたrange over function types
について、ざっくりと理解するための記事です。
対象読者
- A Tour of Goを修了済みの方、または同程度の知識をお持ちの方
背景 : range構文の特性と課題
Goのrange
構文は、配列、スライス、マップなどのデータ構造を簡潔に繰り返し処理するための強力な構文です。
numbers := [5]int{10, 20, 30, 40, 50}
以下のように、配列のループ処理を簡単に書くことができます。
// numbersの要素を2倍して出力するループ処理
for _, num := range numbers {
fmt.Println(num * 2)
}
しかし、range構文がサポートしていないカスタムデータ構造をループさせる場合は、rangeに直接渡すことができません。
// Set型: 重複しない要素の集合を表すコンテナ型
mySet := NewSet()
// 要素を追加
mySet.Add(1)
mySet.Add(2)
mySet.Add(3)
// mySetの要素を2倍して出力するループ処理
for _, value := range mySet { // ❌range構文がサポートしていないので、このように書くことはできない
fmt.Println(value * 2)
}
この課題に対処するため、Go 1.23ではrange構文を拡張し、新たな機能が導入されました。
何が変わったのか : range over function typesの概要
Go 1.23では、カスタムデータ構造でも効率的かつ標準的にrange文を利用できるようになりました。具体的には、イテレータ関数に対してrange構文が使えるようになりました。
旧バージョン(~Go1.22)の場合
Go 1.22以前では、カスタムデータ構造をrange文で扱う方法として、以下のようなアプローチがありました。
A. 内部構造を直接操作する方法
内部のデータ構造を直接range
に渡してループする方法です。
for val := range mySet.elements {
fmt.Println(val * 2)
}
例のSet型は、以下のように定義されています。
// Set型の定義
type Set struct {
elements map[int]struct{}
}
// 新しいSetを作成する関数
func NewSet() *Set {
return &Set{
elements: make(map[int]struct{}),
}
}
// Setに要素を追加するメソッド
func (s *Set) Add(value int) {
s.elements[value] = struct{}{}
}
この例での、Set型は内部的にはMapを使って実装しているため、mySet.elements
でMap型としてrange
に渡しています。
この方法の問題点
内部実装(elementsフィールド)が外部に漏れるため、カプセル化が損なわれる。
カプセル化とは
データと処理を一つの単位としてまとめ、外部から直接データにアクセスできないようにする仕組み。カプセル化が損なわれると、不正なアクセスにより、オブジェクトのデータが破損するなどのリスクがある。
B. スライスに変換して操作する方法
カスタムデータ構造の要素をスライスに変換してから処理します。
for _, value := range mySet.ToSlice() { // Sliceに変換
fmt.Println(value * 2)
}
// Setの全要素をスライスとして取得するメソッド
func (s *Set) ToSlice() []int {
keys := make([]int, 0, len(s.elements))
for key := range s.elements {
keys = append(keys, key)
}
return keys
}
問題点1
スライスへの変換が必要で、大量データの場合に非効率。
非効率な理由
スライスは全てのデータをメモリ上に確保するため。
問題点2
外部ライブラリを利用する場合、スライス変換メソッド(ToSlice)が標準化されておらず、ライブラリごとに異なる実装を学ぶ必要がある。
新バージョンの場合
新バージョンでは同様の処理を以下のように書くことができます。
// 要素を2倍して出力するループ処理
for value := range mySet.All() {
fmt.Println(value * 2)
}
// イテレータ関数を返すメソッドの実装
func (s *Set) All() iter.Seq[int] {
return func(yield func(int) bool) {
for key := range s.elements {
if !yield(key) {
return
}
}
}
}
上記コード内のiter.Seqとは?
iter.Seq
はイテレータ関数です。Go標準ライブラリに含まれるiter
というパッケージ内で、次のように定義されています。
type Seq[V any] func(yield func(V) bool)
引数として受け取っている、yield関数には、for/rangeでのループの中身がこれに対応します。つまり、上記例ではyield
には、fmt.Println(key * 2)
のようなものが渡されます。
以下のコードを見てみると、イテレータ関数は、Setの全ての要素に対して、yield
関数、つまりfmt.Println(value * 2)
を呼び出しています。
for key := range s.elements {
if !yield(key) { // Setの全ての要素について、fmt.Println(key * 2)
return
}
}
yield
関数の戻り値は、bool
型です。この戻り値は、ループを継続するか(true
)中断するか(false
)を表します。break
などによって、呼び出し元のループが中断された場合、yield
からはfalse
が返り、イテレータ側のループも停止します。
range over funcの利点
-
コードの簡潔化
- for/rangeでループを記述することができ、コードが直感的。
-
内部実装の隠蔽
- イテレータを利用することで、データ構造の内部表現を公開せずに要素を提供可能。
-
標準化
- イテレータの記述方法が標準化され、カスタムデータ構造ごとのばらつきが解消。
コラム
yield
の名前の由来
コラム1:yield
の名前についてですが、「与える」「譲る」「生み出す」などの意味があり、イテレータが「次の値を渡す」動作から慣例につけられています。
コラム2:そのほかのイテレータ関数について
for/range
は、iter.Seq
以外にも以下のイテレータ関数を受け取ります。
type Seq2[K, V any] func(yield func(K, V) bool)
iter.Seq
との違いは、引数の個数が2つという点だけです。
Seq2[K, V any]
の使用例
- Mapなどの
key
とvalue
といった、2つの値を扱うコンテナ型で使用。 -
iter.Seq2[value, error]
のように使い、イテレータ側で発生したエラーを呼び出し側に渡すために使用。
コラム3:イテレータの記述方法の標準化について
Goの開発チームは、「すべてのコンテナ型でイテレータを返すAllというメソッドを提供すること」を推奨しています。詳しくは、こちらより。ちなみにMapやSliceに関しては、イテレータを返すAllという関数が既に存在しています。
MapのAll関数
SliceのAll関数サンプルコード
参考文献
- https://go.dev/blog/range-functions
- https://tip.golang.org/doc/go1.23
- https://go.dev/doc/go1.23#language
- https://pkg.go.dev/iter@master
- https://github.com/golang/go/blob/master/src/iter/iter.go
- https://github.com/golang/go/discussions/56413
- https://github.com/deckarep/golang-set
- https://eow.alc.co.jp/search?q=yield
謝辞
基本的な考え方から応用に至るまでご指導いただき、また議論や補足など多方面でお力添えをいただきました。たくてぃんさん、誠にありがとうございました!
Discussion