SOLIDの原則を心で理解する - オープン/クローズドの原則
一つの記事で始めたSOLID原則の探求を続け、今回は2番目の原則であるオープン/クローズドの原則について説明します。
この概念は、1988年にバートランド・メイヤーが彼の著書「オブジェクト指向入門」で表現したように、多くの著者によってオブジェクト指向プログラミングの最も重要な原則の一つと考えられています。
モジュール(クラスや関数など)が「拡張には開かれているが、修正には閉じている」べきだと述べています。
つまり、新しい機能を追加する際には、既存のコードを変更せずに済むように設計することが重要です。これにより、既存の機能に影響を与えずにシステムを拡張することができます。
例えば、あるクラスに新しい機能を追加したい場合、そのクラスを直接変更するのではなく、継承やインターフェースを使って新しいクラスを作成することで、元のクラスを変更せずに機能を拡張することができます。
この原則の概念自体は素晴らしいものです。新しい機能を追加する際に、既存のコードを変更するのではなく、新しいコードを追加することでプロジェクトを設計することを求めています 🤯
アプリケーションに新しい機能を追加する際に、広範囲にわたるコードを変更しなければならないことがあるかもしれません。これらの変更は時に冗長でほぼ同一のものになることがあります。
このような実践は、しばしば脆弱なシステムの症状であり危険です。ソースを変更する前は完全に機能していたモジュールを簡単に壊してしまう可能性があります。
テストされ、実行されている本番コードを、直接関係のない変更のために修正することは常に有害です。
しかし、モジュールの動作を変更するために、そのモジュール自体を修正せずに済む方法があります。
バートランド・メイヤーはこの目的を達成するために継承を使用することを提案しましたが(サブクラスが継承元のクラスの実装詳細に依存する場合、強い結合を導入する可能性があります)、インターフェースに基づくポリモーフィズムの使用を推奨します。これにより、簡単に交換可能なインターフェースを使用することで、低い結合を保ちながら抽象化のレベルを追加できます。
オープン/クローズドの原則の適用例
今回は、新しいエンティティ厩舎(Stable
)を導入し、ゲームの世界とお馴染みの自分たちが必要となるさまざまな種類の馬を生産できるようにしたいと考えています。
この機能はTDDで開発し、次のテスト集合体と実装を記述しました。
// stable.spec.ts
// ----------------------------------------
import { Stable } from '../src/stable'
describe('厩舎(Stable)', () => {
let stable: Stable
beforeEach(() => {
stable = new Stable()
})
describe('馬を作成する(makeHorse)', () => {
it('既知のタイプから馬を作成する', () => {
expect(stable.makeHorse('default-horse')).toMatchObject({
type: 'default-horse',
})
expect(stable.makeHorse('rare-horse')).toMatchObject({
type: 'rare-horse',
})
})
it('未知のタイプの馬に対してはエラーを投げる', () => {
const failingOperation = () => {
stable.makeHorse('donkey')
}
expect(failingOperation).toThrow(new Error('サポートされていない馬の種類です'))
})
})
})
// stable.ts
// ----------------------------------------
export class Stable {
makeHorse(horseType: string) {
switch (horseType) {
case 'default-horse':
return {
type: horseType,
icon: '🐎',
desc: '非常に一般的な馬で、スタミナとスピードが低い',
}
case 'rare-horse':
return {
type: horseType,
icon: '🏇',
desc: '速くて頑丈な馬',
}
default:
throw new Error('サポートされていない馬の種類です')
}
}
}
すべて素晴らしいですね 🎉
ただし、ここで一つの疑問が生じます。
新しい種類の馬を管理するためにこのクラスをどのように拡張するべきなのか?
新しい種類ごとにmakeHorse
メソッドを修正することもできますが、これは手間がかかるだけでなく、OCP(オープン・クローズドの原則)に明らかに違反しますし、クラスは新しい要件に対応できるように変更されるべきではありません。
一つの方法として、HorseMaker
インターフェースを導入し、複数の具体的な実装を持たせることが考えられます。これらの異なるメーカーをStableクラスに渡し、馬の作成の唯一のソースとすることができます。
// stable.spec.ts
// ----------------------------------------
import { DefaultHorseMaker, RareHorseMaker, Stable } from '../src/stable'
describe('厩舎(Stable)', () => {
let stable: Stable
// 各テスト前に新しい厩舎インスタンスを作成
beforeEach(() => {
stable = new Stable(new DefaultHorseMaker(), new RareHorseMaker())
})
describe('makeHorse', () => {
it('既知のタイプから馬を作成する', () => {
// デフォルトの馬を作成するテスト
expect(stable.makeHorse('default-horse')).toMatchObject({
type: 'default-horse',
})
// レアな馬を作成するテスト
expect(stable.makeHorse('rare-horse')).toMatchObject({
type: 'rare-horse',
})
})
it('未知のタイプの馬に対してはエラーを投げる', () => {
// 未知のタイプの馬を作成しようとするとエラーを投げる
const failingOperation = () => {
stable.makeHorse('donkey')
}
expect(failingOperation).toThrow(new Error('サポートされていない馬の種類です'))
})
})
})
// stable.ts
// ----------------------------------------
// Horse 定義
interface Horse {
type: string
icon: string
desc: string
}
// HorseMaker 定義
interface HorseMaker {
type: string
make(): Horse
}
// デフォルトの馬メーカークラス
export class DefaultHorseMaker implements HorseMaker {
type = 'default-horse'
make(): Horse {
return {
type: this.type,
icon: '🐎',
desc: '低いスタミナとスピードを持つ非常に一般的な馬',
}
}
}
// レアな馬メーカークラス
export class RareHorseMaker implements HorseMaker {
type = 'rare-horse'
make(): Horse {
return {
type: this.type,
icon: '🏇',
desc: '速くて頑丈な馬',
}
}
}
// 厩舎クラス
export class Stable {
private horseMakers: Map<string, HorseMaker>
// コンストラクタ
constructor(...horseMakers: HorseMaker[]) {
this.horseMakers = horseMakers.reduce((makers, maker) => {
makers.set(maker.type, maker)
return makers
}, new Map<string, HorseMaker>())
}
// 馬を作成するメソッド
makeHorse(horseType: string) {
const horseMaker = this.horseMakers.get(horseType)
if (typeof horseMaker !== 'undefined') {
return horseMaker.make()
}
throw new Error('サポートされていない馬の種類です')
}
}
厩舎(Stable
)は、そのコードに手を加えることなく、他の種類の馬を生産できるようになりました!
//stable.spec.ts
// ----------------------------------------
- import { DefaultHorseMaker, RareHorseMaker, Stable } from '../src/stable'
+ import { DefaultHorseMaker, RareHorseMaker, UnicornMaker, Stable } from '../src/stable'
describe('厩舎(Stable)', () => {
let stable: Stable
// 各テスト前に新しい厩舎インスタンスを作成
beforeEach(() => {
- stable = new Stable(new DefaultHorseMaker(), new RareHorseMaker())
+ stable = new Stable(new DefaultHorseMaker(), new RareHorseMaker(), new UnicornMaker())
})
describe('makeHorse', () => {
it('既知のタイプから馬を作成する', () => {
+ expect(stable.makeHorse('unicorn')).toMatchObject({
+ type: 'unicorn',
+ }) // -> ✅
})
})
})
// ユニコーンメーカークラスの定義
+ export class UnicornMaker implements HorseMaker {
+ type = 'unicorn';
+ make(): Horse {
+ return {
+ type: this.type,
+ icon: '🦄',
+ desc: '角を持つ妖精のような馬に似た動物',
+ };
+ }
+ }
// `Stable` クラスには変更がありません
export class Stable {
private horseMakers: Map<string, HorseMaker>;
...
}
オープン/クローズドの原則の利点
オープン/クローズドの原則は、以下の理由から私たちの製品を進化させる際に非常に重要です。
- 既存のコードを大幅に修正することなく進化させることができる
- 既存のコードに手を加えないため、副作用の影響を受ける心配がない
オープン/クローズドの原則の有用性と強力さを実感する1つの方法は、私たちが毎日使用しているツール、つまりIDEにオープン/クローズドの原則が適用されているのを見ることです。
Cursor(最近愛用している)、VSCode、またはVimなど、いずれのツールでも、プラグインを使用してコードを変更することなくツールの機能を拡張することが可能です。
なぜそれば可能なのでしょうか?
普段使用しているアプリケーションは、オープン/クローズドの原則を忠実に守っており、実装の詳細から高レベルのルールを保護しています。依存関係を慎重に管理し、重要なアーキテクチャの境界を越えた方向に進む依存関係を逆転させることで、この目的を達成しています。
このようにして、コードを変更せずに新しい機能を追加することが可能となり、既存の機能が予期せぬ影響を受ける心配がありません。
まとめ
この最終目標を達成することが常に簡単である(あるいは可能である)わけではありませんが、それでもこの記事がオープン/クローズドの原則の利点についてあなたを納得させる助けとなることを心より願っています。
Discussion