Featured image of post コードシェフの仕込み帳【Iterator】順に辿る道〜注文履歴の内部表現を知りすぎたコードを、一件ずつ渡す走査インターフェースで整理する〜

コードシェフの仕込み帳【Iterator】順に辿る道〜注文履歴の内部表現を知りすぎたコードを、一件ずつ渡す走査インターフェースで整理する〜

注文履歴を配列で持つクラスの内部をファイルに変えたら、全コードが壊れた。IteratorパターンをPerlとMoo::Roleで実装し、「一件ずつ渡す窓口」を作ることで内部表現の変更が外に波及しない設計へ。仕組みから丁寧に解説します。

夕方の食堂は、準備の時間だ。

ランチとディナーの間、シェフは厨房で仕込みをしている。包丁の音が奥から聞こえてくる。私はホールの黒板の前に立って、今夜のメニューを書いていた。チョークを持つ手が少し寒い——エアコンを切り忘れたままだった。

引き戸が勢いよく開いた。

入ってきたのは若い女性だった。ショルダーバッグの肩が少し重そうで、右手にメモ用紙を持っている。一瞬、食堂の中を見回してから、カウンターのほうへ真っすぐ歩いてきた。

「すみません。コードの相談って、できますか」

慌てている感じではなかったが、切迫していた。飛び込みで入ってきた人間の空気だと思った。「外から厨房っぽいのが見えて」と彼女は続けた。「こんなところに入っていいのかわからなかったんですが」

「どうぞ。シェフを呼んできますね」

そう言いかけて、彼女がカウンターに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_nextnext)をロールとして定義し、実装漏れをロード時に検出する
全件マテリアライズすべてのレコードを一度にメモリへ展開すること。大量データでは高コストになる
create_iteratorコレクションが Iterator を返す窓口メソッド。内部表現が変わってもここだけを変えればよい

対象読者は、次のような人を想定しています。

  • PerlとMooの基本(hasnew)がなんとなく分かる
  • 「内部の実装を変えたら、それを参照しているコードが全部壊れた」という経験がある

技術スタックはPerl / Moo / Moo::Roleです。コードはすべて手元で動かし、テストが通ることを確認しています。

注文履歴が配列前提だった

シェフが厨房から出てきて、エプロンを外しながら彼女のPCを見た。

「説明してもらえるか」

彼女が話した。小さな飲食店向けの予約・注文管理SaaSを個人開発していて、注文履歴を管理する OrderHistory クラスがある。テスト中は件数が少なかったから、全件をメモリ内の配列で持っていた。本番稼働が始まって件数が増えてきたので、CSVファイルに切り替えようとした。records 属性を消して filepath 属性に変えた瞬間、2つの関数が同時に壊れた——と。

元のコードは、こうだった。

1
2
3
4
5
6
package OrderHistory;
use Moo;
use v5.36;

# テスト用: 全件をメモリ内の配列で保持
has records => (is => 'ro', default => sub { [] });

この OrderHistory を使う関数が2つある。合計売上を計算する total_sales と、一覧を表示する list_orders だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 合計売上
sub total_sales {
    my ($history) = @_;
    my $total = 0;
    $total += $_->{amount} for @{$history->records};  # 配列前提
    $total;
}

# 一覧表示
sub list_orders {
    my ($history) = @_;
    for my $rec (@{$history->records}) {              # 配列前提
        printf "%s: %d円\n", $rec->{id}, $rec->{amount};
    }
}

両方とも @{$history->records} と書いている——records が配列を返すことを、利用側が知っている。

ファイルに切り替えようとして records を消した瞬間、エラーが出た。

1
Can't locate object method "records" via package "OrderHistory"

2か所で。同じエラーが。

「直しました」と彼女は言った。「total_saleslist_orders を開いて、ファイルから読むように書き換えました。でも——またほかのところでも同じ書き方をしていて。内部を変えるたびに、全部を探して直すのかと思って」

「形を知りすぎていた」

シェフが2つの関数を並べて見た。包丁は厨房に置いてきたままだった。

「なぜファイルに変えたのか」とシェフが聞いた。

「件数が増えてきたので。テスト時代は配列でよかったんですが、本番で数千件になったので」

「目的は正しい」とシェフは言った。「問題はここだ」と @{$history->records} を指した。「この書き方は、中が配列であることを知っている。ファイルに変えたら使えない——中の持ち方が変わると、この書き方を使っているコードは全部直す必要がある」

「2か所直しました。でも別のところでも——」

「それが問題だ。中の形を知っているコードが、中が変わるたびに連動する」

私は黒板の前から、その話を聞いていた。食材の棚の並び方が変わったら、「あの棚の3段目」と覚えていた人全員が迷子になる——そういう絵が頭に浮かんだ。場所ではなく、受け渡し口を作ればいい。どこに何があるかを知っているのは、受け渡し口だけでいい。

internal-structure-exposure——コレクションの内部表現を利用側が知ってしまっている状態だ」とシェフは言った。「配列なのかファイルなのかDBのクエリなのかを、使う側が知っている。中が変わるたびに使う側が全部変わる」

「一件ずつ渡す」

シェフが厨房の小さなホワイトボードを持ってきた。図を描き始める——厨房の中と、受け渡し口と、ホール側。

「厨房が一件ずつ渡す窓口を作る。外は『次をくれ』か『もう終わりか』だけを聞く。中に何件あるか、どんな形で保持しているかは知らない」

それがIteratorパターンだ。コレクションの内部表現を公開せずに、要素へ順次アクセスする走査インターフェースを提供する振る舞いパターン。

まず、走査の契約を Moo::Role で定義する。

1
2
3
4
package Iterator;
use Moo::Role;
use v5.36;
requires 'has_next', 'next';

requireshas_nextnext を宣言する。このロールを with したクラスがどちらかを実装し忘れた場合、クラスをロードした時点でエラーになる——Perl は動的型付けの言語なので「コンパイル時」ではないが、使う前に必ず検出できる。

次に、CSVファイルから一行ずつ読む CsvOrderIterator を実装する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package CsvOrderIterator;
use Moo;
use v5.36;
with 'Iterator';

has _fh      => (is => 'ro', required => 1);  # ファイルハンドル
has _current => (is => 'rw');                 # 先読み済みの1件

sub BUILD {
    my ($self) = @_;
    $self->_current($self->_parse_next);  # 最初の1件を先読み
}

sub _parse_next {
    my ($self) = @_;
    my $line = readline($self->_fh) // return undef;
    chomp $line;
    my ($id, $amount) = split /,/, $line;
    { id => $id, amount => $amount + 0 };
}

sub has_next { defined $_[0]->_current }

sub next {
    my ($self) = @_;
    my $record = $self->_current;
    $self->_current($self->_parse_next);  # 次の1件を先読み
    $record;
}

BUILD で最初の1件を先読みして _current に置いておく。has_next_current が定義されているかを見るだけ。next_current を返して次の1件を先読む。ファイルを一行ずつしか読まない——全件をメモリに展開しない。

コレクション側の OrderHistory は、create_iterator メソッドで Iterator を返す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package OrderHistory;
use Moo;
use v5.36;

has filepath => (is => 'ro', required => 1);

sub create_iterator {
    my ($self) = @_;
    open(my $fh, '<:utf8', $self->filepath) or die "Cannot open: $!";
    CsvOrderIterator->new(_fh => $fh);
}

利用側は create_iteratorhas_next / next だけを知る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
sub total_sales {
    my ($history) = @_;
    my $iter = $history->create_iterator;
    my $total = 0;
    while ($iter->has_next) {
        $total += $iter->next->{amount};
    }
    $total;
}

sub list_orders {
    my ($history) = @_;
    my $iter = $history->create_iterator;
    while ($iter->has_next) {
        my $rec = $iter->next;
        printf "%s: %d円\n", $rec->{id}, $rec->{amount};
    }
}

@{$history->records} はどこにも出てこない。

依頼人が少し考えてから言った。

create_iterator の中で全件読んでリストに変換してから返せばよかったのでは——Iterator というオブジェクトを作る必要はありますか?」

シェフが少し止まった。ホワイトボードのマーカーを持ったまま、一瞬考えた。

「全部読んだら、全部メモリに乗る」とシェフは言った。「注文履歴が10万件になったら、合計を出すだけで10万件をメモリに展開する。それでいいか?」

依頼人が少し黙った。

「Iterator は今処理している1件だけを知っている。1件読んで、処理して、捨てる。ファイルからDBに変わっても——create_iterator の中だけを変えれば、total_saleslist_orders も変えなくていい」

「走査しかできない、ということですか」

「そうだ。has_nextnext しか持たない。だから利用側は走査にしか依存できない。中が配列でもファイルでもDBクエリでも——次の1件を返せれば、それでいい」

私は一枚のコース料理の皿を想像した。全部まとめてテーブルに出したら、最初から全体が見えてしまう——何皿あるか数えられる、5皿目だけをつまみ食いできる、並び方に文句を言える。一皿ずつ出せば、客は今もらった一皿しか知らない。テーブルに全部出すのは、全件をメモリに乗せることだ。一皿ずつ出すのが Iterator だ——と思った。

Facade・Builder との違い

前々回の Facade は「複数のサブシステムへの呼び出しを一本化する」パターンだった。前回の Builder は「一つのオブジェクトの部品をどう積み上げるか」を引き取るパターンだった。今回の Iterator は「コレクションの内部表現を隠して、一件ずつ渡す走査インターフェースを提供する」——向きが違う。Facade は段取りを隠し、Builder は組み立てを引き取り、Iterator は走査のインターフェースを固定する。

試食合格

テストを走らせた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ok 1  - has_next: データがあれば true
ok 2  - next: 1件目の id が正しい
ok 3  - next: 1件目の amount が正しい
ok 4  - has_next: 全件読み終えたら false
ok 5  - 走査: 3件の id が正しい順に取れる
ok 6  - total_sales: CSV バックエンドで 4300
ok 7  - list_orders: 3件返す
ok 8  - list_orders: 1件目が正しい
ok 9  - list_orders: 3件目が正しい
ok 10 - バックエンド変更: 配列版でも total_sales = 4300(関数は変えていない)
ok 11 - 空の履歴: total_sales = 0
ok 12 - 空の履歴: list_orders は空リスト
ok 13 - requires 未実装: ロード時にエラー(has_next がない)
1..13

全テスト通過、警告なし。

依頼人がAfterのコードを見た。for @{$history->records} という行が、どこにも出てこない。

「これで、内部が変わっても、使う側のコードは変えなくていい」と彼女は言った。

has_nextnext だけを知っていればいい」とシェフが言った。

「DBに変えるときも——create_iterator だけ変える」

「そうだ」

彼女は少し間を置いてから、「ありがとうございました」と言って立ち上がった。簡潔で、過不足がなかった。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
@{$history->records} のように、コレクションの内部表現(配列)を利用側が直接知ってアクセスしており、内部をファイルに変えたとたん全コードが壊れた(internal-structure-exposure)Iteratorパターン:Moo::Rolehas_next / next を契約し、コレクションが create_iterator で走査オブジェクトを返す利用側は走査インターフェースだけを知ればよく、内部表現が変わっても create_iterator だけを変えれば済む。全件マテリアライズを避けて一件ずつ処理できる

工程

  1. for @{$collection->attr} のような「内部の形を前提にした」アクセスを探す
  2. そのアクセスが複数の場所にあり、内部が変わると全部直す必要がある状態を確認する
  3. Moo::RoleIterator ロールを定義し、requires 'has_next', 'next' を宣言する
  4. ConcreteIterator(今回は CsvOrderIterator)を実装し、with 'Iterator' でロールを consume する——requires の未実装はロード時に検出される
  5. コレクション側(OrderHistory)に create_iterator メソッドを追加し、内部表現を隠して Iterator を返す
  6. 利用側を create_iteratorwhile ($iter->has_next) { $iter->next } の形に変更する
  7. テストを実行し、バックエンドが変わっても利用側のコードが変わらないことを確認する

シェフより

「中身を全部テーブルに出すな。一件ずつ渡せ。受け取る側は今もらった1件だけを見ればいい。厨房が何を用意しているかは知らなくていい。内部が変わっても、渡す窓口さえ守れば外は変えなくていい。10万件あっても、今日扱うのは今の1件だ」


彼女が帰って、引き戸が閉まった。シェフはまた野菜を切りに厨房に戻った。

私はカウンターの黒板の前に戻ったが、手が止まった。

@{$order->history}——自分が書いた注文履歴のコードが、頭に浮かんだ。あれ、そういう書き方をしていた気がする。配列で履歴を持っていて、@{$order->history} で全件回していた気がする。

先週、在庫管理のコードを見たときは「いつか確認しよう」と思った。今日は違う。

黒板のチョークを置いて、エプロンのポケットからスマホを取り出した。自分のGitリポジトリを開いて、history で検索した。

「あった」と小さく言ってしまった。

for my $h (@{$order->history})——まさにそれだ。直すかどうかは後で考えればいい。でも、今ここに、それがあることはわかった。

前は「おかしい気がします」だった。次は「ここが違います」だった。今日は——コードを見る前に、壊れ方が見えた。

片付けに戻りながら、少し怖いような、少し嬉しいような気持ちがした。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。