設計模式大冒險第四關:單例模式,如何成為你的“唯一”

dreamapplehappy發表於2020-12-14

image

這一篇文章是關於設計模式大冒險系列的第四篇文章,這一系列的每一篇文章我都希望能夠通過通俗易懂的語言描述或者日常生活中的小例子來幫助大家理解好每一種設計模式。今天這篇文章來跟大家一起學習一下單例模式。相信讀完這篇文章之後,你肯定會有所收穫的。

關於單例模式,這應該是設計模式中最簡單的一種了。大家如果學習過設計模式,可能很多設計模式長時間不用就忘記了,但是對於單例模式來說,你肯定不會忘記。因為它的理論知識比較簡單,實踐起來也很方便

但是,你真的會正確的使用單例模式嗎?你知道單例模式在什麼情況下使用是合適的,什麼情況下使用會造成很多麻煩嗎?還是你只是把它當做一個全域性變數去使用,只是因為這樣開發很方便,不用寫很多的程式碼。今天這篇文章我們就來一起好好學習一下單例模式。讓我們開始吧。

單例模式的介紹

首先我們先來看一下單例模式的定義是什麼。所謂的單例模式,就是指對於一個具體的類來說,它有且只有一個例項,這個類負責建立唯一的例項,並且對外提供一個全域性的訪問介面

單例模式的UML類圖可以用下圖表示:

image

那麼我們為什麼要使用單例模式呢?舉一個生活中的場景,在平時你過馬路的時候,給你訊號提示你能不能穿過馬路的交通訊號燈是不是隻有一個?因為在這種情況下,如果同時有兩個訊號燈的話,你是不知道該不該在此時穿過馬路的

所以類比到我們的軟體開發中,也是這麼一個道理。在一個系統中,某種用途的例項會存在唯一的一個。這個例項可能用來儲存應用中的一些狀態,或者執行某些任務。比如在前端開發中,我們常常會使用一些應用的狀態管理庫,比如Vuex或者Redux。那麼在我們的應用中,對於管理狀態的例項也只能有一個,如果有多個的話就會讓應用的狀態出現問題,從而導致應用發生一些錯誤。

單例模式的實現

接下來我們來看一下單例模式是如何實現的。通過上面的UML類圖,我們可以知道,對於一個類來說,我們需要一個靜態變數來儲存例項的引用,還需要對外提供一個獲取例項的靜態方法。如果使用 ES6 的類的語法來實現的話,可以簡單的用下面的程式碼來表示:

class Singleton {
    // 類的靜態屬性
    static instance = null;

    // 類的靜態方法
    static getInstance() {
        if (this.instance === null) {
            this.instance = new Singleton();
        }
        return this.instance;
    }
}

const a = Singleton.getInstance();
const b = Singleton.getInstance();

console.log(a === b); // true

上面的程式碼還是比較簡單的,相信大家看一下就知道怎麼實現了。需要注意的一點是,在類的靜態方法中,this指的是類,而不是例項

下面我們再使用函式的方式來實現一次:

const Singleton = (function() {
    let instance;

    // 初始化單例物件的方法
    function initInstance() {
        return {};
    }

    return {
        getInstance() {
            if (instance === null) {
                instance = initInstance();
            }
            return instance;
        },
    };
})();

const a = Singleton.getInstance();
const b = Singleton.getInstance();

console.log(a === b);   // true

上面這兩種方法的實現都是差不多的,你可以根據自己的喜好選擇不同的實現方式。

多執行緒環境中的單例模式

作為Web前端開發者來說,因為我們使用的開發語言基本上是JavaScript,又因為JavaScript是一種單執行緒語言,所以我們一般不會遇到在多執行緒環境中使用單例模式會遇到的一些問題。

那麼我們如果在多執行緒的環境中使用單例模式需要注意什麼呢?首先在單例還沒有初始化的時候,如果有多個執行緒訪問建立單例模式的程式碼,在沒有做額外處理的情況下,就有可能會建立多個單例

當然也有解決的方法,一種方法就是我們在類初始化的時候就把單例生成了,這樣以後通過獲取單例的介面獲取到的都是最開始生成的那個單例。但是這樣就失去了延時初始化單例的好處。如果單例的初始化需要花費的資源或者時間比較少,這種方法是可以的。反之,這樣做有就有一些浪費了。因為可能在整個應用的執行過程中,這個單例一次也沒有被使用過

另一種方式就是在建立單例的時候需要加鎖,保證同時只能有一個執行緒在建立單例。這樣的話我們就保證了建立的單例是唯一的。當然具體的操作還跟實現單例模式選擇的語言有關係,這裡就不在深入討論了。

單例模式的適用場景和優勢

單例模式適合用在這樣的場景中:系統中需要一個唯一的物件去控制、管理和分享系統的狀態,或者執行某一個特定的任務又或是實現某一個具體的功能。在我們的前端開發中,最常見的就是應用的狀態管理物件,比如 VuexRedux。又或者是列印日誌的物件,或者是某一個功能外掛等等。總之單例模式在我們平時的開發中還是比較常見的。

那麼單例模式的優勢有哪些呢?下面簡單列舉了一些:

  • 全域性只有一個例項,提供統一的訪問與修改,保證狀態功能的一致性
  • 簡單、方便,容易實現
  • 延遲的初始化,只有在需要的時候才去初始化物件

單例模式的劣勢

雖然單例模式的優勢很突出,但是它的缺點可是一點都不少,甚至有些開發者覺得它是反模式的。所以我們使用單例模式的時候一定要好好思考一下,確定是不是必須要使用單例模式。因為單例模式的不恰當使用會給整個應用的測試,開發和維護帶來很大的困難。我們接下來就來看看單例模式有哪些缺點。

單例模式的濫用會造成跟全域性變數一樣的一些問題

比如會增加程式碼的耦合性,因為單例模式全域性都是可以訪問到的,那麼我們就很有可能在很多個地方使用這個唯一的物件,這樣也就造成了程式碼的耦合。

因為程式中使用到這個單例物件的地方都可以對全域性的狀態進行修改,所以一旦程式在這裡出現了問題,你可能要在很多個地方進行排查,這就增加了除錯和排查問題的難度。

單例模式給測試帶來了很多麻煩

為什麼說單例模式對測試來說是一個災難呢?因為如果程式碼中使用了單例,那麼我們需要在進行程式碼測試的時候,提前把單例初始化好。這導致了我們不能夠在單例沒有初始化好的時候對程式碼進行單元測試。

而且因為單例模式產生的例項只有一個,這就導致了對相同程式碼進行多次測試的時候容易出現問題,因為例項的狀態很可能在上一次測試的時候發生了改變,從而導致了下一次測試的失敗或者異常。

所以說單例模式增加了測試的難度與複雜度,增加了測試程式碼的工作量。

單例模式違背了軟體設計的單一職責原則

這個比較容易理解,因為一般情況下,對於一個類來說它只負責這個類的例項具有什麼功能;但是對於單例模式來說,單例模式的類還需要負責只能夠產生一個例項。這違背了軟體設計的單一職責原則,類應該只負責其例項的具體功能,而不應該對類產生的例項個數負責。

但是對於這個缺點來說,大家可能會有不同的看法。顯而易見的是這樣做確實更加方便,設計實現上也相對簡單一些。

單例模式隱藏了它所需要的依賴

對於一般的類來說,如果我們的類依賴了其它的類,一般情況下,我們可以通過類的建構函式將依賴的類顯式的表示出來。這樣我們在初始化具體的類的例項的時候就知道這個類需要那些依賴。

但是對於單例模式來說,它把它的依賴封裝在內部,對於外部的使用者來說它是一個黑盒。使用者並不知道初始化這個單例需要那些依賴,所以很容易在初始化單例的時候把單例所需要的依賴忘記掉,進而導致單例初始化失敗。

有時就算我們知道了初始化單例需要那些依賴,但是這些依賴也許是有先後的順序的。我們也很容易在匯入和使用依賴的時候把順序搞錯了,從而導致單例的初始化出現問題。

單例模式的總結

從上面的內容我們已經知道單例模式是一把雙刃劍,所以你在使用的時候一定要考慮清楚。先從場景的需求上考慮,是不是一定要使用單例模式才能夠解決當前的問題,有沒有其它的方案。如果一定要使用單例模式的話,如何規範單例模式的使用,如何在程式的開發,可維護性,可擴充性以及測試的簡易性上做好平衡,是一個值得考慮的問題

文章到這裡就結束了,如果大家有什麼問題和疑問歡迎大家在文章下面留言,或者在這裡提出來。也歡迎大家關注我的公眾號關山不難越,獲取更多關於設計模式講解的內容。

下面是這一系列的其它的文章,也歡迎大家閱讀,希望大家都能夠掌握好這些設計模式的使用場景和解決的方法。如果這篇文章對你有所幫助,那就點個贊,分享一下吧~

參考連結:

相關文章