单例模式有哪些缺点或不足之处?

2167

单例模式GoF设计模式书中的成员之一,但最近似乎被开发者世界所遗弃。我仍然经常使用很多单例模式,特别是对于工厂类,虽然你必须小心处理多线程问题(像任何类一样),但我无法理解为什么它们如此糟糕。

特别是Stack Overflow似乎认为每个人都认为单例模式是邪恶的。为什么呢?

请用“事实、参考资料或专业知识”来支持您的答案。


7
最近在尝试调整代码时,我必须说使用单例设计模式让我感到很沮丧。由于我只是在业余时间做这个,我几乎懒得重构它。这对生产力来说是个坏消息。 - Marcin
82
回答中有很多“缺点”,但我也希望看到一些好的例子来对比坏的例子,以展示这种模式何时是好的。 - DGM
51
我在几个月前写了一篇关于这个主题的博客文章:http://jalf.dk/blog/2010/03/singletons-solving-problems-you-didnt-know-you-never-had-since-1995/ --让我直言不讳地说。 我个人无法想出一个情况,其中 Singleton 是正确的解决方案。 这并不意味着不存在这样的情况,但是...说它们很少见是轻描淡写了。 - jalf
9
@AdamSmith 这并不意味着你必须这样做,但是它意味着你可以像那样访问它。如果你不打算像那样访问它,那么首先将它作为单例模式的意义就很小了。因此,你的论点实际上是“如果我们不将其视为单例模式,那么制作一个单例模式也没有什么坏处。是啊,太好了。如果我不开车,我的车也不会污染环境。但这样做可能比一开始就不买车更容易。;)(完全透明化:我实际上没有车) - jalf
55
这个话题最糟糕的部分是,那些讨厌单例模式的人很少给出具体建议来代替它。例如,这篇SO文章中的所有期刊文章和自我发布的博客链接都在详细阐述为什么不使用单例模式(它们都是非常好的理由),但它们在替代方案上极其含糊。很多只是泛泛而谈。我们试图教新程序员为什么不要使用单例模式,但没有太多好的第三方反例可供参考,只能用一些假想例子。这真是令人疲倦。 - Ti Strga
显示剩余19条评论
36个回答

12

单例模式是一种模式,就像其他工具一样,它可以被使用或滥用。

单例的不良之处通常在于用户(或者应该说是在不适当地使用单例来处理其未设计用途的问题上)。最大的问题在于将单例用作虚假全局变量。


1
谢谢Loki :) 我的意思正是如此。整个巫女狩猎让我想起了goto辩论。工具是为那些知道如何使用它们的人准备的;使用你不喜欢的工具可能对你很危险,所以避免使用它,但不要告诉别人不要使用/不要学习正确使用它。我并不完全支持“单例”,但我喜欢有这样的工具,我可以感觉到在什么情况下使用它是合适的。就这样。 - dkellner

10
当你使用单例模式编写代码时,例如记录器或数据库连接,之后你发现你需要多个日志或多个数据库时,你就会陷入麻烦。
单例模式使得从它们转换为常规对象非常困难。
此外,编写一个非线程安全的单例太容易了。
与其使用单例模式,你应该将所有需要的实用对象从函数传递到函数。如果将它们全部包装成助手对象,可以简化这个过程,像这样:
void some_class::some_function(parameters, service_provider& srv)
{
    srv.get<error_logger>().log("Hi there!");
    this->another_function(some_other_parameters, srv);
}

5
为每个方法传递参数是一种聪明的做法,它应该至少进入污染API的前十大方式。 - Dainius
1
如果您有一个依赖于另一个模块的模块,并且您不知道/无法模拟它,那么无论它是单例还是其他什么,都会很困难。人们经常使用继承方式,而应该使用组合,但您并不认为继承是不好的,也不应该尽可能避免面向对象编程,因为很多人在那里犯了设计错误,或者您认为呢? - Dainius
@Dainius 但你不需要将它传递给每个方法。你只需要将它传递给需要它的方法和类。你是否希望隐藏一个类需要访问其他对象的事实?我们应该将CustomerList设置为全局变量,这样我们就不必一直将其传递给其他类了吗? - LegendLength
@LegendLength 但是你有类成员,不需要将它们传递给每个类方法,因为你想知道哪个方法需要它们,或者你不想知道吗?而单例模式并不是全局变量。 - Dainius
类应该足够小,以便在类内部对成员进行“全局”访问不会有问题。但是对于一个具有500个类的程序,当其中任何一个类直接访问单例时,这将变得困难。我对非全局的单例没有任何问题。我只是在谈论它们被全局访问的常见情况:MyConfiguration.getUsername()。 - LegendLength
显示剩余4条评论

9

关于这个主题的最新文章是Chris Reath在Coding Without Comments上发表的。

注意:Coding Without Comments已经无效。然而,被链接的文章已被另一个用户克隆。

链接


8

太多人将不支持线程安全的对象放到单例模式中。我见过一些关于DataContext(LINQ to SQL)的例子,它是以单例模式实现的,尽管DataContext不支持线程安全,而且纯粹只是一个工作单元对象。


许多人编写不安全的多线程代码,这是否意味着我们应该消除线程? - Dainius
@Dainius:事实上,我同意。在我看来,默认的并发模型应该是多进程,两个进程之间应该有一种简单的方式来传递消息,或者根据需要共享内存。如果你想要分享所有东西,那么线程是有用的,但你永远不会真正想要那样做。它可以通过共享整个地址空间来模拟,但这也被认为是一种反模式。 - cHao
@Dainius:当然,这是在假设真正的并发性是必需的情况下。通常你只需要在等待操作系统执行其他任务时能够完成一件事情。(例如,在读取套接字时更新UI)。一个不错的异步API可以使得在绝大多数情况下完全不需要线程。 - cHao
如果你有一个巨大的数据矩阵需要处理,最好使用单线程进行操作,因为太多人可能会做错。 - Dainius
@Dainius:在许多情况下是的。(如果将同步添加到混合中,线程实际上可能会减慢速度。这是为什么多线程不应该是大多数人首先考虑的一个典型例子。)在其他情况下,最好有两个进程共享所需的内容。当然,您必须安排进程共享内存。但坦率地说,我认为这是一件好事——至少比默认共享所有模式要好得多。它要求您明确说明(因此理想情况下要知道)哪些部分是共享的,因此哪些部分需要是线程安全的。 - cHao

8
单例模式的问题在于范围增加,因此会耦合。不可否认,有些情况确实需要访问单个实例,并且可以通过其他方式实现。
我现在更喜欢设计一个控制反转(IoC)容器并允许容器控制生命周期。这使得依赖于实例的类不知道存在单个实例的事实。单例的生命周期可以在将来更改。最近我遇到的一个例子就是从单线程轻松调整到多线程。
顺便说一句,如果在单元测试时很麻烦,那么在调试、修复错误或增强功能时也会很麻烦。

7
单例模式并不是坏的。只有当你让某些本应不唯一的东西变成了全局唯一时,才会变得糟糕。
然而,存在“应用程序范围服务”(比如消息系统,使组件相互交互)- 这就需要单例,“MessageQueue” - 一个类,其中有一个方法“SendMessage(...)”。
然后,您可以从任何地方执行以下操作:
MessageQueue.Current.SendMessage(new MailArrivedMessage(...));
当然,还可以在实现IMessageReceiver的类中执行以下操作:
MessageQueue.Current.RegisterReceiver(this);

6
如果我想创建第二个消息队列,且范围更小,为什么不能重用您的代码来创建它?单例会防止这样做。但是,如果您只是创建了一个常规的消息队列类,然后创建一个全局实例作为“应用程序范围”的实例,我可以为其他用途创建第二个实例。但是,如果您将类设计为单例,我就必须编写第二个消息队列类。 - jalf
1
还有,为什么我们不应该只有一个全局的CustomerOrderList,这样我们就可以像MessageQueue一样从任何地方轻松调用它呢?我认为答案对于两者都是相同的:这实际上是在创建一个全局变量。 - LegendLength

6

关于单例模式,还有一件事情是没有人提到的。

在大多数情况下,“单例性”是某个类实现细节,而不是其接口特征。控制反转容器可以将此特征隐藏在类用户之外;您只需要将类标记为单例(例如,在Java中使用@Singleton注释),然后IoCC将完成其余工作。您不需要提供对单例实例的全局访问,因为访问已由IoCC管理。因此,IoC单例并没有什么问题。

与IoC单例相反,GoF单例通过getInstance()方法在接口中暴露“单例性”,因此它们受到了上述所有问题的影响。


3
在我看来,“单例性”是一个运行时环境的细节,不应由编写类代码的程序员考虑。相反,这是需要由类的使用者考虑的问题。只有使用者知道实际上需要多少个实例。 - Earth Engine

6

如果您使用单例模式适当且最小化,那么它并不是邪恶的。有很多其他好的设计模式可以在某些时候替代单例模式(并且也能得到最佳结果)。但是一些程序员不知道这些好的模式,并将单例模式用于所有情况,这使得对他们来说单例模式变得邪恶。


1
太棒了!完全同意!但是你可以,也许应该,更详细地阐述。例如,扩展哪些设计模式最常被忽视以及如何“适当且最小化”使用单例模式等。这比说起来容易做起来难啊! :P - cregox
基本上,另一种选择是通过方法参数传递对象,而不是通过全局状态访问它们。 - LegendLength

5
因为它们基本上是面向对象的全局变量,所以您通常可以设计您的类,使得您不需要它们。

如果你的类不仅限于一个实例,那么你需要在你的类中使用由信号量管理的静态成员,这基本上是一样的!你有什么其他的建议? - Jeach
我有一个巨大的应用程序,拥有一个“MainScreen”屏幕,它会打开许多较小的模态/非模态窗口/UI表单。在我看来,MainScreen应该是一个singleton(单例)类,这样就可以实现例如:应用程序某个角落的widget想要在MainScreen的状态栏中显示其状态时,只需调用MainScreen.getInstance().setStatus("Some Text")即可。你提出有什么其他替代方案吗?到处传递MainScreen吗? :D - Salvin Francis
2
@SalvinFrancis:我的建议是,你停止让对象关心它们不应该关心的东西,并悄悄地穿过应用程序来互相搞砸。 :) 你的例子最好使用事件来完成。当你正确地处理事件时,一个小部件甚至不必关心是否有一个MainScreen存在;它只需广播“嘿,发生了一些事情”,而任何已订阅“发生了一些事情”事件的东西(无论是MainScreen、WidgetTest还是其他任何东西!)都决定它想要如何响应。这就是面向对象编程应该被完成的方式。 :) - cHao
1
@Salvin 要考虑到当许多组件“默默地”更新MainScreen时,在调试时理解MainScreen是多么困难。你的例子正是单例模式不好的完美原因。 - LegendLength

5
首先,一个类及其协作者应该首先完成它们的预定目的,而不是专注于依赖关系。生命周期管理(实例何时创建以及何时超出作用范围)不应该归类的责任范围内。这方面的最佳实践是,使用依赖注入来制作或配置新组件来管理依赖性。
通常,当软件变得更加复杂时,具有不同状态的单例类的多个独立实例是有道理的。在这种情况下,提交代码来简单地获取单例是错误的。使用Singleton.getInstance()对于小而简单的系统可能还可以,但是当需要相同类的不同实例时,它无法运行/扩展。
没有哪个类应被视为单例,而应该将其用法或如何用于配置依赖项视为它的应用。对于快速和肮脏的解决方案,这并不重要 - 就像硬编码文件路径一样,这并不重要,但是对于更大的应用程序,这些依赖项需要被分解并以更适当的方式使用 DI 进行管理。
单例引起的测试问题是其硬编码的单一用例/环境的症状。测试套件和许多测试都是各自独立的,这与硬编码单例不兼容。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接