SOLID原則とは?
SOLID原則とは、オブジェクト指向プログラミングにおいて、変更しやすい・理解しやすい・再利用しやすいモジュール(クラスや関数などの、ソフトウェアのを構成する部品)を設計・開発するための原則のことです。ソフトウェアエンジニアRobert C. Martinに提唱された多くの設計原則を5つにまとめたものの頭文字をとって命名されました。
- S … Single Responsibility Principle: 単一責任の原則
- O … Open-Closed Principle: 開放閉鎖の原則
- L … Liskov Substitution Principle: リスコフの置換原則
- I … Interface Segregation Principle: インターフェイス分離の原則
- D … Dependency Inversion Principle: 依存性逆転の原則
オブジェクト指向プログラミングは、機能の変更や追加に対して柔軟に対応できるソフトウェアを構築するための手法です。しかし、複数の開発者が様々な事情でソフトウェアに機能の変更や追加を施し続けていくことによって、ソフトウェアは柔軟性を失っていきがちです。
- 新しい機能を追加するために既存のコードに大量の修正を加える必要がある
- 既存の機能を変更しようとした場合、既存コードの変更による影響範囲がはっきりせず、バグを生んでしまう可能性が高い
- 既存コードを理解するために多くの時間を費やしてしまい、機能の追加や変更に時間がかかる
- 既存コードが再利用しにくくなっており、機能の追加時に似たような処理を別の場所に書かなければならず、開発効率が悪い
SOLID原則を学ぶことで、ソフトウェアが上記のようなツラい状態になることを回避できる(もしくはツラい状態から回復できる)可能性があります。
本記事では、SOLID原則のひとつ「開放閉鎖の原則」について考え方、アンチパターンとその解決策の例をTypeScriptのサンプルコードとともに紹介します。
開放閉鎖の原則とは?
概要
開放閉鎖の原則とは、ソフトウェアを構成するモジュールは、拡張に対して開いていて(Open)、修正に対して閉じている(Closed)べきであるという原則です。つまり、ソフトウェアに新しく機能を追加するとき、既存のコードを変更せず新しいコードを追加するだけで済むようにしておくべきであるという意味です。
修正に対して閉じているとは?その理由
既存のコードを変更するということは、すでに動作しているコードに変更を加えるということです。すでに動作しているコードに変更を加えると、バグを生んでしまう可能性があり、バグを生まないために動作確認を行うなどのコストを支払う必要があります。
たとえばswitch
文に新しい条件分岐を追加するという簡単な修正であっても、break
の書き忘れで、すでに動作している機能が動作しなくなってしまう可能性があります。そういったケアレスミスを避けるためにも、新しい機能を追加する際は、なるべく既存のコードを修正しなくてもいいようにしておくべきです。
拡張に対して開いているとは?その理由
既存のコードを変更せずに機能を追加するためには、新しいモジュールを追加するだけで機能が追加できるようなつくりにしておくべきです。そのようなつくりにしておくことで、既存の動作している機能を破壊することを恐れることなく、新しい機能を追加できるようになります。
開放閉鎖の原則に違反している例
TypeScriptのサンプルコードで、開放閉鎖の原則に違反している例を紹介します。
以下は、社員に支払う給与の総額を計算するCalculateSalaryService
クラスの例です。Intern
やStaff
などの役職とベースBASE
に基づいて計算される基本給と、何かしらの手当allowance
を加算して、社員に支払う給与の総額totalPayment
を計算します。
type Position = "Intern" | "Staff" | "Manager"; export class Employee { public constructor(public name: string, public position: Position) {} }
export class CalculatePaymentService { private readonly BASE = 100; private totalPayment = 0; public constructor( private readonly employee: Employee, private readonly allowance: number = 0, ) {} public execute = (): number => { this.addSalaryToTotalPayment(); this.addAllowanceToTotalPayment(); return this.totalPayment; }; private addSalaryToTotalPayment = (): void => { switch (this.employee.position) { case "Intern": this.totalPayment += this.BASE * 0.5; break; case "Staff": this.totalPayment += this.BASE; break; case "Manager": this.totalPayment += this.BASE * 2; break; default: const _check: never = this.employee.position; } }; private addAllowanceToTotalPayment = (): void => { this.totalPayment += this.allowance; }; }
Staff
であるBob(今月は10
の手当がある)の給与の支払額を計算する場合は、以下のようにインスタンスを作成→メソッドを実行します。
const employee = new Employee("Bob", "Staff"); const allowance = 10; const totalPayment = new CalculatePaymentService(employee, allowance).execute(); console.log(totalPayment); // => 110
CalculateSalaryService
クラスは、開放閉鎖の原則に違反しているといえます。
原則に違反している理由
CalculateSalaryService
クラスは、以上のコードでも問題なく動作します。しかし、たとえばLeader
などの新しい役職を加えるときのシチュエーションを考えると、既存のswitch
文にcase
を追加したり、Position
型にLeader
を追加する対応をしなければならず、上述したようなバグを引き起こす可能性があります。これは、拡張に対して開いている/修正に対して閉じているとはいえず、開放閉鎖の原則に違反しています。
解決策
CalculateSalaryService
クラス・Employee
クラスを次のように書き換えることで、既存のコードを書き換えずに新しい役職を加えることができるようになります。
export interface Employee { name: string; getSalary: (base: number) => number }
export class CalculatePaymentService { private readonly BASE = 100; private totalPayment = 0; public constructor( private readonly employee: Employee, private readonly allowance: number = 0, ) {} public execute = (): number => { this.addSalaryToTotalPayment(); this.addAllowanceToTotalPayment(); return this.totalPayment; }; private addSalaryToTotalPayment = (): void => { this.totalPayment += this.employee.getSalary(this.BASE) }; private addAllowanceToTotalPayment = (): void => { this.totalPayment += this.allowance; }; }
ここでは、Employee
クラスをinterfaceにして、CalculateSalaryService
クラスで行っていた給与計算の処理をEmployee
に移しています。また、Intern
やStaff
などの情報は文字列で受け取ってswitch
文で判定していましたが、Employee
を実装したクラスとして用意するようにします。
export class Intern implements Employee { public constructor(public name: string) {} public getSalary = (base: number) => base * 0.5; }
export class Staff implements Employee { public constructor(public name: string) {} public getSalary = (base: number) => base; }
export class Manager implements Employee { public constructor(public name: string) {} public getSalary = (base: number) => base * 2; }
Staff
であるBob(今月は10
の手当がある)の給与の支払額を計算する場合は、以下のようにインスタンスを作成→メソッドを実行します。
const employee = new Staff("Bob") const allowance = 10; const totalPayment = new CalculatePaymentService(employee, allowance).execute() // => 110
このようにすることで、Leader
などの新しい役職を加えるときのシチュエーションでも、Employee
を実装したLeader
クラスを用意して利用するだけで済みます(Open)。既存のクラス(CalculateSalaryService
クラスなど)に変更を加える必要はありません(Closed)。
export class Leader implements Employee { public constructor(public name: string) {} public getSalary = (base: number) => base * 1.5; }
所感
拡張に開いている/修正に閉じている設計にしておくことで、バグを恐れずに機能を追加できる例を紹介しました。
開放閉鎖の原則を意識して設計することの利点として、他にも、コード全体の見通しが良くなるという点もあります。例としてあげたLeader
を実装したいシチュエーションでは、「interfaceEmployee
があって、それを実装したIntern
、Staff
、Manager
が並んでいるな〜、ア、Leader
を他に倣って作ればいいんだな」とすぐ理解できます。違反している例としてあげた状態だと「switch
にcase
追加すればいいわけね、ア、Position
にも追加するのか、他に変えなきゃいけないところないかな…?」とちょっと不安になりそうです。
注意しなければいけない点として、拡張されたモジュール(Intern
、Staff
、Manager
)はお互いに副作用がないことが前提になっています。ここでいう副作用というのは、関数やメソッドの実行時に他のモジュールに与える影響のことです。Intern
、Staff
、Manager
のgetSalary
メソッドは、ただnumber
を受け取って計算して返すだけなので、副作用はありません。Intern
がgetSalary
を実行したことによってIntern
の持つプロパティ以外の変数などが変化するようなことは避けましょう。