SOLIDの原則 - 現代プログラミング勉強会
この記事は、「現代プログラミング勉強会」と称して僕が社内向けの勉強会で話した内容を一般公開できるように再編集したものです。
現代プログラミング勉強会シリーズは全4回に分かれています。
勉強会のターゲットとしては、以下のようなエンジニアを想定しています。
- 普段の業務ではレガシーなコードしか見る機会がない若手エンジニア
- 一定のプログラミング経験があるものの、主にレガシーシステムの保守を担当しているエンジニア
たとえどんな姿をしていようとも、本番環境で稼働しているコードは価値のあるものです。 一方で、コーディングスタイルや設計思想に関して言えば、いまあるものを将来に渡って盲信しつづけるのは非常に危険です。 この勉強会は、現代のアプリケーションに要求される特性を学ぶことと、これまで培ってきたプログラミングの常識について学習棄却の機会となることを目的としています。
SOLIDの原則
SOLIDとは、オブジェクト指向プログラミングの世界においてメンテナンス性の高いソフトウェアを作るために守るべき5つの原則の頭文字をあつめたものです。 2000年ごろにRobert C. Martinさんが中心となって世に出した論文1で言及されたものが最初と言われています。 それ以前のオブジェクト指向プログラミングの成果物でSOLIDの原則を守っているものがあれば、それは奇跡か天才の所業といって差し支えないでしょう。 すでに発表から20年が経過しているものの、現代において新しく生み出されるコードにおいてもこれらの原則が守られていないものが少なくありません。 世間に浸透するまでにはもうちょいかかるかなという気持ちです。
余談ですが、MartinさんはAgile Manifesto(アジャイルソフトウェア開発宣言)を発表したメンバーのひとりでもあります。 Agile Manifestoは2001年に発表されたものです。順番としては、Martinさんが論文を発表した後になります。 変化への対応の重要性はAgile Manifestoでも取り上げられており、SOLIDの各原則はアジャイルソフトウェア開発の礎となっています。
つまり、SOLIDの原則を理解していない人間が語るアジャイル開発はだいたい嘘です。
単一責任の原則 (Single Responsibility Principle)
Martinさんの最近の言説によると、単一責任の原則は次のように説明されています。
モジュールはたったひとつのアクターに対して責務を負うべきである。2
ここでいうモジュールとはプログラムの論理的な集合単位くらいに捉えておけばOKです。 名称から勘違いされがちですが、単一責任の原則はモジュールが単一の機能性を有するようにせよ、ということを意味しているのではありません。 言わんとしていることはむしろ逆であり、1つの変更要求に対して修正するモジュールが1つになるように機能性を凝集させよということを言っています。
よく陥りがちな罠としては、現実の事物をそのままモデル化してしまうことです。 例えば、「1人のユーザを表すモデルとしてUserクラスを作り、現実世界のオブジェクトとアプリケーション上のオブジェクトを1対1で対応させる」みたいなやり方では単一責任の原則を守ることは難しいでしょう。 「現実世界では1つのオブジェクトであったとしても、それを表現するシステム上のモデルはユースケースに応じてそれぞれ異なる」というような設計ができるようになる必要がありそうです。
単一責任の原則を守っているコードは、変更に際しての影響範囲の特定が容易になります。 これは単に改修コストが低くなるだけでなく、改修に伴うテストの範囲を限定したりコードレビューの負荷を下げることにも繋がります。 総じて、変更スピードと変更に携わる人の心理的安全性を向上させます。
開放閉鎖原則 (Open-Closed Principle)
ソフトウェア・エンティティは拡張に対して開いており、修正に対して閉じているべきであるという原則です。 ざっくりいうと、機能追加するときは新しいクラス(や関数etc...)を追加することによって実現し、(バグ以外の理由で)既存のコードを改修するなというようなことを言っています。 これは同時に、既存のコードを修正することなく機能追加ができるような拡張性を担保せよ、ということも意味しています。 この原則に従うすることで、機能追加時に余計な副作用を気にする必要がなくなり、品質保証にかかるコストを削減できます。
お気づきかもしれませんが、「グローバル変数を使うな」とか「インスタンス変数はprivateにしておけ」とかカプセル化の観点で初期に学習するであろう諸原則はこの開放閉鎖原則として昇華されています。
実装上はインタフェースの継承をうまく使うと開放閉鎖原則を守れるということにコードをあらかた書き終わってから気づきます。 前もってうまく設計するには天才的な手腕が必要になります。 また、将来に発生するであろう変更を完全に予測するのは(それが起こるかどうかを含めて)不可能ですので、あまりこだわりすぎるとエンジニアは脳みそが擦り切れてしまいます。 これに関しては開放閉鎖原則を紹介した論文の執筆者であるMartinさんも困難さを認めているようです。
というような事情や、機能追加するのと同時に古い機能を消すことも多々あったりして実質既存の修正に見えるというような話もあるため、厳密にこの原則を守ろうとすることにこだわってほしいとは思いません。 とはいえ、何も気にしなくていいというわけではないです。 コードを書くときは、将来どのような変更が発生することを予想していて、どんな変更に対して開放閉鎖原則を守れると思っているのかが初見の人にも伝わるように書くことが大事です。 これを怠ったコードは信念なき産業廃棄物と成り果てます。
リスコフの置換原則 (Liskov Substitution Principle)
バーバラ・リスコフさんとジャネット・ウイングさんによって提唱された、かつてのエンジニアが一番守れなかった原則です。 僕も若き時分にさんざんやらかしました。当時の僕の先輩エンジニアもやらかしてました。 定式化された表現はWikipediaとかを見てもらうことにして、 とりあえずオブジェクト指向プログラミングの世界では「S型がT型の派生(サブクラス)であれば、プログラム内でT型のオブジェクトが使われている場所はすべてS型のオブジェクトで置換可能でなければならない」ということを言っています。 対偶のほうがわかりやすいかもしれませんが、この原則を守らない継承はすべてヤバいということになります。
この原則を守っていないものの最たる例が便利な共通機能を詰め込んだ基底クラス、いわゆる神クラスです。 MVCのコントローラーに相当するクラスの基底クラスに共通機能を作り込んだりした結果生まれるアレです。 本来は委譲によって表現される関係を誤って継承によって表現してしまった場合などにこの闇に堕ちます。
/* 神クラス */
class SuperClass {
// 共通機能が詰め込まれた『便利な』既定クラス
}
/* 誤った継承 */
class SubClassA extends SuperClass {
// ユースケースAでしか使えない機能...
}
class SubClassB extends SuperClass {
// ユースケースBでしか使えない機能...
}
このような実装では、サブクラスたちがそれぞれのユースケースに対応するために独自の機能を詰め込まれていきます。 サブクラスたちはどれも神クラスを継承していますが、異なる機能性を持ち、相互に置換不可能となるためコンポーネント内で基底クラスとして参照されることはありません。 結果として、なぜ継承をつかったのかよくわからなくなってしまいます。
新たなクラスが別のクラスを継承すべきかどうか悩んだとき、あるいは継承を使うことに強い確信が持てない場合は継承を使うのをやめてください。 自信を持って他人に説明できない継承は99%この原則に反しています。 一般に誤った継承が将来的にもたらす損失にくらべて、継承すべき箇所でそうしなかったことによる損失は無視できるくらい小さいので大丈夫です。
なお、こういったケースで一般的にベターな実装となるのは移譲を用いるパターンです。
/* 良い例 */
interface Context {
// 便利な共通機能のシグネチャ......
}
class SuperClass implements Context {
// ......
}
class SubClassA {
// ※フレームワークによっては、依存性の解決にはDIが使われます
private context: Context
constructor(context: Context) {
this.context = context
}
}
最近のWebアプリケーションフレームワークでは、注入される共通機能とか諸々が詰め込まれたオブジェクトにContextっていう名前がついていることが多いですね。
インタフェース分離の原則 (Interface Segregation Principle)
あらゆるクライアント(ソフトウェア・エンティティの利用者)は自身が利用しないメソッドに依存することを強いられてはならないという原則です。 これはちょっとわかりにくいので擬似コードで例を示します。
ここに一般的な社畜を表すクラスEmployeeがあります。Employeeは人間なので、仕事をしたり休憩したり不満を言ったりします。
class Employee {
work() {
//......
}
rest() {
//......
}
complain() {
//......
}
}
Employeeを利用する立場(=クライアント)である上司はEmployeeを働かせます。
class Boss {
manage(employee: Employee) {
employee.work()
}
}
さて、この例では上司は社畜に働いてもらえさえすればいいのでwork()する能力だけに依存しています。 しかし、Employeeはrest()とかcomplain()とか(上司から見て)余計なインターフェイス(publicメソッド)を持っており、 コードを精査しない限りはこれらのメソッドが。 これが「クライアントが利用しないメソッドに依存している」とよばれる状態です。
インターフェイス分離の原則とは、クライアントにとって必要最小限となる機能だけが見えるようにして、不要な依存関係を排除しなさいという原則です。 では、この原則に従ってインターフェイスを分離していきましょう。 上司は社畜に仕事をしてほしい(workメソッドにのみ依存している)ので、仕事をする能力だけをもつWorkerインターフェイスを作ります。
interface Worker {
work(): void
}
class Boss {
manage(worker: Worker) {
worker.work()
}
}
EmployeeはWorkerインタフェースを実装します。
class Employee implements Worker {
work() {
//......
}
rest() {
//......
}
complain() {
//......
}
}
こうすることで、上司はWorkerを実装しているものであればなんでも働かせることができるようになりました。
将来的に休憩もしないし不満も言わないエリート社畜が入社してきても、上司クラスに修正を入れることなくこれまでの社畜と同様に扱うことができます。
class EliteEmployee implements Worker {
work() {
//......
}
}
運用フェーズにはいったシステムでは、複雑なインタフェースをもつエンティティを野放しにしておくと徐々にクライアントが増えていきます。 「誰がどのメソッドを呼ぶのか」を気にするのがどんどん大変になり、不必要な結合がうまれ変更が困難になります。 インタフェースを分離して、関心がある部分にだけ依存することで疎結合な状態を維持することができます。
依存性逆転の原則 (Dependency Inversion Principle)
- 上位のモジュールは下位のモジュールに依存してはならない(どちらのモジュールも抽象に依存すべきである)
- 「抽象」は実装の詳細に依存してはならない(実装の詳細が「抽象」に依存すべきである)
という2つのことを述べている原則です。 昨今のドメイン駆動設計とかクリーンアーキテクチャの世界では基本的なテクニックとして採用されているようです。
依存性逆転の原則をプログラムの全域に渡って完全に守ることは不可能です。 プログラムはどこかで具象に依存することを避けられません。 変化しやすい具象に依存したままにするのではなく、比較的安定している抽象に依存させることで変更に対する影響をコントロールすることが重要です。
上位のモジュールは下位のモジュールに依存してはならない
よくある上位から下位に向かって依存してる例を見てみましょう。
/* 上位モジュール */
class HogeController {
action() {
logic: FugaLogic = new FugaLogic()
logic.execute()
// ......
}
}
/* 下位モジュール */
class FugaLogic {
execute() {
const data = FooDao.get()
// ......
}
}
AがBに依存しているというのは、AがBを使っていることを意味します。 この例でいうと、HogeControllerはFugaLogicを使っているのでFugaLogicに依存しているといえます。 コードを素直な順番で書くと自然とこうなるのですが、これがあんまりよくないねというのが依存性逆転の原則の示唆するところです。
一般に、上位モジュールほどより業務上複雑かつ重要な処理を扱います。下位モジュールに変更が発生したときにそれが上位モジュールへ波及するのは避けるべきです。 依存するなと言われているので、HogeControllerからFugaLogicへの依存をむりやり消してみます。
class HogeController {
private logic: DoSomethingLogic
constructor(logic: DoSomethingLogic) {
this.logic = logic
}
action() {
logic.execute()
// ......
}
}
// ソースコードのパッケージで整理する言語の場合、このインタフェースはHogeControllerと同じパッケージにしまう
interface DoSomethingLogic {
execute()
}
class FugaLogic implements DoSomethingLogic {
execute() {
const data = FooDao.get()
// ......
}
}
インタフェースを間に挟むことでもはやHogeControllerはFugaLogicの存在を気にしなくてもよくなりました。 (この例ではコンストラクタで受け取ってしまっているのでさらに上位の何かがHogeControllerにFugaLogicを渡すことになりますが、 例によってフレームワークによってはこのへんを依存性注入で解決したりFactoryパターンを使ったりします)
こうすることで、上位のモジュールにはインタフェース(抽象)を満たすものなら何でも渡して良くなりました。 下位モジュールであるFugaLogicを修正しても、FugaLogicがインタフェースを変えない限りは変更の影響を上位モジュールが気にしなくてもよくなります。
抽象は実装の詳細に依存してはならない
抽象と聞くと条件反射的にインタフェースを持ち出してしまいがちですが、インタフェースとはなにかというと手続きの仕様を定めたものです。 手続きが思ったとおりに完了しさえすれば、誰がどのようにやっているかには一切関心を持たないでおくことによって疎結合を維持しようというのが依存性逆転の原則です。
ここで上位モジュールから抽出されるインタフェースは上位モジュールから下位モジュールに対する要求であり、上位モジュールの持ち物です。 下位モジュールは上位モジュールからの要求に合わせて実装されなければいけません。これが、「抽象は実装の詳細に依存してはならない」の意図するところだと考えられます。 変更の主導権は常により複雑なロジックを扱う側に握らせておくことで、修正が容易になります。
まとめ
色々書いてみましたが、SOLIDの原則として決定版といえるものにはなりませんでした。 とはいえ、書き起こしてみたことでSOLIDの原則の根底にある考え方がなんとなくみえてきた気がします。
SOLIDの原則は、ソフトウェアに変更が発生することを前提とし、修正にかかるコストを低く抑える方法を説いています。 キーワードは抽象と疎結合で、これらを軽視して短期的な開発効率を向上させるようなテクニックとは相容れません。 各原則の細かい内容を暗記することに執心するよりも、こういったポリシーのほうを吸収・実践していくほうが良いかもしれませんね。