🎵

Go 1.23 range over func 使い倒してみた 〜カリー化の恩恵をシンタックスシュガーで楽に享受しよう〜

2024/12/05に公開

GDG Greater Kwansai Padawan Organizer のたくてぃん (@taku_ting) です。

まずは、 Go の range について、

そもそも range とは?

  • range over a slice or map
    • slice (可変長配列) や map を range に与えると含まれる要素をイテレート
  • range over integers
    • integer を range に与えるとその回数分イテレート

range over a slice or map

var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}

func main() {
	for _, v := range pow {
		fmt.Println(v)
	}
}
// Output:
// 1
// 2
// 4
// 8
// 16
// 32
// 64
// 128

playground

range over integers (since: 1.22)

func main() {
	for v := range 10 {
		fmt.Println(v)
	}
}
// Output:
// 0
// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9

playground

ここまでが 1.23 より前のバージョンで使用していた range です。
※ すべてでは無いです。 (余談: こんなのもあります Range and Close)

では、 range over func とはどんなものでしょう?

range over func (since: 1.23)

1.23 からは自作のイテレーションを以下の型で実装して range に渡せるようになりました。

type Seq[V any] func(yield func(V) bool)

type Seq2[K, V any] func(yield func(K, V) bool)

iter package

それぞれ、

  • iter.Seq[V]
    • 「V 型を受け取る関数」を受け取る関数
  • iter.Seq2[K, V]
    • 「K 型と V 型を受け取る関数」を受け取る関数

を作れば range に渡すことが出来ます。

例: 「1 〜 "与えられた数" まで 2 乗した数を回す。」

func main() {
	for num := range powNums(10) {
		fmt.Printf("num: %v\n", num)
	}
}
// Output:
// num: 1
// num: 4
// num: 9
// num: 16
// num: 25
// num: 36
// num: 49
// num: 64
// num: 81
// num: 100


// powNums は 1 から `to` までの数を順に 2 乗して与えられた処理を行うイテレータを作成します。
func powNums(to int) iter.Seq[int] {
	return func(yield func(int) (_continue bool)) {
		for i := range to {
			if !yield(pow(i + 1)) {
				break
			}
		}
	}
}

func pow(n int) int {
	return n * n
}

playground

iter.Seq[V] による使用メモリ削減

1.23 より前のバージョンではこういった処理を行う場合に、愚直に記述するとメモリを多く使用してしまいます。

処理の流れは、

  1. powNumSlice(10) を呼び出した際に 「10 要素」分をメモリに格納
  2. for-range で要素を順にコピーして[1]for内の処理に、、、

といったものになります。

func main() {
	for _, num := range powNumSlice(10) {
		fmt.Printf("// num: %v\n", num)
	}
}
// Output:
// num: 1
// num: 4
// num: 9
// num: 16
// num: 25
// num: 36
// num: 49
// num: 64
// num: 81
// num: 100

// powNumSlice は 1 から `to` までの数の 2 乗を要素に持つ slice を作成します。
func powNumSlice(to int) []int {
	pows := make([]int, 0, to)
	for i := range to {
		pows = append(pows, pow(i+1))
	}
	return pows
}

playground

ベンチマーク (メモリ使用量)

まずはベンチマークがしやすいコードにまとめてみます。

main.go
// powNums と powNumSlice は省略

func PrintPowNumbers(w io.Writer, count int) {
	for num := range powNums(count) {
		fmt.Fprintf(w, "// num: %v\n", num)
	}
}

func PrintPowNumbersWithoutRangeOverFunc(w io.Writer, count int) {
	for _, num := range powNumSlice(count) {
		fmt.Fprintf(w, "// num: %v\n", num)
	}
}

↓ ベンチマーク用コード

main_test.go
const COUNT = 1000000

func BenchmarkWithRangeOverFunc(b *testing.B) {
	b.ResetTimer()

	for range b.N {
		PrintPowNumbers(io.Discard, COUNT)
	}
}

func BenchmarkWithoutRangeOverFunc(b *testing.B) {
	b.ResetTimer()

	for range b.N {
		PrintPowNumbersWithoutRangeOverFunc(io.Discard, COUNT)
	}
}

※ 標準出力に書き出す処理は邪魔になるので io.Discard を指定して出力をスキップ

では実行。

% go test -benchmem -bench .
goos: darwin
goarch: arm64
pkg: urlshortener
cpu: Apple M3 Pro
BenchmarkWithRangeOverFunc-12                 22          53435252 ns/op         8004035 B/op     999992 allocs/op
BenchmarkWithoutRangeOverFunc-12              22          49228227 ns/op        16005465 B/op     999989 allocs/op
PASS
ok      rangeoverfunc    3.340s

range over func での処理はメモリ使用量がおおよそ半分ですね! 🎉🎉

理由は、

  • iter.Seq[V] という「繰り返し処理」自体に for-range で「for 内の処理」を与える(追加で行わせる)

という動作になるためです。

主だったメモリのアロケート (太字部分) を追うと、

  1. powNum(10) で「1 から 10 までの数を順に 2 乗して与えられた処理を行うイテレータ」を作成。
  2. for-range でイテレータからイテレーション内の値(2乗された数)を受け取って追加の処理 (for内の処理) を実行

といったものになります。

1.23 より前の記述での流れと比べてみましょう。

処理の流れは、

  1. powNumSlice(10) を呼び出した際に 「10 要素」分をメモリに格納
  2. for-range で要素を順にコピーしてfor内の処理に、、、

2乗された数を作りながら順次処理出来る range over func の方がメモリ確保が最適に実行可能なわけです。

可読性について考える。

iter.Seq[V] を range を使わずに呼び出すと、、、

func main() {
	powNums(count)(func(num int) /* continue */ bool {
		fmt.Fprintf(w, "// num: %v\n", num)
		return true // continue
	})
}

関数名、変数名が相当わかり易くないとイテレーションであることが把握できない。

→ range over func なら

    for num := range powNums(count) {
        fmt.Fprintf(w, "// num: %v\n", num)
    }

for, (count) でイテレーションであることと処理回数が容易に把握できる。

エラーが発生する可能性がある処理の場合は工夫が必要。

スコープ外へのアクセスで簡単に対応するならこんな感じ。

    var err error
    powNums(count)(func(num int) /* continue */ bool {
        if num == 25 {
            err = errors.New("pow of 5 is not allowed")
            return false // break
        }
        fmt.Fprintf(w, "// num: %v\n", num)
        return true // continue
    })

スコープ外への操作をなくしたい & エラーハンドリングが内部で必要な場合は追加で実装。

func main() {
    err := powNumsWithErrorHandle(10)(func(num int) (bool, error) {
        if num == 25 {
            return false, errors.New("pow of 5 is not allowed")
        }
        fmt.Printf("// num: %v\n", num)
        return true, nil
    })
    fmt.Printf("err: %v\n", err)
    return
}

func powNumsWithErrorHandle(to int) func(yield func(int) ( /* continue */ bool, error)) error {
    return func(yield func(int) ( /* continue */ bool, error)) error {
        for i := range to {
            _continue, err := yield(pow(i + 1))
            if err != nil {
                return err
            }
            if !_continue {
                break
            }
        }
        return nil
    }
}

→ range over func なら

func run () error {
    for num := range powNums(count) {
        if num == 25 {
            return errors.New("pow of 5 is not allowed")
        }
        fmt.Fprintf(w, "// num: %v\n", num)
    }
    return nil
}

めっちゃ良いシンタックスシュガーだ 😳

例: 再帰関数を扱う例

※ 実際のプロダクションコードからの抜粋のため掻い摘んで読んでくださいね〜 🙏

  • ディレクトリ(フォルダ)、ファイルの木構造
  • mp4, QuickTime File Format (mov, m4a など) の Box 構造(木構造)

などなど。

再帰で実装した関数をラップして iter.Seq[V], iter.Seq2[K, V] に変換。

QuickTime File Format (m4aファイル) の木構造に連なる Box をイテレートする再帰処理。

https://github.com/tingtt/qtffilst/blob/v0.2.0/walk.go#L45-L99

行っている処理は、

  1. Box のヘッダ読み取り(ボックス名、サイズ)
  2. yield 呼び出し(range で呼び出した際の for 内の処理を実行する部分)
  3. コンテナであれば(中に更にボックスがある可能性があるなら)
    • 1階層潜って再帰呼び出し
  4. 再帰呼び出し(同階層にある次の Box 以降のデータを探索)

といった単純な再帰構造の探索です。

これを iter.Seq2[Box, error] に変換します。

https://github.com/tingtt/qtffilst/blob/v0.2.0/walk.go#L33-L43

  • walkBoxes() に渡せる型に変換
  • walkBoxes() に渡して実行
  • walkBoxes() 内のエラーによる breakreturn されたエラーを後処理する

今回のケースでは、読み込みにエラーが発生した場合に continue することは無いため再帰関数側が受け取る yield の型は func(Box) (_continue bool) としています。
ただ、これではエラーを for-range 内の処理で受け取ることが出来ないため、外からは func(Box, error) (_continue bool))、つまり iter.Seq2[Box, error] が受け取れる引数を用意する必要があります。

再帰関数自体を `iter.Seq2[Box, error]` にしてみた。

再帰関数なのに再帰呼び出し部分の行頭が for になってしまうため腑に落ちず revert しました、、、

※ 実際の diff ではありません。再帰呼び出し部分を目立たせるために diff 表示にしています。

	func WalkIter(rs io.ReadSeeker, size int64) iter.Seq2[Box, error] {
		return walkBoxesIter(rs, size, 0, ROOT_LEVEL /* start from */, ROOT_PATH /* start from */)
	}
	
	func walkBoxesIter(rs io.ReadSeeker, parentEndsAt, offset int64, level int8, path string) iter.Seq2[Box, error] {
		return func(yield func(Box, error) bool) {
			startPosition, err := rs.Seek(offset, io.SeekStart)
			if err != nil && !yield(Box{}, err) {
				return
			}
			if startPosition >= parentEndsAt {
				return
			}
		
			boxSize, boxName, err := readBoxHeader(rs)
			if err != nil && !yield(Box{}, err) {
				return
			}
			endPosition := startPosition + int64(boxSize)
		
			box := Box{
				Name:          boxName,
				Level:         level,
				Path:          path + "." + boxName,
				DataPosition:  startPosition + 8, /* add size (bytes) of fixed fields (size, name)) */
				DataSize:      boxSize - 8,       /* add size (bytes) of fixed fields (size, name)) */
				IsContainable: containableBox(boxName),
			}
			_continue := yield(box, nil)
			if !_continue {
				return
			}
		
			if box.IsContainable {
				childOffset := startPosition + 8 /* add size (bytes) of fixed fields (size, name)) */
				if boxName == "meta" {
					childOffset += 4 /* bytes */
				}
+				for box, err := range walkBoxesIter(rs, endPosition, childOffset, level+1, path+"."+boxName) {
+					if !yield(box, err) {
+						return
+					}
+				}
				_continue := yield(Box{
					Name:          boxName,
					Level:         level,
					Path:          path + "." + boxName,
					DataPosition:  startPosition + 8, /* add size (bytes) of fixed fields (size, name)) */
					DataSize:      boxSize - 8,       /* add size (bytes) of fixed fields (size, name)) */
					IsContainable: true,
				}, nil)
				if !_continue {
					return
				}
			}
		
			nextStartPosition, err := rs.Seek(endPosition, io.SeekStart)
			if err != nil {
				return
			}
+			for box, err := range walkBoxesIter(rs, parentEndsAt, nextStartPosition, level, path) {
+				if !yield(box, err) {
+					return
+				}
+			}
		}
	}

例: iter.Seq[V], iter.Seq2[K, V] にしたイテレータにフィルタ・マップなど加工処理を追加する。

iter.Seq[V], iter.Seq2[K, V] という型が用意されているということは、
iter.Seq[V], iter.Seq2[K, V] から iter.Seq[V], iter.Seq2[K, V] を生成すればイテレータに加工が出来ます!

この場合も処理自体は一番外の層で呼び出した際にデータを順次メモリに格納しながらイテレートしてくれるのでメモリ効率は変わらず良いです。

フィルタリング

https://github.com/tingtt/qtffilst/blob/v0.2.0/read.go#L70-L80

https://github.com/tingtt/iterutil/blob/main/filter.go#L17-L27

pow(pow(num)) とかしてみる (マップ加工)

func main() {
	for num := range powNums(powNums(rangeIntFrom1( /* to */ 10))) {
		fmt.Printf("// num: %v\n", num)
	}
}
// Output:
// num: 1
// num: 16
// num: 81
// num: 256
// num: 625
// num: 1296
// num: 2401
// num: 4096
// num: 6561
// num: 10000

// powNums は iter.Seq[int] に 2 乗する処理を追加します。
func powNums(seq iter.Seq[int]) iter.Seq[int] {
	return func(yield func(int) bool) {
		for num := range seq {
			if !yield(pow(num)) {
				break
			}
		}
	}
}

// rangeIntFrom1 は 1 から `to` までの数を処理するイテレータを作成します。
func rangeIntFrom1(to int) iter.Seq[int] {
	return func(yield func(int) bool) {
		for index := range to {
			if !yield(index + 1) {
				break
			}
		}
	}
}

playground

最後は超お遊びコードでした笑

まとめ

  • イテレーション自前実装のデファクトスタンダードありがてぇ 🙏
  • カリー化による多態性確保時の難読化回避
    • 行頭が for
      • 可読性の維持 🙌
      • 再帰関数も for で呼び出せるため、再帰であることを隠蔽するべきでない場合は工夫が必要 🤔
        • 再帰関数をラップして iter.Seq[V], iter.Seq2[K, V] に変換可能なので隠蔽したい層でラップ
    • for で処理出来ることによる return, continue, break の注入
      • 各スコープの責務が小さくなり凝集度が適切 🫶

あとがき

読んでいただいてありがとうございました!
たまたま 1.23 がリリースした頃に range over func と相性が良い m4a ファイルに関するライブラリを開発していたので使い込んで得た知見を講演(と言うほどのものでも無いLTですが)と投稿という流れになりました。

Gopher たちの参考になれば嬉しいです!! 🐀 (<- Gopherくんでは無い)
また、Go にあまり触れてない方には Go に興味を持って頂けたら超喜びます 🤭

来週、再来週も GDG Kwansai の Advent Calendar 2024 で Go に関連する投稿をするのでお楽しみに〜 ✋
(テストコードについて書くつもりです〜)

src

  • https://go.dev/doc/go1.23
  • https://go.dev/wiki/RangefuncExperiment
    • (またまた書ききれなかった)
    • range over func はコンパイル時にインライン化と Devirtualize (非仮想化) が行われるためオーバーヘッド無いよ〜
      • つまりは普通に for 文一個で書ききったコードにまで内部で変換してくれます。

range over func 使い込んで開発したライブラリ

https://github.com/tingtt/qtffilst

登壇スライド

https://docs.google.com/presentation/d/1swI3AoPrLfHx31Ao0kc9dgL-6DkFgwpuY7KqtXnjx1E

イベント

https://gdgkwansai.connpass.com/event/329411/

イベント全体レポート

https://zenn.dev/greek_academy/articles/47809f2eeeeff9

脚注
  1. range ↩︎

Discussion