避免单例滥用

2,914 阅读12分钟

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

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

避免单例滥用——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设置合适的值。这存在几个问题:

  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]并使用单例。这意味着与单例交互的任何副作用都会影响到程序的任何地方的任何代码。

@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开发的领域,单例已司空见惯,以至于有时会忘记多年来从其他地方的面向对象编程习得的教训。

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