🛠️

TypeScriptで学ぶ!SOLID原則

2024/01/09に公開

はじめに

皆さんこんにちは、株式会社エムアイ・ラボのエンジニアです!
今回はソフトウェア設計のSOLID原則について学習したので、弊社のメインの開発言語であるTypeScriptのサンプルコードを使って共有できればと思います。
SOLID原則は、オブジェクト思考プログラミングにおいて、ソフトウェアがメンテナンスしやすく、拡張や変更に強いソフトウェア設計を行うための原則です。
SOLID原則にはSOLIDの頭文字をそれぞれとった、5つの原則があります。

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

単一責任の原則とは、クラスが一つの機能や責任を持つべきで、クラスが変更される理由は一つであるべきというです。
クラスが一つの機能や責任のみを持つようにすることにより、コードは再利用可能でテストが容易になります。

単一責任の原則を遵守している例

以下のBirdクラスは単一責任の原則を遵守している例です。
鳥の種類(species)と名前の管理の機能のみで、情報の設定と取得に関するメソッド複数がありますが、これらはすべて鳥の基本的な情報管理という一貫した1つの責任をもっています。

class Bird {
  private species: string;
  private name: string;

  constructor(species: string, name: string) {
    this.species = species;
    this.name = name;
  }

  setSpecies(species: string) {
    this.species = species;
  }

  setName(name: string) {
    this.name = name;
  }

  getSpecies(): string {
    return this.species;
  }

  getName(): string {
    return this.name;
  }
}

単一責任の原則に違反している例

以下の例では、先述の鳥の基本情報を管理する機能(責任)に加え、鳥を観察する機能(責任)を持っています。
鳥の基本情報の管理と鳥を観察することは異なる機能、責任になります。
よって、以下のBirdクラスは単一責任の原則の違反例となります。

class Bird {
  private species: string;
  private name: string;

  constructor(species: string, name: string) {
    this.species = species;
    this.name = name;
  }

  setSpecies(species: string) {
    this.species = species;
  }

  setName(name: string) {
    this.name = name;
  }

  getSpecies(): string {
    return this.species;
  }

  getName(): string {
    return this.name;
  }

  birdWatch(): string {
    // 鳥の観察記録を管理するロジック
    console.log(`Observing and recording the behavior of ${this.name}the ${this.species}`);
  }
}

複数の機能、責任を1つのクラスにまとめてしまうと、コードは複雑になり、バグの発生リスクを高め、保守性を下げてしまいます。

オープンクローズドの原則(Open/Closed Principle)

オープンクローズドの原則とは、クラスや関数が拡張に対してはオープンであるべきですが、変更に対しては閉じられているべきだという考え方です。
新しい機能や要件が出現した際に、既存のコードを大幅に変更することなく、システムを容易に拡張できることを目的としています。

オープンクローズドの原則を遵守している例

以下の例は少し極端ですが、単一責任の原則(泳ぐという単一機能、単一責任)とオープンクローズドの原則(仮に新しい種類の魚を追加してもfishSwimメソッドは変更する必要がない)の適用しています。


abstract class Fish {
    abstract swim(): void;
}

class Salmon extends Fish {
    swim(): void {
        console.log("Salmon swimming");
    }
}

class Shark extends Fish {
    swim(): void {
        console.log("Shark swimming fast");
    }
}

function fishSwim(fish: Fish) {
    fish.swim();
}

オープンクローズドの原則に違反している例

以下のコードでは、新しい魚種を追加するたびにmakeAllFishSwimメソッドを変更する必要があります。この場合オープンクローズドの原則に違反していると言えます。

class Aquarium {
    // 魚のリストを保持
    private fishList: string[] = [];

    addFish(fishType: string) {
        this.fishList.push(fishType);
    }

    // 魚の種類に基づいて泳ぐ方法を決定
    // 新しい魚種を追加するたびに、このメソッドを変更することになる
    makeAllFishSwim() {
        for (const fishType of this.fishList) {
            if (fishType === "Goldfish") {
                console.log("Goldfish is swimming slowly");
            } else if (fishType === "Shark") {
                console.log("Shark is swimming fast");
            }
            // 他の魚種が追加された場合、ここに新しく追加
        }
    }
}

const aquarium = new Aquarium();
aquarium.addFish("Goldfish");
aquarium.addFish("Shark");
aquarium.makeAllFishSwim();

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

リスコフの置換原則は継承が適切に使用されていることを保証するための考え方です。
サブクラスが基底クラスと置換可能であればコードの再利用性が向上します。

リスコフの置換原則に違反している例

以下の例ではPenguinはFlyingBirdを正しく置換できません。
よってこのFlyingBirdクラスはリスコフの置換原則に反しています。
FlyingBirdクラスのインスタンスは「飛べる」という期待を持つので、Penginのような飛べない鳥はFlyingBirdクラスを継承すべきではありません。

class FlyingBird {
    fly() {
        console.log("Flying");
    }
}

class Eagle extends FlyingBird {}

class Penguin extends FlyingBird {
    fly() {
        throw new Error("Cannot fly");
    }
}

function makeBirdFly(bird: FlyingBird) {
    bird.fly();
}

const eagle = new Eagle();
// const penguin = new Penguin(); 

リスコフの置換原則を遵守している例

リスコフの置換原則に従った正しい継承を行うためには、Birdのような基底クラスを作成して、サブクラスとしてFlyingBIrdクラスとNonFlyingBIrdクラスを定義するべきです。

// 基底クラス
class Bird {
    // 鳥に共通の特性を定義する
}

// 飛べる鳥のサブクラス
class FlyingBird extends Bird {
    fly() {
        console.log("Flying");
    }
}

// 飛べない鳥のサブクラス
class NonFlyingBird extends Bird {
    notfly() {
        console.log("Not Flying");
    }// 飛べない鳥のための特定の振る舞いや特性をここに定義する
}

class Eagle extends FlyingBird {}
class Penguin extends NonFlyingBird {}

// 飛べる鳥のみが飛ぶ機能を持つ
function makeBirdFly(bird: FlyingBird) {
    bird.fly();
}

const eagle = new Eagle();
const penguin = new Penguin();

makeBirdFly(eagle); // EagleはFlyingBirdのインスタンスなので飛べる

このようにすることでFlyingBirdクラスのインスタンスは必ず飛ぶことができ、NonFlyingBirdクラスのインスタンスは飛ぶことができないという設計ができます。

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

クライアント(インターフェースの使用するもの)が不要な依存関係に縛られることなく、必要な機能のみを持つインターフェースにのみ依存するべきという考え方です。
より小さく、特定の機能に焦点をあてたインターフェースを作成することでコードの再利用性が向上します。また機能に変更が必要な場合は、影響範囲を最低限に抑えることができ保守性が向上します。

インターフェース分離の原則を遵守している例

以下の例では、DuckとTunaの両クラスが、それぞれ必要なインターフェースを実装しています。
Duck(アヒル)は飛ぶことも泳ぐこともできるため、SwimmerとFlyerの両方のインターフェースを実装します。
Tuna(マグロ)は飛ぶことはできず、泳ぐことのみできるのでSwimmerインターフェースのみ実装します。
このように、インターフェース分離の原則に従うことで各クラスは必要な機能のみ実装でき、インターフェースの不必要な依存関係を避けることができます。

interface Swimmer {
    swim(): void;
}

interface Flyer {
    fly(): void;
}

class Duck implements Swimmer, Flyer {
    swim(): void {
        console.log("Duck swimming");
    }

    fly(): void {
        console.log("Duck flying");
    }
}

class Tuna implements Swimmer {
    swim(): void {
        console.log("Tuna swimming fast");
    }
}

インターフェース分離の原則に違反している例

以下の例ではAnimalインターフェースにswimメソッドと、flyメソッド両方を持っています。
本来Tunaクラスはflyメソッドを実装する必要はありませんが、依存関係により不必要なメソッドを実装せざるを得ない状態になってしまっています。
このようなインターフェースはコードの可読性や保守性を下げ、後の変更が困難になってしまいます。

interface Animal {
    swim(): void;
    fly(): void;
}

class Duck implements Animal {
    swim(): void {
        console.log("Duck swimming");
    }

    fly(): void {
        console.log("Duck flying");
    }
}

class Tuna implements Animal {
    swim(): void {
        console.log("Tuna swimming fast");
    }

    fly(): void {
        // マグロは飛べないが、インターフェースの制約によりflyメソッドを実装しなければならない
        throw new Error("Cannot fly");
    }
}

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

高レベルのモジュールは低レベルのモジュールに直接依存せず、両者は抽象的に依存(インターフェースやabstractクラス)すべきという考え方です

依存性逆転の原則を遵守している例

以下の例では、高レベルのモジュール(BirdCareクラス)は低レベルのモジュール(SimpleBirdFeederクラス)に直接依存せず、共通のインターフェース(BirdFeederインターフェース)に依存しています。
BirdCareクラスはBirdFeederインターフェースに依存して、具体的な餌やりのビジネスロジックを含まないことで、クラスの拡張、変更に強くなります。
仮にAdvancedBirdFeederのように新しい餌やりの方法を追加するときに、BirdCareクラスを変更する必要がありません。

// Birdクラス定義
class Bird {
    private species: string;

    constructor(species: string) {
        this.species = species;
    }

    getSpecies(): string {
        return this.species;
    }
}

// BirdFeederインターフェース
interface BirdFeeder {
    feedBird(bird: Bird): void;
}

// SimpleBirdFeederクラス
class SimpleBirdFeeder implements BirdFeeder {
    feedBird(bird: Bird): void {
        console.log(`Feeding a ${bird.getSpecies()} with simple food`);
    }
}

// AdvancedBirdFeederクラス(追加する餌やり方法)
class AdvancedBirdFeeder implements BirdFeeder {
    feedBird(bird: Bird): void {
        console.log(`Feeding a ${bird.getSpecies()} with new food`);
    }
}

// BirdCareクラスはBirdFeederインターフェースに依存
class BirdCare {
    private feeder: BirdFeeder;

    constructor(feeder: BirdFeeder) {
        this.feeder = feeder;
    }

    feed(bird: Bird) {
        this.feeder.feedBird(bird);
    }
}

const simpleFeeder = new SimpleBirdFeeder();
const advancedFeeder = new AdvancedBirdFeeder();

const birdCareWithSimpleFeeder = new BirdCare(simpleFeeder);
const birdCareWithAdvancedFeeder = new BirdCare(advancedFeeder);

birdCareWithSimpleFeeder.feed(new Bird("sparrow"));
birdCareWithAdvancedFeeder.feed(new Bird("eagle"));

依存性逆転の原則に違反している例

以下のクラスのように直接的に依存(BirdCareクラスがSimpleBirdFeederクラスに依存)してしまうと、餌やりの方法を追加する際に(ここではAdvancedBirdFeederクラス)BirdCareクラスに変更を加えなければならなくなってしまいます。


class Bird {
    private species: string;

    constructor(species: string) {
        this.species = species;
    }

    getSpecies(): string {
        return this.species;
    }
}

// SimpleBirdFeederクラス
class SimpleBirdFeeder {
    feedBird(bird: Bird): void {
        console.log(`Feeding a ${bird.getSpecies()} with simple food`);
    }
}

// AdvancedBirdFeederクラス(異なる餌やりの方法の追加)
class AdvancedBirdFeeder {
    feedBird(bird: Bird): void {
        console.log(`Feeding a ${bird.getSpecies()} with advanced nutrition`);
    }
}

// BirdCareは高レベルのモジュールで、SimpleBirdFeederに直接依存
class BirdCare {
    private feeder: SimpleBirdFeeder;

    constructor() {
        this.feeder = new SimpleBirdFeeder();
    }

    feed(bird: Bird) {
        this.feeder.feedBird(bird);
    }

    // AdvancedBirdFeederを使用するためには、クラスを修正する必要がある
    setAdvancedFeeder() {
        this.feeder = new AdvancedBirdFeeder(); 
    }
}



まとめ

長くなってしまいましたが、以上がSOLID原則でした。
設計は難しそう…と思っていましたが、SOLID原則を学んでみたところ、TypeScriptの機能が何のためにあって、どのように使うと効果的であるのか理解するのにとても役に立ちました。
デザインパターンなどさらに設計への理解を深めて、また皆さんに共有できればと思います!

採用情報

エムアイ・ラボでは一緒に開発に携わってくれるエンジニアを積極的に採用中です!
未経験エンジニアの方も募集しております!
(経験者の方、他職種も募集中です)

ぜひお気軽にご連絡ください。

https://www.wantedly.com/companies/milab-inc

Discussion