💨

デザインパターン~Iteratorとは?要素の列挙概念~

に公開

クリーンアーキテクチャやSOLID原則(特にDIP)は、システムを壊れにくくするための「骨格(木の幹)」です。

https://zenn.dev/hashidev/articles/887cf754cd4fa1

しかし、骨格だけではシステムは動きません。

実際の複雑なビジネスロジック(特定のニーズ)を、美しく、変更に強く実装するための「筋肉や関節(枝葉)」となるのがデザインパターンです。

今回はその中でも、大量データを安全に扱うための関節、「Iterator」について解説します。

Iterator: 要素を列挙する概念を一般化したもの

つまり、データ構造をiteratorというように抽象化した概念です。

例えば、map、スライス、DBなどのさまざまなデータ構造がありますが、その中身を理解することなく順番に中身からデータを運んできてくれる存在がIteratorです。

Iteratorはちょうど、巨大な倉庫の入り口にいる運び屋にあたります。

倉庫内の事情を知ることなく、「次のデータはある?あるなら持ってきてくれないか?」と頼むだけで、データを運び屋がとって来てくれる感覚です。

Iteratorの真価:遅延評価

Iteratorは巨大なデータ群からデータを処理する必要がある場合に特に威力を発揮します。

仮にIteratorを使わない場合、大量のデータを一挙に取り出すことにより、CPUやメモリに大きな負荷がかかります。しかし、iteratorパターンによって、動的にデータを取得することで、ログファイルやDBなどの数GBのようなデータ群からの取得処理もスムーズに処理することができるのです。

Iteratorを使うべき状況

  1. ログが数GBあり、メモリに乗り切らない
  2. APIレスポンスがページングされており、一度に全てを取得できない
  3. パフォーマンス改善のために裏側のデータ構造を頻繁に変える必要がある

言い換えると、扱うデータ量がメモリに乗るサイズのような、上記と反対の状況では、Iteratorがオーバーエンジニアリングになる場合があります。

GOにおけるIteratorの実装

ここまで学習してきたIteratorを、実際に自ら作ってみましょう。

外部APIからの大量データの取得の状況もあり得る「求人APIクライアント」を実装してみます。

1: インターフェース・構造体を使ってIteratorを実装

まずは、Iteratorとして必要な機能としてインターフェースを定義します。

package iterator

type Iterator[T any] interface{
	HasNext() bool
	Next() T
}

次に、定義したインターフェースを実装する具象イテレータークラスを実装します。

このクラスが、扱うデータ構造(今回はJob)を管理するバッファをメンバ変数として持っています。

package jobapiiterator

import (
	"fmt"
	"strconv"
)

// 具象iterator。求人データをiteratorとして処理する
type JobAPIIterator struct{
	CurrentPage int
	Buffer []Job //APIから取得した1ページ分のデータを一時保存
	Index int //現在のバッファ内のどこをみているか
	IsDone bool //全データ取り切ったかどうか
}

type Job struct{
	ID int
	Title string
}

// ダミーのAPI呼び出し関数。今回は1ページに2件返す設定
func FetchJobsFromAPI(page int)[]Job{
	if page > 3{
		return []Job{} // 3ページ目で終わり
	}
	fmt.Printf("\n [API 通信発生] ページ %d を取得中... \n", page)
	return []Job{
		{ID: page*10 + 1, Title: "エンジニア" + strconv.Itoa(page*10+1)},
		{ID: page*10 + 2, Title: "デザイナー" + strconv.Itoa(page*10+2)},
	}
}

// バッファが空または全て読み切った場合、次のページを裏側でフェッチする
func (it *JobAPIIterator) HasNext() bool{
	// バッファが空、または全て読み切った場合、次のページを裏側でフェッチする
	if it.Index >= len(it.Buffer) && !it.IsDone{
		it.CurrentPage++
		it.Buffer = FetchJobsFromAPI(it.CurrentPage)
		it.Index = 0

		if len(it.Buffer) == 0{
			it.IsDone = true
		}
	}

	return !it.IsDone
}

// バッファから1件取り出してインデックスを1つ進める
func (it *JobAPIIterator) Next() Job{
	// バッファから1件取り出して、インデックスを進める
	job := it.Buffer[it.Index]
	it.Index++
	return job
}

実際に実装したIteratorを使用してみます(クライアントコード)。

package main

import (
	"fmt"
	"iterator/iterator"
	jobapiiterator "iterator/job_api_iterator"
)

// 目的:外部APIから大量のデータを取得して処理する
func main(){
	var iterator iterator.Iterator[jobapiiterator.Job] = &jobapiiterator.JobAPIIterator{
		CurrentPage: 0,
		Index: 0,
	}

	fmt.Println("=== 求人データ処理開始 ===")

	count := 0
	for iterator.HasNext(){
		job := iterator.Next()
		fmt.Printf("処理中: ID=%d, Title=%s\n", job.ID, job.Title)
		count++
	}

	fmt.Printf("処理回数:%d", count)
}

2024の革命 Iterパッケージによるクロージャー

ここまでの実装で、GOでは、インターフェースおよび構造体を自ら実装することによってIteratorを実装する必要がありました。

これによって、主に以下が課題となっていました。

1. インデックス・データ構造を持ったバッファの管理を手動で管理する必要性

インデックスのずれ・無限ループなどのバグリスクがあります。

2. 実装されたイテレーターのメソッド名の統一

Iteratorとして実装されたメソッド名に統一がされていないことによる、利用者の混乱のリスクがあります。例えば、標準ライブラリでさえも、Scan, Nextのような動作自体は本質的に同じであるにも関わらず、名前が異なります。

そこで、2024年に、GOが新たな標準パッケージであるiterをリリースしました。これによって、Iteratorのための複雑な構造体の実装や、Iterator探索におけるインデックスの管理や命名による認識のズレなどを解消します。

以下に実際のIterパッケージを用いた実装してみます。

重要なのは、クロージャ内でのyield関数です。この点を、従来の構造体の実装を通したIteratorと比較してみましょう。

従来(Pull型): 使用側が Next() を呼んでデータを引っ張り出す。状態管理(インデックス等)の責任がIterator側にあるためバグりやすい。
最新(Push型・Go 1.23〜): Iterator側が yield() を呼んでデータを押し出す。状態はクロージャ内のローカル変数としてGoのランタイムが安全に保持してくれる(コルーチン的な動き)。

package jobapiiterator

import (
	"fmt"
	"iter"
	"strconv"
)

type Job struct{
	ID int
	Title string
}

// ダミーのAPI呼び出し関数。今回は1ページに2件返す設定
func FetchJobsFromAPI(page int)[]Job{
	if page > 3{
		return []Job{} // 3ページ目で終わり
	}
	fmt.Printf("\n [API 通信発生] ページ %d を取得中... \n", page)
	return []Job{
		{ID: page*10 + 1, Title: "エンジニア" + strconv.Itoa(page*10+1)},
		{ID: page*10 + 2, Title: "デザイナー" + strconv.Itoa(page*10+2)},
	}
}


func FetchAllJobs() iter.Seq[Job] {
	// 戻り値は関数→どんな関数?:yieldという関数を受け取る
	return func(yield func(Job) bool){
		page := 1 //状態は単なるローカル変数

		for {
			// APIから1ページ分を取得
			jobs := FetchJobsFromAPI(page)
			if len(jobs) == 0{
				return
			}
			// 取得したデータを1件ずつ、使う側(for range)に「yield(渡す)」する
			for _, job := range jobs{
				// yield(job)を呼ぶと、main側のforループの中身が実行される
				// もしmain側でbreakされたら、yieldはfalseを返す。これによって、クライアンが処理をやめた瞬間にクリーンアップを実行することが自然にかける
				if !yield(job){
					fmt.Println(" [System] クライアントがループを中断しました。通信を遮断します.")
					return //即座に終了してリソースを解放
				}
			}
			page++
		}
	}
}

package main

import (
	"fmt"
	jobapiiterator "iterator/job_api_iterator"
)

// 目的:外部APIから大量のデータを取得して処理する
func main(){

	fmt.Println("=== 求人データ処理開始 ===")

	count := 0
	for job := range jobapiiterator.FetchAllJobs(){
		fmt.Printf("処理中: ID=%d, Title=%s\n", job.ID, job.Title)
		count++

		// 3件処理したら途中でやめてみる 
		if count == 3{
			fmt.Println(">> 3件処理したのでbreakします")
			break
		}
	}

	fmt.Println("=== 処理完了 ===")
}

Discussion