設計模式學習(一)單例模式的幾種實現方式

paw5zx發表於2024-03-18

目錄
  • 前言
  • 餓漢式
  • 懶漢式
  • 懶漢式DCLP
  • 區域性靜態式(Meyers' Singleton)
  • 單例模板
  • 參考文章

前言

單例模式,其核心目標是確保在程式執行的過程中,有且只有存在一個例項才能保證他們的邏輯正確性以及良好的效率。因此單例模式的實現思路就是確保一個類有且只有一個例項,並提供一個該例項的全域性訪問點。
單例模式設計要點:

  • 私有構造、析構
  • 禁止賦值、複製
  • 靜態私有成員:全域性唯一例項
  • 提供一個用於獲取全域性唯一例項的介面,若例項不存在則建立。

除了上面提到的四點還要注意執行緒安全以及資源釋放的問題。

本文從最基本的懶漢式和餓漢式單例模式開始,循序漸進地討論單例模式形式的特點及變化過程

餓漢式

餓漢式單例模式的核心思路就是不管需不需要用到例項都要去建立例項。餓漢模式的例項在類產生時候就建立了,它的生存週期和程式一樣長。

對於餓漢模式而言,是執行緒安全的,因為線上程建立之前唯一的例項已經被建立好了。而且在程式的退出階段,類內唯一例項instance也會被銷燬,~CSingleton會被呼叫,資源可以正常被釋放。

//無延遲初始化
//多執行緒安全,資源自動釋放
class CSingleton
{
public:
    static CSingleton* getInstance();
private:
    CSingleton(){std::cout<<"建立了一個物件"<<std::endl;}
    ~CSingleton(){std::cout<<"銷燬了一個物件"<<std::endl;}
    CSingleton(const CSingleton&) 			 = delete;
    CSingleton& operator=(const CSingleton&) = delete;
    
    static CSingleton instance;  //將指標改為普通的變數
};
  
CSingleton CSingleton::instance;

CSingleton* CSingleton::getInstance()
{
    return &instance;
}
//測試程式碼,後面不贅述
int main()
{
    std::cout << "Now we get the instance" << std::endl;
    std::thread t1([](){auto instance = CSingleton::getInstance();});
    std::thread t2([](){auto instance = CSingleton::getInstance();});
    std::thread t3([](){auto instance = CSingleton::getInstance();});

    t1.join();
    t2.join();
    t3.join();
    std::cout << "Now we destroy the instance" << std::endl;
    return 0;
}

測試結果:

餓漢式的缺點:

  • 在程式啟動時立即建立單例物件,若單例類中包含耗時的初始化操作時,會增加程式的啟動時間
  • 若有多個單例類分佈在不同編譯單元,且這些單例類間存在依賴關係,那麼在初始化時可能會有問題,因為C++標準不能保證不同編譯單元中靜態物件的初始化順序

懶漢式

與餓漢式單例模式相比,懶漢式的關鍵區別在於它延遲了單例例項的建立,即直到第一次被使用時才建立例項:

//延遲初始化
//多執行緒不安全,資源無法自動釋放
class CSingleton
{
public:
    static CSingleton* getInstance();

private:
    CSingleton(){std::cout<<"建立了一個物件"<<std::endl;}
    ~CSingleton(){std::cout<<"銷燬了一個物件"<<std::endl;}
    CSingleton(const CSingleton&) 			 = delete;
    CSingleton& operator=(const CSingleton&) = delete;
    
    static CSingleton* instance;  
};

CSingleton* CSingleton::instance; 
 
CSingleton* CSingleton::getInstance()
{
    if(nullptr == instance)
        instance = new CSingleton();
    return instance;
}

測試結果:

但是上述程式碼有幾個缺點:

  • 執行緒安全問題:多執行緒環境下不安全,可能會有多個單例例項被建立,這違反了單例模式的原則。
  • 資源釋放問題:執行結束無法自動呼叫解構函式(因為單例物件建立在堆上,在程式結束時,指標變數被銷燬了,而它所指向的堆上的記憶體並沒有被銷燬),可能會導致資源洩漏。

為了解決執行緒安全的問題,下面討論加鎖的懶漢式單例模式:

懶漢式DCLP

為了讓懶漢式做到執行緒安全,我們首先會想到加鎖:

class CSingleton
{
public:
    ...
    static std::mutex mtx;

private:
    ...
};

CSingleton* CSingleton::instance;
 
std::mutex CSingleton::mtx;
 
CSingleton* CSingleton::getInstance()
{
	mtx.lock();    
    if(nullptr == instance)
    {
        instance = new CSingleton();
    }
    mtx.unlock();    
    return instance;
}

但是要注意,加鎖和解鎖的操作是需要時間的,上述方法在多執行緒的情況下,每次呼叫都會浪費時間在上鎖和解鎖上,導致效率下降。其實我們真正需要的,只是在instance 初始化時上鎖保證執行緒安全,即只有getInstance()第一次被呼叫時上鎖才是必要的。若在程式中,getInstance()被呼叫了n次,那麼只有第一次呼叫鎖是起真正作用的,其餘n-1次做操作都是沒必要的。

所以要想改進上述問題,我們在加鎖之前先判個空,當判斷結果為真(即instance還沒有被初始化),才進行加鎖操作,然後再次檢查instance是否為空。

//雙檢查鎖模式DCLP
CSingleton* CSingleton::getInstance()
{
	if (nullptr == instance)
	{
		mtx.lock();   
	    if(nullptr == instance)
	    {
	        instance = new CSingleton();
	    }
	    mtx.unlock(); 
	}
    return instance;
}

第二次檢查必不可少,這是因為在第一次檢查instance 和加鎖之間,可能會有別的執行緒對instance 進行初始化。

測試結果:

但是遺憾的是,這種方法其實也不是執行緒安全的,具體原因可見:補充-指令重排

其實,使用了DCLP的懶漢式單例模式不但執行緒不安全,而且無法透過RAII機制呼叫解構函式釋放相關資源。具體原因可見:補充-單例模式析構

為了解決執行緒安全問題和資源釋放問題,Scott Meyers提出了區域性靜態變數形式的單例模式。

區域性靜態式(Meyers' Singleton)

這種形式的單例模式使用函式中的區域性靜態變數來代替類中的靜態成員指標:

//延遲初始化
//多執行緒安全,資源自動釋放
class CSingleton
{
private:
    CSingleton() {std::cout << "建立了一個物件" << std::endl;}
    ~CSingleton() {std::cout << "銷燬了一個物件" << std::endl;}
    CSingleton(const CSingleton&)            = delete;
    CSingleton& operator=(const CSingleton&) = delete;
public:
    static CSingleton& getInstance() 
    {
        static CSingleton instance;
        return instance;
    }
};

//測試程式碼
int main()
{
    std::cout << "Now we get the instance" << std::endl;
    std::thread t1([](){auto& instance = CSingleton::getInstance();});
    std::thread t2([](){auto& instance = CSingleton::getInstance();});
    std::thread t3([](){auto& instance = CSingleton::getInstance();});

    t1.join();
    t2.join();
    t3.join();
    std::cout << "Now we destroy the instance" << std::endl;
    return 0;
}

測試結果:

對於執行緒安全問題:在C++11及更高版本中,靜態區域性變數的初始化是執行緒安全的。即當多個執行緒同時首次訪問區域性靜態變數,編譯器可以保證其初始化程式碼僅執行一次,防止了任何可能的競態條件或重複初始化。

對於資源釋放問題:程式碼中區域性靜態變數instance的生命週期開始於第一次呼叫getInstance方法時,終止於程式結束時。在程式的退出階段區域性靜態變數instance被銷燬,~CSingleton被呼叫,確保了資源的正確釋放。

單例模板

在大型專案中,如果有多個類都被設計為要具有單例行為,那麼為了方便這些類的建立,我們可以將單例屬性封裝為一個模板類,在需要時繼承這個模板基類,這樣這些子類就可以繼承它的單例屬性。

因為這種單例模式是基於靜態區域性變數的,所以它是多執行緒安全的而且是可以正常進行資源釋放的:

template <typename T>
class CSingleton 
{
protected:
    CSingleton(){std::cout<<"建立了一個物件"<<std::endl;}
    ~CSingleton(){std::cout<<"銷燬了一個物件"<<std::endl;}
    CSingleton(const CSingleton&)            = delete;
    CSingleton& operator=(const CSingleton&) = delete;

public:
    static T& getInstance() 
    {
        static T instance;
        return instance;
    }
};

//使用模板
class MyClass : public CSingleton<MyClass>
{
    friend class CSingleton<MyClass>;
private:
    MyClass(){std::cout<<"this is MyClass construct"<<std::endl;}
    ~MyClass(){std::cout<<"this is MyClass destruct"<<std::endl;}
public:
    void dosomething()
    {
        std::cout<<"dosomething"<<std::endl;
    }
};

測試結果:

這種形式使用了奇異遞迴模板模式(Curiously Recurring Template Pattern, CRTP)。在使用時要注意,子類需要將自己作為模板引數傳遞給CSingleton模板進行模板類例項化,用做基類;同時需要將基類宣告為友元,這樣才能在透過CSingleton<T>::getInstance()方法建立MyClass唯一例項時,呼叫到MyClass的私有建構函式。

參考文章

1.C++ 單例模式

相關文章