目次
はじめに
株式会社エブリーでDELISH KITCHEN事業のバックエンドエンジニアをしている、GopherのYuki(@YukiBobier)です。主に広告サービスを担当しています。
前回の記事では、Goにおけるヒープ使用量改善手法についてご紹介しました。今回は、every Tech Blog Advent Calendar 2023 の9日目の記事として、変わりゆくGoのrange
について取り上げます。
なお、ここで取り上げる内容は開発中のものとなりますので、将来的に変更される可能性があることを申し添えておきます。
range over int
range over intとは
早い話、これが
for i := 0; i < 5; i++ { fmt.Println(i) }
こう書けるようになるということです。
for i := range 5 { fmt.Println(i) }
2022年10月25日にRuss Coxによって後述するrange over funcと合わせてディスカッションが開始され、2023年7月18日にプロポーザルが出されたのち、同年10月27日にAcceptedとなりました。
また、そこで合意された内容として、range over intはGo 1.22に追加されることとなりました(後述しますが、range over funcはGo 1.22ではGOEXPERIMENT入りにとどまります)。
range over intをGo Playgroundで触ってみる
Goの開発ブランチにはrange over intがすでに実装されているので、さっそくGo Playgroundで”Go dev branch”を選択して実行してみましょう。
package main import "fmt" func main() { for i := range 5 { fmt.Println(i) } }
次のような結果が得られるはずです。
0 1 2 3 4 Program exited.
range over intを用いる利点
よくあるfor文が簡潔になる
私たちがrange over intを用いる1つ目の利点として、for i := 0; i < N; i++ {
のような、0からN-1までカウントアップするよくあるfor文が簡潔になります。
特に、カウンタに関心がない場合は前述の例よりもさらに簡潔になります。例えば、ベンチマークテストは下のようになります。
for range b.N { doSomething() }
リーディングコストが軽減される
2つ目の利点として、リーディングコストが軽減されます。
通常の3節からなるfor文は、それが0からN-1までカウントアップするよくあるfor文なのか、それとも例えば1からNまでカウントアップするunusualなfor文なのか、その区別はちゃんと読まないとつきません。逆に、どうせよくあるfor文だと思って読み飛ばしていると、実はそうではなくてミスリードが生じるということもあるでしょう。
for i := 0; i < 5; i++ { fmt.Println(i) } // 👆よくあるfor文とunusualなfor文はちゃんと読まないと区別できない👇 for i := 1; i <= 5; i++ { fmt.Println(i) }
この点、よくあるfor文をrange over intに置き換えることで、そうではないunusualなfor文が通常の3節からなるfor文として際立ちます。つまり、通常のfor文に出会った時だけちゃんと読めばいいので、for文を読む負担が減ります。
for i := range 5 { fmt.Println(i) } // 👆range over intとunusualなfor文はちゃんと読まなくても区別がつく👇 for i := 1; i <= 5; i++ { fmt.Println(i) }
range over func
range over funcとは
range
がイテレータの標準として機能するようになるということです。
ディスカッションでRuss Coxは次のように述べています。
When you want to iterate over something, you first have to learn how the specific code you are calling handles iteration. This lack of uniformity hinders Go’s goal of making it easy to easy to move around in a large code base. People often mention as a strength that all Go code looks about the same. That’s simply not true for code with custom iteration.
現在のGoにはイテレータの標準がなく、それぞれがそれぞれのアプローチをしています。標準ライブラリ内でさえ、それぞれの方法でイテレーションをハンドリングしています。
例えば、database/sql.Rows
のイテレータは次のようになっています。
for rows.Next() { var name string if err := rows.Scan(&name); err != nil { log.Fatal(err) } fmt.Println(name) }
一方で、archive/tar.Reader.Next
では次のようになっています。
for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { log.Fatal(err) } fmt.Println(hdr.Name) }
だいぶ違いますね。
このような状況なので、コードを書くにしろ読むにしろ、イテレータについては個々のハンドリング方法を知らなければなりません。同じような目的は同じようなコードで達成されるというGoの長所が、イテレータに関しては発揮されていないということになります。
このような状況を解決するべく、range
を拡張する形でイテレータの標準化が図られることとなりました。それがどのようなものであるかの詳細については、コードをみるのが一番分かりやすいので次項に譲ります。
なお、range over intとともにAcceptedとなりましたが、こちらはGo 1.22ではGOEXPERIMENT入りにとどまり、引き続き詳細が検討されることになりました。環境変数GOEXPERIMENT
にrangefunc
を設定することで実験的な使用が可能になります。
range over funcをGo Playgroundで触ってみる
こちらもGoの開発ブランチにすでに実装されているので、さっそくGo Playgroundで”Go dev branch”を選択して実行してみましょう。
なお、range over funcには次の3タイプがあるため、ひとつひとつ取り上げます。
func(func()bool)
func(func(V)bool)
func(func(K, V)bool)
ちなみに、プロポーザルの冒頭ではそれぞれbool
を返すとされていますが、検討を経た結果としてこの戻り値は取り除かれるとDiscussion Summary / FAQでは述べられており、実際に開発ブランチではそのように実装されています。
func(func()bool)
まずは、ループ変数に値を渡さないタイプです。
// GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func() bool) { if !yield() { return } if !yield() { return } if !yield() { return } if !yield() { return } if !yield() { return } } func main() { for range rangeFive { fmt.Println("Hello") } }
次のような結果が得られるはずです。おそらく開発中のバグでGo vetが転けていますが、実行には成功しています。
# [play] vet: ./prog.go:26:12: cannot range over rangeFive (value of type func(yield func() bool)) Go vet failed. Hello Hello Hello Hello Hello Program exited.
func(func(V)bool)
次に、1つのループ変数に値を渡すタイプです。
// GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func(string) bool) { if !yield("H") { return } if !yield("e") { return } if !yield("l") { return } if !yield("l") { return } if !yield("o") { return } } func main() { for s := range rangeFive { fmt.Println(s) } }
次のような結果が得られるはずです。
# [play] vet: ./prog.go:26:17: cannot range over rangeFive (value of type func(yield func(string) bool)) Go vet failed. H e l l o Program exited.
func(func(K, V)bool)
最後に、2つのループ変数に値を渡すタイプです。
// GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func(string, int) bool) { if !yield("H", 4) { return } if !yield("e", 3) { return } if !yield("l", 2) { return } if !yield("l", 1) { return } if !yield("o", 0) { return } } func main() { for s, i := range rangeFive { fmt.Println(s, i) } }
次のような結果が得られるはずです。
# [play] vet: ./prog.go:26:20: cannot range over rangeFive (value of type func(yield func(string, int) bool)) Go vet failed. H 4 e 3 l 2 l 1 o 0 Program exited.
range over funcを用いる利点
シンプルで標準的なイテレータを実装できる
私たちがrange over funcを用いる利点としてはやはり、range
を介したシンプルで標準的なイテレータを実装できることです。
例えば、二分木のイテレータを実装したいとします。現在のGoでは次のようになるでしょう。
package main import "fmt" type binaryTreeNode struct { v int left *binaryTreeNode right *binaryTreeNode } func (btn *binaryTreeNode) getIterator() iterator { return iterator{ stack: []*binaryTreeNode{btn}, } } type iterator struct { stack []*binaryTreeNode } func (i *iterator) hasNext() bool { return len(i.stack) > 0 } func (i *iterator) getNext() binaryTreeNode { var btn *binaryTreeNode btn, i.stack = i.stack[len(i.stack)-1], i.stack[:len(i.stack)-1] if btn.right != nil { i.stack = append(i.stack, btn.right) } if btn.left != nil { i.stack = append(i.stack, btn.left) } return *btn } func main() { bt := binaryTreeNode{ v: 1, left: &binaryTreeNode{ v: 2, left: &binaryTreeNode{ v: 3, left: &binaryTreeNode{ v: 4, left: nil, right: nil, }, right: nil, }, right: nil, }, right: &binaryTreeNode{ v: 5, left: &binaryTreeNode{ v: 6, left: nil, right: nil, }, right: &binaryTreeNode{ v: 7, left: nil, right: nil, }, }, } iter := bt.getIterator() for iter.hasNext() { btn := iter.getNext() fmt.Println(btn.v) } }
気になるのは次の点です。
- イテレーションの状態を管理する必要があるので、イテレータが別途必要であり実装が複雑である
- デザインパターン(Iterator)に則ったインターフェースにしているとはいえ、将来の読み手に使い方や意図が伝わるか心配がある
これをrange over funcで書き直すと、次のようになります。
// GOEXPERIMENT=rangefunc package main import "fmt" type binaryTreeNode struct { v int left *binaryTreeNode right *binaryTreeNode } func (btn *binaryTreeNode) all(yield func(binaryTreeNode) bool) { if btn == nil { return } if !yield(*btn) { return } btn.left.all(yield) btn.right.all(yield) } func main() { bt := binaryTreeNode{ v: 1, left: &binaryTreeNode{ v: 2, left: &binaryTreeNode{ v: 3, left: &binaryTreeNode{ v: 4, left: nil, right: nil, }, right: nil, }, right: nil, }, right: &binaryTreeNode{ v: 5, left: &binaryTreeNode{ v: 6, left: nil, right: nil, }, right: &binaryTreeNode{ v: 7, left: nil, right: nil, }, }, } for btn := range bt.all { fmt.Println(btn.v) } }
このバージョンには次のような利点があります。
yield func(binaryTreeNode) bool
を介してforループとイテレータをシーケンシャルに行き来することから状態管理が不要であるため、イテレータを別途設ける必要がなく実装がシンプルであるrange
を介したシンプルで標準的なインターフェースにより、使い方や意図が明らかである
将来的に、各種ライブラリがrange
を介したシンプルで標準的なイテレータを実装するようになれば、利用者としてもその恩恵を受けることができるでしょう。
おわりに
Go 1.22がリリースされrange over intが使用できるようになったら、既存のforループをゴリゴリ書き換えて可読性を高めていきたいと思っています。
また、最終的にどのような形に落ち着くかはまだ確定していないものの、もしrange over funcが正式に追加されたら、既存の各種ライブラリに大きな変化を促すGenerics以来の機能追加になると思います。こういった大きな変更にリリース前から触れて慣れておくことができるのは、オープンに開発されているメリットだと感じます。
いやあ、やっぱりGoっていいですね。
Appendix: range over funcを理解するコツ
この記事を書くにあたって、range over funcについてきっちり調べたのですが、実は、range over funcに触れたのは今回が初めてではありませんでした。以前とある勉強会で登壇者が紹介しているのを聞いたのがファーストコンタクトだったのですが、その時は仕組みを理解できませんでした。
きっとrange over funcを理解するのが難しいというのは私に限ったことではないと思うので、最後におまけとしてこれを理解するコツを紹介したいと思います。
range over funcを理解するコツは、
これは被訪問者側に主導権があるVisitorパターンだと思ってコードを読んでみること
です。
デザインパターンのVisitorは、被訪問者クラスがVisitorクラスを受け入れ、それによりVisitorクラスが被訪問者クラスの構造を巡りながら処理を行うパターンです。
range over funcにおけるVisitorは、上の二分木の例におけるyield
です。このyield
というVisitorが引数という玄関からall
メソッドというイテレータに訪問し、値を受け取ってはループに送り込んでいます。
ただし、本家のVisitorパターンと異なるのが、range over funcにおけるVisitorは構造を巡る主導権を持たないということです。次の値、次の値と構造を巡る実装が書かれているのは被訪問者側であるall
メソッド側です。言うなれば、訪問者yield
は引数という玄関から入ったのち、主のall
に手を引かれて屋敷の中を案内され、次から次へとお土産を持たされるイメージです。そして、all
が渡すべきお土産が尽きるか、yield
がこれ以上はいらないと言うかして(break
)訪問が終了するというわけです。
なんとなくお分かりいただけたでしょうか? 理解の助けになったならば幸いです。