🐵

solid原則を理解する

2023/11/07に公開

はじめに

ドメイン駆動設計を学習する必要が出てきたので、まず基礎学習としてsolid原則から学習することにしました。
solid原則を学習した内容をアウトプットするためにこの記事を投稿します。

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

一つのクラスはたった一つの対象に責務を負うべきであるという原則です。

下記の例のようにEmployeeクラスがあり、給料を計算するロジックと労働時間をレポートするロジックの共通部分をgetScheduleHoursに定義しています。
ここで、給料の計算ロジックを変更したいとなった際にgetScheduleHoursの修正も必要になりました。
getScheduleHoursの修正により、reportHoursでバグが発生してしまいました。
共通ロジックの修正はcalculateSalaryとreportHoursに影響を与えてしまいます。

singleResponsibility/badExample.ts
class Employee {
  constructor(public name: string) {}

  calculateSalary() {
    this.getScheduleHours();
    console.log(`${this.name}の給料を計算`);
  }

  reportHours() {
    this.getScheduleHours();
    console.log(`${this.name}の労働時間をレポート`);
  }

  getScheduleHours() {
    console.log("所定労働時間を計算");
  }
}

そのため下記のように、給料を計算するクラスと労働時間をレポートするクラスに分け、1つのクラスには1つの責務を負うようにします。
コメント部分を変更しても影響は、calculateSalaryにしか及びません。

singleResponsibility/goodExample.ts
class Employee {
  constructor(public name: string) {}
}

class SalaryCalculator {
  calculateSalary(employee: Employee) {
    this.getScheduleHours();
    console.log(`${employee.name}の給料を計算`);
  }

  // このメソッドを修正する
  getScheduleHours() {
    console.log("所定労働時間を計算");
  }
}

class HoursReporter {
  reportHours(employee: Employee) {
    this.getScheduleHours();
    console.log(`${employee.name}の労働時間をレポート`);
  }

  getScheduleHours() {
    console.log("所定労働時間を計算");
  }
}

2.オープンクローズドの原則(Open Closed)

拡張に対して開かれていて、修正に対して閉じていなければならないという原則です。

拡張に対して開かれているというのは、新たなコードを追加することで機能を拡張することができることです。
修正に対して閉じているというのは、機能の拡張によって既存のコードを修正しないことです。
一言で言うと、既存のソースコードを変更することなく機能を拡張できるようにすべきということです。

下記のように現在あるグレードは、beginner、junior、seniorの3つですが、middleというグレードを増やしたいとなったとしましょう。
getSalaryの条件分岐を増やす必要があります。
既存のコードを修正することになるので、これまで正常に動いていたコードでも軽微なミスで動かなくなる可能性があります。

openClosed/badExample.ts
type Grade = "beginner" | "junior" | "senior";

class Employee {
  constructor(public name: string, public grade: Grade) {}
}

class SalaryCalculator {
  constructor(public base: number) {}

  getSalary(employee: Employee) {
    if (employee.grade === "beginner") {
      return this.base * 1.2;
    } else if (employee.grade === "junior") {
      return this.base * 1.4;
    } else {
      return this.base * 2;
    }
  }
}

下記のようにすると、MiddleEmployeeクラスを追加することで実現することができます。

openClosed/goodExample.ts
interface IEmployee {
  name: string;
  getSalary(base: number): number;
}

class BeginnerEmployee implements IEmployee {
  constructor(public name: string) {}

  getSalary(base: number): number {
    return base * 1.2;
  }
}

class JuniorEmployee implements IEmployee {
  constructor(public name: string) {}

  getSalary(base: number): number {
    return base * 1.4;
  }
}

class SeniorEmployee implements IEmployee {
  constructor(public name: string) {}

  getSalary(base: number): number {
    return base * 2;
  }
}

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

サブタイプはそのスーパータイプと置換可能であるべきであるという原則です。
簡単に言うと、継承元と継承先のクラスの振る舞いを同じにしようということです。

スーパータイプのsetWidthは幅だけ更新しているのに対して、サブタイプのsetWidthは幅と高さを更新しています。
そのため、RectangleのインスタンスをSquareのインスタンスで置き換えると、プログラムの振る舞いが変わる可能性があります。

liskovSubstitution/badExample.ts
// スーパータイプ
export class Rectangle {
    width = 0;
    height = 0;

    setWidth(width: number) {
        this.width = width;
    }

    setHight(height: number) {
        this.height = height;
    }

    getArea(): number {
        return this.width * this.height;
    }
}

// サブタイプ
export class Square extends Rectangle {
    setWidth(width: number) {
        super.setWidth(width);
        super.setHight(width);
    }

    setHight(height: number) {
        super.setWidth(height);
        super.setHight(height);
    }
}

この問題を解決するために、継承関係を見直しました。
ShapeはgetAreaだけの機能を提供することだけを保証したインターフェースです。

liskovSubstitution/goodExample.ts
interface Shape {
    getArea(): number;
}

class Rectangle implements Shape {
    private width = 0;
    private height = 0;

    setWidth(width: number) {
        this.width = width;
    }


    setHeight(height: number) {
        this.height = height;
    }

    getArea(): number {
        return this.width * this.height;
    }
}

class Square implements Shape {
    private length = 0;

    setLength(length: number) {
        this.length = length;
    }

    getArea(): number {
        return this.length * this.length;
    }
}

この原則に違反すると、利用者が想定していないバグが発生する可能性が高まります。
利用者は、スーパータイプとサブタイプは同じ挙動になることを期待して利用しますが、この原則に違反していると、サブタイプまで全て理解した上で利用しなければならなくなります。

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

インターフェースの利用者にとって利用しないフィールドやメソッドへの依存を避けるべきであるという原則です。
インターフェースに用意されている不要なフィールドやメソッドに利用者が依存しなくてもいいように適切にインターフェースは分割すべきであるということです。

Carクラスではflyメソッドを使用することはできませんが、Vehicleを実装しているので、利用者がflyメソッドを使用してしまう可能性が高まります。

interfaceSegregation/badExample.ts
interface Vehicle {
    start(): void;
    stop(): void;
    fly(): void;
}

class Airplane implements Vehicle {
    start() {
        console.log("start!");
    }
    stop() {
        console.log("stop!");
    }
    fly() {
        console.log("fly!");
    }
}

class Car implements Vehicle {
    start() {
        console.log("start!");
    }
    stop() {
        console.log("stop!");
    }
    fly() {
        throw new Error("車は空を飛べません");
    }
}

下記の実装でCarクラスが不要なflyメソッドを持つ必要がなくなりました。

interfaceSegregation/goodExample.ts
interface Movable {
    start();
    stop();
}

interface Flyable {
    fly();
}

class Airplane implements Movable, Flyable {
    start() {
        console.log("start!");
    }
    stop() {
        console.log("stop!");
    }
    fly() {
        console.log("fly!");
    }
}

class Car implements Movable {
    start() {
        console.log("start!");
    }
    stop() {
        console.log("stop!");
    }
}

この原則に違反していると、インターフェースを変更する必要があった際に実装側で使用されていないメソッドも修正しなければならなくなります。

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

上位モジュールは下位モジュールに依存してはいけない、そしてどちらもモジュールの抽象に依存すべきであるという原則です。
上位モジュールとは依存している側、下位モジュールとは依存されている側です。

下記のコードは、ユーザーからの操作をUserControllerで受け取り、DBへの操作をUserRepositoryで行っています。

UserControllerがUserRepositoryに依存してしまっている状態です。

dependencyInversion/badExample.ts
class User {}

class UserController {
    private userRepository = new UserRepository();

    create(user: User): User {
        return this.userRepository.create(user);
    }

    findById(id: string): User {
        return this.userRepository.findById(id);
    }
}

class UserRepository {
    create(user: User): User {
        console.log("userを登録")
        return user
    }

    findById(id: string): User {
        console.log(`${id}のユーザーを検索`)
        return new User();
    }
}

下記のカードはUserControllerとUserRepositoryの間にIUserRepositoryというインターフェースを挟み、使う側(UserController)も使われる側(UserRepository)も抽象(IUserRepository)に依存しています。

dependencyInversion/goodExample.ts
class User {}

interface IUserRepository {
  create(user: User): User;
  findById(id: string): User;
}

class UserController {
  constructor(private userRepository: IUserRepository) {}

  create(user: User): User {
    return this.userRepository.create(user);
  }

  findById(id: string): User {
    return this.userRepository.findById(id);
  }
}

class UserRepository implements IUserRepository {
  create(user: User): User {
    console.log("userを登録");
    return user;
  }

  findById(id: string): User {
    console.log(`${id}のユーザーを検索`);
    return new User();
  }
}

この原則に違反すると、下位モジュールの変更が上位モジュールに影響を与えてしまいます。
また、下位モジュールがないと上位モジュールが開発できないといったことが発生します。

最後に

次回はデザインパターンについて解説しようと考えています。

solid原則については、勉強中なので解説やコードへの指摘があればコメントしていただければ幸いです。

Discussion