避免单例滥用

为避免撕逼,提早声明:本文纯属翻译,仅仅是为了学习,加上水平有限,见谅!程序员

【原文】https://www.objc.io/issues/13-architecture/singletons/web

避免单例滥用——by Stephen Poletto

单例是整个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设置合适的值。这存在几个问题:bash

  1. computeSum方法没有经过把_a_b的值做为参数而显式的指出方法依赖于上述的两个值。其余阅读代码的人必须经过检查实现去理解依赖关系,而不是经过检查接口并理解哪些变量控制函数输出。隐藏依赖关系这样是很差的。
  2. 当为了准备调用computeSum而修改_a_b的时候,程序员须要肯定这些修改不会影响其它依赖这些变量的代码的正确性。这在多线程环境尤其困难。

把这下面这个例子与上述的例子比较一下:服务器

+ (NSUInteger)computeSumOf:(NSUInteger)a plus:(NSUInteger)b {
	return a + b;
}
复制代码

这里方法对ab的依赖就很明显。为了调用这个方法咱们不须要改变实例的状态。咱们也没必要担忧因为调用此方法而致使的持久的反作用,咱们甚至能够把这个方法当作类方法,以代表咱们调用此方法不须要修改实例状态。多线程

可是,这个例子和单例有什么关系呢?用Miško Hevery的话说,“单例是披着羊皮的全局状态。”单例可使用在任何地方,而不用明确的声明依赖关系。就像computeSum方法中的_a_b没有明确的依赖关系同样,程序的任何模块均可以调用[SPMySingleton sharedInstance]并使用单例。这意味着与单例交互的任何反作用都会影响到程序的任何地方的任何代码。async

@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的行为。在不使用单例的状况下,只有在消费者B中引入消费者A,明确二者之间的关系才能达到上述这样的效果。在单例中,因为它的全局有状态的性质,致使了看似两个不相关的模块之间的隐藏和隐式的耦合。

让咱们看一个更具体的例子,并提出另一个由全局可变状态而引发的问题。假设咱们想在咱们的应用中建立一个web查看器。为了支持这个web查看器,咱们建立了一个简单地URL缓存:

@interface SPURLCache

+ (SPURLCache *)sharedURLCache;
- (void)storeCacheResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request;

@end
复制代码

编写web查看器的开发者开始写几个单元测试,以保证代码在指望的几个不一样的状况下可以正常工做。首先,写一个测试程序保证web查看器在没有设备链接的时候会显示一个错误。而后,写一个测试程序保证web查看器能够适当的处理服务器错误。最后,为简单地成功状况写一个测试程序,保证返回的web内容能被适当的展现出来。开发者运行全部的测试程序,而且它们会像预期的那样工做。Nice!

几个月后,这些测试程序开始失败,尽管web查看器的代码自从第一次写事后在没有进行任何更改!发生了什么?

结果是有人改变了测试程序的执行顺序。成功状况的测试首先执行,其次是另外的两个。如今失败的状况之外的成功了,由于整个测试是经过单例URL缓存对结果进行缓存的。

持久状态是单元测试的死敌,由于单元测试是由每一个测试的相对立而产生的。若是状态从一个测试保留到下一个测试,而后,测试的执行循序忽然就变的重要了。Buggy测试,特别是当测试应该失败的时候而它反而成功了,这不是一个好现象。

对象生命周期

单例的另一个主要的问题是他们的生命周期。当向你的代码中添加添加单例时,很容易想到“只存在这样的一个。”可是,我在本身项目以外看到的大部分iOS代码中,这个假设都有可能失效。

例如,假设咱们要建立一个能看见用户好友列表的应用。他们的每个好友都有一个头像,而且咱们想让应用把这个照片下载下来并把它缓存到设备上。使用dispatch_once代码片断很方便,但咱们可能会发现本身正在编写一个SPThumbnailCache单例:

@interface SPThumbnailCache: NSObject
+ (instancetype)sharedThumbnailCache;
- (void)cacheProfileImage:(NSData *)imageData forUserId:(NSString *)userId;
- (NSData *)cachedProfileImageForUserId:(NSString *)userId;
@end
复制代码

咱们继续开发这个应用,而且看起来一切正常,直到某一天,当咱们决定是时候实现“log out”函数了,这样就能够在应用中切换用户了。忽然,咱们出现了一个难以处理的问题:特定用户的状态保存到了全局的单例中了。当用户退出登陆,我但愿可以把磁盘上的持久状态清除掉。不然,咱们会在用户设备上遗留下孤立数据,从而浪费宝贵的磁盘空间。万一,用户退出后转用另外一个帐户登陆,咱们一样但愿可以为新用户建立一个新的SPThumbnailCache单例。

这里的问题是,根据定义,单例被假定为“建立一次,永远存活”的实例。对于上述的问题你可能会想到好几个解决方案。也许当用户退出登录的时候咱们能够把单例实例销毁掉:

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

+ (void)tearDown {
	sharedThumbnailCache = nil;
}
复制代码

这是明目张胆的对单例模式的滥用,可是很管用对不对?

咱们固然可让这个解决方案起做用,可是代价太大了。举例来讲,咱们已经失去了dispatch_once方案的简单性,而且这解决方案能够保证线程安全,全部的代码都调用[SPThumbnailCache sharedThumbnailCache]这个方法只是获取同一个实例。对于使用缩略图缓存的代码的执行顺序,咱们须要格外的当心。假设在用户退出登录的过程当中,有一些保存图片到缓存的后台任务正在执行:

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

咱们须要肯定在后台任务执行完以前不能执行tearDown方法。这保证newImage数据可以正确的清除掉。或者,咱们须要保证当缩略图缓存被清除的时候能把后台任务取消。否者,新的缩略图缓存将被懒建立而且旧用户状态(也就是newImage)将被存储到它里面。

由于,单例实例没有明显的全部者(例如:单例本身管理声明周期),因此,‘关闭’单例就变得很是困难。

就由于这点,我但愿你说,“缩略图缓存就不该该使用单例的!”问题是在项目刚开始并不能彻底理解对象的生命周期。对于一个具体的例子,DropboxiOS应用仅仅支持单用户的登录。直到有一天,当咱们容许多用户(我的用户和企业帐户)同时登录时,应用在单用户登录这种状况下已经存在好几年了。忽然,假定“同一时刻只容许一个用户登陆”开始闪退了。经过假设一个对象的生命周期匹配你的应用的生命周期,你将会限制你的代码的扩展性,而且当产品须要改变的时候你须要为此付出代价。

这里的教训是,单例应该保存为全局的状态,而不是在某一个范围内。若是把状态限制在任何一个比“应用完整生命周期”短的会话范围内,这个状态则不该该被单例管理。管理特定用户状态的单例是“代码异味”,你应该审慎的从新评估你的对象图的设计。

避免(使用)单例

因此,若是单例对于范围化的状态如此的不利,那如何避免使用它们呢?

从新看一下上面例子。因为咱们有一个缓存特定个体用户状态的缩略图缓存,让咱们定义一个用户对象:

@interface SPUser:NSObject
@property (nonatomic, readonly) SPThumbnailCache *thumbnailCache;
@end

@implementation SPUser
- (instancetype)init {
	if((self = [super init])) {
		_thumbnailCache = [[SPThumbnailCache alloc] init];
	}
	return self;
}
@end
复制代码

如今咱们有一个对象能够模拟受权的用户会话了,咱们能够把全部的特定用户状态存储在这个对象内。如今,假设咱们有一个渲染了好友列表的视图控制器。

@interface SPFriendListViewController: UIViewController
- (instancetype)initWithUser:(SPUser *)user;
@end
复制代码

咱们能够明确地把受权的用户对象传递到视图控制器中。这种传递依赖到独立的对象中的技术的一个更为正式的名字叫依赖注入(dependency injection),而且他有一大堆的好处:

  1. 它可以让阅读此接口的人清楚的明白:当用户登录的时候SPFriendListViewController才会显示出来。
  2. 只要SPFriendListViewController在使用它就能够保持用户对象的强引用。例如,更新先前的例子,咱们可使用下面的后台任务把图片保存到缩略图缓存。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
	[_user.thumbnailCache cacheProfileImage:newImage forUserId:userId];
});
复制代码

即便这个后台任务仍然没有完成,应用中其余地方的代码也能够建立并使用全新的SPUser对象,而不须要阻塞进一步的交互由于第一个实力已经被销毁了。

为了进一步证实第二点,让咱们想象一下使用依赖注入先后的对象图。

假设,咱们的SPFriendListViewController是当前窗口的根视图控制器。在单例对象模型中,咱们有以下如这样的一个对象图:

视图控制器和自定义图片视图列表与 sharedThumbnailCache交互。当用户退出,咱们但愿清空更试图控制器并把用户带入登陆界面。
问题是,好友列表试图控制器可能仍然在执行代码(因为后台操做),所以,仍会有未结束的调用挂起 sharedThumbnailCache方法。

把这解决方案同使用依赖注入的解决方案对比:

假设,为简单起见, SPApplicationDelegate管理 SPUser实例(事实上,你可能想会想着把用户状态的管理拆分到里一个对象里面以保持你的应用代理更轻)。当列表视图控制器被安装到了窗口上后,用户对象的引用也被传了进去。这个应用也会顺着对象图到我的图片视图。如今,当用户退出时,咱们的对象图想起来是这样的:
这个对象图看起来和咱们使用单例的状况没有什么区别。因此有什么严重的问题?

问题是做用域。在单例状况下,sharedThumbnailCache在程序中的任何模块都是可用的。假设,用户快速的登陆一个新的帐户。新用户想看他的好友,这意味着又一次和缩略图缓存交互:

当用户使用新帐户登录时,咱们应该能够从新构建并与全新的 SPThumbnailCache进行交互,而没必要关心旧缩略图缓存的销毁。根据对象管理的标准规则,旧的视图控制器和缩略图缓存应该在后台自动清理。简言之,咱们应该把用户A的状态和用户B的状态隔离开来:

结论

这篇文章没有什么新颖的东西。人们对单例的抱怨已经存在多年,并且也知道全局的状态很是很差。可是在iOS开发的领域,单例已司空见惯,以致于有时会忘记多年来从其余地方的面向对象编程习得的教训。

全部这一切的关键是,在面向对象编程中,咱们但愿最小化可变状态的做用域。单例站在了这种状况的对立面,由于它能让可变状态从程序中的任何地方获取到。下一次在你想要使用单例的时候,我但愿你考虑一下依赖注入做为替代。

相关文章
相关标签/搜索