Featured image of post コードシェフの仕込み帳【Null Object】何もしない会員〜`defined` チェックが散らばったコードを、同じインターフェースを持つ「何もしない」オブジェクトで整理する〜

コードシェフの仕込み帳【Null Object】何もしない会員〜`defined` チェックが散らばったコードを、同じインターフェースを持つ「何もしない」オブジェクトで整理する〜

undef チェックが3つの関数に散在し、1か所の書き忘れで実行時エラー。Null ObjectパターンをPerlとMooで実装し、本物と同じMoo::Roleを持つ「何もしない会員」で undef を置き換え、呼び出し元の defined チェックをなくす。

ランチの後片付けが終わったころ、食堂は静かになる。

夕方5時を過ぎていた。ディナーはまだ先で、シェフはひと足早く退けていた。私はホールの椅子を拭きながら、今日のことを頭の中で整理していた。

引き戸が開いた。

45歳前後の男性が入ってきた。エプロンの折りじわが残っていた——仕事の合間に来た人間の空気だった。「常連さんに紹介してもらいました」と言った。

「ありがとうございます」と私は言って、椅子を元の位置に戻した。

そのとき、厨房の奥でエプロンを外す音がした。シェフがまだいた。

男性のコードをひと目見て、私に向かって「お前が答えろ」と言った。それだけ言って、また厨房に戻った。

私は一瞬固まった。それから「見せてもらえますか」と言った。

この記事で学ぶこと

この記事は、「defined $member チェックが3つの関数に散らばり、1か所の書き忘れが実行時エラーになった」という問題を、Null Objectパターンで整理する話です。本物と同じインターフェースを持つ「何もしない会員」オブジェクトを導入し、呼び出し元の defined チェックをなくします。

学ぶことひとことで言うと
Null Object パターンnull(Perlでは undef)の代わりに、本物と同じインターフェースを持つ「何もしないオブジェクト」を使う。呼び出し元は defined チェックを書かなくていい
null-check-scatterundef チェックが複数の関数に重複して書かれており、1か所の書き忘れがエラーになる状態
Moo での実装Moo::Rolerequires を使ってインターフェースを定義し、RealMemberNullMember の両方が with 'MemberRole' で実装する
nullブランチの移動defined チェックを削除するのではなく、1か所(NullMember->new を渡す場所)に集める

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

  • PerlとMooの基本(hasnewwith)がなんとなく分かる
  • undef の扱いに困ったことがある。defined チェックを書き忘れてエラーにしたことがある

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

書き忘れた1か所

男性がノートPCを開いた。ネット注文システムのコードだった。

「去年からポイント会員サービスを始めました」と男性は言った。「会員と非会員(ゲスト)の両方から注文が来ます。先週——ゲスト注文でエラーが出ました」

apply_discountadd_loyalty_pointsrecord_purchase——3つの関数が並んでいた。どれも $member を受け取っている。

apply_discount の先頭に return $order_total unless defined $member が書いてある。record_purchase には if (defined $member) がある。add_loyalty_points には——ない。

 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
30
31
32
33
34
35
36
37
package Member;
use Moo;
use v5.36;
has email         => (is => 'ro', required => 1);
has discount_rate => (is => 'ro', default => 0.05);
has _points       => (is => 'rw', default => 0);
has _history      => (is => 'rw', default => sub { [] });
sub add_points  { my ($self, $amount) = @_; $self->_points($self->_points + $amount); }
sub add_history { my ($self, $amount) = @_; push @{$self->_history}, $amount; }
sub points      { my ($self) = @_; $self->_points; }
sub history     { my ($self) = @_; $self->_history; }

package main;

# 割引適用: undef のとき return で抜ける
sub apply_discount {
    my ($order_total, $member) = @_;
    return $order_total unless defined $member;       # ← チェックあり
    return $order_total * (1 - $member->discount_rate);
}

# ポイント付与: defined チェックなし
sub add_loyalty_points {
    my ($order_total, $member) = @_;
    $member->add_points($order_total);                # ← チェックなし・バグ
}

# 購入履歴記録: ゲスト用に別配列を用意してしまった
my @guest_history;
sub record_purchase {
    my ($order_total, $member) = @_;
    if (defined $member) {
        $member->add_history($order_total);           # ← 会員はオブジェクトに
    } else {
        push @guest_history, $order_total;            # ← ゲストは別の配列に
    }
}

add_loyalty_pointsdefined チェックが書いていないからです」と私は言った。「ゲスト注文で $memberundef のとき、add_points を呼ぼうとして止まります——Can't call method "add_points" on an undefined value

「そうでしたか——」と男性は言った。「なぜ書き忘れたんでしょうね」

「3か所に同じチェックを書いていたからです」と私は続けた。「同じことを3回書くと、どこか忘れる。それがこのバグの原因です」

男性がゆっくり頷いた。「それで——どうすればよいですか」

私はホワイトボードに向かった。

null-check-scatter——undef チェックが複数の関数に重複して散在しており、書き忘れた1か所がエラーになる状態。3つの関数が「ゲストのとき何をするか」をそれぞれ独自に決めている——apply_discountreturn で素通り、add_loyalty_points はクラッシュ、record_purchase は別配列に書き出す。「ゲストのときどうするか」が3種類の答えで実装されている。

何もしない会員

NullMember を作ります」と私は言った。

「ゲスト注文のとき、undef の代わりに NullMember->new を渡します。NullMember は本物の会員と同じインターフェースを持つ——でも何もしない」

まずインターフェースを定義する。Moo::RoleMemberRole を作り、会員が持つべきメソッドを requires で列挙する。

1
2
3
4
5
6
7
package MemberRole;
use Moo::Role;
use v5.36;
requires 'discount_rate';
requires 'add_points';
requires 'points';
requires 'add_history';

本物の会員(RealMember)は with 'MemberRole' でこのロールを実装する。既存の Member クラスに近い構造だが、with を追加してロールに縛る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package RealMember;
use Moo;
use v5.36;
has email         => (is => 'ro', required => 1);
has discount_rate => (is => 'ro', default => 0.05);
has _points       => (is => 'rw', default => 0);
has _history      => (is => 'rw', default => sub { [] });
with 'MemberRole';                                   # has の後に with(requires チェックのタイミング)
sub add_points  { my ($self, $amount) = @_; $self->_points($self->_points + $amount); }
sub add_history { my ($self, $amount) = @_; push @{$self->_history}, $amount; }
sub points      { my ($self) = @_; $self->_points; }

そして NullMember。同じ MemberRole を持つ——でも何もしない。

1
2
3
4
5
6
7
8
package NullMember;
use Moo;
use v5.36;
has discount_rate => (is => 'ro', default => 0);    # 割引なし
with 'MemberRole';                                   # has の後に with
sub add_points  { }                                  # 何もしない
sub add_history { }                                  # 何もしない(ゲストに履歴なし)
sub points      { 0 }                               # 常に0

「ゲスト注文では undef の代わりに NullMember->new を渡します。apply_discountadd_loyalty_pointsrecord_purchase——どの関数も defined チェックなしで呼べます。NullMemberadd_points が何もしないだけです」

男性が少し考えた。「なるほど——」と言いかけて、止まった。

if (defined $member) と書けば済むのでは? 新しいクラスが必要ですか?」

私は一拍置いた。

「1か所なら defined でいい、と思います」

私は言葉を続けた。「でも今のコードには3か所ある——apply_discountadd_loyalty_pointsrecord_purchase。今回のバグは、3か所のうち1か所の書き忘れが原因でした。次に機能を追加するたびに、同じチェックを書く必要がある。書き忘れれば、また今回と同じことが起きます」

NullMember を一度作れば——ゲスト注文では NullMember->new を渡すだけです。3か所に同じ判断を書かなくていい」

男性が静かに「分かりました」と言った。

厨房の方で音がした。シェフが出てきて——少しだけ頷いた。言葉はなかった。

Null Objectパターン——null(Perlでは undef)の代わりに、本物と同じインターフェースを持つ「何もしないオブジェクト」を使う構造パターン。呼び出し元から defined チェックが消える。Mooでは Moo::Role でインターフェースを定義し、RealMemberNullMember の両方が with 'ロール名' で実装する。

Perlは動的型付け言語なので、メソッドが存在しない場合は実行時エラーになる——コンパイル時ではなく、呼んだ瞬間に Can't call method "..." on undef になる。Moo::Rolerequireswith のタイミングに実装を検証するので、書き忘れをモジュールロード時に検出できる。

Proxy・Decorator との違い

前回のProxyも「本物と同じインターフェースを持つ」パターンだった。違いは目的にある——Proxyはアクセスを制御する(認可・キャッシュ)。本物への委譲が主な処理だ。Null Objectは「委譲先が本物ではなく『無』」——何もしないことが仕事だ。Decoratorは振る舞いを重ねて拡張する——本物の処理に何かを追加する。Null Objectは追加ではなく、置き換え。undef という「存在しない」状態を「何もしないオブジェクト」として扱えるようにする。

試食合格

テストを走らせた。Before(null-check-scatter)で、まず問題の現場を確認する。

1
2
3
4
5
6
7
ok 1 - 会員: apply_discount → 5%割引
ok 2 - ゲスト(undef): apply_discount → 割引なし(defined チェックで return)
ok 3 - 会員: add_loyalty_points → ポイントが積まれる
ok 4 - ゲスト(undef): add_loyalty_points → die(defined チェック忘れバグ)
ok 5 - 会員: record_purchase → 会員の履歴に追加
ok 6 - ゲスト(undef): record_purchase → 別配列に追加(独自実装が散在)
1..6

テスト4番——ゲストで add_loyalty_points を呼ぶと die する。これが今週のバグだ。テスト6番——record_purchase はゲストのために別配列を用意している。3つの関数が3種類の方法でゲストを扱っている。

次にAfter(Null Objectパターン)で確認する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ok 1 - RealMember は MemberRole を持つ
ok 2 - NullMember は MemberRole を持つ
ok 3 - RealMember: apply_discount → 5%割引
ok 4 - NullMember: apply_discount → 割引なし(die しない)
ok 5 - RealMember: add_loyalty_points → ポイントが積まれる
ok 6 - NullMember: add_loyalty_points → 何もしない(die しない)
ok 7 - NullMember: points は常に0
ok 8 - RealMember: record_purchase → 履歴に追加
ok 9 - NullMember: record_purchase → 何もしない(die しない)
ok 10 - RealMember->can('discount_rate')
...
ok 17 - NullMember->can('add_history')
1..17

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

テスト1番・2番——RealMemberNullMemberMemberRole を持つ。DOES('MemberRole') が両方で true になる。テスト4番・6番・9番——NullMember で3つの関数を呼んでも die しない。apply_discountdiscount_rate=0 で計算されるので割引なし、add_loyalty_points は何もしない、record_purchase は何もしない。

男性がAfterのコードを見た。apply_discountadd_loyalty_pointsrecord_purchase——どれにも defined $member が書いていない。

「チェックがどこにもない——」

NullMember に書いてある」と私は言った。「ゲストのとき何もしないのは、Null Memberの仕事です」


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
defined $member チェックが3か所に散在(null-check-scatter)。1か所の書き忘れで undef 呼び出しエラー。機能追加のたびに同じチェックが必要になるNull Objectパターン:本物と同じ Moo::Role を持つ NullMember が「何もしない」で undef を置き換えるdefined チェックが呼び出し元から消える。書き忘れがなくなる。機能追加のたびにチェックを書かなくていい

工程

  1. undef チェックが複数の関数に重複していないかを探す(defined $xxx の検索)
  2. Moo::Role でインターフェースを定義する(requires で必要なメソッドを列挙)
  3. 本物のクラスに with 'ロール名' を追加する(既存コードはそのまま)
  4. NullMemberuse Moo で作り、with 'ロール名' を宣言する(has を先に書いてから with
  5. 各メソッドを「何もしない」または「デフォルト値を返す」で実装する(数値なら 0、文字列なら ''
  6. undef を渡していた場所を NullMember->new に差し替える(1か所だけ)
  7. 呼び出し元から defined チェックを削除する
  8. テストで: 両クラスが DOES('ロール名') を返すことと、各関数が NullMember で安全に動くことを確認する

シェフより

(今回は見習いの言葉として記録する)

undef を3か所でチェックするな。書いた数だけ、書き忘れる場所が増える。ゲストは存在しないのではなく、『何もしない会員』として存在する。NullMember を一度作れば——次は渡すだけでいい」


男性が帰った後、私はホールの椅子を元の位置に戻した。

今日は最後まで言えた。

ep13のとき——「テーブルで動く、でもなぜクラスなのか」が言えなかった。シェフが続きを言った。ep14のとき——「代理クラスを間に挟む」まで言えたが、「同じ窓口」という言葉が出てこなかった。シェフが補完した。

今日は——「defined チェックの方が短いのでは?」という問いに、自分の言葉で答えられた。「1か所なら defined でいい。でも3か所ある。書き忘れれば、また今回と同じことが起きる。NullMember を一度作れば、次は考えなくていい」

シェフが頷いた。言葉はなかった。それだけで十分だった。

言いながら、分かった気がした。ep13もep14も、黙って聞いていた。今日は口に出した。声に出して説明することで——自分の中で何かが繋がったような気がした。

黙って理解するのと、声に出して説明するのは、違うことだった。

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