SOLID原則を理解し、JavaScriptで実践するためのガイド
ソフトウェア開発者にとって、堅牢でテスト可能で拡張性があり、保守性の高いオブジェクト指向のソフトウェアシステムを設計することは重要です。
そこで登場するのがSOLID原則です。
SOLIDは、ソフトウェア開発中に生じるかもしれない特定の問題を解決するために5つの設計原則が組み合わさったセットです。
この記事では、SOLID設計の原則について詳しく学んでいきます。
具体的には、SOLID原則が何を意味しているのか、各部分がそれぞれ何を表しているのか、また実際のプログラム例を挙げながら現役のプログラマーが説明します。
さらに、JavaScriptを使ってこれらの原則を実装する方法も紹介します。
SOLID設計原則とは?
SOLIDは、オブジェクト指向プログラミングにおける、以下を表すソフトウェア設計の5つの基本原則を指す用語です。
・ 単一責任原則(Single Responsibility Principle): SRP原則では、1つのクラスは1つの責任を持つべきです。
・ オープン/クローズド原則(Open/Closed Principle): OCP原則では、拡張に対して開かれ、変更に対して閉じているべきです。
・ リスコフの置換原則(Liskov Substitution Principle): LSP原則では、基本クラスのインスタンスは、その派生クラスのインスタンスと置換可能であるべきです。
・ インターフェース分離の原則(Interface Segregation Principle): ISP原則では、クライアントは、使わないメソッドへの依存を持たないようにするため、必要なインターフェースのみを持つべきです。
・ 依存関係逆転の原則(Dependency Inversion Principle): DIP原則では、高レベルのモジュールは低レベルのモジュールに依存すべきではなく、抽象化に依存すべきです。
SOLID原則は、オブジェクト指向プログラミングの概念として非常に古くから存在しています。
これらの原則は、1990年代初頭にアメリカのコンピュータ科学者である、ロバート・C・マーチン「英:Robert C. Martin(別名Uncle Bob)」によって提唱され、2000年代以降も広く使用されているものです。
これらの原則に従うことは、オブジェクト指向プログラミングとソフトウェア設計の分野で非常に重要です。
単一責任原則 (SRP)
単一責任の原則 は、クラス、モジュール、または関数が変更する理由は1つだけであるべきで、実行することも1つである必要があるという考え方です。
SRPは、ソフトウェアの保守性、拡張性、再利用性を向上させるために重要な原則です。
クラスや関数が複数の異なる責任を持つと、1つの変更が他の責任に影響を及ぼす可能性が高まり、コードが複雑化して理解や修正が難しくなります。
それに対して、責任を1つに絞ることで、変更の影響範囲を限定し、保守性を高めることができます。
例えば、カテゴリー分けの例を示します。
ここでは、商品を追加するクラスと商品を表示するクラスの2つのクラスを考えてみましょう。
以下は、悪い例(SRPを守っていない)です。
class ProductManager { constructor() { this.products = []; } addProduct(product) { this.products.push(product); } displayProducts() { this.products.forEach((product) => { console.log(`${product.name} - ${product.price}円`); }); } }
上記の悪い例のProductManagerクラスは、商品を管理する責任だけでなく、商品の表示も行っています。
つまり、複数の責任を持っています。
これは単一責任原則に反しています。クラスが複数の責任を持つ場合、将来的な変更や保守が困難になる可能性があります。
SRPは、モジュール(クラスや関数など)が1つの責任だけを持つべきという原則ですが、この例ではそれが守られていません。
改善するためには、商品の管理と表示の責任を分離することが適切です。
以下は、単一責任原則を守るように改善したコードです。
// 商品を表すクラス class Product { constructor(name, price) { this.name = name; this.price = price; } } // 商品の管理を担当するクラス class ProductCatalog { constructor() { this.products = []; // 商品リストを初期化 } addProduct(product) { this.products.push(product); // 商品をリストに追加するメソッド } } // 商品の表示を担当するクラス class ProductDisplay { constructor(catalog) { this.catalog = catalog; // 商品リストを持つProductCatalogのインスタンスを受け取る } displayProducts() { this.catalog.products.forEach((product) => { console.log(`${product.name} - ${product.price}円`); // 商品リストを表示するメソッド }); } }
上記では、Productクラスが商品を表すためのプロパティとコンストラクタを持ち、ProductCatalogクラスが商品の管理(追加)を担当し、ProductDisplayクラスが商品リストを表示する役割を持っています。
それぞれのクラスが独立して責任を担っており、コードがより理解しやすく、保守性が向上しています。
機能ごとにクラスや関数を分割することで、再利用性の向上にもなります。
例えば、他のプロジェクトで同じ機能を使用したい場合、単一の責任を持つモジュールを別のプロジェクトに移植しやすくなります。
また、クラス間の依存関係が明確になっており、変更も容易になります。
コードが増えることで、クラスや関数の数が増えることになりますが、可読性と保守性が向上します。このような設計は、長期的なプロジェクトの観点から非常に価値があると言えます。
Open/Closed原則
ソフトウェアの設計において、Bertrand Meyer氏が提唱した「オープン・クローズド原則」は、重要な基本原則の一つです。
この原則は、ソフトウェアのコンポーネント(クラス、モジュール、関数など)が、新しい機能の追加に対してはオープンであるべきであり、同時に既存のコードの変更に対してはクローズされるべきであるという指針を示しています。
具体的には、既存のコードを変更することなく、新しい機能を追加できるように設計されるべきという意味です。
オープン・クローズド原則の遵守によって、ソフトウェアの保守性や拡張性が向上し、変更による副作用を最小限に抑えることができます。
例を挙げると、以下のようなJavaScriptのコードがオープン・クローズド原則を無視している悪い例と言えます。
// 悪い例: オブジェクトの操作が複雑 const person = { name: "Taro", age: 30, job: "Engineer", location: "Tokyo", }; function printPersonInfo(person) { console.log("Name: " + person.name); console.log("Age: " + person.age); console.log("Job: " + person.job); console.log("Location: " + person.location); } function updatePersonLocation(person, newLocation) { person.location = newLocation; } printPersonInfo(person); // Taroの情報を表示 updatePersonLocation(person, "Osaka"); // 居住地を更新 printPersonInfo(person); // 更新された情報を表示
上記の例では、個別にプロパティを表示するprintPersonInfo関数や、直接プロパティを変更するupdatePersonLocation関数によって、既存のコードを修正せざるを得ない状況となっています。
これでは新しい機能の追加が難しくなり、保守性が低下してしまいます。
修正例として、オープン・クローズド原則を遵守したシンプルかつ効率的な方法があります。
// 修正例: シンプルなオブジェクトの操作 class Person { constructor(name, age, job, location) { this.name = name; this.age = age; this.job = job; this.location = location; } printInfo() { console.log("Name: " + this.name); console.log("Age: " + this.age); console.log("Job: " + this.job); console.log("Location: " + this.location); } updateLocation(newLocation) { this.location = newLocation; } } const person = new Person("Taro", 30, "Engineer", "Tokyo"); person.printInfo(); // Taroの情報を表示 person.updateLocation("Osaka"); // 居住地を更新(例:日本の別の都市に変更) person.printInfo(); // 更新された情報を表示
修正例では、Personクラスを定義し、コンストラクタでオブジェクトのプロパティを初期化しました。
また、printInfoメソッドで情報を表示し、updateLocationメソッドで居住地を更新するようにしました。
このようなカプセル化によって、オープン・クローズド原則を守ることができます。
以上のような原則を順守することで、ソフトウェアの品質を向上させ、プロジェクトの長期的な成功に貢献することができます。
リスコフ置換原理 (LSP)
前に解説したコードは問題のない例であり、ご指摘いただいた皆様に感謝申し上げます。
コード自体は正確ですが、リスコフ置換原則の悪い例として取り上げられることを心よりお詫び申し上げます。
当記事は完璧な正確性を保証しているわけではありませんので、ご理解いただけますようお願い申し上げます。
記事を修正いたしましたことをご了承くださいませ。
リスコフ置換原則(Liskov Substitution Principle、LSP)は、オブジェクト指向プログラミング(OOP)において重要な原則の一つです。
バーバラ・リスコフ氏(英:Barbara Liskov)とジャネット・ウィング氏(英:Jeannette Wing)が共著した1987年の論文で初めて提唱されました。
両者とも、コンピュータサイエンスの分野で非常に重要な貢献をした有名なアメリカのコンピュータ科学者です。
リスコフ置換原則によれば、子クラス(サブクラス)は親クラス(スーパークラス)の代わりに使用できる必要があります。
つまり、基本クラスに属するオブジェクトを派生クラスのオブジェクトに置き換えても、プログラムの意味や振る舞いが変わらないことが求められます。
これにより、コードの振る舞いが予測可能になり、抽象化やポリモーフィズムの恩恵を受けることができます。
リスコフ置換原則は、継承関係にあるクラス間で一貫性を保つために重要です。
もしリスコフ置換原則に従わないような設計が行われると、予期しない動作やバグの原因となる可能性が高まります。
したがって、良いOOP設計を行う上で、この原則を遵守することが非常に重要です。
悪い例として、以下のようなコードを考えてみましょう
class Bird { fly() { console.log('I can fly!'); } } class Penguin extends Bird { fly() { console.log('Sorry, I cannot fly.'); } }
この例では、Birdクラスには"fly"メソッドがあり、PenguinクラスがBirdクラスを継承していますが、Penguinクラスでは"fly"メソッドがオーバーライドされ、ペンギンは飛ぶことができないため、「Sorry, I cannot fly.」というメッセージが出力されます。
しかし、これはリスコフ置換原則に違反しています。
Birdクラスのインスタンスに対して"fly"メソッドを呼び出すと、飛ぶことができるという振る舞いが期待されるのに対して、Penguinクラスのインスタンスでは飛べないという振る舞いになっています。
つまり、PenguinクラスはBirdクラスの代替ではありません。このような場合、リスコフ置換原則に違反していると言えます。
修正したコード例は以下のようになります。
class Bird { fly() { console.log('I can fly!'); } } class Penguin extends Bird { swim() { console.log('I can swim!'); } } // 以下はリスコフ置換原則を満たす使用例 const bird1 = new Bird(); bird1.fly(); // Output: I can fly! const penguin1 = new Penguin(); penguin1.fly(); // Output: I can fly! (Inherited from Bird) penguin1.swim(); // Output: I can swim!
この修正では、Penguinクラスに新しい"swim"メソッドを追加し、"fly"メソッドはオーバーライドせずにそのまま継承しています。
これにより、PenguinクラスはBirdクラスの代替として機能し、リスコフ置換原則を満たすようになりました。
Penguinクラスのインスタンスは、"fly"メソッドを呼び出すと"I can fly!"と表示されることが期待されます。
リスコフ置換原則は、「ある目的を満たすクラスとそのサブクラス」に対して意味がある原則です。
その意味で、親クラスがBirdであり、子クラスがPenguinのように振る舞いを変更する場合は、リスコフ置換原則に違反することになります。
ただし、リスコフ置換原則は具体的な目的や用途によって柔軟に解釈されることもあります。
例えば、Birdクラスを親クラスとして、Penguinクラスを子クラスとして、Swimmerという別のクラスを作成して、水族館で飼う鳥類の振る舞いを表現することもできます。
この場合、Penguin クラスは Bird クラスの振る舞いを変更することなく、Swimmerインターフェースを実装してリスコフ置換原則を満たすことができます。
class Bird { fly() { console.log('I can fly!'); } } class Swimmer { swim() { console.log('I can swim!'); } } class Penguin extends Bird { // No fly() method here, but implements Swimmer interface swim() { console.log('I can swim underwater.'); } } const penguin = new Penguin(); penguin.fly(); // Output: "I can fly!" penguin.swim(); // Output: "I can swim underwater."
このように、リスコフ置換原則は柔軟な解釈が可能であり、用途に応じて異なる設計アプローチを取ることができます。
ただし、原則を守ることで、クラスの階層構造をより予測可能で拡張性のあるものにすることができます。
以下に他の例を示します。
class Shape { getArea() { throw new Error("Method not implemented."); } } class Circle extends Shape { constructor(radius) { super(); this.radius = radius; } getArea() { return Math.PI * this.radius * this.radius; } } // リスコフ置換原則に違反している function printShapeArea(shape) { console.log(shape.getArea()); } const shape = new Shape(); const circle = new Circle(5); printShapeArea(shape); // throws Error: "Method not implemented." printShapeArea(circle); // 78.53981633974483
上記のコードではShapeクラスのインスタンスとCircleクラスのインスタンスをprintShapeArea関数に渡しています。
ShapeクラスのgetAreaメソッドはエラーをスローするだけで実装されていませんが、CircleクラスのgetAreaメソッドは円の面積を返すように実装されています。
つまり、ShapeクラスのインスタンスとCircleクラスのインスタンスでは振る舞いが異なるため、リスコフ置換原則に違反しています。
リスコフ置換原則を守るためには、ShapeクラスのgetAreaメソッドを適切に実装するか、Shapeクラスを抽象クラスにしてgetAreaメソッドを抽象メソッドとして定義する必要があります。
リスコフ置換原則に厳密に従うために、以下のようにコードを修正します。
class Shape { getArea() { throw new Error("Method not implemented."); } } class Circle extends Shape { constructor(radius) { super(); this.radius = radius; } getArea() { return Math.PI * this.radius * this.radius; } } function printShapeArea(shape) { if (!(shape instanceof Shape)) { throw new Error("Invalid argument: shape must be an instance of Shape."); } console.log(shape.getArea()); } const shape = new Shape(); const circle = new Circle(5); printShapeArea(shape); // throws Error: "Method not implemented." printShapeArea(circle); // 78.53981633974483
printShapeArea関数内で、引数shapeがShapeクラスのインスタンスであることを確認するためにinstanceof演算子を使用します。もしshapeがShapeクラスのインスタンスでない場合は、エラーをスローします。
ShapeクラスのgetAreaメソッドは依然としてエラーをスローするだけですが、CircleクラスのgetAreaメソッドは円の面積を返すように実装されています。
これにより、printShapeArea関数はShapeクラスのインスタンスとCircleクラスのインスタンスの両方を受け入れますが、リスコフ置換原則に違反することはありません。
インターフェース分離原則 (ISP)
オブジェクト指向プログラミングにおける重要な原則の一つである「インターフェイス分離の原則(Interface Segregation Principle, ISP)」について考えてみましょう。
インターフェイス分離の原則は、クライアントが利用しないメソッドに依存しないようにすることを目的としています。
具体的には、クライアントが使用するために必要なメソッドだけを含む小さなインターフェイスを定義することを推奨しています。
一部の初心者は、ISPとSRPを混同してしまう方がいらっしゃいます。
初心者が混同する可能性がある理由の一つは、両方の原則が「責任」に焦点を当てていることです。
SRPはクラスやモジュールの責任を単一化することを強調しており、ISPはクライアントが必要とするメソッドのみを提供することを強調しています。この類似点から、初心者は両方の原則を同じようなものとして捉えることがあります。
SRPとISPは、共通点としてクラスやモジュールの責任を明確にすることを重視していますが、異なる観点からアプローチしています。
SRPは、クラスやモジュールの責任を単一化することに焦点を当てていますが、ISPは、クライアントが必要とするメソッドのみを提供することに焦点を当てています。
この原則により、クライアントが無駄なメソッドに依存することを防ぎ、より柔軟なコードの構築が可能となります。
では、具体的なコード例を使ってISPの重要性を考えてみましょう。
悪い例として、名前と年齢を持つPersonクラスを考えます。
このクラスでは、名前の表示と年齢の判定という2つの異なる役割を持っています。このような設計では、クラスが複数の責任を持つため、保守性や拡張性に問題が生じます。
class Person { constructor(name, age) { this.name = name; this.age = age; } displayInfo() { return `名前: ${this.name}、年齢: ${this.age}歳`; } isAdult() { return this.age >= 18; } }
上記の悪い例では、Personクラスには名前と年齢を保持するプロパティがあり、またdisplayInfo()メソッドでは名前と年齢の情報を組み合わせた文字列を返し、isAdult()メソッドでは年齢が成人かどうかを判定しています。
この設計では、Personクラスが名前と年齢の情報の保持、情報の表示、成人判定という3つの異なる役割を持っています。
これを修正するために、各メソッドの役割を適切に分離したクラス設計を行います。
// Personクラス: 名前と年齢を保持するクラス class Person { constructor(name, age) { this.name = name; // 名前を初期化 this.age = age; // 年齢を初期化 } } // NameDisplayクラス: Personクラスの名前を表示するクラス class NameDisplay { constructor(person) { this.person = person; // 表示するPersonオブジェクトを保持 } display() { return `名前: ${this.person.name}`; // Personオブジェクトの名前を表示 } } // AgeCheckerクラス: Personクラスの年齢を成人かどうかを判定するクラス class AgeChecker { constructor(person) { this.person = person; // 判定するPersonオブジェクトを保持 } isAdult() { return this.person.age >= 18; // Personオブジェクトの年齢が18歳以上ならtrueを返す } }
修正後の設計では、Personクラスは名前と年齢の情報を保持するだけに留め、名前の表示を行うNameDisplayクラスと年齢の判定を行うAgeCheckerクラスを別々に作成しました。
これにより、各クラスが単一の責任を持ち、保守性と拡張性が向上しました。
例えば、Personクラスの情報表示を変更したい場合は、NameDisplayクラスのみを変更すれば良くなりますし、成人判定のロジックを変更したい場合は、AgeCheckerクラスのみを修正すれば良くなります。
このように、ISPを適用した設計では、コードの保守性を高め、変更に柔軟に対応することができます。
各クラスが特定の機能や責任を持つように設計することで、コードベース全体がより管理しやすくなり、新しい機能の追加も容易になります。
インターフェイス分離の原則は、プロの開発者にとって必須の設計手法と言えるでしょう。
依存関係逆転の原則
依存関係逆転の原則(Dependency Inversion Principle)は、ソフトウェアの設計原則の一つであり、高レベルのモジュールが低レベルのモジュールに直接依存せず、両者が共通の抽象化に依存するようにすることを提唱しています。
依存関係(Dependency)とは、ソフトウェアの中で一つのモジュール(クラス、メソッド、関数など)が他のモジュールを使用することを指します。つまり、あるモジュールが別のモジュールに依存しているということです。
具体的には、高レベルのモジュールは、低レベルの実装の詳細に依存するのではなく、共通の抽象化インターフェースに依存するように設計されるべきです。
一方、低レベルのモジュールは、その抽象化に従って振る舞うように実装されます。
これにより、下位レベルの実装を変更しても、上位レベルのモジュールに影響を与えることなく、ソフトウェア全体の変更が容易になります。また、テストや保守がしやすくなるという利点もあります。
依存関係逆転の原則は、オブジェクト指向プログラミングの中で重要な設計原則の一つであり、ソフトウェアの柔軟性や再利用性を高めるために遵守されるべき原則の一つとなっています。
では、最後も悪い例のJavaScriptコードから見ていきましょう。
以下のコードは、Personクラスが直接ageプロパティにアクセスしてしまっています。
これは依存関係逆転の原則に違反しています。
class Person { constructor(name, age) { this.name = name; this.age = age; // 依存関係逆転の原則に違反しています。高レベルのモジュール(Personクラス)が低レベルのモジュール(ageプロパティ)に直接依存しています。 } isAdult() { return this.age >= 18; // 依存関係逆転の原則に違反しています。高レベルのモジュール(Personクラス)が低レベルのモジュール(ageプロパティ)に直接依存しています。 } } const person = new Person("Yamada Taro", 25); console.log(`${person.name} is an adult? ${person.isAdult()}`);
修正した例では、依存関係逆転の原則に従い、Personクラスはageプロパティの代わりに、AgeValidatorというインターフェース(抽象化)に依存するようになります。
これにより、Personクラスが具体的な年齢の計算方法に依存することを避けることができます。
// AgeValidatorインターフェース class AgeValidator { isValidAge(age) { throw new Error("This method must be implemented by subclasses."); } } // AdultAgeValidatorクラス(AgeValidatorの実装) class AdultAgeValidator extends AgeValidator { isValidAge(age) { return age >= 18; } } // Personクラス class Person { constructor(name, ageValidator) { this.name = name; this.ageValidator = ageValidator; } isAdult() { return this.ageValidator.isValidAge(this.age); } setAge(age) { this.age = age; } } const adultAgeValidator = new AdultAgeValidator(); const person = new Person("Yamada Taro", adultAgeValidator); person.setAge(25); console.log(`${person.name} is an adult? ${person.isAdult()}`);
上記の修正した例では、PersonクラスがAgeValidatorのインスタンスを受け取り、そのインスタンスに年齢の妥当性を判断する責任を委譲しています。
これにより、Personクラスは具体的な年齢の判定方法に依存しなくなり、AgeValidatorクラスを継承して新しいバリデーションロジックを作成することも簡単になります。
これにより、依存関係逆転の原則を守った柔軟で拡張可能なコードが実現されます。
このように、依存関係逆転の原則は、オブジェクト指向プログラミングにおいて重要な設計原則の一つであり、ソフトウェアの品質や保守性を高めるために広く利用されています。
最後に
SOLID原則は、ソフトウェア開発において極めて重要な概念です。
SOLID原則は、長期的なプロジェクトの成功に欠かせない基盤を提供します。
これらの原則を理解し、適切に適用することで、私たちはバグの少ない、保守性に優れ、柔軟性があり、拡張性もあり、そして再利用可能な優れたソフトウェアシステムを構築することができます。
また、開発チーム全体がコードの品質と可読性に向けて一丸となることができるでしょう。
この記事では、SOLID設計の原則とその重要性についてあなたは学びました。
それぞれの原則を具体的なコード例を交えて説明し、JavaScriptを使った実装方法についても詳細に解説しました。
本日は以上となります。
最後まで読んで頂きありがとうございます。
この記事が役に立ったら、ブックマークと共有をしていただけると幸いです。