夕方の食堂は、準備の時間だ。
ランチとディナーの間、シェフは厨房で仕込みをしている。包丁の音が奥から聞こえてくる。私はホールの黒板の前に立って、今夜のメニューを書いていた。チョークを持つ手が少し寒い——エアコンを切り忘れたままだった。
引き戸が勢いよく開いた。
入ってきたのは若い女性だった。ショルダーバッグの肩が少し重そうで、右手にメモ用紙を持っている。一瞬、食堂の中を見回してから、カウンターのほうへ真っすぐ歩いてきた。
「すみません。コードの相談って、できますか」
慌てている感じではなかったが、切迫していた。飛び込みで入ってきた人間の空気だと思った。「外から厨房っぽいのが見えて」と彼女は続けた。「こんなところに入っていいのかわからなかったんですが」
「どうぞ。シェフを呼んできますね」
そう言いかけて、彼女がカウンターにPCを開くのが目に入った。エラー画面と、2つの関数のコードが並んでいる。
なんとなく見てしまった。for my $rec (@{$history->records}) という行が、2か所に出てきた。
——あ。
私はチョークを持ったまま、声に出す前に少し考えた。$history->records で全件取り出して、for で回している。それが2か所にある。records という名前は、何かのリストを直接返しているんだろう。
内部の持ち方が変わったら——records がなくなったら——この書き方を使っているコードは、全部直しになるんじゃないか。
「あの」と私は言った。「$history->records で全件アクセスしていますよね。内部の持ち方を変えたら、ここを使っているコードが全部直しになるんじゃないですか」
彼女がこちらを見た。驚いた顔をしていた。
「そうなんです。それが問題で、全部壊れて——」
包丁の音が止まった。シェフが厨房から顔を出したのは、そのあとだった。
この記事で学ぶこと
この記事は、「注文履歴クラスの内部を配列からCSVファイルへ変えたとき、その内部表現に直接依存していた全コードが壊れた」という問題を、Iteratorパターンで整理する話です。「一件ずつ渡す窓口(Iterator)」を作ることで、なぜ利用側のコードが内部表現の変更に影響されなくなるのかを、仕組みから解説します。
| 学ぶこと | ひとことで言うと |
|---|---|
| Iterator パターン | コレクションの内部表現を公開せずに、要素へ順次アクセスする走査インターフェースを提供する振る舞いパターン |
| internal-structure-exposure | コレクションの内部表現(配列・ハッシュ・ファイル等)を利用側が直接知ってアクセスしている状態 |
Moo::Role の requires | 走査の契約(has_next と next)をロールとして定義し、実装漏れをロード時に検出する |
| 全件マテリアライズ | すべてのレコードを一度にメモリへ展開すること。大量データでは高コストになる |
| create_iterator | コレクションが Iterator を返す窓口メソッド。内部表現が変わってもここだけを変えればよい |
対象読者は、次のような人を想定しています。
- PerlとMooの基本(
has、new)がなんとなく分かる - 「内部の実装を変えたら、それを参照しているコードが全部壊れた」という経験がある
技術スタックはPerl / Moo / Moo::Roleです。コードはすべて手元で動かし、テストが通ることを確認しています。
注文履歴が配列前提だった
シェフが厨房から出てきて、エプロンを外しながら彼女のPCを見た。
「説明してもらえるか」
彼女が話した。小さな飲食店向けの予約・注文管理SaaSを個人開発していて、注文履歴を管理する OrderHistory クラスがある。テスト中は件数が少なかったから、全件をメモリ内の配列で持っていた。本番稼働が始まって件数が増えてきたので、CSVファイルに切り替えようとした。records 属性を消して filepath 属性に変えた瞬間、2つの関数が同時に壊れた——と。
元のコードは、こうだった。
| |
この OrderHistory を使う関数が2つある。合計売上を計算する total_sales と、一覧を表示する list_orders だ。
| |
両方とも @{$history->records} と書いている——records が配列を返すことを、利用側が知っている。
ファイルに切り替えようとして records を消した瞬間、エラーが出た。
| |
2か所で。同じエラーが。
「直しました」と彼女は言った。「total_sales と list_orders を開いて、ファイルから読むように書き換えました。でも——またほかのところでも同じ書き方をしていて。内部を変えるたびに、全部を探して直すのかと思って」
「形を知りすぎていた」
シェフが2つの関数を並べて見た。包丁は厨房に置いてきたままだった。
「なぜファイルに変えたのか」とシェフが聞いた。
「件数が増えてきたので。テスト時代は配列でよかったんですが、本番で数千件になったので」
「目的は正しい」とシェフは言った。「問題はここだ」と @{$history->records} を指した。「この書き方は、中が配列であることを知っている。ファイルに変えたら使えない——中の持ち方が変わると、この書き方を使っているコードは全部直す必要がある」
「2か所直しました。でも別のところでも——」
「それが問題だ。中の形を知っているコードが、中が変わるたびに連動する」
私は黒板の前から、その話を聞いていた。食材の棚の並び方が変わったら、「あの棚の3段目」と覚えていた人全員が迷子になる——そういう絵が頭に浮かんだ。場所ではなく、受け渡し口を作ればいい。どこに何があるかを知っているのは、受け渡し口だけでいい。
「internal-structure-exposure——コレクションの内部表現を利用側が知ってしまっている状態だ」とシェフは言った。「配列なのかファイルなのかDBのクエリなのかを、使う側が知っている。中が変わるたびに使う側が全部変わる」
「一件ずつ渡す」
シェフが厨房の小さなホワイトボードを持ってきた。図を描き始める——厨房の中と、受け渡し口と、ホール側。
「厨房が一件ずつ渡す窓口を作る。外は『次をくれ』か『もう終わりか』だけを聞く。中に何件あるか、どんな形で保持しているかは知らない」
それがIteratorパターンだ。コレクションの内部表現を公開せずに、要素へ順次アクセスする走査インターフェースを提供する振る舞いパターン。
まず、走査の契約を Moo::Role で定義する。
| |
requires で has_next と next を宣言する。このロールを with したクラスがどちらかを実装し忘れた場合、クラスをロードした時点でエラーになる——Perl は動的型付けの言語なので「コンパイル時」ではないが、使う前に必ず検出できる。
次に、CSVファイルから一行ずつ読む CsvOrderIterator を実装する。
| |
BUILD で最初の1件を先読みして _current に置いておく。has_next は _current が定義されているかを見るだけ。next は _current を返して次の1件を先読む。ファイルを一行ずつしか読まない——全件をメモリに展開しない。
コレクション側の OrderHistory は、create_iterator メソッドで Iterator を返す。
| |
利用側は create_iterator → has_next / next だけを知る。
| |
@{$history->records} はどこにも出てこない。
依頼人が少し考えてから言った。
「create_iterator の中で全件読んでリストに変換してから返せばよかったのでは——Iterator というオブジェクトを作る必要はありますか?」
シェフが少し止まった。ホワイトボードのマーカーを持ったまま、一瞬考えた。
「全部読んだら、全部メモリに乗る」とシェフは言った。「注文履歴が10万件になったら、合計を出すだけで10万件をメモリに展開する。それでいいか?」
依頼人が少し黙った。
「Iterator は今処理している1件だけを知っている。1件読んで、処理して、捨てる。ファイルからDBに変わっても——create_iterator の中だけを変えれば、total_sales も list_orders も変えなくていい」
「走査しかできない、ということですか」
「そうだ。has_next と next しか持たない。だから利用側は走査にしか依存できない。中が配列でもファイルでもDBクエリでも——次の1件を返せれば、それでいい」
私は一枚のコース料理の皿を想像した。全部まとめてテーブルに出したら、最初から全体が見えてしまう——何皿あるか数えられる、5皿目だけをつまみ食いできる、並び方に文句を言える。一皿ずつ出せば、客は今もらった一皿しか知らない。テーブルに全部出すのは、全件をメモリに乗せることだ。一皿ずつ出すのが Iterator だ——と思った。
Facade・Builder との違い
前々回の Facade は「複数のサブシステムへの呼び出しを一本化する」パターンだった。前回の Builder は「一つのオブジェクトの部品をどう積み上げるか」を引き取るパターンだった。今回の Iterator は「コレクションの内部表現を隠して、一件ずつ渡す走査インターフェースを提供する」——向きが違う。Facade は段取りを隠し、Builder は組み立てを引き取り、Iterator は走査のインターフェースを固定する。
試食合格
テストを走らせた。
| |
全テスト通過、警告なし。
依頼人がAfterのコードを見た。for @{$history->records} という行が、どこにも出てこない。
「これで、内部が変わっても、使う側のコードは変えなくていい」と彼女は言った。
「has_next と next だけを知っていればいい」とシェフが言った。
「DBに変えるときも——create_iterator だけ変える」
「そうだ」
彼女は少し間を置いてから、「ありがとうございました」と言って立ち上がった。簡潔で、過不足がなかった。
シェフの仕込み工程表
| 問題(調理ミス) | 技法(パターン) | 効果(仕上がり) |
|---|---|---|
@{$history->records} のように、コレクションの内部表現(配列)を利用側が直接知ってアクセスしており、内部をファイルに変えたとたん全コードが壊れた(internal-structure-exposure) | Iteratorパターン:Moo::Role で has_next / next を契約し、コレクションが create_iterator で走査オブジェクトを返す | 利用側は走査インターフェースだけを知ればよく、内部表現が変わっても create_iterator だけを変えれば済む。全件マテリアライズを避けて一件ずつ処理できる |
工程
for @{$collection->attr}のような「内部の形を前提にした」アクセスを探す- そのアクセスが複数の場所にあり、内部が変わると全部直す必要がある状態を確認する
Moo::RoleでIteratorロールを定義し、requires 'has_next', 'next'を宣言するConcreteIterator(今回はCsvOrderIterator)を実装し、with 'Iterator'でロールを consume する——requiresの未実装はロード時に検出される- コレクション側(
OrderHistory)にcreate_iteratorメソッドを追加し、内部表現を隠して Iterator を返す - 利用側を
create_iterator→while ($iter->has_next) { $iter->next }の形に変更する - テストを実行し、バックエンドが変わっても利用側のコードが変わらないことを確認する
シェフより
「中身を全部テーブルに出すな。一件ずつ渡せ。受け取る側は今もらった1件だけを見ればいい。厨房が何を用意しているかは知らなくていい。内部が変わっても、渡す窓口さえ守れば外は変えなくていい。10万件あっても、今日扱うのは今の1件だ」
彼女が帰って、引き戸が閉まった。シェフはまた野菜を切りに厨房に戻った。
私はカウンターの黒板の前に戻ったが、手が止まった。
@{$order->history}——自分が書いた注文履歴のコードが、頭に浮かんだ。あれ、そういう書き方をしていた気がする。配列で履歴を持っていて、@{$order->history} で全件回していた気がする。
先週、在庫管理のコードを見たときは「いつか確認しよう」と思った。今日は違う。
黒板のチョークを置いて、エプロンのポケットからスマホを取り出した。自分のGitリポジトリを開いて、history で検索した。
「あった」と小さく言ってしまった。
for my $h (@{$order->history})——まさにそれだ。直すかどうかは後で考えればいい。でも、今ここに、それがあることはわかった。
前は「おかしい気がします」だった。次は「ここが違います」だった。今日は——コードを見る前に、壊れ方が見えた。
片付けに戻りながら、少し怖いような、少し嬉しいような気持ちがした。
