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

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

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原則のひとつ「開放閉鎖の原則」について考え方、アンチパターンとその解決策の例をTypeScriptのサンプルコードとともに紹介します。

開放閉鎖の原則とは?

概要

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

修正に対して閉じているとは?その理由

既存のコードを変更するということは、すでに動作しているコードに変更を加えるということです。すでに動作しているコードに変更を加えると、バグを生んでしまう可能性があり、バグを生まないために動作確認を行うなどのコストを支払う必要があります。

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

拡張に対して開いているとは?その理由

既存のコードを変更せずに機能を追加するためには、新しいモジュールを追加するだけで機能が追加できるようなつくりにしておくべきです。そのようなつくりにしておくことで、既存の動作している機能を破壊することを恐れることなく、新しい機能を追加できるようになります。

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

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

以下は、社員に支払う給与の総額を計算するCalculateSalaryServiceクラスの例です。InternStaffなどの役職とベースBASEに基づいて計算される基本給と、何かしらの手当allowanceを加算して、社員に支払う給与の総額totalPaymentを計算します。

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

export class Employee {
  public constructor(public name: string, public position: Position) {}
}
export class CalculatePaymentService {
  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 allowance = 10;
const totalPayment = new CalculatePaymentService(employee, allowance).execute();
console.log(totalPayment);
// => 110

CalculateSalaryServiceクラスは、開放閉鎖の原則に違反しているといえます。

原則に違反している理由

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

解決策

CalculateSalaryServiceクラス・Employeeクラスを次のように書き換えることで、既存のコードを書き換えずに新しい役職を加えることができるようになります。

export interface Employee {
  name: string;
  getSalary: (base: number) => number
}
export class CalculatePaymentService {
  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;
  };
}

ここでは、Employeeクラスをinterfaceにして、CalculateSalaryServiceクラスで行っていた給与計算の処理をEmployeeに移しています。また、InternStaffなどの情報は文字列で受け取ってswitch文で判定していましたが、Employeeを実装したクラスとして用意するようにします。

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

  public getSalary = (base: number) => base * 0.5;
}
export class Staff implements Employee {
  public constructor(public name: string) {}

  public getSalary = (base: number) => base;
}
export class Manager implements Employee  {
  public constructor(public name: string) {}

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

StaffであるBob(今月は10の手当がある)の給与の支払額を計算する場合は、以下のようにインスタンスを作成→メソッドを実行します。

const employee = new Staff("Bob")
const allowance = 10;
const totalPayment = new CalculatePaymentService(employee, allowance).execute()
// => 110

このようにすることで、Leaderなどの新しい役職を加えるときのシチュエーションでも、Employeeを実装したLeaderクラスを用意して利用するだけで済みます(Open)。既存のクラス(CalculateSalaryServiceクラスなど)に変更を加える必要はありません(Closed)。

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

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

所感

拡張に開いている/修正に閉じている設計にしておくことで、バグを恐れずに機能を追加できる例を紹介しました。

開放閉鎖の原則を意識して設計することの利点として、他にも、コード全体の見通しが良くなるという点もあります。例としてあげたLeaderを実装したいシチュエーションでは、「interfaceEmployeeがあって、それを実装したInternStaffManagerが並んでいるな〜、ア、Leaderを他に倣って作ればいいんだな」とすぐ理解できます。違反している例としてあげた状態だと「switchcase追加すればいいわけね、ア、Positionにも追加するのか、他に変えなきゃいけないところないかな…?」とちょっと不安になりそうです。

注意しなければいけない点として、拡張されたモジュール(InternStaffManager)はお互いに副作用がないことが前提になっています。ここでいう副作用というのは、関数やメソッドの実行時に他のモジュールに与える影響のことです。InternStaffManagergetSalaryメソッドは、ただnumberを受け取って計算して返すだけなので、副作用はありません。InterngetSalaryを実行したことによってInternの持つプロパティ以外の変数などが変化するようなことは避けましょう。

採用情報

メンバーズエッジで最高のチームで最高のプロダクトを作りませんか?

最高のプロダクトをつくる 最高のチームで働く

在宅でも、地方でも、首都圏でも。多様な働き方で最高のチームをつくり、お客様のプロダクトパートナーを目指します。アジャイル開発を通じ、開発現場の第一線で活躍し続けたいエンジニアを募集しています。