ランチの後片付けが終わったころ、食堂は静かになる。
夕方5時を過ぎていた。ディナーはまだ先で、シェフはひと足早く退けていた。私はホールの椅子を拭きながら、今日のことを頭の中で整理していた。
引き戸が開いた。
45歳前後の男性が入ってきた。エプロンの折りじわが残っていた——仕事の合間に来た人間の空気だった。「常連さんに紹介してもらいました」と言った。
「ありがとうございます」と私は言って、椅子を元の位置に戻した。
そのとき、厨房の奥でエプロンを外す音がした。シェフがまだいた。
男性のコードをひと目見て、私に向かって「お前が答えろ」と言った。それだけ言って、また厨房に戻った。
私は一瞬固まった。それから「見せてもらえますか」と言った。
この記事で学ぶこと
この記事は、「defined $member チェックが3つの関数に散らばり、1か所の書き忘れが実行時エラーになった」という問題を、Null Objectパターンで整理する話です。本物と同じインターフェースを持つ「何もしない会員」オブジェクトを導入し、呼び出し元の defined チェックをなくします。
| 学ぶこと | ひとことで言うと |
|---|---|
| Null Object パターン | null(Perlでは undef)の代わりに、本物と同じインターフェースを持つ「何もしないオブジェクト」を使う。呼び出し元は defined チェックを書かなくていい |
| null-check-scatter | undef チェックが複数の関数に重複して書かれており、1か所の書き忘れがエラーになる状態 |
| Moo での実装 | Moo::Role で requires を使ってインターフェースを定義し、RealMember と NullMember の両方が with 'MemberRole' で実装する |
| nullブランチの移動 | defined チェックを削除するのではなく、1か所(NullMember->new を渡す場所)に集める |
対象読者は、次のような人を想定しています。
- PerlとMooの基本(
has、new、with)がなんとなく分かる undefの扱いに困ったことがある。definedチェックを書き忘れてエラーにしたことがある
技術スタックはPerl / Mooです。コードはすべて手元で動かし、テストが通ることを確認しています。
書き忘れた1か所
男性がノートPCを開いた。ネット注文システムのコードだった。
「去年からポイント会員サービスを始めました」と男性は言った。「会員と非会員(ゲスト)の両方から注文が来ます。先週——ゲスト注文でエラーが出ました」
apply_discount・add_loyalty_points・record_purchase——3つの関数が並んでいた。どれも $member を受け取っている。
apply_discount の先頭に return $order_total unless defined $member が書いてある。record_purchase には if (defined $member) がある。add_loyalty_points には——ない。
| |
「add_loyalty_points に defined チェックが書いていないからです」と私は言った。「ゲスト注文で $member が undef のとき、add_points を呼ぼうとして止まります——Can't call method "add_points" on an undefined value」
「そうでしたか——」と男性は言った。「なぜ書き忘れたんでしょうね」
「3か所に同じチェックを書いていたからです」と私は続けた。「同じことを3回書くと、どこか忘れる。それがこのバグの原因です」
男性がゆっくり頷いた。「それで——どうすればよいですか」
私はホワイトボードに向かった。
null-check-scatter——undef チェックが複数の関数に重複して散在しており、書き忘れた1か所がエラーになる状態。3つの関数が「ゲストのとき何をするか」をそれぞれ独自に決めている——apply_discount は return で素通り、add_loyalty_points はクラッシュ、record_purchase は別配列に書き出す。「ゲストのときどうするか」が3種類の答えで実装されている。
何もしない会員
「NullMember を作ります」と私は言った。
「ゲスト注文のとき、undef の代わりに NullMember->new を渡します。NullMember は本物の会員と同じインターフェースを持つ——でも何もしない」
まずインターフェースを定義する。Moo::Role で MemberRole を作り、会員が持つべきメソッドを requires で列挙する。
| |
本物の会員(RealMember)は with 'MemberRole' でこのロールを実装する。既存の Member クラスに近い構造だが、with を追加してロールに縛る。
| |
そして NullMember。同じ MemberRole を持つ——でも何もしない。
| |
「ゲスト注文では undef の代わりに NullMember->new を渡します。apply_discount・add_loyalty_points・record_purchase——どの関数も defined チェックなしで呼べます。NullMember の add_points が何もしないだけです」
男性が少し考えた。「なるほど——」と言いかけて、止まった。
「if (defined $member) と書けば済むのでは? 新しいクラスが必要ですか?」
私は一拍置いた。
「1か所なら defined でいい、と思います」
私は言葉を続けた。「でも今のコードには3か所ある——apply_discount・add_loyalty_points・record_purchase。今回のバグは、3か所のうち1か所の書き忘れが原因でした。次に機能を追加するたびに、同じチェックを書く必要がある。書き忘れれば、また今回と同じことが起きます」
「NullMember を一度作れば——ゲスト注文では NullMember->new を渡すだけです。3か所に同じ判断を書かなくていい」
男性が静かに「分かりました」と言った。
厨房の方で音がした。シェフが出てきて——少しだけ頷いた。言葉はなかった。
Null Objectパターン——null(Perlでは undef)の代わりに、本物と同じインターフェースを持つ「何もしないオブジェクト」を使う構造パターン。呼び出し元から defined チェックが消える。Mooでは Moo::Role でインターフェースを定義し、RealMember と NullMember の両方が with 'ロール名' で実装する。
Perlは動的型付け言語なので、メソッドが存在しない場合は実行時エラーになる——コンパイル時ではなく、呼んだ瞬間に Can't call method "..." on undef になる。Moo::Role の requires は with のタイミングに実装を検証するので、書き忘れをモジュールロード時に検出できる。
Proxy・Decorator との違い
前回のProxyも「本物と同じインターフェースを持つ」パターンだった。違いは目的にある——Proxyはアクセスを制御する(認可・キャッシュ)。本物への委譲が主な処理だ。Null Objectは「委譲先が本物ではなく『無』」——何もしないことが仕事だ。Decoratorは振る舞いを重ねて拡張する——本物の処理に何かを追加する。Null Objectは追加ではなく、置き換え。undef という「存在しない」状態を「何もしないオブジェクト」として扱えるようにする。
試食合格
テストを走らせた。Before(null-check-scatter)で、まず問題の現場を確認する。
| |
テスト4番——ゲストで add_loyalty_points を呼ぶと die する。これが今週のバグだ。テスト6番——record_purchase はゲストのために別配列を用意している。3つの関数が3種類の方法でゲストを扱っている。
次にAfter(Null Objectパターン)で確認する。
| |
全テスト通過、警告なし。
テスト1番・2番——RealMember も NullMember も MemberRole を持つ。DOES('MemberRole') が両方で true になる。テスト4番・6番・9番——NullMember で3つの関数を呼んでも die しない。apply_discount は discount_rate=0 で計算されるので割引なし、add_loyalty_points は何もしない、record_purchase は何もしない。
男性がAfterのコードを見た。apply_discount・add_loyalty_points・record_purchase——どれにも defined $member が書いていない。
「チェックがどこにもない——」
「NullMember に書いてある」と私は言った。「ゲストのとき何もしないのは、Null Memberの仕事です」
シェフの仕込み工程表
| 問題(調理ミス) | 技法(パターン) | 効果(仕上がり) |
|---|---|---|
defined $member チェックが3か所に散在(null-check-scatter)。1か所の書き忘れで undef 呼び出しエラー。機能追加のたびに同じチェックが必要になる | Null Objectパターン:本物と同じ Moo::Role を持つ NullMember が「何もしない」で undef を置き換える | defined チェックが呼び出し元から消える。書き忘れがなくなる。機能追加のたびにチェックを書かなくていい |
工程
undefチェックが複数の関数に重複していないかを探す(defined $xxxの検索)Moo::Roleでインターフェースを定義する(requiresで必要なメソッドを列挙)- 本物のクラスに
with 'ロール名'を追加する(既存コードはそのまま) NullMemberをuse Mooで作り、with 'ロール名'を宣言する(hasを先に書いてからwith)- 各メソッドを「何もしない」または「デフォルト値を返す」で実装する(数値なら
0、文字列なら'') undefを渡していた場所をNullMember->newに差し替える(1か所だけ)- 呼び出し元から
definedチェックを削除する - テストで: 両クラスが
DOES('ロール名')を返すことと、各関数がNullMemberで安全に動くことを確認する
シェフより
(今回は見習いの言葉として記録する)
「undef を3か所でチェックするな。書いた数だけ、書き忘れる場所が増える。ゲストは存在しないのではなく、『何もしない会員』として存在する。NullMember を一度作れば——次は渡すだけでいい」
男性が帰った後、私はホールの椅子を元の位置に戻した。
今日は最後まで言えた。
ep13のとき——「テーブルで動く、でもなぜクラスなのか」が言えなかった。シェフが続きを言った。ep14のとき——「代理クラスを間に挟む」まで言えたが、「同じ窓口」という言葉が出てこなかった。シェフが補完した。
今日は——「defined チェックの方が短いのでは?」という問いに、自分の言葉で答えられた。「1か所なら defined でいい。でも3か所ある。書き忘れれば、また今回と同じことが起きる。NullMember を一度作れば、次は考えなくていい」
シェフが頷いた。言葉はなかった。それだけで十分だった。
言いながら、分かった気がした。ep13もep14も、黙って聞いていた。今日は口に出した。声に出して説明することで——自分の中で何かが繋がったような気がした。
黙って理解するのと、声に出して説明するのは、違うことだった。
