What?
Null Object パターンとは
プログラムを書いていると, 何もしない・何もないということを表したいシチュエーションがしばしば登場します.
ナイーブな実装ではこのような場合に null
のような特殊な値を使いますが, 多くの言語では null
に対してはメソッドの定義や呼び出しができないため, 以下のようなデメリットがあります1.
- メソッドを呼び出すたびに都度
null
かどうかの検査が必要になり, コードが冗長になる null
の場合にどうするかという処理を都度記述することになり, コードの凝集度が下がるnull
かどうかの検査を忘れてランタイムエラーが発生する
Null Object パターンはこれに対する解決案の一つで, 何もしない・何もないことを表すオブジェクト (Null Object) を導入します. これによってメソッド呼び出し前の検査が不要になったり, 何もない場合のロジックを紐づけることができるようになります.
クラス図にするとこういう感じ.
classDiagram direction LR Client --> AbstractObject: 使う class AbstractObject { <<interface>> +doSomething() } AbstractObject <|-- RealObject: 継承 AbstractObject <|-- NullObject: 継承 note for NullObject "何もしない・何もない"
守らなければいけない原則
上記のクラス図のとおり, NullObject
は AbstractObject
を継承します.
継承というのは一般に is-a 関係です.
したがって NullObject
は AbstractObject
の一種ですし, AbstractObject
が要求される場面ではいつでも NullObject
を使うことができるべきです (リスコフの置換原則).
もしそうでなければ, 利用者 (Client
) は AbstractObject
のように抽象化したままオブジェクトを扱うことができず, 常に NullObject
でないかどうかを気にする必要が生じて, Null Object パターンのメリットが損なわれてしまいます.
リスコフの置換原則は単に型が合っていれば良いという話ではなく, 型に現れない契約も含めて満たされるべきものです.
例えば AbstractObject
のメソッドを呼び出したときに文字列が返るとして, それが特定のフォーマットに従っていることが期待されるのであれば, NullObject
も同じ (またはより条件の厳しい) フォーマットの文字列を返さなくてはいけない, といったことです.
ぎこちない Null Object パターンにありがちなこと
Null Object パターン自体は決して悪いものではないのですが, 上記の原則が守れていないと, どこかぎこちないものになってしまいます.
特によく見られる失敗としては, モデリング (AbstractObject
や NullObject
の概念化) が上手くいっていないまま, 無理やり Null Object パターンを当てはめてしまった結果, is-a 関係になっていない (あるいはそれすら判定できない) ということがあるように思います.
例えばあるサービスには有料プランがあり, プランに加入しているかどうかと, 加入しているプランの種類によって利用できる機能が異なるとします.
ユーザーのプランへの加入状況を表すインターフェース PlanStatus
を用意しました.
サービスの他のコンポーネントでは PlanStatus
に基づいて個々の機能を有効化しますが, PlanStatus
は他にもユーザーへプランの加入状況を表示したりするのにも使われます.
interface PlanStatus { getUserId(): UserId; getPlanId(): PlanId; getPlanName(): string; getNextRenewalDate(): Date; canUseFeatureA(): boolean; canUseFeatureB(): boolean; }
有料プランに加入している場合は, プランを表すオブジェクト Plan
が利用でき, 以下の PaidPlanStatus
のようにプランに応じて機能の利用可否を返すような実装ができます.
class PaidPlanStatus implements PlanStatus { userId: UserId; plan: Plan; nextRenewalDate: Date; getUserId(): UserId { return this.userId; } getPlanId(): PlanId { return this.plan.id; } getPlanName(): string { return this.plan.name; } getNextRenewalDate(): Date { return this.nextRenewalDate; } canUseFeatureA(): boolean { return this.plan.canUseFeatureA(); } canUseFeatureB(): boolean { return this.plan.canUseFeatureB(); } }
有料プランに加入していない場合は Plan
は利用できないため, 以下の FreePlanStatus
のように, 機能の利用可否については定数を返すような Null Object を実装しました.
class FreePlanStatus implements PlanStatus { userId: UserId; getUserId(): UserId { return this.userId; } getPlanId(): PlanId { throw new Error("plan is not available"); } getPlanName(): string { return "Free"; } getNextRenewalDate(): Date { throw new Error("nextRenewalDate is not available"); } canUseFeatureA(): boolean { return false; } canUseFeatureB(): boolean { return false; } }
これによって, 機能の利用可否については常に PlanStatus
に対して canUseFeatureA()
といったメソッド呼び出しを行えば良いことになり, Null Object パターンが成功しているかのように見えます.
ここで PlanStatus
と FreePlanStatus
がそれぞれ何を表すのか考えてみましょう.
PlanStatus
は加入しているプランの状況と, 機能の利用可否を表すFreePlanStatus
はプランに加入していないときの, 機能の利用可否を表す
さて, これらは is-a 関係になっている, つまり FreePlanStatus
は PlanStatus
であると言えるでしょうか?
答えは実装からも明らかで, FreePlanStatus
に対する getPlanId()
や getNextRenewalDate()
といったメソッドの呼び出しは, PlanStatus
のインターフェースの通りに値を返すのではなく, 常にエラーを発生させます.
これでは PlanStatus
が要求される場面で FreePlanStatus
を使うことができない, つまり is-a 関係になっているとは言えず, Null Object パターンの価値が半減してしまっています.
この問題を解決するには, そもそも PlanStatus
や FreePlanStatus
といったものが何を表すのか, といったモデリングから考え直す必要があります.
解決策の一つは, 有料プランに加入しているかどうかを抽象化する役割を, PlanStatus
ではなく Plan
に持たせること, つまりこれまでプラン非加入としていた場合も, デフォルトのプランに加入しているものとして扱うことです.
もしそのようになっていれば, PlanStatus
は加入しているプランの状況と, 機能の利用可否を表すものとして, 以下のように (単一の実装で) 定義できそうです.
class PlanStatus { userId: UserId; plan: Plan; nextRenewalDate: Date; getUserId(): UserId { return this.userId; } getPlanId(): PlanId { return this.plan.id; } getPlanName(): string { return this.plan.name; } getNextRenewalDate(): Date { return this.nextRenewalDate; } canUseFeatureA(): boolean { return this.plan.canUseFeatureA(); } canUseFeatureB(): boolean { return this.plan.canUseFeatureB(); } }
ただしこの方法で全ての問題が解決するとは限らず, 例えばデフォルトのプランの有効期限はおそらく定義できないにも関わらず getNextRenewalDate()
のようなメソッドは残ってしまっていますし, もしかしたら Plan
をデフォルトのプランも表せるように一般化する方がもっと大変かもしれません.
別の解決策としては, PlanStatus
と FreePlanStatus
は機能の利用可否を表すことは共通しつつも, プランへの加入状況を表すことについては共通しなかったことを踏まえて, 共通する部分とそうでない部分の二つに分割してしまうことが考えられます.
まず PlanStatus
については, 何らかのプランに加入中の状態のみを表し, プランに加入していない状態は表さないものとして再定義してしまいましょう.
プランに加入しているかどうかによらずに必要な, 機能の利用可否の判定については別のところに追い出します.
class PlanStatus { userId: UserId; plan: Plan; nextRenewalDate: Date; getUserId(): UserId { return this.userId; } getPlan(): Plan { return this.plan; } getNextRenewalDate(): Date { return this.nextRenewalDate; } }
追い出した先が以下の FeatureAvailability
で, 機能の利用可否の判定という単一の目的に絞ったインターフェースになっています.
interface FeatureAvailability { canUseFeatureA: boolean; canUseFeatureB: boolean; }
これを使うと Plan
は以下のようなメソッドを実装できますし,
class Plan { // ... getFeatureAvailability(): FeatureAvailability { return { // ... }; } // ... }
プラン非加入の状態については以下のように定数値を定義しておくことができます.
const freeFeatureAvailability: FeatureAvailability = { canUseFeatureA: false, canUseFeatureB: false, };
サービスの他のコンポーネントは, プランの加入状況ごとに用意された FeatureAvailability
に依存して, 個々の機能を有効化します.
この条件分岐は毎回記述する必要はなく, 呼び出し元などの一箇所で記述されれば十分です.
const featureAvailability = planStatus ? planStatus.getPlan().getFeatureAvailability() : freeFeatureAvailability;
プランに加入しておらず PlanStatus
が存在しない場合は getNextRenewalDate()
といったメソッドを検査なしには呼び出せませんが, そもそも検査なしに呼び出されるべきではないのでこれで構いません.
まとめ
Null Object パターンの原則とよくある失敗, そこからの脱出の例を紹介しました. ぎこちない Null Object パターンに対しては, Null Object やその親である抽象クラスが何を表すかを考えてみましょう. もし is-a 関係になっていない, そもそも何を表しているのかわからないといった場合は, それらを整理することでより扱いやすい形に変えられるかもしれません.
(ところで正直なところ, わざわざ Null Object パターンに言及するのって, この手のぎこちなさ・不自然さを正当化するような場合が多いと思うんですよね. というのも, それがごく自然に登場する概念であれば, あえて Null Object であると言及したり意識されることは少ないため...)
-
デメリットが生まれる原因は
null
が型情報を持たず動的ディスパッチができないことによるものなので, 静的ディスパッチを行う場合や, Go のようにnil
が型情報を持つような場合はデメリットを回避することができます.↩