引子:類也需要計畫生育#

小菜寫了一個 MDI 應用,「工具箱」窗體應該最多只出現一個。但他在按鈕事件裡每次 new FormToolbox(),於是按越多次就跳越多個工具箱。

這個類超生了——大鳥用「計畫生育」的比喻形容這個情境。

第一輪:判斷是否為 null#

FormToolbox 提升為類別變數,在開啟前判斷:

private FormToolbox ftb;

private void ToolStripMenuItemToolbox_Click(object sender, EventArgs e)
{
    if (ftb == null)
    {
        ftb = new FormToolbox();
        ftb.MdiParent = this;
        ftb.Show();
    }
}

啟動工具箱後關閉再點擊按鈕,會出現「已經 Disposed」異常——關閉只是 Dispose,不會把 ftb 設為 null。

修正:增加 IsDisposed 判斷。

if (ftb == null || ftb.IsDisposed) { ... }

並提取為 openToolbox() 方法給多個地方呼叫,避免複製貼上。

第二輪:誰負責判斷?#

父母的計畫生育責任在他們自己,不是別人。

同樣道理:「工具箱」是否實例化過,應由「工具箱自己」判斷,而不是由 Form1 來判斷。

實作方式:

  • 把建構子改為 private外界不能 new
  • 提供一個 public static GetInstance() 方法,由類別自己決定回傳已存在的或新建一個
public partial class FormToolbox : Form
{
    private static FormToolbox ftb = null;

    private FormToolbox()
    {
        InitializeComponent();
    }

    public static FormToolbox GetInstance()
    {
        if (ftb == null || ftb.IsDisposed)
        {
            ftb = new FormToolbox();
            ftb.MdiParent = Form1.ActiveForm;
        }
        return ftb;
    }
}

客戶端:

private void ToolStripMenuItemToolbox_Click(object sender, EventArgs e)
    => FormToolbox.GetInstance().Show();

private void toolStripButton1_Click(object sender, EventArgs e)
    => FormToolbox.GetInstance().Show();

單例模式#

單例模式(Singleton Pattern):保證一個類僅有一個實例,並提供一個訪問它的全域訪問點。[DP]

結構#

classDiagram
    class Singleton {
        -Singleton instance$
        -Singleton()
        +GetInstance() Singleton$
    }
class Singleton
{
    private static Singleton instance;

    private Singleton() { }

    public static Singleton GetInstance()
    {
        if (instance == null)
            instance = new Singleton();
        return instance;
    }
}

全域變數能讓物件被訪問,但不能阻止實例化多個物件

最好的辦法:讓類別自身負責保存它的唯一實例

單例 vs. 實用類#

兩者都常用私有建構子,但:

  • 實用類(如 .NET 的 Math):不保存狀態,只提供靜態方法/屬性;不能被繼承多型
  • 單例:有狀態;可以被子類繼承

多執行緒下的單例#

多執行緒環境下,多個執行緒同時呼叫 GetInstance(),可能造成創建多個實例

加鎖(lock):

class Singleton
{
    private static Singleton instance;
    private static readonly object syncRoot = new object();

    private Singleton() { }

    public static Singleton GetInstance()
    {
        lock (syncRoot)
        {
            if (instance == null)
                instance = new Singleton();
        }
        return instance;
    }
}

不能 lock(instance)——加鎖時 instance 可能還沒被建立,沒辦法對 null 加鎖。

因此用一個獨立的 syncRoot 物件作為鎖。

雙重鎖定(Double-Check Locking)#

每次呼叫都加鎖會影響效能。改良為雙重鎖定

public static Singleton GetInstance()
{
    if (instance == null)             // 第一重檢查
    {
        lock (syncRoot)
        {
            if (instance == null)     // 第二重檢查
                instance = new Singleton();
        }
    }
    return instance;
}

為什麼需要兩重檢查?

  • 對於已存在的情況,第一重判斷直接回傳,不用進入鎖
  • instance 為 null 且兩個執行緒同時通過第一重時,lock 只讓一個進入;第二重判斷防止第二個執行緒在第一個執行緒建立後再次建立實例

靜態初始化(餓漢式)#

C# 與公共語言運行庫提供了一種靜態初始化方式,不需顯式編寫執行緒安全程式碼即可解決多執行緒問題:

public sealed class Singleton
{
    private static readonly Singleton instance = new Singleton();
    private Singleton() { }
    public static Singleton GetInstance() => instance;
}
  • sealed:阻止派生(派生可能增加實例)
  • readonly:意味著只能在靜態初始化期間或類別建構函式中分配變數

餓漢式(靜態初始化):類別載入時就實例化,提前佔用系統資源

懶漢式(前面的雙重鎖定):第一次被引用時才實例化,面臨多執行緒安全問題,需要雙重鎖定處理

實際選用視需求而定。對 C# 而言,餓漢式通常已足夠