🤝

SOLID原則とは:設計の基本を理解する

2024/12/23に公開

SOLID原則

SOLID原則は、Robert C. Martin(通称:"Uncle Bob")によって提唱されたオブジェクト指向プログラミングの設計原則です。この原則を守ることで、コードの可読性、保守性、拡張性が向上し、結果として高品質なソフトウェアの構築が可能となります。

SOLIDは以下の5つの原則の頭文字を取ったものです:

  1. Single Responsibility Principle(単一責任の原則)
  2. Open/Closed Principle(開放/閉鎖の原則)
  3. Liskov Substitution Principle(リスコフの置換原則)
  4. Interface Segregation Principle(インターフェース分離の原則)
  5. Dependency Inversion Principle(依存性逆転の原則)

以下では、各原則について説明し、悪い例と良い例をTypeScriptで示します。


1. 単一責任の原則(SRP)

説明: クラスは一つの責任を持つべきであり、その責任を完全に果たすためにのみ変更されるべきです。

メリット:

  • クラスの変更が他のクラスに波及しづらくなる。
  • テストやデバッグが容易になる。

悪い例:

class User {
    constructor(public name: string, public email: string) {}

    saveToDatabase(): void {
        console.log(`Saving ${this.name} to the database.`);
        // データベース保存の処理
    }

    sendEmail(): void {
        console.log(`Sending email to ${this.email}.`);
        // メール送信の処理
    }
}

問題点:

  • Userクラスがデータベース保存やメール送信の責任を持っており、責任が多すぎる。例えば、開発チームがメール送信のロジックを変更する必要がある場合、このクラス全体を編集する必要が出てきます。また、データベース保存やメール送信の処理が複雑になると、クラスが肥大化し、他の機能の変更時に思わぬバグを引き起こす可能性があります。

良い例:

class User {
    constructor(public name: string, public email: string) {}
}

class UserRepository {
    save(user: User): void {
        console.log(`Saving ${user.name} to the database.`);
        // データベース保存の処理
    }
}

class EmailService {
    sendEmail(user: User): void {
        console.log(`Sending email to ${user.email}.`);
        // メール送信の処理
    }
}

改善点:

  • Userクラスはデータを持つだけの責任に限定。
  • データベース保存とメール送信は専用クラスに分離。

2. 開放/閉鎖の原則(OCP)

説明: クラスは拡張に対して開かれていなければならず、変更に対して閉じられていなければなりません。

メリット:

  • 新しい機能を追加しても既存のコードを変更する必要がない。
  • バグが導入されるリスクが減る。

悪い例:

class Discount {
    calculate(price: number, type: string): number {
        if (type === 'seasonal') {
            return price * 0.9;
        } else if (type === 'clearance') {
            return price * 0.5;
        }
        return price;
    }
}

問題点:

  • 新しい割引タイプを追加するたびに条件文を追加する必要があり、既存コードの変更が発生する。例えば、ECサイトで季節ごとの割引や会員限定の割引を追加する場合、現在のコードに毎回条件文を挿入する必要があります。この作業は新たなバグを生むリスクがあり、コード全体の複雑性も増大させます。

良い例:

interface DiscountStrategy {
    calculate(price: number): number;
}

class SeasonalDiscount implements DiscountStrategy {
    calculate(price: number): number {
        return price * 0.9;
    }
}

class ClearanceDiscount implements DiscountStrategy {
    calculate(price: number): number {
        return price * 0.5;
    }
}

class PriceCalculator {
    calculate(price: number, discountStrategy: DiscountStrategy): number {
        return discountStrategy.calculate(price);
    }
}

改善点:

  • 割引の種類を追加する際、新しいクラスを作成するだけでよく、既存コードを変更する必要がない。

3. リスコフの置換原則(LSP)

説明: サブクラスはその親クラスで置き換え可能でなければなりません。

メリット:

  • ポリモーフィズムを正しく利用できる。
  • サブクラスの誤用を防げる。

悪い例:

class Bird {
    fly(): void {
        console.log('Flying');
    }
}

class Penguin extends Bird {
    fly(): void {
        throw new Error('Penguins cannot fly');
    }
}

問題点:

  • PenguinをBirdの代わりに使用すると、予期しないエラーが発生する。例えば、あるアプリケーションでBirdクラスを使用して鳥の群れを管理している場合、Penguinが追加されると飛べないためにflyメソッドが例外をスローし、システム全体がクラッシュする可能性があります。こうした問題は特に、異なる種類の鳥を扱う必要があるシステムで顕著に現れます。

良い例:

class Bird {
    // 共通の特性を定義
}

class FlyingBird extends Bird {
    fly(): void {
        console.log('Flying');
    }
}

class Penguin extends Bird {
    swim(): void {
        console.log('Swimming');
    }
}

改善点:

  • 飛ぶ鳥と泳ぐ鳥を別々のクラスで表現し、適切に設計。

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

説明: クライアントは自分が使用しないメソッドに依存してはならない。

メリット:

  • 不必要な依存関係が減る。
  • 小さいインターフェースに分割することで、柔軟性が向上。

悪い例:

interface Worker {
    work(): void;
    eat(): void;
}

class HumanWorker implements Worker {
    work(): void {
        console.log('Working');
    }

    eat(): void {
        console.log('Eating');
    }
}

class RobotWorker implements Worker {
    work(): void {
        console.log('Working');
    }

    eat(): void {
        throw new Error('Robots do not eat');
    }
}

問題点:

  • ロボットに必要ないeatメソッドを強制されている。例えば、製造ライン管理システムで、人間労働者とロボットが一緒に作業を行う場合、両者を同じインターフェースで扱おうとすると、ロボットに必要ないeatメソッドを実装しなければならず、コードに不要な例外処理が増え、メンテナンス性が低下する可能性があります。

良い例:

interface Workable {
    work(): void;
}

interface Eatable {
    eat(): void;
}

class HumanWorker implements Workable, Eatable {
    work(): void {
        console.log('Working');
    }

    eat(): void {
        console.log('Eating');
    }
}

class RobotWorker implements Workable {
    work(): void {
        console.log('Working');
    }
}

改善点:

  • 必要な機能ごとにインターフェースを分離。

5. 依存性逆転の原則(DIP)

説明: 高レベルのモジュールは低レベルのモジュールに依存してはならず、両方が抽象に依存すべきです。

メリット:

  • 実装の詳細が変更されても高レベルモジュールに影響を与えない。
  • テストが容易になる。

悪い例:

class Database {
    save(data: string): void {
        console.log('Saving data to database:', data);
    }
}

class UserService {
    private database = new Database();

    saveUser(data: string): void {
        this.database.save(data);
    }
}

問題点:

  • UserServiceがDatabaseクラスに直接依存しているため、変更が困難。例えば、アプリケーションのデータストアをMySQLからMongoDBに変更する必要が生じた場合、Databaseクラスを差し替えるだけではなく、UserServiceのコード全体を修正する必要があります。このような状況では、既存コードの変更が多くなり、新たなバグが発生するリスクが高まります。

良い例:

interface DataStore {
    save(data: string): void;
}

class Database implements DataStore {
    save(data: string): void {
        console.log('Saving data to database:', data);
    }
}

class UserService {
    constructor(private dataStore: DataStore) {}

    saveUser(data: string): void {
        this.dataStore.save(data);
    }
}

改善点:

  • UserServiceがインターフェースに依存することで、データ保存の実装を簡単に差し替え可能。

SOLID原則を理解するメリット

  • 保守性の向上: 変更の影響を最小限に抑えることができる。
  • 再利用性の向上: 各クラスやモジュールが独立して機能するため、再利用しやすい。
  • テストの容易化: テスト可能な小さな単位に分割されるため、単体テストがしやすい。
  • 拡張性の向上: 新機能の追加が既存コードに影響を与えずに行える。

これらのメリットを実感するには、日々の開発で意識的にSOLID原則を適用し、実際のコードに反映させることが重要です。

Discussion