さて、ここまでに適用してきた色々な考え方は、実は SOLID 原則としてよく知られている。
これはソフトウェア構築時に従うべき5つのガイドラインで、ソフトウェアの拡張性や保守性を高めるためのもの。この辺の話は噛めば噛むほど味がするスルメみたいなものなので、ここで細かくは話さないが、今までの流れを振り返りながら一つずつ紹介していく。
S:SRP [ Single Responsibility Principle] 【単一責任の原則】
サブスク管理システムでは、
Aさんは値引きなどを全部ひっくるめて
Subscription
クラスに実装してしまっていた
。これを
申込、割引、固定値値引きのそれぞれを別のクラスに分離することで、見通しをよくした
。見通しが良いと、今後のコード変更による影響範囲を最小化し、把握しやすくすることができる。
単一責任の原則を軽んじていると、すべての処理が一つのクラスに収まっているいわゆる「神クラス」というものができてしまう。処理通しの依存関係が複雑化し、それを把握しきれなくなり、コード修正にともなって思いもよらぬ場所を破壊してしまうことがある…。
ただし、 (余裕がある人向け)
では全ての関数を別々のクラスに分ければ良いのかと言うと、これもまた良くない。共通の意味を持っているメソッドが散逸している状態 (低凝集) は修正ミスを誘ったり、認知不可の向上につながったりする。
要はバランスである。
O:OCP [Open-Closed Principle]【オープン・クローズドの原則】
サブスク管理システムでは、 Aさんは条件分岐によってキャンペーンを適用していたため、新しいキャンペーンが追加されたら Subscription クラスを変更しなければならなかった 。一方、継承によって整理したことにより、 新たなキャンペーンを追加したときも対応する子クラスを追加するだけでよい状態になった。
ただし、 (余裕がある人向け)
今後想定しうるすべての修正に耐える構造というのは存在しないので、抽象化は容量・用法を守って行う必要がある。例えば今のキャンペーンクラスでは金額以外に作用することはできない (例えば一定期間は上位プランの機能が使えるキャンペーンとか) 。必要以上の抽象化でいたずらに複雑性を上げることも、オーバーエンジニアリングあるいは YAGNI と呼ばれ、注意されている。
要はバランスである。
L:LSP [Liskov Substitution Principle]【リスコフの置換原則】
サブスク管理システムでは、 AdditionalOptionServiceBilling という悪い継承の例を出しながら、継承では is-a 関係、意味上の共通部分が大切であることを説明した。 このようにキャンペーンではない想定外の継承を行うと、たとえば割引額が負になるなど想定外の状況が起こってしまい、思わぬバグやクラッシュの原因となってしまう。親クラスを求めている場所に子クラスを渡しても問題なく動作するような正しい継承をしよう。
ただし、 (余裕がある人向け)
子クラスでのオーバーライドを通して間接的に親クラスの動作を変更できるという特性上、「親クラスがどのように使われても正しい状態を保つ」ということは不可能に近い面もある。継承を利用するときは、意識的か無意識的か、親クラスや子クラスの使われ方になんらかの仮定を置くことになる。その仮定が大きくなりすぎるとその仮定を破ってしまう可能性が上がってしまい、壊れやすいプログラムになる。
例になっているかわからないけど
例えば、固定値値引き500円のキャンペーンがあるとしよう。これを100円の価格に対して適用すると、現在の実装では値引き後の金額は -400 円となる。これを考慮していないシステムでは普通にバグが起こり得る。
→ 固定値値引きの 500 円引きを実装するときに、親クラスで 500 円未満の金額が与えられないことを暗黙的に仮定してしまったケース
あるいは、それを意識していて、値引き後の金額を最低でも 0 円に抑えるよう修正したとする。すると今度は compute_discounted_price() が必ずしも 500 を返さなくなる。上のように100 円が渡されたケースだと、100 円が 0 円になるので、compute_discounted_price() は 100 を返すようになる。
ここで、compute_discounted_price() を使って「割引のお得度」を比較するコードがあったりすると、本当は 500 円引きの効果を持つのに 100 円引きの効果として過小に認識されてしまうことになる。
→ お得度を比較するコードを実装したとき、子クラスのオーバーライド方法によらず compute_discounted_price() が必ず値引きの本来の特性を教えてくれると暗黙的に仮定してしまったケース
要は。バランスである。
継承のこの面を嫌って、継承を避ける風潮も最近はある。
I:ISP [Interface Segregation Principle]【インターフェイス分離の原則】
サブスク管理システムでは、メール送信をキャンセルするメソッドをインターフェースに入れてしまうと子クラスでの実装が難しくなるという例を挙げた。 一つのインターフェースに全てまとめるのではなく、小さなインターフェースに分けた方がいいというものである。
- 大きいインターフェースは単一責任の原則に違反している可能性が高い。
- インターフェースを実装するときはすべてのメソッドを実装しないといけないので、「呼び出してはいけない」メソッドが発生しがち。リスコフの置換原則に反する。
ただし、 (余裕がある人向け)
単一責任の原則と同じような注意が必要になる。1 メソッドごとに全部別のインターフェースにするなどの極端なことをすると、処理が分散してまとまりをつかみにくくなるという問題がある。
要はバランスである。
D:DIP [Dependency Inversion Principle]【依存関係逆転の原則】
わかりやすい例では、クラスを直接参照するのではなく、インターフェースに依存させる。
サブスク管理システムでは、 申込処理の中でメール送信関数を直接利用するのではなくインターフェースを介して利用するように修正した 。それにより処理同士の独立性がより高まり (疎結合) 、コードの再利用性が向上した。結果、 テストコードでだけ実装を差し替えることができるようになった 。
なぜこれが依存性逆転と呼ばれているのだろう?観点は2通りある。
-
「実装クラスの依存関係」という観点から見た話
普通にコードを書いた場合、依存の関係はシンプルに呼び出し順になる。
インターフェースを挟むとこうなる。
- ActualMailClient に向かう依存の矢印が、ActualMailClient から出る依存の矢印になっている。
- また、アーキテクチャのレイヤーについて、「ビジネスロジックの世界」と「外とやりとりする世界」の境界線上で依存方向が逆向きになっている!
-
「依存が発生している位置」という観点から見た話
- 通常の実装では、subscribe() の内で MailClient を生成する。
- 逆転すると、subscribe() の外で生成された MailClient が渡されてくる。
ただし、 (余裕がある人向け)
任意のクラス呼び出しにすべてのインターフェースを挟むのが良いわけではない。それを始めると、ほとんどのクラスはインターフェースと 1:1 に対応するようになるだけで、修正工数と認知負荷を爆増させる以外の効果はない…。
要はバランスである。DDD などの設計手法では、そこにある程度の指針を与えてくれたりはする。
まとめ