1. 程式人生 > >避免濫用單例

避免濫用單例

單例是整個 Cocoa 中被廣泛使用的核心設計模式之一。事實上,蘋果開發者庫把單例作為 "Cocoa 核心競爭力" 之一。作為一個iOS開發者,我們經常和單例打交道,比如UIApplicationNSFileManager等等。我們在開源專案、蘋果示例程式碼和 StackOverflow 中見過了無數使用單例的例子。Xcode 甚至有一個預設的 "Dispatch Once" 程式碼片段,可以使我們非常簡單地在程式碼中新增一個單例:

+ (instancetype)sharedInstance
{
    static dispatch_once_t once;
    static id
sharedInstance; dispatch_once(&once, ^{ sharedInstance = [[self alloc] init]; }); return sharedInstance; }

由於這些原因,單例在 iOS 開發中隨處可見。問題是,它們很容易被濫用。

儘管有些人認為單例是 '反模式', '魔鬼' 以及'病態的說謊者',我不會去完全否認單例所帶來的的好處,而是會展示一些使用單例所帶來的問題,這樣下一次在使用dispatch_once程式碼片段的自動補全功能時,你可以對它的影響進行評估,三思而行。

全域性狀態

大多數的開發者都認同使用全域性可變的狀態是不好的行為。太多狀態使得程式難以理解,難以除錯。我們這些面向物件的程式設計師在最小化程式碼的狀態複雜程度的方面,有很多需要向函數語言程式設計學習的地方。

@implementation SPMath {
    NSUInteger _a;
    NSUInteger _b;
}

- (NSUInteger)computeSum
{
    return _a + _b;
}

在上面這個簡單的數學庫的實現中,程式設計師需要在呼叫computeSum前正確的設定例項變數_a_b。這樣有以下問題:

  1. computeSum沒有顯式地通過使用引數的形式宣告它依賴於_a_b的狀態。與僅僅通過檢視函式宣告就可以知道這個函式的輸出依賴於哪些變數不同的是,另一個開發者必須檢視這個函式的具體實現才能明白這個函式依賴那些變數。隱藏依賴是不好的。

  2. 當為呼叫computeSum

    做準備而修改_a_b的數值時,程式設計師需要保證這些修改不會影響任何其他依賴於這兩個變數的程式碼的正確性。而這在多執行緒的環境中是尤其困難的。

把下面的程式碼和上面的例子做對比:

+ (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b
{
    return a + b;
}

這裡,對變數ab的依賴被顯式地聲明瞭。我們不需要為了呼叫這個方法而去改變例項變數的狀態。並且我們也不需要擔心呼叫這個函式會留下持久的副作用。我們甚至可以把這個方法宣告為類方法,這樣就告訴了程式碼的閱讀者這個方法不會修改任何例項的狀態。

那麼,這個例子和單例又有什麼關係呢?用 Miško Hevery 的話來說,"單例就是披著羊皮的全域性狀態"。一個單例可以被使用在任何地方,而不需要顯式地宣告依賴。就像變數_a_bcomputeSum內部被使用了,卻沒有被顯式宣告一樣,程式的任意模組都可以呼叫[SPMySingleton sharedInstance]並且訪問這個單例。這意味著任何和這個單例互動產生的副作用都會影響程式其他地方的任意程式碼。

@interface SPSingleton : NSObject

+ (instancetype)sharedInstance;

- (NSUInteger)badMutableState;
- (void)setBadMutableState:(NSUInteger)badMutableState;

@end

@implementation SPConsumerA

- (void)someMethod
{
    if ([[SPSingleton sharedInstance] badMutableState]) {
        // ...
    }
}

@end

@implementation SPConsumerB

- (void)someOtherMethod
{
    [[SPSingleton sharedInstance] setBadMutableState:0];
}

@end

在上面的例子中,SPConsumerASPConsumerB是兩個完全獨立的模組。但是SPConsumerB可以通過使用單例提供的共享狀態來影響SPConsumerA的行為。這種情況應該只能發生在 consumer B 顯式引用了 A,並表明了兩者之間的關係時。這裡使用了單例,由於其具有全域性和多狀態的特性,導致隱式地在兩個看起來完全不相關的模組之間建立了耦合。

讓我們來看一個更具體的例子,並且暴露一個使用全域性可變狀態的額外問題。比如我們想要在我們的應用中構建一個網頁檢視器。為了支援這個檢視器,我們構建了一個簡單的 URL cache:

@interface SPURLCache

+ (SPCache *)sharedURLCache;

- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;

@end    

這個開發者開始寫一些單元測試來保證程式碼在一些不同的情況下都能達到預期。首先,他寫了一個測試用例來保證網頁檢視器在裝置沒有連線時能夠展示出錯誤資訊。然後他寫了一個測試用例來保證網頁檢視器能夠正確的處理伺服器錯誤。最後,他為成功情況時寫了一個測試用例,來保證返回的網路內容能夠被正確的顯示出來。這個開發者運行了所有的測試用例,並且它們都如預期一樣正確。贊!

幾個月以後,這些測試用例開始出現失敗,儘管網頁檢視器的程式碼從它寫完後就從來沒有再改動過!到底發生了什麼?

原來,有人改變了測試的順序。處理成功的那個測試用例首先被執行,然後再執行其他兩個。處理錯誤的那兩個測試用例現在竟然成功了,和預期不一樣,因為 URL cache 這個單例把不同測試用例之間的 response 快取起來了。

持久化狀態是單元測試的敵人,因為單元測試在各個測試用例相互獨立的情況下才有效。如果狀態從一個測試用例傳遞到了另外一個,這樣就和測試用例的執行順序就有關係了。有 bug 的測試用例,尤其是那些本來不應該通過的測試用例,是非常糟糕的事情。

物件的生命週期

另外一個關鍵問題就是單例的生命週期。當你在程式中新增一個單例時,很容易會認為 “永遠只會有一個例項”。但是在很多我看到過的 iOS 程式碼中,這種假定都可能被打破。

比如,假設我們正在構建一個應用,在這個應用裡使用者可以看到他們的好友列表。他們的每個朋友都有一張個人資訊的圖片,並且我們想使我們的應用能夠下載並且在裝置上快取這些圖片。 使用dispatch_once程式碼片段,我們可以寫一個SPThumbnailCache單例:

@interface SPThumbnailCache : NSObject

+ (instancetype)sharedThumbnailCache;

- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;

@end

我們繼續構建我們的應用,一切看起來都很正常,直到有一天,我們決定去實現‘登出’功能,這樣使用者可以在應用中進行賬號切換。突然我們發現我們將要面臨一個討厭的問題:使用者相關的狀態儲存在全域性單例中。當用戶登出後,我們希望能夠清理掉所有的硬碟上的持久化狀態。否則,我們將會把這些被遺棄的資料殘留在使用者的裝置上,浪費寶貴的硬碟空間。對於使用者登出又登入了一個新的賬號這種情況,我們也想能夠對這個新使用者使用一個全新的SPThumbnailCache例項。

問題在於按照定義單例被認為是“建立一次,永久有效”的例項。你可以想到一些對於上述問題的解決方案。或許我們可以在使用者登出時移除這個單例:

static SPThumbnailCache *sharedThumbnailCache;

+ (instancetype)sharedThumbnailCache
{
    if (!sharedThumbnailCache) {
        sharedThumbnailCache = [[self alloc] init];
    }
    return sharedThumbnailCache;
}

+ (void)tearDown
{
    // The SPThumbnailCache will clean up persistent states when deallocated
    sharedThumbnailCache = nil;
}

這是一個明顯的對單例模式的濫用,但是它可以工作,對吧?

我們當然可以使用這種方式去解決,但是代價實在是太大了。我們不能使用簡單的的dispatch_once方案了,而這個方案能夠保證執行緒安全以及所有呼叫[SPThumbnailCache sharedThumbnailCache]的地方都能訪問到同一個例項。現在我們需要對使用縮圖 cache 的程式碼的執行順序非常小心。假設當用戶正在執行登出操作時,有一些後臺任務正在執行把圖片儲存到快取中的操作:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    [[SPThumbnailCache sharedThumbnailCache] cacheProfileImage:newImage forUserId:userId];
});

我們需要保證在所有的後臺任務完成前,tearDown一定不能被執行。這確保了newImage資料可以被正確的清理掉。或者,我們需要保證在縮圖 cache 被移除時,後臺快取任務一定要被取消掉。否則,一個新的縮圖 cache 的例項將會被延遲建立,並且之前使用者的資料 (newImage物件) 會被儲存在它裡面。

由於對於單例例項來說它沒有明確的所有者,(因為單例自己管理自己的生命週期),“關閉”一個單例變得非常的困難。

分析到這裡,我希望你能夠意識到,“這個縮圖 cache 從來就不應該作為一個單例!”。問題在於一個物件得生命週期可能在專案的最初階段沒有被很好得考慮清楚。舉一個具體的例子,Dropbox 的 iOS 客戶端曾經只支援一個賬號登入。它以這樣的狀態存在了數年,直到有一天我們希望能夠同時支援多個使用者賬號登入 (同時登陸私人賬號和工作賬號)。突然之間,我們以前的的假設“只能夠同時有一個使用者處於登入狀態”就不成立了。如果假定了一個物件的生命週期和應用的生命週期一致,那你的程式碼的靈活擴充套件就受到了限制,早晚有一天當產品的需求產生變化時,你會為當初的這個假定付出代價的。

這裡我們得到的教訓是,單例應該只用來儲存全域性的狀態,並且不能和任何作用域繫結。如果這些狀態的作用域比一個完整的應用程式的生命週期要短,那麼這個狀態就不應該使用單例來管理。用一個單例來管理使用者繫結的狀態,是程式碼的壞味道,你應該認真的重新評估你的物件圖的設計。

避免使用單例

既然單例對區域性作用域的狀態有這麼多的壞處,那麼我們應該怎樣避免使用它們呢?

讓我們來重溫一下上面的例子。既然我們的縮圖 cache 的快取狀態是和具體的使用者繫結的,那麼讓我們來定義一個user物件吧:

@interface SPUser : NSObject

@property (nonatomic, readonly) SPThumbnailCache *thumbnailCache;

@end

@implementation SPUser

- (instancetype)init
{
    if ((self = [super init])) {
        _thumbnailCache = [[SPThumbnailCache alloc] init];

        // Initialize other user-specific state...
    }
    return self;
}

@end

我們現在用一個物件來作為一個經過認證的使用者會話的模型類,並且我們可以把所有和使用者相關的狀態儲存在這個物件中。現在假設我們有一個view controller來展現好友列表:

@interface SPFriendListViewController : UIViewController

- (instancetype)initWithUser:(SPUser *)user;

@end

我們可以顯式地把經過認證的 user 物件作為引數傳遞給這個 view controller。這種把依賴性傳遞給依賴物件的技術正式的叫法是依賴注入,它有很多優點:

  1. 對於閱讀這個SPFriendListViewController標頭檔案的讀者來說,可以很清楚的知道它只有在有登入使用者的情況下才會被展示。
  2. 這個SPFriendListViewController只要還在使用中,就可以強引用 user 物件。舉例來說,對於前面的例子,我們可以像下面這樣在後臺任務中儲存一個圖片到縮圖 cache 中:

     dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
         [_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
     });
    

    就算後臺任務還沒有完成,應用其他地方的程式碼也可以建立和使用一個全新的SPUser物件,而不會在清理第一個例項時阻塞使用者互動。

為了更詳細的說明一下第二點,讓我們畫一下在使用依賴注入之前和之後的物件圖。

假設我們的SPFriendListViewController是當前 window 的 root view controller。使用單例時,我們的物件圖看起來如下所示:

view controller 自己,以及自定義的 image view 的列表,都會和sharedThumbnailCache產生互動。當用戶登出後,我們想要清理 root view controller 並且退出到登入頁面:

這裡的問題在於這個好友列表的 view controller 可能仍然在執行程式碼 (由於後臺操作的原因),並且可能因此仍然有一些沒有執行的涉及到sharedThumbnailCache的呼叫。

和使用依賴注入的解決方案對比一下:

簡單起見,假設SPApplicationDelegate管理SPUser的例項 (在實踐中,你可能會把這些使用者狀態的管理工作交給另外一個物件來做,這樣可以使你的 application delegate簡化)。當展現好友列表 view controller 時,會傳遞進去一個 user 的引用。這個引用也會向下傳遞給 profile image views。現在,當用戶登出時,我們的物件圖如下所示:

這個物件圖看起來和使用單例時很像。那麼,區別是什麼呢?

關鍵問題是作用域。在單例那種情況中,sharedThumbnailCache仍然可以被程式的任意模組訪問。假如使用者快速的登入了一個新的賬號。該使用者也想看看他的好友列表,這也就意味著需要再一次的和縮圖 cache 產生互動:

當用戶登入一個新賬號,我們應該能夠構建並且與全新的SPThumbnailCache互動,而不需要再在銷燬老的縮圖 cache 上花費精力。基於物件管理的典型規則,老的 view controllers 和老的縮圖 cache 應該能夠自己在後臺延遲被清理掉。簡而言之,我們應該隔離使用者 A 相關聯的狀態和使用者 B 相關聯的狀態:

結論

希望這篇文章中的內容讀起來不像奇幻小說那樣難以理解。人們已經對單例的濫用抱怨了很多年了,並且我們也都知道全域性狀態是很不好的事情。但是在 iOS 開發的世界中,單例的使用是如此的普遍以至於我們有時候忘記了我們多年來在其他面向物件程式設計中學到的教訓。

這一切的關鍵點是,在面向物件程式設計中我們想要最小化可變狀態的作用域。但是單例卻因為使可變的狀態可以被程式中的任何地方訪問,而站在了對立面。下一次你想使用單例時,我希望你能夠好好考慮一下使用依賴注入作為替代方案。


相關推薦

避免濫用

單例是整個 Cocoa 中被廣泛使用的核心設計模式之一。事實上,蘋果開發者庫把單例作為 "Cocoa 核心競爭力" 之一。作為一個iOS開發者,我們經常和單例打交道,比如UIApplication和NSFileManager等等。我們在開源專案、蘋果示例程式碼和 Stac

濫用之dispatch_once死鎖

 不錯的一篇libdispatch原始碼的文章,雖然看過,但記錄一下。 轉載連線:http://satanwoo.github.io/2016/04/11/dispatch-once/ 現象 上週排查了一個bug,現象很簡單,就是個Crash問題。但是讀了一下

iOS 濫用和用依賴注入替代

單例是整個Cocoa中被廣泛使用的核心設計模式之一。事實上,蘋果開發者庫把單例作為"Cocoa核心競爭力"之一。作為一個iOS開發者,我們經常和單例打交道,比如UIApplication和NSFileManager等等。我們在開源專案、蘋果示例程式碼和StackOverf

模式

實現 程序 先來 null effective 如果 ava 不同的 aps 單例模式:確保某一個類只有一個實例,而且自行實例化並向整個系統提供這個實例。 優點: 1、省略創建對象所花費的時間減少系統開銷,尤其是重量級對象。 2、減少對象的創建,減輕GC壓力。 3、設置全局

GOF23—模式(2)

應該 一個 img bsp 漏洞 資源 nbsp 創建 就會 本文介紹單例模式(不包含枚舉單例模式)漏洞問題以及如何防止漏洞   1.反射可以破解單例模式,例子如下:       此時,我們運行Client類,發現s1和s2是一個對象,但s3和s4是不同的對象。 那麽如何防

溫故而知新(java實現)模式的七種寫法

反序 防止 代碼 工作 html 我想 變種 evel 才會 第一種(懶漢,線程不安全): Java代碼 public class Singleton { private static Singleton instance; private S

模式和線程安全

tac 可能 存在 版本 線程不安全 廣東 多線程 一個 單例 前幾天給項目的省市區加了redis。結果上線沒多久就發生了數據錯亂的現象,需要讀取山東省的市可能返回的市廣東的。一開始不明白哪裏有問題,因為測試組的同學有測試過,在測試過程種並沒有發生這樣的情況。由於使用了se

C#模式的多種寫法

code if語句 規則 最簡 lock 實現 readonly led 三種 它的主要特點不是根據客戶程序調用生成一個新的實例,而是控制某個類型的實例數量-唯一一個。(《設計模式-基於C#的工程化實現及擴展》,王翔)。也就是說,單例模式就是保證在整個應用程序的生命周期中,

Swift、Objective-C 模式 (Singleton)

app 賦值 uil imp ide 效果 func instance 發現 本文的單例模式分為嚴格單例模式和不嚴格單例模式。單例模式要求一個類有一個實例,有公開接口可以訪問這個實例。嚴格單例模式,要求一個類只有一個實例;不嚴格單例模式,可以創建多個實例。 有的類只能有一個

設計模式

機制 構造方法 出錯 保持 什麽 方法 交易 註意 let 這是一種常見常說的設計模式 餓漢式 懶漢式 其核心思想是: 保證在一個JVM中只有一個實例對象 好處: 1.針對於某些類的創建比較頻繁,對於一些很大的對象來說系統開銷很大 2.節省new 操作符,降低內存使用頻

模式-Singleton

加載 null 允許 nbsp 訪問 加載類 public tin style 單例模式:保證一個類僅有一個實例,並提一個訪問它的全局訪問點。   通常我們可以讓一個全局變量使得一個對象被訪問,但它不能防止你實例化多個對象。一個最好的辦法就是,讓類自身負責保存它的唯一實例。

Python

cal obj 如果 def ins __call__ class type n) 方法一   實現__new__方法,然後將類的一個實例綁定到類變量_instance上;如果cls._instance為None,則說明該類還沒有被實例化過,new一個該類的實例,並返回;

(一二三)基於GCD的dispatch_once實現設計

super dispatch ret 強引用 一次 nslog span imp int 要實現單例,關鍵是要保證類的alloc和init僅僅被調用一次。而且被自身強引用防止釋放。 近日讀唐巧先生的《iOS開發進階》。受益匪淺,通過GCD實現單例就是收獲之中的一個,以下

java軟件設計模式——設計模式中的【餓漢式】與 【懶漢式】示例

nal pre turn new對象 構造方法 sta 餓漢式 () urn 以下為單例設計模式中的兩種經典模式的代碼示意: 1 單例設計模式(spring框架IOC,默認創建的對象都是單例的): 2 餓漢式: 3 public class Sing

Spring與線程安全小結

null 存在 sta 發的 壓力 _for 處理方式 好處 common 轉:http://www.cnblogs.com/doit8791/p/4093808.html 一、Spring單例模式與線程安全 Spring框架裏的bean,或者說組件,獲取實例的時候

java的

宇宙 blog external 個人 lin 發生 ext product 原因 原文出處: 張新強 1. 前言 單例(Singleton)應該是開發者們最熟悉的設計模式了,並且好像也是最容易實現的——基本上每個開發者都能夠隨手寫出——但是,真的是這樣嗎?作為一個Java

[轉]設計模式--模式(一)懶漢式和餓漢式

打印 是否 調用構造 餓漢 一段 tools 會有 輸出結果 java 單例模式是設計模式中比較簡單的一種。適合於一個類只有一個實例的情況,比如窗口管理器,打印緩沖池和文件系統, 它們都是原型的例子。典型的情況是,那些對象的類型被遍及一個軟件系統的不同對象訪問,因此需要一個

23種設計模式介紹以及模式的學習

單例模式 餓漢式 23種設計模式 gof23 1、GOF23 設計模式總共分成創建型模式、結構型模式和行為型模式三種: a、創建型模式: - 單例模式、工廠模式、抽象工廠模式、建造者模式、原型模式 b、構建型模式: - 適配器模式、橋接模式、裝配模式、組合模式、建造者模

c++之模式

lsi 但是 desc 模式 單例模式 ron spl 希望 構造函數 1 本篇主要討論下多線程下的單例模式實現:   首先是 double check 實現方式: 這種模式可以滿足多線程環境下,只產生一個實例。 template<typename T>

面向對象編程思想-模式

構造 turn 什麽 性能 線程池 線程並發 管理器 成員 man 單例模式的定義:只能有一個實例,提供唯一公開、可訪問實例的全局訪問點 單例模式的三種實現方式: 示例1      private Singleton() {