シングルトンパターン
クラスのインスタンスが 1 つだけ存在することを保証するデザインパターン
シングルトンパターンとは
シングルトンパターンは、クラスのインスタンスが 1 つだけ存在することを保証し、グローバルなアクセスポイントを提供するデザインパターンである。GoF (1994 年の『Design Patterns』) が挙げた 23 パターンのうち、生成に関するパターン 5 種の 1 つとして収録されている。
要点は「1 つだけ」を保証する主体がクラス自身であることにある。呼び出し側は生成方法を選べず、決まった入口から受け取るだけになる。この選べなさが、使い回したい接続では利点になり、後述するテストの困難さでは欠点になる。
TypeScript での実装
典型的な実装は、コンストラクタを private にして外からの new を禁じ、静的メソッド経由でのみ取得させる形になる。
class DatabaseClient {
private static instance: DatabaseClient;
private constructor() { /* 初期化 */ }
static getInstance(): DatabaseClient {
if (!DatabaseClient.instance) {
DatabaseClient.instance = new DatabaseClient();
}
return DatabaseClient.instance;
}
}
const db1 = DatabaseClient.getInstance();
const db2 = DatabaseClient.getInstance();
db1 === db2; // true (同じインスタンス)
getInstance の中で初めて生成しているのは遅延初期化 (lazy initialization) で、一度も使われなければ接続や重い準備が走らない。実行して確かめると、2 回の取得は同じインスタンスを指し、コンストラクタの実行は 1 回に留まる。
「無ければ作る」は競合し得る
存在確認と代入は 2 つの操作である。Java や C# のように複数のスレッドが同時に走る言語では、両方のスレッドが「まだ無い」の判定を通り抜けてインスタンスが 2 つできる。この言語ではクラスの静的初期化子や enum を使って読み込み時に確定させるか、フィールドを volatile にしてロックで囲む書き方 (double-checked locking) で防ぐ。
JavaScript は 1 本のイベントループで同期コードが中断されないため、上の同期版で競合は起きない。ただし初期化に await が入ると同じ穴が開く。
// 危険: await の間に別の呼び出しが判定を通り抜ける
let client: Client | undefined;
const getClient = async () => {
if (!client) client = await connect(); // 2 回 connect される
return client;
};
// 安全: インスタンスではなく Promise をキャッシュする
let clientPromise: Promise<Client> | undefined;
const getClientSafe = () => (clientPromise ??= connect());
同時に 2 回呼び出して実測すると、前者は接続が 2 回走って別のインスタンスが返り、後者は接続 1 回で同じインスタンスが返る。競合状態はスレッドが無くても、待ちを挟んだ時点で発生する。
モジュールスコープのシングルトン (推奨)
JavaScript と TypeScript では、モジュールの評価が 1 回しか起きない性質を使い、クラスを書かずに同じ効果を得られる。
// TypeScript ではモジュールがシングルトンとして機能
// db.ts
export const db = new DynamoDBClient({});
// どこから import しても同じインスタンス
// handler.ts
import { db } from './db';
同じモジュールを何箇所から import しても評価は 1 回だけで、束縛はモジュール側に保持される。そのため export const がそのまま単一のインスタンスになり、getInstance を書く必要がない。
「1 つ」の範囲はプロセスの中だけ
一意性が成り立つのは、解決先のモジュール 1 つに対して評価 1 回という単位である。ここを取り違えると保証が崩れる。
- 同じ実装が ES Modules と CommonJS の両方の経路で読み込まれた場合や、依存の重複でパスが分かれた場合は、別々に評価されて別のインスタンスになる。
- プロセスをまたげば当然別になる。Lambda はハンドラーの外で宣言したオブジェクトを実行環境が生きている間は初期化済みのまま使い回すが、実行環境が持続する保証は無く、新しい環境では初期化からやり直す。接続の使い回しには十分だが、「全体で 1 つの値を保持する」目的には使えない。
- import した時点で副作用 (接続の確立、環境変数の読み取り) が走る。遅延させたい場合や初期化に
awaitが必要な場合は、export constではなく前掲の Promise をキャッシュする関数にする。
シングルトンの問題点
問題の根は、インスタンスが 1 つであること自体ではなく、取得経路がグローバルで呼び出し側の外から見えないことにある。
| 問題 | どう表れるか |
|---|---|
| テストが順序に依存する | 抱えた状態が次のテストへ残り、単独では通るのに一括実行で落ちる。モジュールスコープでも同じで、切り離すにはテストランナー側でモジュールの評価結果を捨てる必要がある |
| 依存が引数に現れない | 関数の内部で直接取得していると、シグネチャにも型にも依存が出ない。影響範囲の確認が全文検索になる |
| 差し替え地点が無い | モックに置き換えるには、取得経路そのものに手を入れることになる |
| 初期化が競合する | 複数スレッドの言語、あるいは初期化に待ちを挟む場合に前述の二重生成が起きる |
| 生存期間がプロセスと同じ | 抱えたキャッシュや接続が解放されず、想定より長く残る |
依存性注入はこの裏返しで、「1 つに保つ」ことを否定するのではなく、1 つに保つ判断を組み立て地点へ移す。生成の一意性 (1 つだけ作る) と入手経路 (どこから受け取るか) を分けるので、本番では 1 つだけ作って全体へ渡し、テストでは別物を渡せる。
いつシングルトンを使うか
1 つに保つ相手が「状態を持たない接続や設定」か「差し替えたくなるロジック」かで扱いが分かれる。
| 場面 | 取り方 | 理由 |
|---|---|---|
| DB クライアントの接続を使い回したい | モジュールスコープ | ES Modules は 1 回しか評価されないため export const がそのまま単一のインスタンスになり、getInstance を書く必要がない |
| 環境変数から読んだ設定を全体で参照する | モジュールスコープ | 起動時に一度組み立てた値を読むだけで、後から書き換わらないので隠れた依存が問題になりにくい |
| ビジネスロジックを持つオブジェクト | 依存性注入 | どこからでも呼べる形にすると呼び出し関係が引数に現れず、変更の影響範囲を追うのに全文検索が必要になる |
| テストで挙動を切り替えたい部分 | 依存性注入 | 単一のインスタンスを共有するとテスト間で状態が残り、実行順によって結果が変わる不安定さを招く |
迷ったときは、そのオブジェクトを差し替えたくなる日が来るかで分ける。差し替えたくなるものは引数で受け取り、接続や設定のように差し替えの必要がないものはモジュールスコープに置く。クラスに getInstance を書くのは、遅延初期化や初期化の順序を自分で制御する必要がある場合に限られる。
この記事は役に立ちましたか?
関連用語
デザインパターン
ソフトウェア設計で繰り返し現れる問題に対する再利用可能な解決策のカタログ
ファクトリパターン
オブジェクトの生成ロジックをカプセル化し、生成方法の詳細を隠蔽するデザインパターン
依存関係
モジュールやサービスが他のモジュールやサービスに依存する関係で、結合度と変更の影響範囲を決定する
オブジェクトプールパターン
生成コストの高いオブジェクトを事前に確保してプールし、再利用することでパフォーマンスを向上させるパターン
コネクションプール
データベース接続を事前に確保してプールし、リクエストごとの接続コストを削減する仕組み
Worker Threads
Node.js で CPU 集約的な処理をメインスレッドをブロックせずに別スレッドで実行する仕組み