簡潔代碼之道(2):避免全局可變狀態

文章轉載自: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

a==ba==b
當 X 被實例化時,它可能會同時創建多個其它對象,當它執行 doSomething() 的時候,得到的結果是一樣。說明它是無狀態的,每次執行都像 1+1=2一樣有一個確定的值。

第二種情況:X 類受到全局狀態的影響,此時 a != b

a!=ba!=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需要 OfflineQueueOfflineQueue 需要 Database。於是我們可以很清楚的寫下上面的測試代碼,不需要文檔的輔助,我們也知道如何正確的使用 CreditCard。這就是好的 API 設計。

如果我們要讓上面的配置類單例化,只需要使用 IoC 容器進行管理即可,通過依賴注入的方式,可以使代碼更加清晰,易測試。

總結

  • 全局狀態是大多數測試問題的根源。
  • 全局狀態無法被測試控制,無法控制意味着無法進行徹底的測試。
  • 單例模式是封裝了全局狀態的常用形式。這也是我們不提倡使用單例模式的原因,推薦用容器管理的單例模式。
  • 全局狀態會讓 API 具有誤導性。

參考資料
全局狀態與單例模式


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章