Go1.23の新機能!ざっくり理解するrange over function types!

2024/12/01に公開

本稿は、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.elementsMap型として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でループを記述することができ、コードが直感的。
  • 内部実装の隠蔽
    • イテレータを利用することで、データ構造の内部表現を公開せずに要素を提供可能。
  • 標準化
    • イテレータの記述方法が標準化され、カスタムデータ構造ごとのばらつきが解消。

コラム

コラム1:yieldの名前の由来

yieldの名前についてですが、「与える」「譲る」「生み出す」などの意味があり、イテレータが「次の値を渡す」動作から慣例につけられています。

コラム2:そのほかのイテレータ関数について

for/rangeは、iter.Seq以外にも以下のイテレータ関数を受け取ります。

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

iter.Seqとの違いは、引数の個数が2つという点だけです。

Seq2[K, V any]の使用例

  1. Mapなどのkeyvalueといった、2つの値を扱うコンテナ型で使用。
  2. iter.Seq2[value, error]のように使い、イテレータ側で発生したエラーを呼び出し側に渡すために使用。

コラム3:イテレータの記述方法の標準化について

Goの開発チームは、「すべてのコンテナ型でイテレータを返すAllというメソッドを提供すること」を推奨しています。詳しくは、こちらより。ちなみにMapやSliceに関しては、イテレータを返すAllという関数が既に存在しています。

MapのAll関数
https://github.com/golang/go/blob/c5adb8216968be46bd11f7b7360a7c8bde1258d9/src/maps/iter.go#L12
SliceのAll関数
https://github.com/golang/go/blob/c5adb8216968be46bd11f7b7360a7c8bde1258d9/src/slices/iter.go#L14

サンプルコード

https://github.com/nanikasi/range-over-function-demo/blob/main/range-over-func-loop/main.go

参考文献

謝辞

基本的な考え方から応用に至るまでご指導いただき、また議論や補足など多方面でお力添えをいただきました。たくてぃんさん、誠にありがとうございました!

GitHubで編集を提案

Discussion