マジックナンバーとデータ抽象
コードに直接書かれた 3 や 0 のような数値リテラル、いわゆるマジックナンバーを名前付き定数に置き換える。if (retryCount > 3) を if (retryCount > MAX_RETRY_COUNT) に直す。リファクタリングの定番として、ほとんど反射的にに行われている。
マジックナンバーを定数にする一番の理由としてよく挙がるのは、値の変更が一箇所で済むことである。同じ 3 がコードのあちこちに散らばっていると、上限を5に変えるとき変更漏れが起きうる。これは重複コードのコストとして実証されている。コードクローンの研究は、修正を全ての複製に反映し忘れると同じバグが再発すること、if条件を含む重複ほどバグ伝播のリスクが高いことを報告している(An empirical study on bug propagation through code cloning)。定数にまとめておけば、定義を一箇所直せば全ての参照に反映され、この漏れを防げる。読みやすさも上がる。if (retryCount > 3) より if (retryCount > MAX_RETRY_COUNT) のほうが、リトライ上限と比べているのだと読み取れる。
ただし、これらの利点は値に名前を付けることの効用であって、定数を一箇所に集めることの効用ではない。むしろ名前付けにしか出せない価値は、どの 3 が同じ概念で、どの 3 が無関係な別物かを区別することにある。リトライ上限の3と、たまたま値が同じ別の3を、名前で弁別する。値が同じというだけなら機械にも分かるが、同じ概念かどうかは人間が名前で示すしかない。同じ名前にまとめた値は、まとめて変わる。無関係な3を同じ定数に巻き込めば、片方だけ変えたいときに破綻する。変更を一箇所で済ませられるのは、この弁別ができて初めて安全に成り立つ。
問題は、マジックナンバーを定数に置き換えるだけで、定数だけが定義されたファイルやクラスを作ったときに起きる。
見かけ上の読みやすさは向上するが、判断に必要な情報量は減っていない。むしろ、名前の妥当性を検証する手間が増えている。
これは二つのことを指している。ひとつ、MAX_RETRY_COUNT という名前を見ても、なぜ3なのか、変えていい値なのか、何を根拠に決まった値なのかは依然どこにも書かれていない。リテラルが名前に変わっただけで、読み手が判断に使う情報は何も増えていない。もうひとつ、定数が Constants クラスのような別の場所に集約されると、その名前が実態に合っているかを確かめるために、定義元へジャンプして値を見に行く往復が発生する。if (x > 3) ならその場で完結していた確認に、一手間挟まる。名前が値の意味を取り違えていれば、むしろ誤解を招く。
定数だけを集めた Constants.java や constants.py は、値が使われる文脈から切り離されている。定義側を見ても何のための値か分からず、使用側を見ても値が分からない。どちらを見ても判断がつかない。
「Enumを使え」でも、まだ足りない
数値の定数がダメなら列挙型を使えばよい。注文ステータスを int STATUS_SHIPPED = 3 のような定数で持つのをやめて、enum OrderStatus { UNPAID, PAID, SHIPPED, CANCELLED } にする。値のとりうる範囲が型で閉じ、概念のまとまりはできる。
それでも、単なる名前付き値の集合として公開するだけなら、使う側が値を知らずに済むようにはならない。注文をキャンセルできるのは未払いか支払い済みのときだけ、という業務ルールがあるとする。Enumにしても、使う側はこう書く。
code: (java)
if (order.getStatus() == OrderStatus.UNPAID
|| order.getStatus() == OrderStatus.PAID) {
order.cancel();
}
「キャンセル可能なステータスはどれとどれか」という判定が、使う側に書かれている。同じOR条件は、キャンセルを扱う画面やバッチのあちこちに複製される。ここに予約済み(RESERVED)のような新しいステータスを足してキャンセル可能の範囲が変われば、この複合条件を持つ全ての箇所を探して直さなければならない。Enumにしても、どのステータスで何ができるかという知識を、使う側がずっと持たされたままである。
int→定数→Enumという改善は、突き詰めると値に名前を付けるという同じ軸の上にある。マジックナンバーに名前を付け、名前付き値の集合を型に閉じる。やっていることは「値をどう名前で呼ぶか」の精度を上げることで、使う側に値の知識を持たせ続けている点は何も変わっていない。データを持つだけで業務ルールを使う側に任せるのは、ドメインモデル貧血症 が指すものと同じ構図である。
使う側が関心を持つのは振る舞いであって値ではない
注文ステータスを使う側は、ステータスの値そのものに関心があるわけではない。知りたいのは「この注文はキャンセルできるか」「請求書を発行していいか」「再出荷できるか」といった、その注文に対して何ができるかである。ステータスがどれとどれならキャンセル可能か、というのは、その判断にいたる途中の事情でしかない。
code: (java)
if (order.canCancel()) {
order.cancel();
}
こう書けるなら、使う側はステータスという値の存在すら知らなくていい。「キャンセルできるか」という問いをそのまま投げて、答えを受け取る。出荷済みだからキャンセルできないのか、すでにキャンセル済みだからできないのか、そういう内部事情は Order の内側に隠れている。ステータスが増えても、判定ロジックが変わっても、影響は Order の中で閉じる。
これがデータ抽象である。Liskov & Zilles 1974 "Programming with abstract data types"(CLUにつながる仕事)が示したのは、実装と抽象は別物だということであった。実装はコードで、抽象は仕様として記述される振る舞いである。データ抽象とは、データの表現を内側に隠し、その上で許された操作だけを外に見せることを指す。Parnas 1972 "On the Criteria To Be Used in Decomposing Systems into Modules" の情報隠蔽も、変わりやすい決定をモジュールの内側に隠し、外からはそれに依存させないという原則である。ステータスの値とその上の判定は、変わりやすい決定そのものである。
この情報隠蔽の観点からも、同じ結論になる。マジックナンバーを定数にする、Enumにする、というのは、隠蔽をやっていない。変わりやすい判定を内側に隠さず、値に名前を付けて外に見せたまま、使う側に解釈させている。名前付けの精度をいくら上げても、値の解釈や判断を利用側に書かせている限り、データ抽象にはならない。問題は値が見えることそのものではなく、その値をどう分類し、どの操作を許すかという変更されやすい知識が、利用側に漏れていることである。
変更の局所化にも、別の軸がある。定数化が一箇所に畳めるのは値そのものの変更(3を5にする)だが、変わるのは値だけではない。その値をどう使うか、つまり判定ロジックのほうも変わる。値を外に見せたままだと、この判定は各使用箇所に散らばって書かれる。キャンセル可能なステータスの組み合わせが変わったとき、その複合条件を持つ箇所をすべて直すことになる。Order に隠してしまえば、値の変更も、それをどう使って判定するかの変更も、型の内側の一箇所で閉じる。定数化は値の変更を局所化し、データ抽象は値の使い方の変更まで局所化する。
数値でも同じことが起きる
ステータスのような列挙的な値だけの話ではない。MAX_RETRY_COUNT = 3 のような純粋な数値でも、構図は同じである。
code: (java)
if (attempt.count() > Config.MAX_RETRY_COUNT) {
throw new RetryExhaustedException();
}
定数にしたことで 3 というリテラルは消えたが、「リトライ回数の上限と現在の試行回数を比べて、超えていたら打ち切る」という判定ロジックは使う側に書かれたままである。上限が3なのか5なのか、超えたときにどう振る舞うのか、その知識を使う側が持っている。
code: (java)
if (retryPolicy.isExhausted(attempt)) {
throw new RetryExhaustedException();
}
RetryPolicy という型に閉じてしまえば、上限が固定値なのか、指数バックオフで動的に決まるのか、サーキットブレーカーと連動するのか、使う側は知らなくていい。「もう打ち切るべきか」だけを問う。3 という値も、それをどう使うかという知識も、RetryPolicy の内側に隠れる。
値が列挙的か数値かは関係ない。名前を付けただけでは足りず、振る舞いの内側に隠して初めて、使う側を値の知識から解放できる。
Enumが公開されているライブラリは間違いなのか
HTTPステータスコードを表す HttpStatus のような型は、たいてい汎用ライブラリにEnumとして実装され、publicに公開されている。401や404が利用側から見える。これはデータ抽象に反した設計に見えるが、間違いではない。
ライブラリがステータスコードをEnumで公開するのは、ライブラリが利用用途を知らないからである。401を受け取ってリトライするか、再ログインに飛ばすか、ログだけ出すか。この判断はライブラリの側にない。利用するアプリケーションごとに違う。隠すべき判断が無ければ、値をそのまま渡すしかない。Parnas 1972 の情報隠蔽が隠せと言うのは「変わりやすい判断」であって、判断がモジュールに無ければ隠すものも無い。
判断は利用側にある。401を「再認証が必要な状態」と解釈し、403や期限切れトークンと一緒に扱うか分けるかを決めるのは、そのアプリケーションのドメインの判断である。だから利用側が if (res.statusCode == HttpStatus.UNAUTHORIZED) をあちこちに書くのではなく、その判断を authResult.requiresReauthentication() のような自分のドメインの型に込める。HttpStatus を isUnauthorized() のように一対一でメソッドに置き換えるだけなら、判定を使う側に残したままで、Enumをメソッド名に言い換えたにとどまる。データ抽象を作る責任は、判断を持つ側にある。
軸はレイヤーの別でも、ライブラリかアプリかの別でもない。そのコードが値に対する判断を持つかどうかである。判断を持つ側がデータ抽象を作り、持たない側は値を公開してよい。汎用ライブラリがEnumを公開できるのは「汎用だから」ではなく、判断を持たないという条件を満たすからである。同じライブラリでも、内部に変わりやすい判断があれば、たとえばコネクションプールの取得可否なら、Enumで見せず pool.canAcquire() で隠す。自社アプリの中のユーティリティでも、利用用途を特定できない位置にあれば値を公開する側に回る。
名前付けの最終段階はドメイン抽象である
Arlo Belsheeの 命名のプロセス は、命名を一回で決める作業ではなく、七つの段階を経て進化させるものだと捉える。名前が無い(Missing)から始まり、無意味(Nonsense)、実直(Honest)、実直かつ完全(Honest and Complete)、正しいことをする(Does the Right Thing)、意図を表す(Intent)、そして最終段階のドメイン抽象(Domain Abstraction)へ進む。
この段階の上にマジックナンバーの話を並べると、3 を MAX_RETRY_COUNT にするのは、その値が何であるかを正直に名乗らせる段階までの仕事である。中間のどこに位置づけるにせよ、最終段階のドメイン抽象には届いていない。ステータスをEnumにするのも同じで、個々の値に意図のある名前を与えるところで止まり、それらを束ねる概念には至らない。
最終段階のドメイン抽象について、命名のプロセスではこう書いている。
ドメイン抽象化とは、あるコードの集合のための共有コンテキストにすぎない
定数を集めたファイルは、共有コンテキストではない。名前の付いた値が並んでいるだけで「アイデアの寄せ集め」のままである。個々の名前は自分自身をうまく表現していても、それらを束ねる概念がそこにはない。canCancel() を持つ Order や、isExhausted() を持つ RetryPolicy になって初めて、値とその上の操作が一つの概念に束ねられ、使う側に振る舞いとして差し出せる。
命名のプロセスでは、hardwareModeFlags: int や rangeInMeters: int のように「許容される値を狭めたり解釈が必要となる名前」を、Primitive Obsession のサインとして挙げている。ステータスや上限値をintやEnumのまま使う側に解釈させている状態が、これにあたる。欠けているのは名前ではなく、その値を束ねるべきドメインの概念のほうである。
定数クラスは、命名のプロセスの7段階でいえばドメイン抽象まで行かずに途中で止まったものに、それらしい器を与えた状態である。器があるぶん、止まっていることに気づきにくい。だから「マジックナンバーを定数にした、改善した」で終わってしまう。
マジックナンバーに名前を付けたら、そこで止めない。値を振る舞いの内側に隠し、使う側に値を見せないところまで進める。それがデータ抽象であり、定数化やEnum化が達していない段階である。
裏を返せば、コードに転がるマジックナンバーやベタなステータス比較は、まだ名前を与えられていないドメインの概念が埋もれている目印である。canCancel() を持つ Order、isExhausted() を持つ RetryPolicy は、3 や OrderStatus.PAID を手がかりに見つかった概念である。マジックナンバーを反射で定数に置き換えるのではなく、そこにどんな概念が隠れているかを問う。その問いがドメインモデリングそのものである。