單例設計模式的缺點
1. 概述
Singleton是Gang of Four於1994年發表的創意設計模式之一。
由於其實現簡單,我們往往會過度使用它。因此,如今,它被認為是一種反模式。在我們的程式碼中引入它之前,我們應該問自己是否真的需要它提供的功能。
在本教程中,我們將討論單例設計模式的一般缺點,並了解一些可以使用的替代方案。
2. 程式碼範例
首先,讓我們建立一個將在範例中使用的類別:
public class Logger {
private static Logger instance;
private PrintWriter fileWriter;
public static Logger getInstance() {
if (instance == null) {
instance = new Logger();
}
return instance;
}
private Logger() {
try {
fileWriter = new PrintWriter(new FileWriter("app.log", true));
} catch (IOException e) {
e.printStackTrace();
}
}
public void log(String message) {
String log = String.format("[%s]- %s", LocalDateTime.now(), message);
fileWriter.println(log);
fileWriter.flush();
}
}
上面的類別代表了用於登入文件的簡化類別。我們使用惰性初始化方法將其實作為單例。
3. Singleton的缺點
根據定義,單例模式確保一個類別只有一個實例,此外還提供對該實例的全域存取。因此,我們應該在需要這兩者的情況下使用它。
看看它的定義,我們可以注意到它違反了單一責任原則。該原則規定一個類別應該只承擔一項職責。
然而,單例模式至少有兩個職責——它確保類別只有一個實例並包含業務邏輯。
在接下來的部分中,我們將討論此設計模式的一些其他缺陷。
3.1.全域狀態
我們知道全局狀態被認為是一種不好的做法,因此應該避免。
儘管可能不明顯,單例在我們的程式碼中引入了全域變量,但它們被封裝在一個類別中。
由於它們是全球性的,因此每個人都可以存取和使用它們。此外,如果它們不是一成不變的,那麼每個人都可以更改它們。
假設我們在程式碼中的多個位置使用Logger
類別。每個人都可以存取和修改其值。
現在,如果我們在使用它的一種方法中遇到問題並發現問題出在單例本身中,我們需要檢查整個程式碼庫和使用它的每個方法以找出問題的影響。
這很快就會成為我們應用程式的瓶頸。
3.2.程式碼靈活性
接下來,就軟體開發而言,唯一確定的是我們的程式碼將來可能會發生變化。
當一個專案處於開發的早期階段時,我們可以假設某些類別的實例不會超過一個,並使用單例設計模式來定義它們。
然而,如果需求改變並且我們的假設被證明是不正確的,我們就需要付出巨大的努力來重構我們的程式碼。
讓我們在工作範例中討論上述問題。
我們假設我們只需要一個Logger
類別的實例。如果將來我們認為一個文件不夠怎麼辦?
例如,我們可能需要單獨的文件來儲存錯誤和資訊訊息。此外,一個類別的一個實例已經不夠了。接下來,為了使修改成為可能,我們需要重構整個程式碼庫並刪除單例,這將需要大量的工作。
使用單例,我們使程式碼緊密耦合且靈活性降低。
3.3.依賴隱藏
展望未來,單例會促進隱藏的依賴關係。
換句話說,當我們在其他類別中使用它們時,我們隱藏了這些類別依賴單例實例的事實。
讓我們考慮一下sum()
方法:
public static int sum(int a, int b){
Logger logger = Logger.getInstance();
logger.log("A simple message");
return a + b;
}
如果我們不直接看sum()
方法的實現,我們無法知道它使用了Logger
類別。
我們沒有像往常一樣將依賴項作為參數傳遞給建構函式或方法。
3.4.多執行緒
其次,在多執行緒環境中,單例的實作可能很棘手。
主要問題是全域變數對我們程式碼中的所有執行緒都是可見的。此外,每個執行緒都不知道其他執行緒在同一實例上進行的活動。
因此,我們最終可能會面臨不同的問題,例如競爭條件和其他同步問題。
我們早期的Logger
類別實作在多執行緒環境中不能很好地工作。我們的方法中沒有任何內容可以阻止多個執行緒同時存取getInstance()
方法。結果,我們最終可能會擁有多個Logger
類別的實例。
讓我們用synchronized
關鍵字來修改getInstance()
方法:
public static Logger getInstance() {
synchronized (Logger.class) {
if (instance == null) {
instance = new Logger();
}
}
return instance;
}
我們現在強制每個執行緒等待輪到它。但是,我們應該意識到同步的成本很高。此外,我們也為我們的方法引入了開銷。
如果有必要,我們解決問題的方法之一是應用雙重檢查鎖定機制:
private static volatile Logger instance;
public static Logger getInstance() {
if (instance == null) {
synchronized (Logger.class) {
if (instance == null) {
instance = new Logger();
}
}
}
return instance;
}
然而,我們應該記住,JVM 允許存取部分建構的對象,這可能會導致我們的程式出現意外的行為。因此,需要在instance
變數中加入volatile
關鍵字。
我們可能考慮的其他替代方案包括:
- 急切創建的實例而不是惰性實例
- 枚舉單例
- 比爾·普格·辛格頓
3.5.測試
更進一步,我們在測試程式碼時可以注意到單例的缺點。
單元測試應該只測試程式碼的一小部分,並且不應該依賴可能失敗的其他服務,從而導致我們的測試也失敗。
讓我們測試一下sum()
方法:
@Test
void givenTwoValues_whenSum_thenReturnCorrectResult() {
SingletonDemo singletonDemo = new SingletonDemo();
int result = singletonDemo.sum(12, 4);
assertEquals(16, result);
}
即使我們的測試通過了,它也會建立一個包含日誌的文件,因為sum()
方法使用Logger
類別。
如果我們的Logger
類別出現問題,我們的測試就會失敗。現在,我們該如何防止日誌記錄的發生?
如果適用,一種解決方案是使用 Mockito 模擬靜態getInstance()
方法:
@Test
void givenMockedLogger_whenSum_thenReturnCorrectResult() {
Logger logger = mock(Logger.class);
try (MockedStatic<Logger> loggerMockedStatic = mockStatic(Logger.class)) {
loggerMockedStatic.when(Logger::getInstance).thenReturn(logger);
doNothing().when(logger).log(any());
SingletonDemo singletonDemo = new SingletonDemo();
int result = singletonDemo.sum(12, 4);
Assertions.assertEquals(16, result);
}
}
4. 單例的替代方案
最後,讓我們討論一些替代方案。
如果我們只需要一個實例,我們可以使用依賴注入。**換句話說,我們只能建立一個實例並將其作為參數傳遞到需要的地方。**透過這種方式,我們可以提高對方法或另一個類別正常運作所需的依賴關係的認識。
此外,如果我們將來需要多個實例,我們可以更輕鬆地更改程式碼。
此外,我們可以將工廠模式用於長期存在的物件。
5. 結論
在本文中,我們研究了單例設計模式的主要缺點。
綜上所述,我們應該只在真正需要的時候使用這種模式。在我們實際上不需要單一實例的情況下,過度使用它會帶來不必要的限制。作為替代方案,我們可以簡單地使用依賴注入並將物件作為參數傳遞。
與往常一樣,所有範例的程式碼都可以在 GitHub 上取得。