設計模式之美 - 42 | 單例模式(中):我爲什麼不推薦使用單例模式?又有何替代方案?

這系列相關博客,參考 設計模式之美

上一節課中,我們通過兩個實戰案例,講解了單例模式的一些應用場景,比如,避免資源訪問衝突、表示業務概念上的全局唯一類。除此之外,我們還學習了 Java 語言中,單例模式的幾種實現方法。如果你熟悉的是其他編程語言,不知道你課後有沒有自己去對照着實現一下呢?

儘管單例是一個很常用的設計模式,在實際的開發中,我們也確實經常用到它,但是,有些人認爲單例是一種反模式(anti-pattern),並不推薦使用。所以,今天,我就針對這個說法詳細地講講這幾個問題:單例這種設計模式存在哪些問題?爲什麼會被稱爲反模式?如果不用單例,該如何表示全局唯一類?有何替代的解決方案?

話不多說,讓我們帶着這些問題,正式開始今天的學習吧!

單例存在哪些問題?

大部分情況下,我們在項目中使用單例,都是用它來表示一些全局唯一類,比如配置信息類、連接池類、ID 生成器類。單例模式書寫簡潔、使用方便,在代碼中,我們不需要創建對象,直接通過類似 IdGenerator.getInstance().getId() 這樣的方法來調用就可以了。但是,這種使用方法有點類似硬編碼(hard code),會帶來諸多問題。接下來,我們就具體看看到底有哪些問題。

1. 單例對 OOP 特性的支持不友好

我們知道,OOP 的四大特性是封裝、抽象、繼承、多態。單例這種設計模式對於其中的抽象、繼承、多態都支持得不好。爲什麼這麼說呢?我們還是通過 IdGenerator 這個例子來講解。

public class Order {
	public void create(...) {
		//...
		long id = IdGenerator.getInstance().getId();
		//...
	}
}

public class User {
	public void create(...) {
		// ...
		long id = IdGenerator.getInstance().getId();
		//...
	}
}

IdGenerator 的使用方式違背了基於接口而非實現的設計原則,也就違背了廣義上理解的 OOP 的抽象特性。如果未來某一天,我們希望針對不同的業務採用不同的 ID 生成算法。比如,訂單 ID 和用戶 ID 採用不同的 ID 生成器來生成。爲了應對這個需求變化,我們需要修改所有用到 IdGenerator 類的地方,這樣代碼的改動就會比較大。

public class Order {
	public void create(...) {
		//...
		long id = IdGenerator.getInstance().getId();
		// 需要將上面一行代碼,替換爲下面一行代碼
		long id = OrderIdGenerator.getIntance().getId();
		//...
	}
}

public class User {
	public void create(...) {
		// ...
		long id = IdGenerator.getInstance().getId();
		// 需要將上面一行代碼,替換爲下面一行代碼
		long id = UserIdGenerator.getIntance().getId();
	}
}

除此之外,單例對繼承、多態特性的支持也不友好。這裏我之所以會用“不友好”這個詞,而非“完全不支持”,是因爲從理論上來講,單例類也可以被繼承、也可以實現多態,只是實現起來會非常奇怪,會導致代碼的可讀性變差。不明白設計意圖的人,看到這樣的設計,會覺得莫名其妙。所以,一旦你選擇將某個類設計成到單例類,也就意味着放棄了繼承和多態這兩個強有力的面向對象特性,也就相當於損失了可以應對未來需求變化的擴展性。

2. 單例會隱藏類之間的依賴關係

我們知道,代碼的可讀性非常重要。在閱讀代碼的時候,我們希望一眼就能看出類與類之間的依賴關係,搞清楚這個類依賴了哪些外部類。

通過構造函數、參數傳遞等方式聲明的類之間的依賴關係,我們通過查看函數的定義,就能很容易識別出來。但是,單例類不需要顯示創建、不需要依賴參數傳遞,在函數中直接調用就可以了。如果代碼比較複雜,這種調用關係就會非常隱蔽。在閱讀代碼的時候,我們就需要仔細查看每個函數的代碼實現,才能知道這個類到底依賴了哪些單例類。

3. 單例對代碼的擴展性不友好

我們知道,單例類只能有一個對象實例。如果未來某一天,我們需要在代碼中創建兩個實例或多個實例,那就要對代碼有比較大的改動。你可能會說,會有這樣的需求嗎?既然單例類大部分情況下都用來表示全局類,怎麼會需要兩個或者多個實例呢?

實際上,這樣的需求並不少見。我們拿數據庫連接池來舉例解釋一下。

在系統設計初期,我們覺得系統中只應該有一個數據庫連接池,這樣能方便我們控制對數據庫連接資源的消耗。所以,我們把數據庫連接池類設計成了單例類。但之後我們發現,系統中有些 SQL 語句運行得非常慢。這些 SQL 語句在執行的時候,長時間佔用數據庫連接資源,導致其他 SQL 請求無法響應。爲了解決這個問題,我們希望將慢 SQL與其他 SQL 隔離開來執行。爲了實現這樣的目的,我們可以在系統中創建兩個數據庫連接池,慢 SQL 獨享一個數據庫連接池,其他 SQL 獨享另外一個數據庫連接池,這樣就能避免慢 SQL 影響到其他 SQL 的執行。

如果我們將數據庫連接池設計成單例類,顯然就無法適應這樣的需求變更,也就是說,單例類在某些情況下會影響代碼的擴展性、靈活性。所以,數據庫連接池、線程池這類的資源池,最好還是不要設計成單例類。實際上,一些開源的數據庫連接池、線程池也確實沒有設計成單例類。

4. 單例對代碼的可測試性不友好

單例模式的使用會影響到代碼的可測試性。如果單例類依賴比較重的外部資源,比如DB,我們在寫單元測試的時候,希望能通過 mock 的方式將它替換掉。而單例類這種硬編碼式的使用方式,導致無法實現 mock 替換。

除此之外,如果單例類持有成員變量(比如 IdGenerator 中的 id 成員變量),那它實際上相當於一種全局變量,被所有的代碼共享。如果這個全局變量是一個可變全局變量,也就是說,它的成員變量是可以被修改的,那我們在編寫單元測試的時候,還需要注意不同測試用例之間,修改了單例類中的同一個成員變量的值,從而導致測試結果互相影響的問題。關於這一點,你可以回過頭去看下第 29 講中的“其他常見的 AntiPatterns:全局變量”那部分的代碼示例和講解。

5. 單例不支持有參數的構造函數

單例不支持有參數的構造函數,比如我們創建一個連接池的單例對象,我們沒法通過參數來指定連接池的大小。針對這個問題,我們來看下都有哪些解決方案。

第一種解決思路是:創建完實例之後,再調用 init() 函數傳遞參數。需要注意的是,我們在使用這個單例類的時候,要先調用 init() 方法,然後才能調用 getInstance() 方法,否則代碼會拋出異常。具體的代碼實現如下所示:

public class Singleton {
	private static Singleton instance = null;
	private final int paramA;
	private final int paramB;
	
	private Singleton(int paramA, int paramB) {
		this.paramA = paramA;
		this.paramB = paramB;
	}
	
	public static Singleton getInstance() {
		if (instance == null) {
			throw new RuntimeException("Run init() first.");
		}
		return instance;
	}
	
	public synchronized static Singleton init(int paramA, int paramB) {
		if (instance != null){
			throw new RuntimeException("Singleton has been created!");
		}
		instance = new Singleton(paramA, paramB);
		return instance;
	}
}

Singleton.init(10, 50); // 先init,再使用
Singleton singleton = Singleton.getInstance();

第二種解決思路是:將參數放到 getIntance() 方法中。具體的代碼實現如下所示:

public class Singleton {
	private static Singleton instance = null;
	private final int paramA;
	private final int paramB;
	
	private Singleton(int paramA, int paramB) {
		this.paramA = paramA;
		this.paramB = paramB;
	}
	
	public synchronized static Singleton getInstance(int paramA, int paramB) {
		if (instance == null) {
			instance = new Singleton(paramA, paramB);
		}
		return instance;
	}
}

Singleton singleton = Singleton.getInstance(10, 50);

不知道你有沒有發現,上面的代碼實現稍微有點問題。如果我們如下兩次執行getInstance() 方法,那獲取到的 singleton1 和 signleton2 的 paramA 和 paramB 都是 10 和 50。也就是說,第二次的參數(20,30)沒有起作用,而構建的過程也沒有給與提示,這樣就會誤導用戶。這個問題如何解決呢?留給你自己思考,你可以在留言區說說你的解決思路。

Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);

第三種解決思路是:將參數放到另外一個全局變量中。具體的代碼實現如下。Config 是一個存儲了 paramA 和 paramB 值的全局變量。裏面的值既可以像下面的代碼那樣通過靜態常量來定義,也可以從配置文件中加載得到。實際上,這種方式是最值得推薦的。

public class Config {
public static final int PARAM_A = 123;
public static fianl int PARAM_B = 245;
}

public class Singleton {
	private static Singleton instance = null;
	private final int paramA;
	private final int paramB;
	
	private Singleton() {
		this.paramA = Config.PARAM_A;
		this.paramB = Config.PARAM_B;
	}
	
	public synchronized static Singleton getInstance() {
		if (instance == null) {
			instance = new Singleton();
		}
		return instance;
	}
}

有何替代解決方案?

剛剛我們提到了單例的很多問題,你可能會說,即便單例有這麼多問題,但我不用不行啊。我業務上有表示全局唯一類的需求,如果不用單例,我怎麼才能保證這個類的對象全局唯一呢?

爲了保證全局唯一,除了使用單例,我們還可以用靜態方法來實現。這也是項目開發中經常用到的一種實現思路。比如,上一節課中講的 ID 唯一遞增生成器的例子,用靜態方法實現一下,就是下面這個樣子:

// 靜態方法實現方式
public class IdGenerator {
	private static AtomicLong id = new AtomicLong(0);
	
	public static long getId() {
		return id.incrementAndGet();
	}
}

// 使用舉例
long id = IdGenerator.getId();

不過,靜態方法這種實現思路,並不能解決我們之前提到的問題。實際上,它比單例更加不靈活,比如,它無法支持延遲加載。我們再來看看有沒有其他辦法。實際上,單例除了我們之前講到的使用方法之外,還有另外一個種使用方法。具體的代碼如下所示:

// 1. 老的使用方式
public demofunction() {
	//...
	long id = IdGenerator.getInstance().getId();
	//...
}

// 2. 新的使用方式:依賴注入
public demofunction(IdGenerator idGenerator) {
	long id = idGenerator.getId();
}
// 外部調用demofunction()的時候,傳入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);

基於新的使用方式,我們將單例生成的對象,作爲參數傳遞給函數(也可以通過構造函數傳遞給類的成員變量),可以解決單例隱藏類之間依賴關係的問題。不過,對於單例存在的其他問題,比如對 OOP 特性、擴展性、可測性不友好等問題,還是無法解決。

所以,如果要完全解決這些問題,我們可能要從根上,尋找其他方式來實現全局唯一類。實際上,類對象的全局唯一性可以通過多種不同的方式來保證。我們既可以通過單例模式來強制保證,也可以通過工廠模式、IOC 容器(比如 Spring IOC 容器)來保證,還可以通過程序員自己來保證(自己在編寫代碼的時候自己保證不要創建兩個類對象)。這就類似 Java 中內存對象的釋放由 JVM 來負責,而 C++ 中由程序員自己負責,道理是一樣的。

對於替代方案工廠模式、IOC 容器的詳細講解,我們放到後面的章節中講解。

重點回顧

好了,今天的內容到此就講完了。我們來一塊總結回顧一下,你需要掌握的重點內容。

1. 單例存在哪些問題?

  • 單例對 OOP 特性的支持不友好
  • 單例會隱藏類之間的依賴關係
  • 單例對代碼的擴展性不友好
  • 單例對代碼的可測試性不友好
  • 單例不支持有參數的構造函數

2. 單例有什麼替代解決方案?

爲了保證全局唯一,除了使用單例,我們還可以用靜態方法來實現。不過,靜態方法這種實現思路,並不能解決我們之前提到的問題。如果要完全解決這些問題,我們可能要從根上,尋找其他方式來實現全局唯一類了。比如,通過工廠模式、IOC 容器(比如Spring IOC 容器)來保證,由過程序員自己來保證(自己在編寫代碼的時候自己保證不要創建兩個類對象)。

有人把單例當作反模式,主張杜絕在項目中使用。我個人覺得這有點極端。模式沒有對錯,關鍵看你怎麼用。如果單例類並沒有後續擴展的需求,並且不依賴外部系統,那設計成單例類就沒有太大問題。對於一些全局的類,我們在其他地方 new 的話,還要在類之間傳來傳去,不如直接做成單例類,使用起來簡潔方便。

課堂討論

  1. 如果項目中已經用了很多單例模式,比如下面這段代碼,我們該如何在儘量減少代碼改動的情況下,通過重構代碼來提高代碼的可測試性呢?
public class Demo {
	private UserRepo userRepo; // 通過構造哈函數或IOC容器依賴注入
	
	public boolean validateCachedUser(long userId) {
		User cachedUser = CacheManager.getInstance().getUser(userId);
		User actualUser = userRepo.getUser(userId);
		// 省略核心邏輯:對比cachedUser和actualUser...
	}
}
  1. 在單例支持參數傳遞的第二種解決方案中,如果我們兩次執行 getInstance(paramA, paramB) 方法,第二次傳遞進去的參數是不生效的,而構建的過程也沒有給與提示,這樣就會誤導用戶。這個問題如何解決呢?
Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章