漫談設計模式之單例模式(Singleton)
什麼是單例模式?單例模式顧名思義就是確保一個類在記憶體中只有一份例項,並提供一個訪問它的全域性訪問點,該例項被所有程式模組共享。
這時候有人會擡槓說我就用一個全域性變數(類)不就也是一個單例,根本不需要設計模式。但是這樣的程式碼不是很優雅的。使用全域性變數是可以保證方便的訪問例項,但是不能保證只有一個這個變數的例項---除了通過這個全域性例項2外,還是可以建立該類的區域性(本地)例項,這樣其實就不是嚴格意義上的單例了!
看看GoF(四人幫)這些牛逼的大神們是怎麼去實現一個單例吧。這些大神們通過類本身來管理實現嚴格意義上的單例。定義一個單例類,大神們定義了該類的私有靜態指標,並提供一個公有的靜態方法去獲取該私有靜態指標指向的例項。單例類Singleton在其公有靜態成員函式中隱藏了建立例項的操作。業界習慣上把這個靜態公有函式叫做Instance()或者getInstance(),該函式返回該類唯一例項的指標。單例模式就是通過這個公有靜態成員函式的返回值去訪問到記憶體例項的。
廢話不多說,看程式碼:
#include <iostream> using namespace std; class Singleton { private: //建構函式私有化,防止被構造 Singleton() {} //把複製建構函式和賦值操作符也設為私有,防止被複制 Singleton(const Singleton&) {} Singleton operator=(const Singleton&) {} //定義私有靜態指標給公有靜態介面使用 static Singleton* m_pInstance; public: //公有靜態介面供外部訪問該單例 static Singleton* getInstance() { if(NULL == m_pInstance) { //判斷是否是第一次呼叫,第一次呼叫才分配記憶體,不是 //第一次呼叫的話就不分配記憶體直接返回已經分配的記憶體的指標 m_pInstance = new Singleton(); } return m_pInstance; } }; Singleton* Singleton::m_pInstance = NULL; int main() { Singleton* p1 = NULL; Singleton* p2 = NULL; p1 = Singleton::getInstance(); p2 = Singleton::getInstance(); cout<<"p1 address is:"<<p1<<endl; cout<<"p2 address is:"<<p2<<endl; cout<<"p1 == p2 :"<<static_cast<bool>(p1==p2)<<endl; return 0; }
執行結果:
根據大神們總結的設計思路我們發現確實實現了一個很友好的單例類。使用者訪問唯一例項的方法只有getInstance()成員函式。如果不通過這個函式,任何建立例項的嘗試都將失敗,因為我們已經把建構函式,複製建構函式和賦值運算子過載函式都設定為private。getInstance()使用的的懶漢式初始化方式,也就是說它的返回值是當這個函式首次被訪問時被建立的。這是一種防彈設計----所有getInstance()之後的呼叫都返回相同例項的指標:
Singleton* p1 = Singleton::getInstance();
Singleton* p2 = p1->getInstance();
Singleton& ref = *Single::getInstance();
對getInstance()稍加修改,這個設計模板就可以適應於可變多例項情況,如一個類允許最多五個例項。
到此先總結一下:
單例類Singleton有以下特徵:
單例類有一個指向唯一例項的靜態指標m_pInstance,並且是私有的;
單例類有一個公有的函式,通過這個公有的函式可以獲取這個唯一的例項,並且在需要的時候建立該例項;
單例類的建構函式是私有的(最好把拷貝(複製)建構函式和賦值運算子過載函式也都搞成私有的),這樣就不能從別處建立該類的例項。
大多數時候(注意不是所有的情況下),這樣的實現都不會出現問題。但是單例的記憶體釋放卻是個問題!m_pInstance指向的記憶體空間什麼時候釋放?改例項的解構函式什麼時候執行?如果在類的析構行為中有必須的操作,比如關閉檔案,釋放外部資源,那麼上面的程式碼無法實現這個要求。
可以在程式結束時呼叫getInstance(),並對返回的指標呼叫delete操作。這樣可以實現功能,但是很容易忘記通過這種方式去釋放單例記憶體。
一個好的方法是讓這個類自己知道在合適的時候把自己刪除,或者說把刪除自己的操作掛在作業系統中的某個合適的點上,使其在恰當的時候被自動執行。
我們知道,在程式結束的時候,系統會自動析構所有的全域性變數。事實上,系統也會析構所有類的靜態成員變數,就像這些靜態成員也是全域性變數一樣(其實靜態成員變數和全域性變數都是存在記憶體的靜態資料區)。利用這個特徵,我們可以在單例類中定義一個這樣的靜態成員變數,而它唯一工作就是在解構函式中刪除單例類中的例項。
程式碼如下:
class Singleton
{
private:
//建構函式私有化,防止被構造
Singleton() {}
//把複製建構函式和賦值操作符也設為私有,防止被複制
Singleton(const Singleton&) {}
Singleton operator=(const Singleton&) {}
//定義私有靜態指標給公有靜態介面使用
static Singleton* m_pInstance;
class GC //垃圾回收類,它的唯一工作就是在解構函式中刪除掉單例例項
{
public:
~GC()
{
if(Singleton::m_pInstance) {
delete Singleton::m_pInstance;
}
}
};
static GC m_GC; //定義一個靜態成員變數,程式結束時系統會自動呼叫它的解構函式
public:
//公有靜態介面供外部訪問該單例
static Singleton* getInstance() {
if(NULL == m_pInstance) { //判斷是否是第一次呼叫,第一次呼叫才分配記憶體,不是
//第一次呼叫的話就不分配記憶體直接返回已經分配的記憶體的指標
m_pInstance = new Singleton();
}
return m_pInstance;
}
};
類GC(garbage collector)被定義為Singleton的私有內嵌類,防止該類在其他地方被濫用。
程式執行結束時,系統會呼叫Singleton的靜態成員m_GC的解構函式,該解構函式會刪除單例的唯一例項。
使用這種方法釋放單例物件有以下特徵:
在單例類內部定義專有的巢狀類;
在單例類內定義私有的專門用於釋放的靜態成員;
利用程式在結束時析構全域性變數(或靜態變數)的特性,選擇最終的釋放時機;
使用單例的程式碼不需要任何操作,不必關心物件的釋放(在不知不覺中就完成了單例記憶體的釋放工作)。
上面講的屬於懶漢式單例模式,如果使用多執行緒會出現安全隱患,具體體現在當一個執行緒進入getInstance()的判斷條件但是還沒new的時候例外的執行緒也是可以進入判斷條件的,這就導致了new出了多個單例物件。
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
class Singleton
{
private:
//建構函式私有化,防止被構造
Singleton() {}
//把複製建構函式和賦值操作符也設為私有,防止被複制
Singleton(const Singleton&) {}
Singleton operator=(const Singleton&) {}
//定義私有靜態指標給公有靜態介面使用
static Singleton* m_pInstance;
static mutex m_mutex;
class GC //垃圾回收類,它的唯一工作就是在解構函式中刪除掉單例例項
{
public:
~GC()
{
if(Singleton::m_pInstance) {
delete Singleton::m_pInstance;
}
}
};
static GC m_GC; //定義一個靜態成員變數,程式結束時系統會自動呼叫它的解構函式
public:
//公有靜態介面供外部訪問該單例
static Singleton* getInstance() {
if(NULL == m_pInstance) { //判斷是否是第一次呼叫,第一次呼叫才分配記憶體,不是
//第一次呼叫的話就不分配記憶體直接返回已經分配的記憶體的指標
std::lock_guard<std::mutex> lock(m_mutex); //自解鎖
if(NULL == m_pInstance) {
m_pInstance = new Singleton();
}
}
return m_pInstance;
}
};
Singleton* Singleton::m_pInstance = NULL;
mutex Singleton::m_mutex;
我們在getInstance()中採用了雙檢鎖(DCL),為什麼要採用DCL(Double-Check-Lock)?
簡單來說採用雙檢鎖是為了效能,即使把第一個判斷條件去掉,加鎖執行緒同步之後還是會有一次判斷,因此一個類只建立一個例項依然是有保障的。但是這樣的寫法會使得每個執行緒執行getInstance()方法的時候都必須獲得一個鎖,於是鎖的獲得和釋放的開銷(包括上下文切換、記憶體同步等開銷)就無條件的存在。相反,如果在執行加鎖程式碼塊前先進行一次是否為NULL的判斷,那麼加鎖程式碼塊被多個執行緒執行到的機率就大大降低了(我們假設開了100個執行緒並且第一個執行緒就new了單例物件同時第三個執行緒還沒進行第一次判斷,這意味著只有第一個和第二個執行緒要進行加鎖和解鎖剩下的98個執行緒都是直接得到改單例物件的指標),因此鎖的開銷得以最大化降低。
接著我們在看看餓漢式單例模式:
餓漢式單例模式的特點是不用加鎖,執行效率比較高。餓漢式單例是在類載入時(main函式執行前)就已經初始化了,浪費記憶體。
示例程式碼:
/**
* @brief The Singleton class
* 餓漢式單例模式
*/
class Singleton
{
private:
//建構函式私有化,防止被構造
Singleton() {}
//把複製建構函式和賦值操作符也設為私有,防止被複制
Singleton(const Singleton&) {}
Singleton operator=(const Singleton&) {}
//定義私有靜態指標給公有靜態介面使用
static Singleton* m_pInstance;
class GC //垃圾回收類,它的唯一工作就是在解構函式中刪除掉單例例項
{
public:
~GC()
{
if(Singleton::m_pInstance) {
delete Singleton::m_pInstance;
}
}
};
static GC m_GC; //定義一個靜態成員變數,程式結束時系統會自動呼叫它的解構函式
public:
//公有靜態介面供外部訪問該單例
static Singleton* getInstance() {
return m_pInstance;
}
};
Singleton* Singleton::m_pInstance = new Singleton();
int main()
{
Singleton* p1 = NULL;
Singleton* p2 = NULL;
p1 = Singleton::getInstance();
p2 = Singleton::getInstance();
cout<<"p1 address is:"<<p1<<endl;
cout<<"p2 address is:"<<p2<<endl;
cout<<"p1 == p2 :"<<static_cast<bool>(p1==p2)<<endl;
return 0;
}
執行結果:
比較懶漢式和餓漢式單例的優缺點:
1、時間和空間:比較上面兩種寫法:懶漢式是典型的時間換空間,也就是每次獲取記憶體例項都會進行判斷,看是否需要建立單例,費判斷的時間,當然,如果一直沒有人使用的話,那就不會建立例項,節約記憶體空間。
餓漢式是典型的空間換時間,當類裝載的時候就會建立類例項,不管你用不用,先創建出來,然後每次呼叫的時候,就不需要在判斷了,節約了執行時間。
2、執行緒安全:從執行緒安全性上講,不加鎖的懶漢式執行緒是不安全的(可以通過加鎖實現執行緒安全,DCL最佳),而餓漢式是執行緒安全的(因為餓漢式在main函式還沒執行之前就已經完成了例項化了,不存在併發發生的可能性)。