一、引子
首先來看兩個常見的問題:
1. 單窗體的問題。
在主應用程序菜單點擊菜單,彈出工具箱窗體,現在的問題是,希望工具箱要么不出現,出現也只可以出現一個,但是實際上每次點擊菜單,都會實例化一個“工具箱”並顯示出來,這樣會產生很多個“工具箱”,不是所希望的。注意這里希望的是“工具箱”窗體單例,而不是進程單個實例(進程單個實例:例如PC上已經打開一個迅雷,再次運行迅雷,結果並沒有再開一個迅雷而還是之前的,區分同一PC登陸多個QQ客戶端)。
如上圖,每次單擊菜單都會實例化一個工具箱窗體,與期望不符。
2. 大對象問題
對象有保存對象狀態信息的一些字段,字段過多或者字段本身占據大量內存,都會導致對象過大。下面看一段示例:
class SimpleLargeObject { private const int NUM = 100 * 1024 * 1024;//100MB private byte[] data = null; public SimpleLargeObject() { data = new byte[NUM]; for (int i = 0; i < data.Length; i++) { data[i] = (byte)(i % 255); } } public void Method1() { Console.WriteLine("Method1"); } // other methods.... } class Program { static void Main(string[] args) { SimpleLargeObject obj1=new SimpleLargeObject(); obj1.Method1(); Console.WriteLine("Press enter to create a new object..."); Console.ReadLine(); SimpleLargeObject obj2 = new SimpleLargeObject(); obj2.Method1(); Console.ReadLine(); } }
為了更體現出問題,這里誇張一點,SimpleLargeObject占據內存100MB。
運行發現內存占據100MB,按回車鍵繼續創建另外一個對象,此時內存翻倍增加至200MB… 可以想象,當特定環境下需要產生無數個對象,而這些對象本身的狀態信息由私有字段來維護,字段的取值不同會影響到公開方法的行為,而這些對象又不需要在同一時刻都要存在,或者無數個這樣的對象狀態信息無關緊要,產生這么多對象會導致內存占用過多。
對於第一個問題,常規解決方法是在調用窗體類中聲明一個ToolBoxForm類型的全局,判斷這個ToolBoxForm類型的全局變量是否實例化過就行了。
private ToolBoxForm toolBoxForm = null; private void toolStripMenuItemToolBox_Click(object sender, EventArgs e) { if (toolBoxForm == null) { toolBoxForm = new ToolBoxForm(); toolBoxForm.Show(); } }
這樣似乎解決問題了。
新需求來了:現在不但要在菜單里面啟動“工具箱”,還需要在“工具欄”上的按鈕來快捷啟動“工具箱”。菜單欄有些常用的功能提供快捷按鈕再正常不過的需求了。
這個不難,增加一個工具欄控件,然后添加onclick事件,復制同樣的代碼就行了:
private void toolStripButton1_Click(object sender, EventArgs e) { if (toolBoxForm == null) { toolBoxForm = new ToolBoxForm(); toolBoxForm.Show(); } }
復制代碼潛在的問題也是很明顯的:
- 一份代碼多出重復,如果需求變化或者有BUG時就需要改多個地方。如果有5個地方需要實例化“工具箱”窗體,這個小bug就需要改動5個地方,可見復制粘貼多么害人。
- 復制粘貼是最容易的編程,也是最沒有價值的編程,只求達到目標,如何能有提高。
上面的程序就有潛在的Bug,啟動“工具箱”,然后把“工具箱”窗體關閉,再點啟動按鈕,問題就暴露出來了。原因是關閉“工具箱”窗體時,它的實例並沒有變為null,而只是Disposed。
Form.Show()方法出的窗體,關閉調用Close()會Dispose內存,對象銷毀,但指向對象的引用不為null;
Form.ShowDilog()方法出的窗體,關閉窗體不會釋放對象的內存,窗體的引用也不為null,窗體只是hidden而已。
上述Bug修復,並重構提煉方法后的代碼:
private ToolBoxForm toolBoxForm = null; private void toolStripMenuItemToolBox_Click(object sender, EventArgs e) { OpenToolBox(); } private void toolStripButton1_Click(object sender, EventArgs e) { OpenToolBox(); } private void OpenToolBox() { if (toolBoxForm == null||toolBoxForm.IsDisposed) { toolBoxForm = new ToolBoxForm(); toolBoxForm.Show(); } }
現在基本沒什么問題了。
二 .類的職責
在上面幾步的優化和改善,已經基本沒什么問題了,但是這樣做“工具箱”是否實例化都是在調用顯示“工具箱”的地方來判斷,這樣不符合邏輯,主窗體里面應該只是通知啟動“工具箱”,至於“工具箱”窗體是否實例化過,主窗體根本不關心,這不屬於主窗體的職責,“工具箱”是否實例化過,應該有“工具箱”自己來判斷。對象是否實例化是它自己的責任,而不是別人的責任,別人只是使用它就可以了。
對象的實例化其實就是new的過程,如果要控制對象的實例化由該類自身來維護,那么類的構造函數應該是私有的,這樣外部就不能用new來實例化它了,而讓這個類只能實例化一次,用靜態的類變量能達到目的,因為靜態是該類型共享的,而該類型剛好是這個類本身。
客戶端使用的代碼:
private void toolStripMenuItem1_Click(object sender, EventArgs e) { ToolBoxForm.Instance.Show(); } private void toolStripButton1_Click(object sender, EventArgs e) { ToolBoxForm.Instance.Show(); }
這樣一來,客戶端不再考慮是否需要去實例化的問題,而把責任都給了應該負責的類去處理。這就是一個很根本的設計模式:單例模式。
三、 單例模式
1. 基本的單例
定義:保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。——GOF的《設計模式:可復用面向對象軟件的基礎》
通常我們可以讓一個全局變量使得一個對象被訪問,但它不能防止你實例化多個對象。最好的辦法就是,讓類自身負責保存它的唯一實例。這個類可以保證沒有其他實例可以被創建,並且可以提供一個訪問該實例的方法。
class Singleton { private static Singleton instance; private Singleton() //構造方法為private,這就堵死了外界利用new創建此類型實例的可能 { } public static Singleton GetInstance() //次方法是獲得本類實例的唯一全局訪問點 { if (instance == null) { instance = new Singleton(); } return instance; } } class Program { static void Main(string[] args) { // Singleton s0 = new Singleton();//錯誤,外界不能通過new來創建此類型實例 Singleton s1 = Singleton.GetInstance(); Singleton s2 = Singleton.GetInstance(); if (s1 == s2) { Console.WriteLine("兩個對象是相同的實例"); } Console.ReadLine(); } }
運行結果,s1和s2是同一個實例,都是通過唯一的全局訪問點Singleton.GetInstance()方法返回的。
2. 多線程環境下的單例
先模擬一個多線程的環境:
class Singleton { private static Singleton instance; private Singleton() //構造方法為private,這就堵死了外界利用new創建此類型實例的可能 { Thread.Sleep(50);//此處模擬創建對象耗時 } public static Singleton GetInstance() //次方法是獲得本類實例的唯一全局訪問點 { if (instance == null) { instance = new Singleton(); } return instance; } } class Program { const int THREADCOUNT = 200; static List<Singleton> sList = new List<Singleton>(THREADCOUNT); static object objLock = new object(); static void Main(string[] args) { Task[] tasks=new Task[THREADCOUNT]; for (int i = 0; i < THREADCOUNT; i++) { tasks[i] = Task.Factory.StartNew(ThredFunc); } Task.WaitAll(tasks);//確保所有任務執行完畢 Console.WriteLine("sList.Count:" + sList.Count); int index1 = -1; int index2 = -1; if(HasDifferentInstance(out index1,out index2)) { Console.WriteLine("含有不相同的實例,index1={0},index2={1}", index1, index2); } Console.WriteLine("執行完畢."); Console.ReadLine(); } private static bool HasDifferentInstance(out int index1,out int index2) { index1 = index2 = -1; for (int i = 0; i < sList.Count; i++) { for (int j = i + 1; j < sList.Count - 1; j++) { if (sList[i] != sList[j]) { index1 = i; index2 = j; return true; } } } return false; } private static void ThredFunc() { Singleton singleton = Singleton.GetInstance(); lock (objLock) { sList.Add(Singleton.GetInstance()); } }
我們在Singleton的構造函數延遲50ms來模擬創建對象耗時,這樣在多線程的環境下,很容易出現在一個線程執行Singleton.GetInstance()時創建對象,而這個對象的創建理論上是要消耗時間的,在創建對象之前instance為null,還未返回,此時另一個線程也執行Singleton.GetInstance()判斷instance為null,執行了new創建了對象,這樣出現了對象實例不為同一個對象的情況。
為了解決這個問題,在執行new創建實例的地方加上鎖,同時在鎖定之前判斷下是否為null,這樣如果已經創建就不用進入鎖了。
public static Singleton GetInstance() //次方法是獲得本類實例的唯一全局訪問點 { if (instance == null) { lock (objLock) { if (instance == null) { instance = new Singleton(); } } } return instance; }
對於instance存在的情況,就直接返回;當instance為null並且同時有兩個線程GetInstance()方法時,它們都可以通過第一重instance==null的判斷,然后由於lock機制,這兩個線程則只有一個進入,另一個在排隊等候,必須要其中的一個進入並出來后,另一個才能進入。而此時如果沒有了第二重的instance是否為null的判斷,則第一個線程創建了實例,而第二個線程還是可以繼續再創建新的實例,所以需要兩次判斷。
進行一次加鎖和解鎖是需要付出對應的代價的,而進行兩次判斷,就可以避免多次加鎖與解鎖操作,同時也保證了線程安全。但是,這種實現方法在平時的項目開發中用的很好,也沒有什么問題?但是,如果進行大數據的操作,加鎖操作將成為一個性能的瓶頸;為此,一種新的單例模式的實現也就出現了。
上面的Doule-Check Locking(雙重鎖定) 能進一步優化,利用CLR類型構造器保證線程安全:
class Singleton { private static Singleton instance; static Singleton() //類型構造器,確保線程安全 { instance = new Singleton(); } private Singleton() //構造方法為private,這就堵死了外界利用new創建此類型實例的可能 { Thread.Sleep(50);//此處模擬創建對象耗時 } public static Singleton GetInstance() //次方法是獲得本類實例的唯一全局訪問點 { return instance; } }
不需要null判斷,代碼更加精煉,又能避免加鎖解鎖。
四、 C++ 單例模式
盡管單例模式的思想是一致的,但是C++ 與C#有很多不同點,甚至有時候用到語言平台的獨有特性有意想不到的效果,例如利用CLR的特性,類型構造器能確保線程安全性。這里介紹一下C++實現單例模式。 利用GOF中單例模式的定義,很容易寫出如下的代碼:
版本一:
class Singleton { private: Singleton() { } static Singleton * m_pInstance; public: static Singleton * GetInstance() { if (m_pInstance == NULL) { m_pInstance = new Singleton(); } return m_pInstance; } }; Singleton * Singleton::m_pInstance = NULL;
用戶訪問唯一實例的方法只有GetInstance()成員函數。如果不通過這個函數,任何創建實例的嘗試都將失敗,因為類的構造函數是私有的。GetInstance()使用懶惰初始化,也就是說它的返回值是當這個函數首次被訪問時被創建的,所有GetInstance()之后的調用都返回相同實例的指針:
Singleton *p1 = Singleton::GetInstance();
Singleton *p2 = Singleton::GetInstance(); Singleton *p3 = p2;
P1、p2都是通過GetInstance()全局訪問點訪問的,指向的是同一實例,p3是經過指針賦值,也是指向同一實例,它們的地址相同:
大多數時候,這樣的實現都不會出現問題。有經驗的讀者可能會問,m_pInstance指向的空間什么時候釋放呢?這樣會不會導致內存泄漏呢?
我們一般的編程觀念是,new操作是需要和delete操作進行匹配的;是的,這種觀念是正確的。具體看場景。static Singleton * m_pInstance;m_pInstance 指針本身為靜態的,存儲方式為靜態存儲,生命周期為進程周期;而其指向的實例對象在堆上分配,這個堆對象有個特點就是只有一個實例,堆內存由程序員釋放或程序結束時可能由OS回收。
堆區(heap) — 一般由程序員分配釋放, 若程序員不釋放,程序結束時可能由OS回收 。
注意,這里是可能。具體能不能得看OS,目前windows是可以的,而嵌入式系統有些是不能的。所以還得看場景。
在實際項目中,特別是客戶端開發,其實是不在乎這個實例的銷毀的。因為,盡管這個指向實例的指針為靜態的,而這個實例為堆中對象並且只有一個,進程結束后,它會釋放它占用的內存資源的,所以,也就沒有所謂的內存泄漏了。而針對服務端程序,一般是長期運行,但是這個實例也只有一個,進程結束,操作系統會回收內存。
顯然,把內存回收的責任交給OS,雖然大多數情況下是沒問題的,但是還是看場景的,內存能不能回收也取決於OS內核。
更重要的是,在以下情形,是必須需要進行實例銷毀的:
在類中,有一些文件鎖了,文件句柄,數據庫連接等等,這些隨着程序的關閉而不會立即關閉的資源,必須要在程序關閉前,進行手動釋放;
版本二:添加手動釋放函數
class Singleton { private: Singleton() { } static Singleton * m_pInstance ; public: static Singleton * GetInstance() { if (m_pInstance == NULL) { m_pInstance = new Singleton(); } return m_pInstance; } static void DestoryInstance() { if (m_pInstance != NULL) { delete m_pInstance; m_pInstance = NULL; } } };
我們單例類中添加一個DestoryInstance()函數來刪除實例,可以在進程退出之前來調用這個函數釋放,結合前面“類的職責”小結,很快會發現這樣不是很優雅,理想情況下是類的使用者只管拿來用,而不用關注什么時候釋放,並且程序員忘了調用這個函數也是很容易發生的事。能不能實現像boost中shared_ptr<T>這樣自動釋放內存呢?
由於這個實例的生命周期為直到進程結束,因此可以設計一個包裝類作為靜態變量,靜態變量的生命周期也是到進程結束銷毀,可以在這個包裝類的析構函數里面釋放資源。
以下是改進版本:
版本三:利用RAII自動釋放
class Singleton { private: Singleton() { } static Singleton * m_pInstance ; class GC //內部包裝類 { public: ~GC() { if (m_pInstance != NULL) { std::cout << "Here is the test,delete m_pInstance." << std::endl; delete m_pInstance; m_pInstance = NULL; } } }; static GC m_gc; public: static Singleton * GetInstance() { if (m_pInstance == NULL) { m_pInstance = new Singleton(); } return m_pInstance; } }; Singleton * Singleton::m_pInstance = NULL;//這里初始化Singleton的靜態成員m_pInstance Singleton::GC Singleton::m_gc;//這里初始化Singleton里面嵌套類GC的靜態成員m_gc int _tmain(int argc, _TCHAR* argv[]) { Singleton *p1 = Singleton::GetInstance(); Singleton *p2 = Singleton::GetInstance(); std::cin.get(); return 0; }
運行程序,執行到cin.get()后敲回車,程序即將退出,輸出以下結果:

說明嵌套類GC的析構函數已經執行。此處使用了一個內部GC類,而該類的作用就是用來釋放資源,其定義在Singleton的private部分,外部無法訪問,也不關心。程序在結束的時候,系統會自動析構所有的全局變量,實際上,系統也會析構所有類的靜態成員變量,就像這些靜態變量是全局變量一樣。我們知道,靜態變量和全局變量在內存中,都是存儲在靜態存儲區的,所以在析構時,是同等對待的。在程序運行結束時,系統會調用Singleton的靜態成員static GC m_gc的析構函數,該析構函數會進行資源的釋放,而這種資源的釋放方式是在程序員“不知道”的情況下進行的,而程序員不用特別的去關心,使用單例模式的代碼時,不必關心資源的釋放。這里運用了C++中的RAII機制。
RAII是Resource Acquisition Is Initialization的簡稱,是C++語言的一種管理資源、避免泄漏的慣用法。利用的就是C++構造的對象最終會被銷毀的原則。RAII的做法是使用一個對象,在其構造時獲取對應的資源,在對象生命期內控制對資源的訪問,使之始終保持有效,最后在對象析構的時候,釋放構造時獲取的資源。
前面的各個版本還沒考慮多線程的問題,參考前面C#版本的“雙檢鎖”,而C++語言本身不提供多線程支持的,多線程的實現是由操作系統提供支持的,可以用系統API。這里用
C++ 0x 的線程庫,C++ 0x里面部分庫由boost發展而來。
版本四: 多線程環境下“雙檢鎖”
class Singleton { private: Singleton() { } static Singleton * m_pInstance; class GC //內部包裝類 { public: ~GC() { if (m_pInstance != NULL) { std::cout << "Here is the test,delete m_pInstance." << std::endl; delete m_pInstance; m_pInstance = NULL; } } }; static GC m_gc; static std::mutex m_mutex; public: static Singleton * GetInstance() { if (m_pInstance == NULL) { m_mutex.lock(); if (m_pInstance == NULL) { m_pInstance = new Singleton(); } m_mutex.unlock(); } return m_pInstance; } }; Singleton * Singleton::m_pInstance = NULL;//這里初始化Singleton的靜態成員m_pInstance Singleton::GC Singleton::m_gc;//這里初始化Singleton里面嵌套類GC的靜態成員m_gc std::mutex Singleton::m_mutex; //初始化Singleton靜態成員m
這里使用了C++ 0x的mutex,需要#include <mutex>
繼續參考之前C#版本的優化,提供靜態初始化版本:
版本五:靜態初始化
class Singleton { private: Singleton() { } const static Singleton * m_pInstance; class GC //內部包裝類 { public: ~GC() { if (m_pInstance != NULL) { std::cout << "Here is the test,delete m_pInstance." << std::endl; delete m_pInstance; m_pInstance = NULL; } } }; static GC m_gc; public: static Singleton * GetInstance() { return const_cast<Singleton *>(m_pInstance); } void TestMethod() { std::cout << "Singleton::TestMethod" << std::endl; } }; const Singleton* Singleton::m_pInstance = new Singleton(); //這里靜態初始化 Singleton::GC Singleton::m_gc;//這里初始化Singleton里面嵌套類GC的靜態成員m_gc int _tmain(int argc, _TCHAR* argv[]) { Singleton *p1 = Singleton::GetInstance(); Singleton *p2 = Singleton::GetInstance(); p1->TestMethod(); std::cin.get(); return 0; }
因為靜態初始化在程序開始時,也就是進入主函數之前,由主線程以單線程方式完成了初始化,所以靜態初始化實例保證了線程安全性。在性能要求比較高時,就可以使用這種方式,從而避免頻繁的加鎖和解鎖造成的資源浪費。
語言特性
下面我們看看其它版本,先不考慮多線程(多線程問題前面討論過了,不做重點,也可以在主函數之前以單線程方式先完成初始化來達到目的)。
class Singleton { private: Singleton() { } public: static Singleton& GetInstance() { static Singleton instance; return instance; } void TestMethod() { std::cout << "Singleton::TestMethod()" << std::endl; } };
這個版本不再使用指針,而是返回一個靜態局部變量的引用。也許有人會問,返回局部變量的引用,局部變量過了作用域就析構了啊,但是注意這里是靜態局部變量,存儲
方式為靜態存儲,生命周期為到進程退出,所以不用擔心函數結束就析構了。C# 和Java等沒有靜態局部變量的概念,這個可以說是C/C++的一個特性。
寫程序測試:
int _tmain(int argc, _TCHAR* argv[]) { Singleton::GetInstance().TestMethod(); Singleton s1= Singleton::GetInstance(); Singleton s2 = s1; if (addressof(s1) == addressof(s2)) { cout << "同一實例" << endl; } else { cout << "不同實例" << endl; cout <<"s1的地址:"<<(int)(&s1) << endl; cout <<"s2的地址:" <<(int)(&s2) << endl; } std::cin.get(); return 0; }
發現s1和s2是不同的實例,這是因為對象的創建除了構造函數外還有其他方式,例如復制構造函數、賦值操作符等,都需要禁止。
改進版本:
class Singleton { private: Singleton() { } Singleton(const Singleton&) = delete;//禁止復制 Singleton operator=(const Singleton&) = delete;//禁止賦值操作 public: static Singleton& GetInstance() { static Singleton instance; return instance; } void TestMethod() { std::cout << "Singleton::TestMethod()" << std::endl; } };
這樣,外部企圖通過賦值操作符或者復制來創建對象,都會報錯:
Singleton::GetInstance() 是唯一的全局訪問點和訪問方式。
項目中出現多個需要用到單例的類怎么辦?分別編寫禁止復制構造函數、禁止賦值操作,分別編寫GetInstance()方法 這種重復的工作?我們宏可以解決這個重復性工作:
#define SINGLINTON_CLASS(class_name) \ private:\ class_name(){}\ class_name(const class_name&);\ class_name& operator = (const class_name&);\ public:\ static class_name& Instance()\ {\ static class_name one;\ return one;\ } class Simple { SINGLINTON_CLASS(Simple) public: void Print() { cout<<"Simple::Print()"<<endl; } };
可以把上面的宏寫到一個頭文件中,在需要寫單例的地方include這個頭文件,單例類開頭只需加上SINGLINTON_CLASS(class_name)就行了,其中class_name為當前類名,然后可以講工作重心放到這個類的設計上。
客戶的還是照樣調用:
int _tmain(int argc, _TCHAR* argv[]) { Simple::Instance().Print(); cin.get(); return 0; }
總結
單例模式可以說是設計模式里面最基本和簡單的一種了,為了寫這篇文章,自己調查了很多方面的資料,例如《大話設計模式》,同時加上C++各個版本的實現和自己的理解,如有錯誤,請大家指正。
在實際的開發中,並不會用到單例模式的這么多種版本,每一種設計模式,都應該在最適合的場合下使用,在日后的項目中,應做到有地放矢,而不能為了使用設計模式而使用設計模式。