📗

オンライン書店📗の例で理解するSOLID原則

2023/07/19に公開

オブジェクト指向設計のSOLIDの原則は、ソフトウェアの設計と開発をより理解しやすく、保守しやすくするためのガイドラインです。SOLIDは次の5つの原則の頭文字を取って名付けられました。

  1. Single Responsibility Principle (SRP) - 単一責任の原則

    「1つのクラスは1つの役割を持つべきだ」という考え方を示しています。つまり、1つのクラスが複数の役割を果たしている場合、それらの役割が互いに混ざり合う可能性があり、それが結果としてバグを生み出す可能性があるという考え方です。

  2. Open-Closed Principle (OCP) - 開放閉鎖の原則

    「クラスは拡張に対して開かれ、修正に対して閉じているべきだ」という考え方を示しています。つまり、既存のコードを変更せずに新たな機能を追加できるように設計するべきだ、という考え方です。

  3. Liskov Substitution Principle (LSP) - リスコフの置換原則

    「サブクラスはそのスーパークラスを置換できるべきだ」という考え方を示しています。つまり、あるクラスが別のクラスのサブクラスである場合、そのサブクラスはスーパークラスの置換として使えるべきだ、という考え方です。

  4. Interface Segregation Principle (ISP) - インターフェース分離の原則

    「特定のクライアントに無関係なインターフェースは強制すべきではない」という考え方を示しています。つまり、1つの大きなインターフェースよりも、小さな複数のインターフェースを作るべきだ、という考え方です。

  5. Dependency Inversion Principle (DIP) - 依存関係逆転の原則

    「具体的なクラスよりも抽象的なクラスに依存すべきだ」という考え方を示しています。つまり、高レベルのモジュールは低レベルのモジュールに直接依存せず、どちらも抽象に依存するべきだ、という考え方です。

Single Responsibility Principle (SRP) - 単一責任の原則

オンライン書店のシステムにおいて、新しい本を登録し、それをユーザーに通知する機能が存在します。以下のBookManagerクラスは、本の登録とユーザーへの通知の両方の役割を担っています。

class BookManager {
    public function addBook($bookData) {
        // 本のデータをデータベースに保存
    }

    public function notifyUsers($message) {
        // 全てのユーザーに新しい本の登録を通知
    }
}

このBookManagerクラスは単一責任の原則に違反しています。なぜなら、本の登録とユーザーへの通知という2つの異なる役割を担っているからです。

なぜ問題なのか

このような設計にはいくつかの問題点があります。

  1. 変更の影響: 本の登録方法が変更になった場合、その影響がユーザーへの通知機能にも及ぶ可能性があります。これは意図しないバグを引き起こす可能性があります。
  2. 再利用の困難さ: ユーザーへの通知機能だけを別の場所で再利用したい場合、本の登録機能も一緒に来てしまいます。これは再利用の際に不必要なコードが含まれるため、メモリの無駄使いや新たなバグの原因となります。
  3. テストの困難さ: このクラスは2つの機能をテストする必要があります。一方の機能に問題があると、全体のテストが通らなくなる可能性があります。

原則に従う場合

単一責任の原則に従うためには、各クラスが1つの役割だけを担うように設計することが必要です。

class BookRegister {
    public function addBook($bookData) {
        // 本のデータをデータベースに保存
    }
}

class UserNotifier {
    public function notifyUsers($message) {
        // 全てのユーザーに新しい本の登録を通知
    }
}

このように設計することで、以下のようなメリットがあります。

  1. 変更の影響を最小限に抑える: 一つのクラスが一つの責任だけを持つため、その責任範囲に関連する変更はそのクラスだけに影響します。他のクラスには影響を及ぼさないため、バグを引き起こす可能性が低くなります。
  2. 再利用の容易さ: 一つのクラスが一つの役割だけを果たすため、必要な機能だけを持つクラスを再利用することができます。これにより、不必要なコードを含むことなく、効率的に再利用が可能になります。
  3. テストの容易さ: 各クラスが一つの役割だけを担うため、テストもその役割に対して行うことができます。これにより、テストの規模を小さく保つことができ、バグの特定と修正が容易になります。

Open-Closed Principle (OCP) - 開放閉鎖の原則

拡張に対してオープンな姿勢を取り、変更に対してクローズドな姿勢であるべしというのが開放と閉鎖の意味です。

例えば、次のようなBookFilterクラスがあるとします。このクラスは、本のリストをフィルタリングして、特定のカテゴリの本だけを返す機能を持っています。

class BookFilter {
    public function filterByCategory($books, $category) {
        $filteredBooks = [];
        foreach ($books as $book) {
            if ($book->category == $category) {
                $filteredBooks[] = $book;
            }
        }
        return $filteredBooks;
    }
}

しかし、新たに著者でフィルタリングしたいとなった場合、filterByCategoryメソッドを修正するか、新たなメソッドを追加する必要があります。この設計はOCPに違反しています。

なぜ問題なのか

このような設計にはいくつかの問題点があります。

  1. 変更の影響: フィルタリングの方法が変わった場合、その影響がfilterByCategoryメソッドに及びます。これは意図しないバグを引き起こす可能性があります。
  2. 拡張の困難さ: 新たなフィルタリングの方法を追加したい場合、BookFilterクラスを修正する必要があります。これは新たな機能の追加を困難にします。

原則に従う場合

OCPに従うためには、新たな機能を追加する際に既存のコードを修正することなく、新たなコードを追加する形で行うべきです。上記の例をOCPに従うように修正すると、次のようになります。

interface BookFilter {
    public function filter($books);
}

class CategoryBookFilter implements BookFilter {
    private $category;

    public function __construct($category) {
        $this->category = $category;
    }

    public function filter($books) {
        $filteredBooks = [];
        foreach ($books as $book) {
            if ($book->category == $this->category) {
                $filteredBooks[] = $book;
            }
        }
        return $filteredBooks;
    }
}

// 新たに著者でフィルタリングしたい場合
class AuthorBookFilter implements BookFilter {
    private $author;

    public function __construct($author) {
        $this->author = $author;
    }

    public function filter($books) {
        $filteredBooks = [];
        foreach ($books as $book) {
            if ($book->author == $this->author) {
                $filteredBooks[] = $book;
            }
        }
        return $filteredBooks;
    }
}

この設計では、新たに著者でフィルタリングを追加する際に、既存のコードを修正することなく新たなクラスを追加するだけで済みます。これにより、OCPに従うことができます。

なぜ良いのか

このように設計することで、以下のようなメリットがあります。

  1. 変更の影響を最小限に抑える: 一つのクラスが一つの責任だけを持つため、その責任範囲に関連する変更はそのクラスだけに影響します。他のクラスには影響を及ぼさないため、バグを引き起こす可能性が低くなります。
  2. 拡張の容易さ: 新たなフィルタリングの方法を追加する際に、既存のクラスを修正することなく新たなクラスを追加できます。これにより、新たな機能の追加が容易になります。

もう一つ例を出して考えてみます。

例えば、次のようなPaymentProcessorクラスがあるとします。このクラスは、指定された決済方法(クレジットカードや銀行振り込み)で決済を行う機能を持っています。

class PaymentProcessor {
    public function processPayment($paymentMethod, $amount) {
        if ($paymentMethod == 'creditCard') {
            // クレジットカード決済の処理
        } else if ($paymentMethod == 'bankTransfer') {
            // 銀行振り込みの処理
        }
    }
}

しかし、新たにデジタルウォレットを追加したい場合、processPaymentメソッドを修正する必要があります。この設計はOCPに違反しています。

OCPに従うためには、新たな機能を追加する際に既存のコードを修正することなく、新たなコードを追加する形で行うべきです。上記の例をOCPに従うように修正すると、次のようになります。

interface PaymentMethod {
    public function processPayment($amount);
}

class CreditCardPayment implements PaymentMethod {
    public function processPayment($amount) {
        // クレジットカード決済の処理
    }
}

class BankTransferPayment implements PaymentMethod {
    public function processPayment($amount) {
        // 銀行振り込みの処理
    }
}

// 新たにデジタルウォレットを追加したい場合
class DigitalWalletPayment implements PaymentMethod {
    public function processPayment($amount) {
        // デジタルウォレットの処理
    }
}

この設計では、新たにデジタルウォレットの決済方法を追加する際に、既存のコードを修正することなく新たなクラスを追加するだけで済みます。これにより、OCPに従うことができます。

なぜ良いのか

このような設計にすることで、新しい決済方法が増えても既存の決済方法のクラスには一切手を加えずに済みます。これはメンテナンス性を向上させるだけでなく、新しい決済方法の追加も容易にします。また、各決済方法のクラスが独立しているため、一つの決済方法に問題が生じても他の決済方法に影響を与えません。

Liskov Substitution Principle (LSP) - リスコフの置換原則

サブクラス(派生クラス)の振る舞いは、スーパークラス(基底クラス)の振る舞いを完全にカバーしなければならない原則です。サブクラスによるスーパークラスのオーバーライドは、スーパークラスと同じ使い方を保証しなければなりません、

例えば、次のようなPaymentMethodクラスとそのサブクラスであるCreditCardBankTransferFreeCouponクラスがあるとします。それぞれのクラスは注文の支払いを処理するprocessPaymentメソッドを持っています。

class PaymentMethod {
    public function processPayment($amount) {
        // 一般的な支払い処理
    }
}

class CreditCard extends PaymentMethod {
    public function processPayment($amount) {
        // クレジットカードによる支払い処理
    }
}

class BankTransfer extends PaymentMethod {
    public function processPayment($amount) {
        // 銀行振込による支払い処理
    }
}

class FreeCoupon extends PaymentMethod {
    public function processPayment($amount) {
        // エラー: 無料クーポンでは金額を処理できない
        throw new Exception('Cannot process an amount with a free coupon');
    }
}

なぜ問題なのか

ここで、PaymentMethodprocessPaymentメソッドは金額の支払いを意味しますが、FreeCouponでは金額を処理できません。この設計はLSPに違反しており、意図しない挙動をすることが予想されます。

原則に従う場合

LSPに従うためには、スーパークラスのインスタンスをサブクラスのインスタンスで置換してもプログラムの正しさが保持されるようにしなければならないということです。上記の例をLSPに従うように修正すると、次のようになります。

class MonetaryPaymentMethod {
    public function processPayment($amount) {
        // 一般的な支払い処理
    }
}

class CreditCard extends MonetaryPaymentMethod {
    public function processPayment($amount) {
        // クレジットカードによる支払い処理
    }
}

class BankTransfer extends MonetaryPaymentMethod {
    public function processPayment($amount) {
        // 銀行振込による支払い処理
    }
}

class FreeCoupon {
    // FreeCouponは金額を処理しないため、processPaymentメソッドはない
}

この設計では、金額を処理する支払い方法とそれ以外の支払い方法のクラスが分けられ、それぞれ適切な振る舞いを持っています。これにより、LSPに従うことができます。

なぜ良いのか

LSPを適用することで、クラスの階層が理解しやすくなり、バグの発生を防ぐことができます。スーパークラスとサブクラスが同じ動作を保証するため、スーパークラスのインスタンスをサブクラスのインスタンスで置換した場合でも予期せぬエラーを引き起こさいようになりますね。

Interface Segregation Principle (ISP) - インターフェース分離の原則

クラスが必要としないインターフェースに依存するべきではないという原則です。つまり、一つのインターフェースが大きすぎて、その全てのメソッドが特定のクラスで必要とされていない場合、それは複数のより小さいインターフェースに分割すべきという考え方です。

まず、ISPを適用していないオンライン書店の例を考えてみましょう。

interface Store {
    public function searchBook();
    public function displayBookDetails();
    public function addBookToCart();
    public function checkout();
}

class OnlineStore implements Store {
    public function searchBook() { /* ... */ }
    public function displayBookDetails() { /* ... */ }
    public function addBookToCart() { /* ... */ }
    public function checkout() { /* ... */ }
}

class InStoreTerminal implements Store {
    public function searchBook() { /* ... */ }
    public function displayBookDetails() { /* ... */ }
    public function addBookToCart() { /* ... */ }
    // チェックアウトは店員が行うため、このメソッドは必要ない
    public function checkout() { /* 何もしない */ }
}

なぜ問題なのか

この例では、Storeインターフェースには4つのメソッドがありますが、InStoreTerminalクラスはcheckoutメソッドを必要としていません。しかし、Storeインターフェースに依存するため、このクラスは不要なcheckoutメソッドを持つことになります。これはISPに違反しています。

原則に従う場合

ISPを適用した例を見てみましょう。

interface Searchable {
    public function searchBook();
    public function displayBookDetails();
}

interface Purchasable {
    public function addBookToCart();
    public function checkout();
}

class OnlineStore implements Searchable, Purchasable {
    public function searchBook() { /* ... */ }
    public function displayBookDetails() { /* ... */ }
    public function addBookToCart() { /* ... */ }
    public function checkout() { /* ... */ }
}

class InStoreTerminal implements Searchable {
    public function searchBook() { /* ... */ }
    public function displayBookDetails() { /* ... */ }
}

この例では、StoreインターフェースがSearchablePurchasableの2つの小さいインターフェースに分割されました。InStoreTerminalクラスは必要なSearchableインターフェースのみを実装し、不要なcheckoutメソッドを持つことなくなりました。

以上のように、ISPを適用することで、クラスは必要なインターフェースのみを依存し、不要なメソッドを持つことを避けることができます。これにより、コードの可読性と保守性が向上します。

Dependency Inversion Principle (DIP) - 依存関係逆転の原則

高レベルのモジュールが低レベルのモジュールに依存してはならず、どちらのモジュールも抽象に依存すべきである、という原則です。

オンライン書店の注文処理を保存する際に、具体的なデータベース(MySQLDatabase)に依存している場合を考えてみましょう。

class OnlineStore {
    protected $database;

    public function __construct() {
        $this->database = new MySQLDatabase();
    }

    public function saveOrder($order) {
        $this->database->saveOrder($order);
    }
}

class MySQLDatabase {
    public function saveOrder($order) {
        // MySQLを使って注文を保存
    }
}

なぜ問題なのか

この設計では、OnlineStoreクラスが具体的なMySQLDatabaseクラスに依存しています。これはDIPに違反しています。具体的には以下の問題をかかえています。

  1. 拡張性の欠如:現在の設計では、データベースを別の種類(例えば、PostgreSQL)に変更したい場合、OnlineStoreクラスを直接修正する必要があります。これは理想的ではなく、新しいデータベースクラスが追加されるたびに**OnlineStoreクラスを変更する必要があります。
  2. テスト困難性OnlineStoreクラスは具体的なMySQLDatabaseクラスに直接依存しているため、テスト時にデータベース接続をモック(偽のオブジェクト)に置き換えることが困難です。結果として、ユニットテストを書くのが難しくなります。
  3. コードの再利用性の低下OnlineStoreクラスは、MySQLデータベースに強く依存しているため、それを再利用して異なるデータベースを使用する新しいクラスを作成することはできません。

原則に従う場合

DIPに従うためには、具体ではなく抽象に依存するように設計します。これを適用すると以下のようになります。

interface DatabaseInterface {
    public function saveOrder($order);
}

class OnlineStore {
    protected $database;

    public function __construct(DatabaseInterface $database) {
        $this->database = $database;
    }

    public function saveOrder($order) {
        $this->database->saveOrder($order);
    }
}

class MySQLDatabase implements DatabaseInterface {
    public function saveOrder($order) {
        // MySQLを使って注文を保存
    }
}

class PostgreSQLDatabase implements DatabaseInterface {
    public function saveOrder(Order $order) {
        // PostgreSQLを使って注文を保存します
    }
}

この設計では、OnlineStoreクラスは具体的なデータベースクラスではなく、抽象的な**DatabaseInterface**に依存しています。これにより、DIPに従うことができます。

なぜ良いのか

DIPを適用することで、以下のようなメリットがあります。

  1. 拡張性: 新しいデータベースクラス(例えば、PostgreSQLDatabase)を導入したい場合、DatabaseInterfaceを実装すれば、既存のコードを変更することなく使用することができます。
  2. 交換可能性: データベースの種類を変更したい場合、新しいデータベースクラスのインスタンスをOnlineStoreに渡すだけで、データベースを交換することができます。
  3. テスト容易性: テスト中には実際のデータベースを使用したくない場合、モックオブジェクトを作成してOnlineStoreに渡すことができます。

他の例でもみてみましょう。

具体的な決済方法(例えば、クレジットカード)に依存しているオンライン書店の例を考えてみましょう。

class OnlineStore {
    private $payment;

    public function __construct() {
        $this->payment = new CreditCardPayment();
    }

    public function checkout() {
        $this->payment->processPayment();
    }
}

class CreditCardPayment {
    public function processPayment() {
        // クレジットカードでの決済処理
    }
}

この例では、OnlineStoreクラスは具体的なCreditCardPaymentクラスに依存しています。その結果、決済方法を変更するたびに(例えば、PayPalに変更する)、OnlineStoreクラスを修正しなければなりません。

次に、DIPを適用した例を見てみましょう。

interface PaymentInterface {
    public function processPayment();
}

class OnlineStore {
    private $payment;

    public function __construct(PaymentInterface $payment) {
        $this->payment = $payment;
    }

    public function checkout() {
        $this->payment->processPayment();
    }
}

class CreditCardPayment implements PaymentInterface {
    public function processPayment() {
        // クレジットカードでの決済処理
    }
}

class PayPalPayment implements PaymentInterface {
    public function processPayment() {
        // PayPalでの決済処理
    }
}

この例では、OnlineStoreクラスは具体的な決済クラスではなく、抽象的なPaymentInterfaceに依存しています。これにより、決済方法を変更するたびにOnlineStoreクラスを修正する必要がなくなりました。

Discussion