文章轉載自:http://blog.xiaohansong.com/2015/11/30/avoid-global-state/
前言
本文是我看了 谷歌簡潔代碼演講系列 中的 全局狀態與單例模式 之後的總結。本文的主題是:避免全局可變狀態。下面我們將圍繞幾個問題開展討論:
- 什麼是全局狀態
- 如何設計好的單例模式
- 如何設計好的 API
全局狀態
什麼是全局狀態
Talk is cheap, show me the code. — Linus
我們來用一個例子說明什麼是全局狀態。
class X {
X() {...}
public int doSomething() {...}
}
int a = new X().doSomething()
int b = new X().doSomething()
現在問題來了,a
等於 b
嗎?事實上有兩種可能的情況。
第一種情況:X
類不受全局狀態的影響,此時 a == b
當 X
被實例化時,它可能會同時創建多個其它對象,當它執行 doSomething()
的時候,得到的結果是一樣。說明它是無狀態的,每次執行都像 1+1=2
一樣有一個確定的值。
第二種情況:X
類受到全局狀態的影響,此時 a != b
如果 X
在執行 doSomething()
的時候,其中的 Z
變量受到全局狀態 GS
的影響,此時 a==b
可能就不成立了。因爲程序的執行依賴全局狀態,同樣的方法可能會得到不同的結果。
全局狀態的缺點
全局狀態相當常見,因爲寫起來方便。“啊,有一個新的功能要加入,我們加一個全局變量,再加一個條件語句跳轉到新的方法就行了。”然而,這種方便卻讓程序變得難以維護和測試。所以,有一定經驗的程序員都會認爲全局狀態令人討厭,會避免使用它。
下面我們來列舉全局狀態的罪狀:
- 多次執行同一方法會產生不同的結果
- 測試無法給出一個可靠的結果
- 測試的順序會影響到結果
- 不能並行進行測試
- 很難確定設置狀態的位置
總之,從測試的角度來看,全局狀態是很可怕的東西。
全局狀態和全局變量的區別
- 全局狀態不僅包括了全局變量,還包括系統的環境變量,以及人爲的命令等。
- 全局變量是在程序生命週期中全局可訪問的變量,常用來表示全局狀態。
單例模式
有些程序員討厭全局狀態,卻喜歡單例模式。但是,從某種意義上說,單例模式是另一種全局狀態。當然我不是一棍子打死單例模式,應該說,寫得不好的單例模式起到的作用就如同全局狀態,讓程序難以維護和測試。下面我們來討論什麼是好的單例模式,什麼是壞的單例模式
壞的單例模式
下面是典型的單例模式實現。
class AppSetting {
private static AppSetting instance = new AppSetting();
private Object state1;
private Object state2;
private Object state3;
private AppSetting() {...}
public static AppSetting getInstance() {
return instance;
}
}
我們先來思考一個問題:這個類包括了多少個全局變量?你可能覺得只有一個 instance
,事實上一共有4個。只要 instance
一直存在,它的成員變量也會一直存在。也就是說一共有四個全局變量:state1, state2, state3, instance
。
class App {
int method() {
return AppSetting.getInstance().doX();
}
}
void testApp {
???
}
想想我們怎麼測試上面的代碼。單例模式下,你沒有縫隙進入到 method()
函數中測試。
上面的單例模式存在一個很大的測試問題:測試無法覆蓋所有的狀態。因爲狀態是私有,同時它單例的。如果我們要測試三個狀態怎麼辦,一個解決辦法是在測試的時候把狀態改爲公有的。這看起來有點詭異,我們一方面又想用單例封裝狀態,一方面卻在測試的時候要去修改代碼讓它的狀態公有。可以說,這種單例模式給測試帶來了極大的麻煩。
好的單例模式
那麼,什麼是好的單例模式呢?看下面的代碼。
class AppSetting {
private Object state1;
private Object state2;
private Object state3;
public AppSetting() {...}
}
第一眼看到這個代碼,你可能覺得這哪裏是單例模式,明明就是個普通的類。
是的,它的確是個普通的類。在這裏我們讓它不再着重於類自身的單例。什麼意思?想想單例模式的本質是什麼,單例模式主要是類保證在程序的生命週期內只有一個實例,其它對象訪問到的是同一個實例。我們來看看,這種模式對測試帶來了怎樣的便利。
class App {
AppSetting settings;
App(AppSetting settings) {
this.settings = settings;
}
int method() {
return settings.doX();
}
}
void testApp() {
new App(new AppSetting(...)).method();
}
每個測試我們可以提供一個不同的 AppSetting
來進行測試,相比上面的單例模式,測試得到了更多的控制。我們可以通過不同的 AppSetting
的構造函數,改變程序的狀態來進行測試。
看到這裏,你可能有一個疑問:這樣子寫的代碼根本就不是單例模式。的確,從類的實現上,AppSetting
的確不是單例模式的。這裏我們強調的是邏輯上的單例,而不是代碼實現上的單例。怎麼理解?
首先,單例模式的傳統實現是由類來管理這個唯一的實例,也就是我們上面說的“壞的單例模式”,而“好的單例模式”則是由程序來控制類的唯一實例,例如說,Spring IoC 容器中的 Bean,在容器的生命週期中,Bean 默認是單例的。(詳細的解釋可以看這篇文章 控制反轉(IoC)與依賴注入(DI))簡單說,就是把單例類管理唯一實例的功能轉移給外部容器,當你使用了 IoC 框架之後,你會發現,單例模式的實例完全可以通過容器管理,而不用我們寫“壞的單例模式”。
設計好的 API
全局狀態同樣會影響到 API 的好壞。
壞的 API
我們來看一個壞的 API。
testCharge() {
Database.connect();
OfflineQueue.start();
CreditCardProcessor.init();
CreditCard cc = new CreditCard("123");
cc.charge(100);
}
如果你對單例模式的壞處還沒完全理解,或者你也喜歡寫這樣的代碼,那麼刷新編程觀的時候到了。
上面是一個信用卡測試消費的例子。在實例化 CreditCard
之前要有三個初始化操作(明顯都是單例模式)。現在問題來了:如果你是新來的測試人員,讓你去測試 CreditCard
,你看了 API 文檔之後,興沖沖地寫下一些代碼。
testCharge() {
CreditCard cc = new CreditCard("123");
cc.charge(100);
}
現在滿懷期待的運行,結果卻是熟悉的 NullPointerException
。爲什麼?新來的你當然不知道創建 CreditCard
之前要先連接數據庫,啓動離線隊列,初始化信用卡處理器。所以你只能去問開發人員。現在你知道問題出在哪了嗎?
壞的單例模式讓測試人員很難測試代碼,因爲你看了 API 之後只知道要實例化 CreditCard
,然後調用 charge
,完全不知道 Database
之類的全局狀態是什麼鬼。不要以爲這隻會爲難到測試人員,六個月之後你就能體驗到測試人員的痛苦。因此,全局狀態讓 API 有了誤導性,讓你以爲做了正確的操作。
當然,文檔寫的清楚可以解決這個問題,然而好的文檔可遇不可求,所以我們要有更好的解決辦法。
好的 API
設計好的 API,可以從代碼層面上解決上面的問題,所謂代碼就是最好的註釋。
testCharge() {
db = new Database(...);
queue = new OfflineQueue(db);
ccProc = new CreditCardProcessor(queue);
CreditCard cc = new CreditCard("123", ccProc);
cc.charge(100);
}
上面的代碼通過讓依賴參數化完美地解決上面的問題。現在,我們還是那個新來的測試人員,我們開始寫測試代碼。API 告訴我們,實例化 CreditCard
需要 CreditCardProcessor
作爲參數,CreditCardProcessor
需要 OfflineQueue
,OfflineQueue
需要 Database
。於是我們可以很清楚的寫下上面的測試代碼,不需要文檔的輔助,我們也知道如何正確的使用 CreditCard
。這就是好的
API 設計。
如果我們要讓上面的配置類單例化,只需要使用 IoC 容器進行管理即可,通過依賴注入的方式,可以使代碼更加清晰,易測試。
總結
- 全局狀態是大多數測試問題的根源。
- 全局狀態無法被測試控制,無法控制意味着無法進行徹底的測試。
- 單例模式是封裝了全局狀態的常用形式。這也是我們不提倡使用單例模式的原因,推薦用容器管理的單例模式。
- 全局狀態會讓 API 具有誤導性。
參考資料
全局狀態與單例模式