cv.cm

編碼 · 設計模式

單例模式詳解與範例

閱讀約 6 分鐘

簡答

單例模式是一種建立型設計模式,確保一個類別只有一個實例,並提供一個全域的存取點。在 TypeScript 中,可以用私有建構函式搭配一個靜態方法來實作:靜態方法在第一次使用時建立實例,之後都回傳同一個物件。它適用於真正唯一的資源,例如設定物件或連線池;但它的行為就像全域狀態,所以許多團隊寧願明確地傳遞單一共用的實例。

意圖

確保一個類別只有一個實例,並提供一個全域的存取點。單例模式是《設計模式》(Gang of Four,簡稱 GoF)一書中五種建立型模式之一。

它解決的問題

有些物件在程式中必須恰好存在一個。例如從環境變數載入的應用程式設定、行程內的快取,或是一組資料庫連線池。如果每個模組都呼叫 new Config(),每個模組都會重新讀取一次環境變數,快取會被分散成許多份,連線池的上限也就失去意義。

你需要兩件事:一是保證不會不小心建立出第二個實例,二是讓任何程式碼都能輕鬆取得那唯一的實例。

結構與參與者

參與者職責
單例類別隱藏建構函式,讓其他程式碼無法直接呼叫 new。
靜態實例欄位在實例建立後,保存這唯一的實例。
靜態存取方法(通常叫 getInstance)第一次呼叫時建立實例(延遲初始化),之後每次呼叫都回傳同一個物件。
用戶端只能透過存取方法取得實例。

TypeScript 實作

私有建構函式會讓類別外部的 new AppConfig() 在編譯時期就出錯。靜態方法則以延遲方式建立物件:

class AppConfig {
  private static instance: AppConfig | undefined;
  readonly settings: Record<string, string>;
  private constructor() {
    console.log("loading config once");
    this.settings = { mode: "production", region: "eu" };
  }
  static getInstance(): AppConfig {
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }
}
const a = AppConfig.getInstance(); // loading config once
const b = AppConfig.getInstance();
console.log(a === b);          // true
console.log(a.settings.mode);  // production
// new AppConfig(); // error: constructor is private

在 JavaScript 與 TypeScript 中,ES 模組在整個程式中只會被求值一次,因此從模組匯出一個實例,就是最簡單的單例。每一次 import 拿到的都是同一個物件:

// logger.ts
class Logger {
  private lines: string[] = [];
  log(msg: string): void { this.lines.push(msg); console.log(msg); }
  count(): number { return this.lines.length; }
}
export const logger = new Logger();
// elsewhere: import { logger } from "./logger";
logger.log("started");      // started
console.log(logger.count()); // 1

非同步初始化

如果建立實例是非同步的,例如開啟資料庫連線,兩個呼叫端可能同時看到「還沒有實例」,在第一個連線完成之前就各自開啟兩條連線。這時應該快取 promise,而不是快取結果:

class Database {
  private static ready: Promise<Database> | undefined;
  private constructor(readonly url: string) {}
  static get(): Promise<Database> {
    if (!Database.ready) {
      Database.ready = (async () => {
        await new Promise((r) => setTimeout(r, 10)); // simulate connecting
        console.log("connected once");
        return new Database("db://main");
      })();
    }
    return Database.ready;
  }
}
const [x, y] = await Promise.all([Database.get(), Database.get()]);
console.log(x === y); // true

在 Java、C# 這類多執行緒的語言中,延遲建立也基於同樣的原因需要同步機制。常見的解法有:用靜態欄位做急切初始化、延遲載入的持有者類別(lazy holder),或是在 Java 中使用只有單一元素的 enum。

何時使用、何時不該用

在以下情況使用單例模式:

  • 為了正確性而非僅僅為了方便,必須恰好只有一個實例(例如一個硬體裝置、一個行程範圍的登錄表)。
  • 建立成本高昂,而且物件在建立後是無狀態或執行緒安全的。

在以下情況應避免使用,或加以限制:

  • 你主要只是想要方便的全域存取。單例其實就是換了好聽名字的全域可變狀態:任何程式碼都能改它,而且這個依賴關係不會出現在函式簽章上。
  • 你需要做測試。共用單例的測試會互相影響,也很難把真正的實例換成假物件。
  • 「只有一個實例」的規則日後可能改變,例如需要為每個租戶各設一個快取。

常見的替代做法是依賴注入:在程式啟動時(組合根,composition root)建立一個實例,再傳給需要它的類別。你仍然只有一個實例,但依賴關係是看得見、也可以替換的。

class Cache {
  private data = new Map<string, string>();
  get(key: string): string | undefined { return this.data.get(key); }
  set(key: string, value: string): void { this.data.set(key, value); }
}
class UserService {
  constructor(private cache: Cache) {} // dependency is explicit
  remember(id: string, name: string): void { this.cache.set(id, name); }
}
const sharedCache = new Cache(); // the single instance, created once
const users = new UserService(sharedCache);
users.remember("u1", "Ada");
console.log(sharedCache.get("u1")); // Ada

實際應用

  • Java:Runtime.getRuntime() 會回傳目前執行中 Java 應用程式唯一的 Runtime 物件。
  • Node.js 在第一次載入後會快取模組,因此模組匯出的物件,會被所有 require 或 import 它的檔案共用。
  • 瀏覽器會為每個頁面提供唯一的全域物件,例如 window 和 document。
  • Angular、Spring 這類支援依賴注入的框架,預設會建立「單例範圍」的服務:每個注入器或容器一個實例,但不含此模式中的靜態存取部分。

相關模式

  • 抽象工廠、建造者和原型物件,常常實作成單例,因為一個工廠就夠用了。
  • 外觀物件經常只有一個實例,因為子系統通常只需要一個入口。
  • 享元同樣會共用物件,但它管理的是依狀態作為鍵值的多個共用實例,而單例則恰好只有一個。
  • 依賴注入並不是 GoF 的模式,卻是現代程式碼中最常用來取代單例的做法。

常見問題

單例模式是反模式嗎?

它常被稱為反模式,因為它引入隱藏的全域狀態,讓測試變得更困難。只有一個實例本身沒問題,問題出在全域存取。只建立一個實例並明確地傳遞,就能避開大部分的問題。

如何在 TypeScript 中實作單例?

使用私有建構函式、一個存放實例的私有靜態欄位,以及第一次呼叫時建立實例的靜態方法。另一個較簡單的做法,是從模組中匯出單一實例。

JavaScript 模組是單例嗎?

實際上是的。模組只會被求值一次,同一個程式中所有匯入它的地方都共用它的匯出內容,除非同一份程式碼是從兩個不同的路徑載入。

如何測試使用單例的程式碼?

讓單例實作某個介面,並把它傳入被測試的程式碼,這樣測試就能提供假物件。如果無法更動設計,可以加上一個僅供測試使用的重設方法。

單例與靜態類別有什麼差別?

單例是真正的物件:它可以實作介面、當作參數傳遞,也能延遲建立。只有靜態成員的類別則無法被替換,也不能當成物件傳來傳去。

全部編碼教學