OOD&OOP-單例模式

vector6_發表於2020-12-13

單例模式

為什麼要使用單例?什麼時候使用單例?

當可能出現執行緒不安全、資源訪問衝突以及某些類物件資料只應儲存一份時應使用單例

1.處理資源訪問衝突

我們自定義實現了一個往檔案中列印日誌的Logger類:

class FileWriter {};
class Logger
{
private:
	fstream file;
public:
	Logger() {
		file.open("/Users/test/log.txt",ios::in | ios::out);
	}
	void log(string message)
	{
		file.write(message.c_str(),message.length());
	}
};
class UserController {
public:
	UserController(Logger logger_):logger(logger_){}
	void log(string id, string password) {
		logger.log(id + "login");
	}
private:
	Logger logger;
};

class Order {};
class UserController {
public:
	UserController(Logger logger_):logger(logger_){}
	void log(Order order) {
		logger.log("log" + order.toString());
	}
private:
	Logger logger;
};

在上面的程式碼中,我們注意到,所有的日誌都寫入到同一個檔案/Users/test/log.txt 中。在 UserControllerOrderController 中,我們分別建立兩個 Logger 物件。在多執行緒環境下,如果兩個執行緒同時分別執行 login()函式,並且同時寫日誌到 log.txt 檔案中,那就有可能存在日誌資訊互相覆蓋的情況。

那如何來解決這個問題呢?我們最先想到的就是通過加鎖的方式:給 log() 函式加互斥鎖,同一時刻只允許一個執行緒呼叫執行 log()函式。

不過,你仔細想想,這真的能解決多執行緒寫入日誌時互相覆蓋的問題嗎?答案是否定的。這是因為,這種鎖是一個物件級別的鎖,一個物件在不同的執行緒下同時呼叫 log() 函式,會被強制要求順序執行。但是,不同的物件之間並不共享同一把鎖。在不同的執行緒下,通過不同的物件呼叫執行 log() 函式,鎖並不會起作用,仍然有可能存在寫入日誌互相覆蓋的問題。

那我們該怎麼解決這個問題?我們需要把物件級別的鎖,換成類級別的鎖就可以了。讓所有的物件都共享同一把鎖。這樣就避免了不同物件之間同時呼叫log() 函式,而導致的日誌覆蓋問題。

除了使用類級別鎖之外,實際上,解決資源競爭問題的辦法還有很多,分散式鎖是最常聽到的一種解決方案。不過,實現一個安全可靠、無 bug、高效能的分散式鎖,並不是件容易的事情。除此之外,併發佇列也可以解決這個問題:多個執行緒同時往併發佇列裡寫日誌,一個單獨的執行緒負責將併發佇列中的資料,寫入到日誌檔案。這種方式實現起來也稍微有點複雜。

相對於這兩種解決方案,單例模式的解決思路就簡單一些了。單例模式相對於之前類級別鎖的好處是,不用建立那麼多 Logger 物件,一方面節省記憶體空間,另一方面節省系統檔案控制程式碼。將 Logger 設計成一個單例類,程式中只允許建立一個 Logger 物件,所有的執行緒共享使用的這一個 Logger 物件,也就避免了多執行緒情況下寫日誌會互相覆蓋的問題。

2.表示全域性唯一類

從業務概念上,如果有些資料在系統中只應儲存一份,那就比較適合設計為單例類。

比如,配置資訊類。在系統中,我們只有一個配置檔案,當配置檔案被載入到記憶體之後,以物件的形式存在,也理所應當只有一份。以及,唯一遞增 ID 號碼生成器,如果程式中有兩個物件,那就會存在生成重複 ID 的情況,所以,我們應該將 ID 生成器類設計為單例。

單例存在哪些問題?

大部分情況下,我們在專案中使用單例,都是用它來表示一些全域性唯一類,比如配置資訊類、連線池類、ID 生成器類。單例模式書寫簡潔、使用方便,在程式碼中,我們不需要建立物件,直接通過類似 IdGenerator.getInstance().getId() 這樣的方法來呼叫就可以了。
但是單例模式也會存在一些問題,比如:

  1. 單例對 OOP 特性的支援不友好
    單例對繼承、多型特性的支援也不友好。因為從理論上來講,單例類也可以被繼承、也可以實現多型,只是實現起來會非常奇怪,會導致程式碼的可讀性變差。不明白設計意圖的人,看到這樣的設計,會覺得莫名其妙。所以,一旦你選擇將某個類設計成到單例類,也就意味著放棄了繼承和多型這兩個強有力的物件導向特性,也就相當於損失了可以應對未來需求變化的擴充套件性。

  2. 單例會隱藏類之間的依賴關係
    我們知道,程式碼的可讀性非常重要。在閱讀程式碼的時候,我們希望一眼就能看出類與類之間的依賴關係,搞清楚這個類依賴了哪些外部類。
    通過建構函式、引數傳遞等方式宣告的類之間的依賴關係,我們通過檢視函式的定義,就能很容易識別出來。但是,單例類不需要顯示建立、不需要依賴引數傳遞,在函式中直接呼叫就可以了。如果程式碼比較複雜,這種呼叫關係就會非常隱蔽。在閱讀程式碼的時候,我們就需要仔細檢視每個函式的程式碼實現,才能知道這個類到底依賴了哪些單例類。

  3. 單例對程式碼的擴充套件性不友好
    我們知道,單例類只能有一個物件例項。如果未來某一天,我們需要在程式碼中建立兩個例項或多個例項,那就要對程式碼有比較大的改動。你可能會說,會有這樣的需求嗎?既然單例類大部分情況下都用來表示全域性類,怎麼會需要兩個或者多個例項呢?
    實際上,這樣的需求並不少見。我們拿資料庫連線池來舉例解釋一下。
    在系統設計初期,我們覺得系統中只應該有一個資料庫連線池,這樣能方便我們控制對資料庫連線資源的消耗。所以,我們把資料庫連線池類設計成了單例類。但之後我們發現,系統中有些 SQL 語句執行得非常慢。這些 SQL 語句在執行的時候,長時間佔用資料庫連線資源,導致其他 SQL 請求無法響應。為了解決這個問題,我們希望將慢 SQL 與其他 SQL 隔離開來執行。為了實現這樣的目的,我們可以在系統中建立兩個資料庫連線池,慢 SQL 獨享一個資料庫連線池,其他 SQL 獨享另外一個資料庫連線池,這樣就能避免慢 SQL 影響到其他 SQL 的執行。
    如果我們將資料庫連線池設計成單例類,顯然就無法適應這樣的需求變更,也就是說,單例類在某些情況下會影響程式碼的擴充套件性、靈活性。所以,資料庫連線池、執行緒池這類的資源池,最好還是不要設計成單例類。實際上,一些開源的資料庫連線池、執行緒池也確實沒有設計成單例類。

  4. 單例對程式碼的可測試性不友好
    單例模式的使用會影響到程式碼的可測試性。如果單例類依賴比較重的外部資源,比如 DB,我們在寫單元測試的時候,希望能通過 mock 的方式將它替換掉。而單例類這種硬編碼式的使用方式,導致無法實現 mock 替換。
    除此之外,如果單例類持有成員變數(比如 IdGenerator 中的 id 成員變數),那它實際上相當於一種全域性變數,被所有的程式碼共享。如果這個全域性變數是一個可變全域性變數,也就是說,它的成員變數是可以被修改的,那我們在編寫單元測試的時候,還需要注意不同測試用例之間,修改了單例類中的同一個成員變數的值,從而導致測試結果互相影響的問題。

  5. 單例不支援有引數的建構函式
    單例不支援有引數的建構函式,比如我們建立一個連線池的單例物件,我們沒法通過引數來指定連線池的大小。針對這個問題,我們來看下都有哪些解決方案。
    第一種解決思路是:建立完例項之後,再呼叫 init() 函式傳遞引數。需要注意的是,我們在使用這個單例類的時候,要先呼叫 init() 方法,然後才能呼叫 getInstance() 方法,否則程式碼會丟擲異常。具體的程式碼實現如下所示:

class Singleton
{
private:
	Singleton(int paraA, int paraB) :paraA(paraA), paraB(paraB) {}
	~Singleton() = default;
	Singleton (const Singleton&) = delete;
	Singleton& operator=(const Singleton&) = delete;

private:
	static std::unique_ptr<Singleton> instance;
	static std::once_flag onceFlag;
	int paraA;
	int paraB;
public:
	static std::unique_ptr<Singleton> getInstance(int paraA, int paraB)
	{
		std::call_once(onceFlag, [&] {instance.reset(new Singleton(paraA, paraB)); });
		return instance;
	}

	static void init(int paraA, int paraB)
	{
		if (instance != nullptr)
		{
			perror("Singleton has been created!");
			return;
		}
		instance.reset(new Singleton(paraA, paraB));
		
	}
};
std::unique_ptr<Singleton> Singleton::instance = nullptr;
std::once_flag Singleton::onceFlag;

第二種解決思路是:將引數放到 getIntance() 方法中。不過這種實現方法的問題是。如果我們兩次執行getInstance() 方法,那獲取到的singleton1和singleton2 相同,第二次的引數沒有起作用,而構建的過程也沒有給與提示,這樣就會誤導使用者。

第三種解決思路是:將引數放到另外一個全域性變數中。具體的程式碼實現如下。Config 是一個儲存了 paramA 和 paramB 值的全域性變數。裡面的值既可以像下面的程式碼那樣通過靜態常量來定義,也可以從配置檔案中載入得到。

class Config
{
public:
	static const int paraA = 123;
	static const int paraB = 456;
};

class Singleton
{
private:
	Singleton(int paraA, int paraB) :paraA(Config::paraA), paraB(Config::paraB) {}
	~Singleton() = default;
	Singleton (const Singleton&) = delete;
	Singleton& operator=(const Singleton&) = delete;

private:
	static std::unique_ptr<Singleton> instance;
	static std::once_flag onceFlag;
	int paraA;
	int paraB;
public:
	static std::unique_ptr<Singleton> getInstance(int paraA, int paraB)
	{
		std::call_once(onceFlag, [&] {instance.reset(new Singleton(paraA, paraB)); });
		return instance;
	}

	static void init(int paraA, int paraB)
	{
		if (instance != nullptr)
		{
			perror("Singleton has been created!");
			return;
		}
		instance.reset(new Singleton(paraA, paraB));
		
	}
};
std::unique_ptr<Singleton> Singleton::instance = nullptr;
std::once_flag Singleton::onceFlag;

有什麼替代單例模式的方案?

可以使用靜態方法以依賴注入的方式。
基於新的使用方式,我們將單例生成的物件,作為引數傳遞給函式(也可以通過建構函式傳遞給類的成員變數),可以解決單例隱藏類之間依賴關係的問題。

//老的使用方式
long function()
{	//...
	long id = idGenerator.getInstance().getId();
    //...
}
//新的方式:依賴注入
long function(IdGenerator idGenerator)
{
	long id = idGenerator.getId();
    return id;
}
//外部呼叫function()的時候,傳入idGenerator

相關文章