📚

TypeScriptを使って学ぶSOLID原則2 ”オープン/クローズドの原則(Open/Closed Principle)”

2025/03/12に公開

モチベーション

ソフトウェアを設計する際に重要な5つのガイドラインであるSOLID原則について学んでいます。

そのアウトプットの一環で記事を執筆しています。
前回は単一責任の原則(Single Responsibility Principle)についてアウトプットを行いました。
https://zenn.dev/ayut0/articles/dfd0df8c0be114

今回はオープン/クローズドの原則(Open/Closed Principle) についてのアウトプットを行います。

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

オープン/クローズドの原則は下記のことを表します。

Software entities(classes, modules, functions etc.)should be open for extension but closed for modification.

出典: https://www.geeksforgeeks.org/solid-principle-in-programming-understand-with-real-life-examples/

ソフトウェアを構成する各要素は拡張に対してオープンで、変更に対してはクローズでなければならない

ここでいう拡張とは機能追加を表しており、変更とは既存のコードの修正を表します。つまり、ソフトウェアは新たなコードを追加することで機能を追加することができ、機能追加によって既存のコードが修正されないことを意味します。

言い換えるとオープン/クローズドの原則はソフトウェアの振る舞いは既存のものを変更せずに機能を追加できるべきであるということになります。

実装例

この原則を適用すべき例は種別によってソフトウェアの振る舞いを変更しなければならない場合です。
会員ランク毎の獲得ポイント倍率や等級別のサラリー計算を行うシステム等が挙げられます。

守られていない実装例

例として実在するホテルグループのポイント制度について考えたいと思います。ユーザーのランク毎にホテルでの決済金額に応じて異なる倍率でポイントを獲得できるというシステムです。

会員ランク ポイント倍率
Non-elite 1.0
Silver 1.1
Gold 1.25
Plutinum 1.5
Titanium 1.75

このうちNon-eliteからPlutinumまでは実装済み、新たにTitaniumを追加したいというケースについて考えます。

getBonusPointsPerStayメソッドにて会員ランク毎に分岐を行い、ランク毎に異なる処理を行う実装です。


type MemberLevel = "nonElite" | "silver" | "gold" | "platinum" ;

class Member {
    constructor(
        public name: string,
        public level: MemberLevel,
    ) {}
}

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

    getBonusPointsPerStay(member: Member): number {
        switch(member.level){
            case "nonElite":
                return Math.floor(this.base * 1.0);
            case "silver":
                return Math.floor(this.base * 1.1);
            case "gold":
                return Math.floor(this.base * 1.25);
            case "platinum":
                return Math.floor(this.base * 1.5);
            default:
                return 1.0;
        }
    }
}

会員ランクにtitaniumを追加したい場合は下記のような実装になります。


type MemberLevel = "nonElite" | "silver" | "gold" | "platinum" | "titanium" ;

中略

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

    getBonusPointsPerStay(member: Member): number {
        switch(member.level){
            case "nonElite":
                return Math.floor(this.base * 1.0);
            case "silver":
                return Math.floor(this.base * 1.1);
            case "gold":
                return Math.floor(this.base * 1.25);
            case "platinum":
                return Math.floor(this.base * 1.5);
            case "titanium":
                return Math.floor(this.base * 1.75);
            default:
                return 1.0;
        }
    }
}

このように新しい会員ランクが追加されるにつれて、getBonusPointsPerStayメソッドを修正しなければなりません。

条件分岐を追加するだけの簡単な修正かもしれませんが、その修正が安定して動いているgetBonusPointsPerStayメソッドへ影響を与えてしまう可能性があります。ヒューマンエラーにより分岐を間違える可能性もあります。
この場合、新たに追加したtitaniumのみならず既存のsilvergoldの挙動もテストを行う必要があります。

すなわち、このgetBonusPointsPerStayメソッドはオープン/クローズドの原則に反しているということびなります。

守られている実装例

オープン/クローズドの原則に則り実装した例です。
まず、会員のインターフェイスを実装し、それを基に各会員ランクのサブクラスを実装することで、新たに会員ランクが追加した場合も既存のメソッドを修正することなく機能拡張を行うことができます。


interface IMember {
    name:string;
    getBonusPointsPerStay(base:number):number;
}

class NonEliteMember implements IMember {
    constructor(public name:string){}

    getBonusPointsPerStay(base: number): number {
        return Math.floor(base * 1.0)
    }
}

class SilverMember implements IMember {
    constructor(public name:string){}

    getBonusPointsPerStay(base: number): number {
        return Math.floor(base * 1.1)
    }
}

class GoldMember implements IMember {
    constructor(public name:string){}

    getBonusPointsPerStay(base: number): number {
        return Math.floor(base * 1.25)
    }
}

class PlatinumMember implements IMember {
    constructor(public name:string){}

    getBonusPointsPerStay(base: number): number {
        return Math.floor(base * 1.5)
    }
}

この実装にtitaniumの会員ランクを新たに追加する場合は下記のサブクラスを追加することで機能拡張を実現することができます。


class TitaniumMember implements IMember {
    constructor(public name:string){}

    getBonusPointsPerStay(base: number): number {
        return Math.floor(base * 1.75)
    }
}

このようにオープン/クローズドの原則に則り実装を行うことで、機能拡張を行う際に既存のメソッドの振る舞いに影響を与えることなく実装することが可能になります。

原則に違反した場合はどうなるか

オープン/クローズドの原則に違反した場合、以下のような影響が出ます。

  1. 機能拡張をするために既に安定して動いている実装に修正を加えることになるので、バグの温床になりやすい
  2. 上記の影響で、修正のたびに既存の実装へのテストが必要になり、余分な工数がかかる

原則を守った場合のメリット

この原則を守ることで機能拡張が容易になります。拡張した箇所のみをテストすればよくなるので、テストの工数削減及び保守性が向上します。

どうすれば違反しない設計にできるか

オープン/クローズドの原則を守るためには以下のことが重要です。
拡張される可能性があるものを抽象化し、具体の実装(機能の拡張)はサブクラスで実装することです。

抽象化とは共通の振る舞いを抽出し、それをインターフェイス化することです。今回の例ではIMemberがそれに当たります。このインターフェイスを基に振る舞いの異なる各ランクの会員を作成します。


interface IMember {
    name:string;
    getBonusPointsPerStay(base:number):number;
}

こうすることにより具体的な実装はサブクラスが担うことになるので、既存のクラスは修正せずに済みます。このように同じインターフェイスを基にしても、そのサブクラスの実装によって異なる動きをさせる仕組みをPolymophismといい、OOPの重要な概念の1つです。

まとめ

SOLID原則の一つであるオープン/クローズドの原則について、初心者なりにまとめました。

このようにオープン/クローズドの原則を守った実装をすることで機能拡張を行う際に既存の振る舞いに影響を与える心配をせずに実装を行うことができます。

種別が増えるかどうかを意識し増えるものに関しては原則に従いインターフェイスを用いて抽象化を行うことが重要です。もちろん、仕様変更等により最初は抽象化しなくてもよかったが、後々で必要になるケースもあるはずなので、必要になった際にしておくと影響範囲が狭い状態で修正を行うことができます。

Discussion