every Tech Blog

株式会社エブリーのTech Blogです。

Go 1.22で追加予定のrange over intと、GOEXPERIMENT入り予定のrange over funcを触ってみる

目次

はじめに

株式会社エブリーで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”を選択して実行してみましょう。

[Go Playgroundで実行する]

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入りにとどまり、引き続き詳細が検討されることになりました。環境変数GOEXPERIMENTrangefuncを設定することで実験的な使用が可能になります。

range over funcをGo Playgroundで触ってみる

こちらもGoの開発ブランチにすでに実装されているので、さっそくGo Playgroundで”Go dev branch”を選択して実行してみましょう。

なお、range over funcには次の3タイプがあるため、ひとつひとつ取り上げます。

  1. func(func()bool)
  2. func(func(V)bool)
  3. func(func(K, V)bool)

ちなみに、プロポーザルの冒頭ではそれぞれboolを返すとされていますが、検討を経た結果としてこの戻り値は取り除かれるとDiscussion Summary / FAQでは述べられており、実際に開発ブランチではそのように実装されています。

func(func()bool)

まずは、ループ変数に値を渡さないタイプです。

[Go Playgroundで実行する]

// 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つのループ変数に値を渡すタイプです。

[Go Playgroundで実行する]

// 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つのループ変数に値を渡すタイプです。

[Go Playgroundで実行する]

// 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では次のようになるでしょう。

[Go Playgroundで実行する]

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)
    }
}

気になるのは次の点です。

  1. イテレーションの状態を管理する必要があるので、イテレータが別途必要であり実装が複雑である
  2. デザインパターン(Iterator)に則ったインターフェースにしているとはいえ、将来の読み手に使い方や意図が伝わるか心配がある

これをrange over funcで書き直すと、次のようになります。

[Go Playgroundで実行する]

// 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)
    }
}

このバージョンには次のような利点があります。

  1. yield func(binaryTreeNode) boolを介してforループとイテレータをシーケンシャルに行き来することから状態管理が不要であるため、イテレータを別途設ける必要がなく実装がシンプルである
  2. 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)訪問が終了するというわけです。

なんとなくお分かりいただけたでしょうか? 理解の助けになったならば幸いです。