💡gntk.dev

開放閉鎖の原則 TypeScriptでSOLID原則

SOLID原則のS「開放閉鎖の原則」について自分が理解している内容をまとめました。

post-cover

O … Open-Closed Principle: 開放閉鎖の原則

オブジェクト指向の考え方を用いることによって、関連のあるデータ構造とそれを操作する手続きを結びつけて「オブジェクト」とし、それらオブジェクトを組み合わせてソフトウェアを設計・開発することができる。

関連のあるデータ構造とそれを操作する手続きの結びつけ方、オブジェクト同士の組み合わせ方について適切な取り決めを設け、よりよいソフトウェアを設計・開発するために考案された原則として「SOLID原則」が知られている。

SOLID原則は、ソフトウェアエンジニアRobert C. Martinに提唱された多くの設計原則を5つにまとめたものの頭文字をとって命名された。

  • S … Single Responsibility Principle: 単一責任の原則
  • O … Open-Closed Principle: 開放閉鎖の原則
  • L … Liskov Substitution Principle: リスコフの置換原則
  • I … Interface Segregation Principle: インターフェイス分離の原則
  • D … Dependency Inversion Principle: 依存性逆転の原則

SOLID原則に基づいて設計・開発されたソフトウェアは、以下のような特徴を持つと言われている。

  • 変更しやすい
  • 理解しやすい
  • オブジェクトが再利用しやすい

逆に、SOLID原則に違反した設計・開発を行ってしまうと、

  • 新しい機能を追加するために既存のコードに大量の修正を加える必要がある
  • 既存の機能を変更することでバグを生んでしまう可能性が高い
  • 既存のコードを理解するために多くの時間を費やしてしまい、機能の追加や修正に時間がかかりがちになる
  • 機能の追加時に既存のコードを再利用できないため、開発効率が悪くなる

などの不都合が生まれる。

本記事では、SOLID原則のひとつ「開放閉鎖の原則」について考え方、アンチパターンとその解決策の例をTypeScriptのサンプルコードとともに紹介する。

概要

ソフトウェアを構成する個々の部品は、拡張に対して開いていて(Open)、修正に対して閉じている(Closed)べきであるという原則。つまり、ソフトウェアに新しく機能を追加するとき、既存のコードを変更せず新しいコードを追加するだけで済むようにしておくべきであるという意味。

修正に対して閉じていなければいけない理由

既存のコードを変更するということは、すでに動作しているコードに変更を加えるということである。すでに動作しているコードに変更を加えると、バグを生んでしまう可能性があり、バグを生まないために動作確認を行うなどのコストを支払う必要がある。たとえばswitch文に新しい条件分岐を追加するという簡単な修正であっても、breakの書き忘れですでに動作しているコードが動作しなくなってしまう可能性がある。そういったケアレスミスを避けるためにも、新しい機能を追加する際は、既存のコードを修正しなくてもいいようにしておくべきである。

拡張に対して開いていなければいけない理由

既存のコードを変更せず新しいコードを追加するだけで済むようにしておくことで、既存の動作している機能を破壊することを恐れることなく、新しい機能を追加できるようになる。

開放閉鎖の原則に違反している例

TypeScriptのサンプルコードで、開放閉鎖の原則に違反している例を紹介する。

社員の役職と手当から、社員に支払う給与の支払額を計算するCalculateSalaryServiceクラスの例。

type Position = "Intern" | "Staff" | "Manager";

class Employee {
  public constructor(public name: string, public position: Position) {}
}

class CaluculatePaymentService {
  private readonly BASE = 100;
  private totalPayment = 0;

  public constructor(
    private readonly employee: Employee,
    private readonly allowance: number = 0,
  ) {}

  public execute = (): number => {
    this.addSalaryToTotalPayment();
    this.addAllowanceToTotalPayment();

    return this.totalPayment;
  };

  private addSalaryToTotalPayment = (): void => {
    switch (this.employee.position) {
      case "Intern":
        this.totalPayment += this.BASE * 0.5;
        break;
      case "Staff":
        this.totalPayment += this.BASE;
        break;
      case "Manager":
        this.totalPayment += this.BASE * 2;
        break;
      default:
        const _check: never = this.employee.position;
    }
  };

  private addAllowanceToTotalPayment = (): void => {
    this.totalPayment += this.allowance;
  };
}

StaffであるBob(今月は10の手当がある)の給与の支払額を計算する場合は以下のようになる。

const employee = new Employee("Bob", "Staff")
const totalPayment = new CaluculatePaymentService(employee, 10).execute()
console.log(totalPayment);
// => 110

違反している理由

以上のコードでも問題なく動作する。しかし、たとえばLeaderなどの新しい役職を加えるときのシチュエーションを考えると、既存のswitch文にcaseを追加したり、Position型にLeaderを追加する対応をしなければならず、上述したようなバグを引き起こす可能性がある。これは、開放閉鎖の原則に違反していると言える。

解決策

次のように書き換えることで、既存のコードを書き換えずに新しい役職を加えることができるようになる。

interface Employee {
  name: string;
  getSalary: (base: number) => number
}

class CaluculatePaymentService {
  private readonly BASE = 100;
  private totalPayment = 0;

  public constructor(
    private readonly employee: Employee,
    private readonly allowance: number = 0,
  ) {}

  public execute = (): number => {
    this.addSalaryToTotalPayment();
    this.addAllowanceToTotalPayment();

    return this.totalPayment;
  };

  private addSalaryToTotalPayment = (): void => {
    this.totalPayment += this.employee.getSalary(this.BASE)
  };

  private addAllowanceToTotalPayment = (): void => {
    this.totalPayment += this.allowance;
  };
}

Employeeinterfaceにし、給与額の計算処理をそちらに移している。そして、InternStaffManagerなどの役職は、Employeeimplementsする。

class Intern implements Employee {
  public constructor(public name: string) {}

  public getSalary = (base: number) => base * 0.5;
}

class Staff implements Employee {
  public constructor(public name: string) {}

  public getSalary = (base: number) => base;
}

class Manager implements Employee  {
  public constructor(public name: string) {}

  public getSalary = (base: number) => base * 2;
}

このようにすることで、Leaderなどの新しい役職を加えるときのシチュエーションでも、既存のコードを変更する必要がなくなる(Closed)。Leaderクラスを追加したいなら、Employeeimplementsすればよいということが明らかである。(Open)

class Leader implements Employee  {
  public constructor(public name: string) {}

  public getSalary = (base: number) => base * 1.5;
}
© 2021 gntk.dev