Zenn
🐶

SOLID原則とかをわかりやすく

2025/03/24に公開
1

モチベーション

プラハチャレンジのDDD課題の前準備としてSOLID原則についてしらべました。

SOLID原則とは?

オブジェクト指向プログラミングにおいて、変更しやすい・理解しやすい・再利用しやすいモジュール(クラスや関数などの、ソフトウェアのを構成する部品)を設計・開発するための原則のこと。




以下SOLIDについて1つ1つ説明していきます。


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

概要
クラスが持つべき「お仕事」は1つだけって考え。

"責任"とは「変更・修理の理由」のこと

# ソフトウェアの例:

もしUI制御、ビジネスロジック処理、およびDB操作が異なるクラスに分けられていて、UIにバグが見つかった場合、そのバグの修正責任はUI制御クラスにあると考えられる。しかし実際に修正したところ、UIのバグのためにビジネスロジックのクラスを修正する必要が生じる場合、それは単一責任原則が遵守されていないよってこと。

# 責務の違いがわかる男になるには:

そもそもビジネスに関心がないと単一責任設計が困難。ロボットアニメに興味がない人が、エヴァを「ガンダムみたいなやつ」認識するように、もしくは音楽に疎い人がヴァイオリンとチェロを「ヴァイオリンみたいなやつ」としてひとくくりに認識するように、ドメイン(ビジネス)の知識がないと全体から個々の責務を切り分けて違いを認識することが出来ない。
ミノ駆動さん曰く、「単一責任設計のスキルアップにこそドメイン駆動設計」とのこと。なので、DDDを勉強する必要があります。

単一責任原則で無責任な多目的クラスを爆殺する @MinoDriven(ミノ駆動)





# オープン・クローズドの原則 (Open Closed Principle, OCP)

  • Open(機能を拡張するとき)、Closed(修正を行わない)
  • 簡単に言うと、「新しい機能追加の際に、元のソースを修正しなくても良いように設計すべき」という原則
  • この原則は、変化やバリエーションが発生したときに特に有効である。


# 悪い例: オープン・クローズドの原則を守れていない

  1. 最初に、四角形の面積を求めるclassのコードを考る。

    class CalculateArea {
        constructor(width, height) {
            this.width = width;
            this.height = height;
        }
    
        getArea() {
            return this.width * this.height;
        }
    }
    
  2. 次に、円の面積を求める機能が必要になったとします。そこでCalculateAreaクラスを修正!

    class CalculateArea {
        constructor(width, height, shape = 'square') {
            this.width = width;
            this.height = height;
            this.shape = shape; // 'square' or 'circle'
        }
    
        getArea() {
            switch (this.shape) {
                case 'square':
                    return this.width * this.height;
                case 'circle':
                    // ここでwidthは直径として扱う
                    return (Math.PI * (this.width / 2) * (this.width / 2));
                default:
                    throw new Error('Unsupported shape type');
            }
        }
    }
    

このようにしてしまうと、既存の四角形の面積を求める機能でデグレが発生しないか確認する必要が出てくる。


# 良い例: オープン・クローズドの原則を守れている

  1. 四角形の面積を求めるclassのコードを初めに作成します。
    class CalculateArea {
        constructor(width, height) {
            this.width = width;
            this.height = height;
        }
    
        getArea() {
            return this.width * this.height;
        }
    }
    
  2. 円の面積を求める機能が必要になった際、既存のCalculateAreaクラスは変更せず、新しいクラスCalculateSquareAreaとCalculateCircleAreaを追加。
    class CalculateArea {
        constructor(width, height) {
            this.width = width;
            this.height = height;
        }
    
        getArea() {
            return this.width * this.height;
        }
    }
    
    class CalculateSquareArea extends CalculateArea {}
    
    class CalculateCircleArea {
        constructor(diameter) {
            this.diameter = diameter;
        }
    
        getArea() {
            return (Math.PI * (this.diameter / 2) * (this.diameter / 2));
        }
    }
    

このアプローチを採用することで、既存の機能にデグレが発生することを防ぐことができる。すでに運用されているコードの修正の際に、この原則は大きな力を発揮します。

ただこの例だと、OCPに反してしまうが、class名リファクタして、CalculateAreaCalculateSquareAreaに変更したほうが良いかも。





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

  • オブジェクト指向プログラミングにおけるLSPは、サブタイプのオブジェクトがスーパータイプのオブジェクトの仕様を継承し、それを破らないという原則です。これを簡潔に言うと、「子は親のルールを破ってはいけないよ」というもの。
  • LSPが適用されているケース :
    人間は哺乳類の派生型であり、哺乳類に必要な最低限の機能を全て持つため、哺乳類のClassを人間のClassに置換しても問題が生じないです。これは、哺乳類Class用に作られたUnitTestを人間Classに適用しても、すべてのテストを通過することからも確認できます。
  • is-a関係 (例: 人間は哺乳類の一種である) を確認することで、LSPが適切に適用されているかどうかを判断することができます。
  • LSPが適用できていないケース : このサイトの説明がわかりやすかったから読んでみてね。
  • LSPを違反すると、基本クラスに対する期待がサブクラスで維持されなくなるため、サブクラスの振る舞いを特別に確認しなければならないです。これにより、システム全体の予測可能性と安定性が損なわれてしまいます。したがって、LSPは「システムの安定性を保つための重要な原則」と捉えられるべきです。





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

  • インターフェースの利用者にとって不要なメソッドへの依存を強制してはいけないという原則。噛み砕くと「不要なメソッドが存在する状況を作るな!!」と言ってる。


# 悪い例

interface CreatureAction {
    breathe(): void;        // 呼吸
    photosynthesis(): void; // 光合成
    eat(): void;            // 食う
    run(): void;            // 走る
    speak(): void;          // しゃべる
    swim(): void;           // 泳ぐ
}

class Human implements CreatureAction {
    breathe() {
        console.log("人間は呼吸する。");
    }

    photosynthesis() {
        // 人間は光合成を行いません。しかし、このインターフェイスを実装するためにはこのメソッドが必要です。
        throw new Error("人間は光合成を行えません!");
    }

    eat() {
        console.log("人間は食事をする。");
    }

    run() {
        console.log("人間は走る。");
    }

    speak() {
        console.log("人間はしゃべる。");
    }

    swim() {
        console.log("人間は泳ぐことができる。");
    }
}

class Plant implements CreatureAction {
    breathe() {
        console.log("植物も酸素を取り込む。");
    }

    photosynthesis() {
        console.log("植物は光合成を行う。");
    }

    eat() {
        // 植物は食事をしません。しかし、このインターフェイスを実装するためにはこのメソッドが必要です。
        throw new Error("植物は食事をしません!");
    }

    run() {
        // 植物は走りません。しかし、このインターフェイスを実装するためにはこのメソッドが必要です。
        throw new Error("植物は走りません!");
    }

    speak() {
        // 植物はしゃべりません。しかし、このインターフェイスを実装するためにはこのメソッドが必要です。
        throw new Error("植物はしゃべりません!");
    }

    swim() {
        // 植物は泳ぎません。しかし、このインターフェイスを実装するためにはこのメソッドが必要です。
        throw new Error("植物は泳ぎません!");
    }
}



# 良い例

interface Breathable {
    breathe(): void;
}

interface Photosynthetic {
    photosynthesis(): void;
}

interface Eatable {
    eat(): void;
}

interface Runnable {
    run(): void;
}

interface Speakable {
    speak(): void;
}

interface Swimmable {
    swim(): void;
}

class Human implements Breathable, Eatable, Runnable, Speakable, Swimmable {
    breathe() {
        console.log("人間は呼吸する。");
    }

    eat() {
        console.log("人間は食事をする。");
    }

    run() {
        console.log("人間は走る。");
    }

    speak() {
        console.log("人間はしゃべる。");
    }

    swim() {
        console.log("人間は泳ぐことができる。");
    }
}

class Plant implements Breathable, Photosynthetic {
    breathe() {
        console.log("植物も酸素を取り込む。");
    }

    photosynthesis() {
        console.log("植物は光合成を行う。");
    }
}




interfaceを細かく分けることにより、不要な処理を排除し、コードの冗長性を減少させることができる。
また、不要な関数が含まれていることによる意図しない不具合も防ぐことができる。

# ISPに違反してはいけない理由

クライアントClassが必要ない情報を持っている状態になり、Interfaceの修正が入るとクライアントの機能に無関係なものでも、修正に引っ張られてクライアントの修正をしないといけなくなる可能性がある。






# 依存性逆転の原則 (Dependency Inversion Principle, DIP)

ざっくり言えば、プログラムの重要な部分が、重要でない部分に依存しないよう設計すべきであるということ。

ガイドライン

  • 上位モジュール(重要な部分)は下位モジュール(重要でない部分)に依存すべきではなく、両方とも抽象に依存すべきである。


例: 具体例を「スイッチ」と「電球」のケースで考える。スイッチの開発をしていると想定する。

# 悪い例: 「スイッチ」が「電球」に依存してしまっている状態

class LightBulb {
  turnOn() {
    console.log("LightBulb: Turned on");
  }

  turnOff() {
    console.log("LightBulb: Turned off");
  }
}

class Switch {
  private lightBulb: LightBulb;

  constructor(lightBulb: LightBulb) {
    this.lightBulb = lightBulb;
  }

  operate() {
    this.lightBulb.turnOn();
  }
}



# 良い例: 「スイッチ」も「電球」もInterfaceに依存している

interface Device {
  turnOn(): void;
  turnOff(): void;
}

class LightBulb implements Device {
  turnOn() {
    console.log("LightBulb: Turned on");
  }

  turnOff() {
    console.log("LightBulb: Turned off");
  }
}

class Switch {
  private device: Device;

  constructor(device: Device) {
    this.device = device;
  }

  operate() {
    this.device.turnOn();
  }
}



# DIPって何がいいの?

  • 再利用性が上がる :
    スイッチが電球に直接依存していないので、デバイスにエアコンが追加されてもスイッチのコードを変更しなくても対応できる!!
  • 疎結合になる :
    スイッチクラスと電球クラスは直接的なコードの依存関係がないので、変更が用意でテストもしやすい。


# 疑問 : 上位モジュールであるスイッチクラスは依然として、インターフェイスに依存してるけどいいの?

以下ChatGPTの答え
池上彰風に答えてくれました。


良い質問です。

確かに、SwitchクラスはDeviceインターフェースに依存していますが、この依存性は問題となるものではありません。なぜなら、以下の理由からです:

  1. 抽象に対する依存: 依存性逆転の原則の一部として、具体実装よりも抽象に依存することが推奨されています。Deviceは具体的な実装ではなく、抽象的なインターフェースです。抽象に依存することで、システムはより柔軟かつ拡張性が高まります。

  2. 安定性: 抽象的なインターフェースや抽象クラスは、具体的なクラスよりも変更が少なく、安定しています。これは、新しいデバイスや機能を追加する場合、既存のコードに大きな変更を加えずに新しい実装を追加することができるためです。

  3. 再利用性: 抽象に対する依存性は、コードの再利用性を向上させます。例えば、新しい種類のデバイスを追加したい場合、Deviceインターフェースを実装する新しいクラスを追加するだけで、Switchクラスでその新しいデバイスを操作することができます。

したがって、SwitchクラスがDeviceインターフェースに依存していることは、設計の面で問題とはなりません。むしろ、このような抽象に対する依存性が、ソフトウェアの拡張性や再利用性を向上させる要因となります。


[ぼやき]
これを踏まえて個人的に依存性の逆転という名前はちょっと盛っている気がする、、依存性の中和ぐらいのニュアンスのほうがしっくり来る

LightBulbが外部モジュールや外部のサービスであるという風にイメージすると、interfaceは自分のプロジェクトで定義しているので、依存性の逆転という名前はしっくり来る




# デメトルの法則

「友達の友達の事情は、友達から聞きましょう!あなたがいきなり友達の友達のところにいって、内情を聞くのは良くないよね。そんなことばかりしていたら人間関係が増加し続けちゃうよ」って話。
プラグラミング的に言い換えると
「オブジェクトのメンバのメンバを直接参照してはいけない!そんな事していたら色んなクラスに依存していって、疎結合にならないよ」って感じ。


# 悪い例

vehicle.engine.statusDemeterがEngineを直接参照しているので、デメテルの法則に違反しています。

class Engine { // エンジンクラス
    status: string;
    constructor() {
        this.status = '';
    }
}

class Vehicle { // 乗り物クラス
    engine: Engine;
    constructor() {
        this.engine = new Engine();
    }
}

class Demeter {
    public static main(): void {
        const vehicle = new Vehicle();
        // ×××オブジェクトの内部を直接操作しない×××
        if (vehicle.engine != null) {
            vehicle.engine.status = "発進"; 
        }
        
        console.log(vehicle.engine.status);
    }
}



# 良い例

デメテルの法則を遵守すると

class Engine {
    protected status: string;
    
    // 追加
    getStatus(): string {
        return this.status;
    }
    
    // 追加
    setStatus(status: string): void {
        this.status = status;
    }
}

class Vehicle {
    protected engine: Engine;
    
    constructor() {
        this.engine = new Engine();
    }

    // 追加
    getEngineStatus(): string {
        return this.engine.getStatus();
    }
    
    // 追加
    setEngineStatus(status: string): void {
        this.engine.setStatus(status);
    }
    
    // 追加
    isReady(): boolean {
        return this.engine != null;
    }
}

class Demeter {
    public static main(): void {
        const vehicle = new Vehicle();
        if (vehicle.isReady()) {
            vehicle.setEngineStatus("発進");
        }
        console.log(vehicle.getEngineStatus());
    }
}



# 結局どのようなメリットが?

オブジェクトが他のオブジェクトの内部構造に依存しなくなるので(疎結合)、1つのオブジェクトを修正しても全体への影響が限定的になり、メンテナンスし易いコードになる。



# 何故かデメテルの法則について調べると、みんな「尋ねるな、命じろ (Tell, Don't Ask)」という考え方についても説明していたので、少し考える。

呼び出し側で、状態をあらかじめ確認したりしないで、ただ命じるようにして、もし条件的に命令が厳しく、実行できなければErrorをthrowしよう!という考えかた。
このようにすることで、Demeterは、Engineをほぼ意識しないでVehicleを操作できる。

// エンジンは省略

class Vehicle {
    protected engine: Engine;
    
    constructor() {
        this.engine = new Engine();
    }

    getEngineStatus(): string {
        return this.engine.getStatus();
    }

    /**
     * エンジンを発進させる
     * @throws {Error} もしenginがnullの場合、Errorをthrowする
     */
    start(): void {
        if (this.engine != null) {
            this.engine.setStatus("発進");
        } else {
            throw new Error("車にエンジンがありません!");
        }
    }
}

class Demeter {
    public static main(): void {
        const vehicle = new Vehicle();
        try {
            vehicle.start();                  // ただ命じる!エンジンの状態なんて私は知らない!
            console.log(vehicle.getStatus());
        } catch (error) {
            console.error(error);
        }
    }
}
1
株式会社ソニックムーブ

Discussion

ログインするとコメントできます