Goのrangefuncを最大限悪用する試み
Go 1.23では、特定の型の関数をfor
文のrange
句であたかもスライスやマップかのように使える機能(rangefunc)が追加された。
for i := range func(yield(x int) bool) {
yield(0)
yield(1)
yield(2)
} {
fmt.Println("%d", i)
}
イテレータやジェネレータ、ストリームなどと呼ばれる類のものを実装するときに役に立つ。
rangefuncは、おおよそ次のような読み替え規則による、関数呼び出しの糖衣構文である。
for x := fn {
body
}
===>
fn(func(x T) bool { body })
要するに、for
文の本体を関数リテラルの本体に、ループ変数を仮引数に置き換えている。
同様の糖衣構文は様々な言語で広く普及している[1]。JavaScriptのawait
文やHaskellのdo
構文なども、実はrangefuncと同様の戦略による糖衣構文である。
ということはつまり、JavaScriptのawait
文やHaskellのdo
構文でできることが、rangefuncでもできてしまうかもしれない。
Goでファイルを読み書きする際、典型的には以下のようなコードを書く。
defer file.Close()
を書き忘れるのはよくあるうっかりミスだ。
これをrangefuncでなんとかしちゃおう。
file, err := op.Open(path)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(os.Stdout, file)
目指すゴールは、for
文の中でだけ有効なファイルハンドラreader
を作り出し、ファイルのOpen
、Close
を関数WithFile
の中に閉じ込めること。
type Result[T any] struct {
Ok T
Err error
}
func main() {
for reader := range WithFile("main.go") {
if reader.Err != nil {
panic(reader.Err)
}
io.Copy(os.Stdout, reader.Ok)
}
}
WithFile
の型は、iter.Seq[Result[io.Reader]]
、つまりfunc(yield func(Result[io.Reader]) bool)
になる。
yield
関数はfor
文の本体を表し、その引数はループ変数(↑の場合はreader
)である。
すなわち、WithFile
の実装は以下のようになる。
func WithFile(path string) iter.Seq[Result[io.Reader]] {
return func(yield func(Result[io.Reader]) bool) {
file, err := os.Open(path)
if err != nil {
yield(Result[io.Reader]{Err: err})
return
}
defer file.Close()
yield(Result[io.Reader]{Ok: file})
}
}
何個かネタが貯まるようなら記事にまとめたい
range-over functionは引数を二つ取れるので、Result
型は必要なかった。
package main
import (
"io"
"iter"
"os"
)
func WithFile(path string) iter.Seq2[io.Reader, error] {
return func(yield func(io.Reader, error) bool) {
file, err := os.Open(path)
if err != nil {
yield(nil, err)
return
}
defer file.Close()
yield(file, nil)
}
}
func main() {
for reader, err := range WithFile("main.go") {
if err != nil {
panic(err)
}
io.Copy(os.Stdout, reader)
}
}