メインコンテンツまでスキップ

Singleton — モジュールが単一インスタンスを与える範囲

ES モジュール自体が単一インスタンスになる範囲と、それでも別の手段が要る条件を扱います。

このパターンが解こうとした問題

GoF は Singleton を、あるクラスのインスタンスがちょうど 1 つであることを保証し、それにアクセスするためのグローバルな手段を提供するパターンとして収録しています1

同じ設定やキャッシュを複数のインスタンスが別々に持ってしまうと、どれが正しい値かわからなくなります。かといってグローバル変数に置くと、誰でも上書きできてしまいます。この 2 つを同時に避けるために、生成をクラス自身に握らせるというのが Singleton の発想です。

ここで見落としやすいのは、この形が生まれた言語にはモジュールという単位がなかったという前提です。GoF が例に用いた C++ や Smalltalk にもグローバル変数はありましたが、外から書き換えられないように囲ったうえで 1 つに保つとなると、置き場はクラスの静的メンバーに寄りました。「1 つであること」と「クラスであること」が結び付いているのは、この事情によるものです。

クラスで素直に書く

機能フラグの保管庫を題材にします。パターンどおりに書くと、コンストラクタを非公開にして、生成の経路を静的メソッドに一本化する形になります。

class FeatureFlagStore {
private static instance: FeatureFlagStore | undefined;
private readonly flags = new Map<string, boolean>();

// 外部からの new を封じ、生成経路を getInstance に一本化する
private constructor() {}

static getInstance(): FeatureFlagStore {
FeatureFlagStore.instance ??= new FeatureFlagStore();
return FeatureFlagStore.instance;
}

isEnabled(key: string): boolean {
return this.flags.get(key) ?? false;
}

setFlag(key: string, value: boolean): void {
this.flags.set(key, value);
}
}

// 呼び出し側はどこからでも同じインスタンスを取得できる
function renderCheckout(): string {
return FeatureFlagStore.getInstance().isEnabled('new-checkout') ? 'new' : 'legacy';
}

意図どおりに動きますが、代償があります。renderCheckout の引数を見ても、この関数が機能フラグに依存していることはわかりません。依存がシグネチャに現れず、本体を読むまで気付けない形になっています。

言語機能で置き換える

TypeScript が動く環境には、GoF が例に用いた言語になかったモジュールがあります。MDN は ES モジュールの評価回数について次のように書いています。

Modules are only executed once, even if they have been referenced in multiple <script> tags.2

つまり モジュールのトップレベルに置いた値は、何箇所から import しても 1 つです。同じ保管庫はこう書けます。

ただし「1 つ」が成り立つ範囲には境目があります。Node.js のドキュメントは解決の単位を明記しています。

ES modules are resolved and cached as URLs.3

Modules are loaded multiple times if the import specifier used to resolve them has a different query or fragment.3

同じ実行環境の中で、同じ URL に解決されるかぎり 1 つという条件付きです。依存の重複で同じパッケージが 2 系統入る場合や、Worker のように実行環境そのものが分かれる場合は、同じファイルでも別のインスタンスになります。

const flags = new Map<string, boolean>();

export function isEnabled(key: string): boolean {
return flags.get(key) ?? false;
}

export function setFlag(key: string, value: boolean): void {
flags.set(key, value);
}

flags は export していないので、モジュールの外からは触れません。GoF が静的メンバーで実現しようとした「1 つであること」と「勝手に触らせないこと」を、モジュールの境界がそのまま与えてくれます。

利用側は必要な関数だけを import します。

import { isEnabled } from './feature-flags';

export function renderCheckout(): string {
return isEnabled('new-checkout') ? 'new' : 'legacy';
}

クラスの版と比べると、private static instancegetInstance も消えました。どちらもインスタンスを 1 つに保つための仕組みで、その役割は上に見た範囲でモジュールの解決規則が引き受けています。

本連載の判断

判定: 条件付き。 既定はモジュールで書き、下に挙げる条件に当たるときだけ別の手段を選びます。

モジュールのトップレベルで済ませてよいのは、次のすべてを満たすときです。

  • プロセスの中で 1 つあれば十分で、複数の設定を同時に抱える予定がない
  • 生成に引数が要らない、または環境変数のように起動時に決まる値だけで組み立てられる
  • 実装を差し替える必要がない

一方、次のいずれかに当たるなら、モジュールのトップレベルに状態を置くのはやめて、値を引数で受け取る形に寄せます。クラスにするかどうかは二次的な問題で、重要なのは生成と受け渡しを呼び出し側に返すことです。

  • テストごとに独立した状態が要る
  • 実装を切り替えたい (保存先をメモリと外部サービスで入れ替えるなど)
  • 生成に外から渡す引数が要る、または明示的に後始末したい

この判定が覆る条件

テストの都合は判定を裏返しやすいので、具体的に見ておきます。モジュールの状態はテスト間で持ち越されます。Vitest には登録簿を消す機能がありますが、次の制約があります。

Resets modules registry by clearing the cache of all modules. This allows modules to be reevaluated when reimported. ... Top-level imports cannot be re-evaluated.4

つまり vi.resetModules() を呼んでも、ファイル冒頭で import した束縛はそのままです。読み直すには動的 import が要ります。

import { beforeEach, expect, test, vi } from 'vitest';

beforeEach(() => {
vi.resetModules();
});

test('フラグを立てられる', async () => {
const { isEnabled, setFlag } = await import('./feature-flags');
setFlag('new-checkout', true);
expect(isEnabled('new-checkout')).toBe(true);
});

// 前のテストが立てたフラグが残っていれば、この期待は false にならない
test('前のテストで立てたフラグは持ち越されない', async () => {
const { isEnabled } = await import('./feature-flags');
expect(isEnabled('new-checkout')).toBe(false);
});

この書き方で分離はできますが、テストのたびに動的 import を書くことになります。テストで状態を差し替える場面が続くなら、それはモジュールに状態を置いた設計そのものへの差し戻しの合図と読むほうが素直です。

なお、この節で否定しているのは「モジュールのトップレベルに可変の状態を置くこと」であって、モジュールに関数や定数を置くことではありません。読み取り専用の定数であれば、テストの差し替えは問題になりません。

既存記事との関係

同じ「シングルトン」という語が、本サイトの別の記事では別の意味で使われています。混同しやすいので整理しておきます。

  • Laravel の singleton() — DI コンテナが解決のたびに新しいインスタンスを作るか使い回すかという生存期間の設定です。クラス側が自分で単一性を保証する GoF の Singleton とは、責任の持ち主が逆になっています。
  • PayloadCMS の中核概念 — PayloadCMS の Globals を「シングルトン」と呼んでいますが、これはレコードが 1 件しかないデータ構造を指す用語で、設計パターンの話ではありません。

TypeScript のモジュール構文そのものは モジュールと設定、クラス構文は クラスの基本 で扱っています。

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides『Design Patterns: Elements of Reusable Object-Oriented Software』(Addison-Wesley, 1994)。Singleton は生成に関するパターンとして収録されています。意図の要約は本ガイドによるもので、原典とは照合していません。同書が C++ と Smalltalk を例に用いていることは二次情報で確認しました (出典: Design Patterns(Wikipedia))。

  2. 出典: JavaScript modules(MDN Web Docs)の Other differences between modules and standard scripts。

  3. 出典: Modules: ECMAScript modules(Node.js ドキュメント)の URLs および URLs の file: URLs。 2

  4. 出典: Vi(Vitest API ドキュメント)の vi.resetModules