はじめに
こんにちは! 株式会社iimonでエンジニアをしている中村です。 業務で処理の流れを追う際にこのコードの全体像はどのように設計されたのだろう..?と考えることがあり、今回は「オブジェクト指向における再利用のためのデザインパターン」という本を元にデザインパターンについて調べてみました。
デザインパターンとは?
デザインパターンとは、ソフトウェア設計における一般的な問題に対する再利用可能な解決策です。これらのパターンは、繰り返し発生する設計問題に対して最適な方法を提供し、コードの再利用性、可読性、保守性を向上させます。
デザインパターンには23種類ありますが、それらは、「生成」、「構造」、「振る舞い」3種類に分類されます。この記事では主要なデザインパターン9つにのみ絞ってご紹介していきます。
Creational(生成に関するパターン)
シングルトン(Singleton)
・シングルトンパターンの概要
シングルトンパターンは、あるクラスのインスタンスがただ一つしか存在しないことを保証し、そのインスタンスへのグローバルなアクセス手段を提供するデザインパターンです。つまり、同じクラスから何度もインスタンスを作ろうとしても、常に同じインスタンスを返すようにします。
・具体例と実装方法
class Singleton { constructor() { if (!Singleton.instance) { Singleton.instance = this; } return Singleton.instance; } } const instance1 = new Singleton(); const instance2 = new Singleton(); console.log(instance1 === instance2); // true
メリット
リソースの節約:例えば、データベース接続や設定管理など、重い処理を伴うインスタンスを一つだけ作成することで、リソースの無駄遣いを防ぎます。
グローバルアクセス:シングルトンインスタンスはどこからでもアクセス可能なので、設定情報やログ管理など、アプリケーション全体で共有する必要があるデータに便利です。
一貫性の確保:同じインスタンスを使うことで、データの一貫性を保ちやすくなります。例えば、設定情報が一箇所で管理されるため、設定がずれることがありません。
注意点
グローバルアクセスが可能なため、不必要な依存性を生むリスクがあります。
ファクトリーメソッド(Factory Method)
・ファクトリーメソッドの概要
ファクトリーメソッドパターンは、オブジェクトの生成をサブクラスに委譲することで、生成するオブジェクトの種類を動的に変更できるようにします。
ファクトリーメソッドパターンを理解するために、アイスクリーム工場の例を使ってみます。
・具体例と実装方法
// アイスクリームの基本クラス class IceCream { constructor(flavor) { this.flavor = flavor; } } // 工場の基本クラス class IceCreamFactory { createIceCream(flavor) { throw new Error("このメソッドはサブクラスで実装されるべきです"); } } // チョコレートアイスクリームを作る機械 class ChocolateIceCreamFactory extends IceCreamFactory { createIceCream() { return new IceCream("Chocolate"); } } // バニラアイスクリームを作る機械 class VanillaIceCreamFactory extends IceCreamFactory { createIceCream() { return new IceCream("Vanilla"); } } // 工場を使ってアイスクリームを作る const chocolateFactory = new ChocolateIceCreamFactory(); const chocolateIceCream = chocolateFactory.createIceCream(); console.log(chocolateIceCream.flavor); // Chocolate const vanillaFactory = new VanillaIceCreamFactory(); const vanillaIceCream = vanillaFactory.createIceCream(); console.log(vanillaIceCream.flavor); // Vanilla // クライアントコード const factories = [new ChocolateIceCreamFactory(), new VanillaIceCreamFactory()]; factories.forEach(factory => { const iceCream = factory.createIceCream(); console.log(iceCream.flavor); });
ファクトリーメソッドパターンでは、工場がどの機械を使うかを決めることで、作るアイスクリームの種類を動的に(その時々で)変えることができます。 これにより、工場は新しい種類のアイスクリームを作る機械を追加するだけで、簡単に新しいアイスクリームを作ることができるようになります。 また、一貫したインターフェースを持っているので、クライアントコードは異なる種類のアイスクリームを生成する際に同じ方法で呼び出すことができます。
ファクトリーメソッドを使わない場合
IceCreamFactoryを継承しない デメリット
・コードの再利用性が低下する: 共通の生成ロジックがないため、同じようなコードが複数の場所に存在する可能性があります。
・依存関係が増える: クライアントコードが特定のファクトリークラスに依存するため、柔軟性が低下します。
class ChocolateIceCreamFactory { createIceCream() { return new IceCream("Chocolate"); } } const chocolateFactory = new ChocolateIceCreamFactory(); const chocolateIceCream = chocolateFactory.createIceCream(); console.log(chocolateIceCream.flavor); // Chocolate
ビルダー(Builder)
・ビルダーパターンの概要
ビルダーパターンは、複雑なオブジェクトの生成過程を分割し、段階的にオブジェクトを構築する方法を提供します。オブジェクトの生成過程を分割することで、コードの可読性と保守性が向上します。
・具体例と実装方法
class Product { constructor() { this.parts = []; } addPart(part) { this.parts.push(part); } } class Builder { constructor() { this.product = new Product(); } buildPartA() { this.product.addPart('PartA'); } buildPartB() { this.product.addPart('PartB'); } getResult() { return this.product; } } const builder = new Builder(); builder.buildPartA(); builder.buildPartB(); const product = builder.getResult(); console.log(product.parts); // ['PartA', 'PartB']
Structural(構造に関するパターン)
アダプター(Adapter)
・アダプターパターンの概要
アダプターパターンは、互換性のないインターフェースを持つクラス同士をつなげるためのパターンです。簡単に言うと、異なる形のプラグを持つおもちゃを一緒に遊べるようにするための「変換器」のようなものです。
・具体例と実装方法
// 古いおもちゃは「古い方法(oldMethod)」で動きます。 class OldSystem { oldMethod() { console.log('Old method'); } } // 新しいおもちゃは「新しい方法(newMethod)」で動きます。 class NewSystem { newMethod() { console.log('New method'); } } // 変換器は、古いおもちゃを新しい方法で動かせるようにします。 class Adapter { constructor(oldSystem) { this.oldSystem = oldSystem; } newMethod() { this.oldSystem.oldMethod(); } } // 古いおもちゃを作る const oldSystem = new OldSystem(); // 変換器を作る const adapter = new Adapter(oldSystem); // 新しい方法で古いおもちゃを動かす adapter.newMethod(); // Old method
アダプターパターンは、新旧システム間の互換性を保つために使用されます。
既存のコードを変更せずに新しい機能を追加できるため、移行期間中のリスクを軽減します。
内部的には oldMethod() を使っていますが、外部から見たときに newMethod() として使えるようにしています。
これにより、クライアントコード(外部のコード)は、新しいインターフェース(newMethod)を使って古いシステムを操作できます。
アダプターパターンの目的は、クライアントコードが異なるインターフェースを持つクラスを一貫した方法で扱えるようにすることです。
内部の実装がどうであれ、外部から見たときに統一されたインターフェースを提供することが重要です。
デコレーター(Decorator)
・デコレーターパターンの概要
デコレーターパターンは、オブジェクトに動的に新しい機能を追加するためのパターンです。
・具体例と実装方法
class Component { operation() { console.log('Component operation'); } } class Decorator { constructor(component) { this.component = component; } operation() { this.component.operation(); console.log('Decorator operation'); } } const component = new Component(); const decorator = new Decorator(component); decorator.operation(); // Component operation // Decorator operation
デコレーターパターンは、既存のオブジェクトに対して動的に機能を追加したいとき。 複数の機能を段階的に追加・削除したいときに使用できます。 例えば、コーヒーにミルクを追加したり、砂糖を追加するイメージです。
ファサード(Facade)
・ファサードパターンの概要
ファサードパターンは、複雑なシステムの一部を簡単に利用できるようにするためのシンプルなインターフェースを提供します。
・具体例と実装方法
class SubsystemA { operationA() { console.log('SubsystemA operation'); } } class SubsystemB { operationB() { console.log('SubsystemB operation'); } } class Facade { constructor() { this.subsystemA = new SubsystemA(); this.subsystemB = new SubsystemB(); } operation() { this.subsystemA.operationA(); this.subsystemB.operationB(); } } const facade = new Facade(); facade.operation(); // SubsystemA operation // SubsystemB operation
・適用例と注意点
ファサードパターンは、複雑なシステムを簡単に利用できるようにするために使用されます。システムの内部構造を隠蔽し、クライアントにシンプルなインターフェースを提供できます。
例えばコンピューターを起動するときに、ボタンを押すだけで内部でCPUやメモリなどが始動するイメージです。
Behavioral(振る舞いに関するパターン)
オブザーバー(Observer)
・オブザーバーパターンの概要
オブザーバーパターンは、あるオブジェクトの状態が変わったときに、そのオブジェクトに依存する他のオブジェクトに通知を送るためのパターンです。
・具体例と実装方法
class Subject { constructor() { this.observers = []; this.state = null; // 状態を保持するプロパティ } addObserver(observer) { this.observers.push(observer); } setState(newState) { this.state = newState; this.notifyObservers(); // 状態が変化したら通知を送る } notifyObservers() { this.observers.forEach(observer => observer.update(this.state)); } } class Observer { update(state) { console.log(`Observer updated with state: ${state}`); } } const subject = new Subject(); const observer = new Observer(); subject.addObserver(observer); // 状態を変更する subject.setState('new state'); // Observer updated with state: new state
・Web APIとの使い分け
Web APIにもオブザーバーパターンに類似した機能を提供するものがいくつかあります。例えば、addEventListenerを使ったイベントリスナーや、MutationObserver、IntersectionObserverなどです。
WebAPIが適している処理
特定のユースケース: DOMの変更を監視する場合はMutationObserverが適しています。また、要素がビューポートに入ったときに何かをしたい場合はIntersectionObserverが便利です。
オブザーバーパターンが適している処理
Web APIが提供する機能では対応できないカスタムな状態管理や通知が必要な場合、オブザーバーパターンを使うことが有効です。
・適用例と注意点
オブザーバーパターンは、イベント駆動型のシステムやリアルタイム更新が必要なシステムに適用されます。注意点としては、多数のオブザーバーが存在するとパフォーマンスに影響を与える可能性があります。
ストラテジー(Strategy)
・ストラテジーパターンの概要 ストラテジーパターンというのは、何かをする方法(アルゴリズム)を別々のクラスに分けておいて、必要に応じてその方法を切り替えることができる仕組みのことです。 ゲームに例えて説明します。
・具体例と実装方法
// キャラクタークラス class Character { constructor(attackStrategy) { this.attackStrategy = attackStrategy; // どの攻撃方法を使うかを決める } performAttack() { return this.attackStrategy.attack(); // 実際に攻撃する } } // 剣で攻撃するクラス class SwordAttack { attack() { return 'Sword attack executed'; // 剣で攻撃する } } // 魔法で攻撃するクラス class MagicAttack { attack() { return 'Magic attack executed'; // 魔法で攻撃する } } // キャラクターに剣で攻撃する方法を設定 const characterWithSword = new Character(new SwordAttack()); console.log(characterWithSword.performAttack()); // Sword attack executed // キャラクターに魔法で攻撃する方法を設定 const characterWithMagic = new Character(new MagicAttack()); console.log(characterWithMagic.performAttack()); // Magic attack executed
このように何かをする方法(アルゴリズム)を別々のクラスに分けておくことで、簡単に切り替えることができます。 注意点としては、アルゴリズムの数が増えると管理が複雑になることです。
コマンド(Command)
・コマンドパターンの概要 コマンドパターンとは「何かをする命令(操作)」をオブジェクトとして扱い、その命令を実行する部分と命令をリクエストする部分を分ける仕組みのことです。
・具体例と実装方法
クラスの説明
Commandクラス: 命令を表すための基礎クラスです。実際の命令はこのクラスを基に作ります。
LightOnCommandクラス: 明かりをつける命令を表します。
LightOffCommandクラス: 明かりを消す命令を表します。
Lightクラス: 実際の明かりです。明かりをつけたり消したりする機能を持っています。
RemoteControlクラス: リモコンです。どの命令を実行するかを設定し、その命令を実行します。
// 命令の基礎クラス class Command { execute() { throw new Error("This method should be overridden"); } } // 明かりをつける命令クラス class LightOnCommand extends Command { constructor(light) { super(); this.light = light; } execute() { this.light.on(); } } // 明かりを消す命令クラス class LightOffCommand extends Command { constructor(light) { super(); this.light = light; } execute() { this.light.off(); } } // 明かりクラス class Light { on() { console.log('Light is on'); } off() { console.log('Light is off'); } } // リモコンクラス class RemoteControl { setCommand(command) { this.command = command; } pressButton() { this.command.execute(); } } // 明かりのオブジェクトを作る const light = new Light(); // 明かりをつける命令を作る const lightOn = new LightOnCommand(light); // 明かりを消す命令を作る const lightOff = new LightOffCommand(light); // リモコンのオブジェクトを作る const remote = new RemoteControl(); // リモコンに明かりをつける命令を設定して、ボタンを押す remote.setCommand(lightOn); remote.pressButton(); // Light is on // リモコンに明かりを消す命令を設定して、ボタンを押す remote.setCommand(lightOff); remote.pressButton(); // Light is off
このようにコマンドパターンを使うと、命令をオブジェクトとして扱うので、命令を変更したり、追加したりしやすくなります。
・適用例と注意点
コマンドパターンは、操作の履歴を記録したり、操作を取り消したりする場合に適用されます。注意点としては、コマンドの数が増えると管理が複雑になることです。
ストラテジー(Strategy)とコマンド(Command)の使い分け
どちらも処理を分けて追加しやすくするデザインパターンですが、目的と使い方に違いがあります。
ストラテジー 目的:アルゴリズム(処理方法)をクラスとしてカプセル化し、動的に切り替え可能にします。
同じタスクを異なる方法で実行する場合に適しています。
使い方:特定のタスクを異なる方法で実行する必要がある場合に、その方法(アルゴリズム)を簡単に切り替えられるようにします。
例えば、異なるソートアルゴリズム、異なる計算方法などに使われます。
コマンドパターン 目的:操作(命令)をオブジェクトとしてカプセル化し、操作のリクエストと実行を分離します。
操作の履歴を記録したり、操作を取り消したりする場合に適しています。
使い方: ユーザーが行った操作をオブジェクトとして保存し、その操作を後で再実行したり、取り消したりできます。
例えば、リモコンのボタン操作、メニューのアクション、Undo/Redo機能などに使われます。
まとめ
デザインパターンのメリット
・再利用性の向上: 汎用的な解決策を提供するため、コードの再利用が容易になります。
・保守性の向上: コードの構造が明確になるため、保守が容易になります。
・柔軟性の向上: システムの変更や拡張が容易になります。
デザインパターンを学ぶためのリソース
おわりに
デザインパターンを知ることで、新規設計をするときに拡張や、保守面について考えることができるようなりました。
また普段の業務でもコードの設計を知ることでより全体像の理解が深まるので調べられて良かったです。
皆さんの普段の開発に役立てていただけると幸いです。
読んでいただき、ありがとうございました!