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

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个回答

37

单例模式不仅仅是单个实例!

与其他答案不同,我不想谈论单例模式的问题,而是向您展示当正确使用时它们是多么强大和棒极了!

  • 问题:在多线程环境中,单例模式可能会带来挑战。 解决方案:使用单线程引导过程来初始化单例的所有依赖项。
  • 问题:很难模拟单例模式。
    解决方案:使用工厂模式进行模拟。

您可以将MyModel映射到继承自它的TestMyModel类中,每当注入MyModel时,您将得到TestMyModel

  • 问题:单例可能会导致内存泄漏,因为它们从未被处理。
    解决方案:好吧,处理它们!在您的应用程序中实现回调以正确处理单例。您应该删除与它们相关联的任何数据,并最终:从工厂中删除它们。

正如我在标题中所述,单例不是指拥有单个实例。

  • 单例模式提高可读性:您可以查看您的类并查看注入的单例,以确定其依赖关系。
  • 单例模式提高维护性:一旦您从类中删除了一个依赖项,您只需删除一些单例注入,而不需要去编辑其他类的大量链接,这些类只是将您的依赖项移动了(对我来说,这是有问题的代码@Jim Burger)。
  • 单例模式提高内存和性能:当应用程序发生某些事情,并且需要长时间的回调链才能传递时,您正在浪费内存和性能。通过使用单例模式,您可以切掉中间人,并改善性能和内存使用情况(避免不必要的本地变量分配)。

2
这并没有解决我对单例模式的主要问题,即它们允许从项目中的任何数千个类访问全局状态。 - LegendLength
15
那将是目的... - Ilya Gazman
3
LegendLength为什么是错误的?例如,在我的应用程序中,我有一个单例“Settings”对象,可以从任何小部件访问,以便每个小部件都知道如何格式化显示的数字。如果它不是全局可访问的对象,我将不得不在构造函数中向每个小部件注入它,并将其引用作为成员变量保留下来。这是一种可怕的浪费内存和时间的方式。 - HiFile.app - best file manager
1
也许如果它是不可变的,那么没关系,但是仅仅拥有全局状态可以从应用程序的任何地方修改意味着你将会遇到难以追踪问题发生位置的情况。你不会轻易知道哪些代码片段实际上正在进行更改。 - Brian Reading

36

垄断是魔鬼,非只读/可变状态的单例模式是“真正”的问题...

在阅读《单例是病态的说谎者》(由jason的答案建议)后,我发现这个小贴士提供了一个最好的例子来说明单例模式通常被滥用的方法。

全局变量不好,因为:

  • a. 它会导致命名空间冲突
  • b. 它以不合适的方式暴露状态

当谈到单例模式时

  • a. 显示调用它们的OO方法可以防止冲突,所以问题a不是个问题
  • b. 没有状态的单例模式(像工厂一样)不是一个问题。有状态的单例模式又可以分为两种,那些是不可变的或者写入一次并多次读取的(config/property文件)。这些不是坏的。可变单例模式,就像是引用持有器,才是你所说的问题。

在最后一句话中,他提到了博客概念中的“单例模式是说谎者”。

这如何应用于垄断?

要开始玩垄断游戏,首先:

  • 我们先制定规则,以便每个人都在同一个页面上
  • 每个人在游戏开始时都有平等的机会
  • 只展示一组规则以避免混淆
  • 规则不允许在游戏过程中改变
如果有人真的没有玩过大富翁,那么这些规则最多只能被视为理想标准。在大富翁中失败是很难接受的,因为它涉及到金钱。如果你输了,你将不得不痛苦地看着其他玩家完成游戏,而且失败通常是迅速而毁灭性的。因此,规则通常会在某个地方扭曲,以服务于一些玩家的自身利益,而牺牲其他人的利益。
所以,你和朋友鲍勃、乔和艾德玩大富翁。你正在快速建立你的帝国,并以指数增长的速度占领市场份额。你的对手正在削弱,你开始嗅到血腥味(比喻)。你的朋友鲍勃把所有的钱都投入到尽可能多的低价值地产上,但他没有像他预期的那样获得高额回报。不幸的是,鲍勃落在了你的海滨大道上,被赶出了游戏。
现在,游戏从友好的掷骰子变成了严肃的业务。鲍勃成为了失败的典范,乔和艾德不想像“那个家伙”一样结束。因此,作为领先的玩家,你突然成为了敌人。乔和艾德开始练习背地里的交易、秘密注资、低估的房屋交换以及任何能够削弱你作为玩家的手段,直到他们中的一个人崛起为最终胜利者。
然后,游戏并不是一个人赢了,过程重新开始。突然间,有限的规则变成了移动目标,游戏退化成了构成自生存以来每个高评价真人秀基础的社会互动。为什么呢?因为规则正在改变,没有达成关于它们应该代表什么以及如何/为什么要这样做的共识,更重要的是,没有一个人在做决定。在那个时候,游戏中的每个玩家都在制定自己的规则,混乱随之而来,直到其中两个玩家累得无法继续伪装,并慢慢放弃。
因此,如果一本游戏规则书准确地代表单例,那么大富翁规则书就是滥用的一个例子。
这与编程有什么关系?

除了单例模式的明显线程安全和同步问题之外,如果你有一组数据可以被多个不同源并发读取/操作,并且在应用程序运行期间存在,那么现在是时候退后一步,问问自己“我是否在这里使用了正确的数据结构类型”。

就我个人而言,我曾经见过一个程序员滥用单例模式,将其用作应用程序中扭曲的跨线程数据库存储。通过直接处理代码,我可以证明它很慢(因为需要所有线程锁定以使其线程安全)并且很难处理(因为同步错误的不可预测/间歇性本质),在“生产”条件下几乎不可能进行测试。当然,可以开发系统使用轮询/信号来克服一些性能问题,但这无法解决测试问题,而且为什么要这样做,既然一个“真正”的数据库可以以更加强大/可伸缩的方式完成相同的功能。

只有在需要单例提供的情况下,单例模式才是一个选择。写入-读取实例对象。同样的规则也应该适用于对象的属性/成员。


1
如果单例缓存了一些数据会怎样呢? - Yola
@Yola 如果单例被安排在预定和/或固定的时间表上更新,将减少写入次数,从而减少线程争用。但是,除非模拟一个模拟相同使用情况的非单例实例,否则您将无法准确测试与单例交互的任何代码。TDD人员可能会感到不适,但这将起作用。 - Evan Plaice
即使使用模拟对象,代码中仍然包含 Singleton.getInstance() 会出现一些问题。支持反射的语言可以通过设置存储“单例实例”的字段来解决这个问题。但我认为,一旦你开始在另一个类的私有状态上进行修改,测试就变得不那么可信了。 - cHao

26

11
该模式的描述并未解释为什么它被认为是邪恶的... - Jrgns
2
很不公平:“一些人认为这也是一种反模式,他们觉得它被过度使用,在不需要实际上只有一个类实例的情况下引入了不必要的限制。”看看参考文献...无论如何,对我来说就是RTFM。 - GUI Junkie

25
我对“单例模式为何不好”的回答通常是:“难以正确使用”。语言中许多基础组件都是单例的(类、函数、命名空间甚至操作符),计算机其他方面的组件也是如此(本地主机、默认路由、虚拟文件系统等)。这不是偶然的。虽然它们偶尔会带来麻烦和挫败感,但它们可以让很多东西运作得更好。
我看到的最大问题是:将其视为全局变量并未定义单例封闭。
人们总是把Singleton当作全局变量,因为它们基本上就是全局变量。然而,很多(可悲的是,并非全部)全局变量的问题并不在于它本身,而在于你的使用方式。对于单例也是一样的。实际上,更多时候,“单个实例”实际上并不需要意味着“全局可访问”。这更像是一个自然副产品,鉴于我们知道的所有问题,我们不应该急于利用全局可访问性。一旦程序员看到一个Singleton,他们似乎总是通过其实例方法直接访问它。相反,你应该像访问任何其他对象一样导航到它。大多数代码甚至不应该意识到它正在处理一个Singleton(松耦合,对吧?)。如果只有一小部分代码像访问全局变量那样访问对象,很多伤害就会消除。我建议通过限制对实例函数的访问来强制执行。
单例上下文也非常重要。单例的定义特征是“只有一个”,但事实上它是“在某种上下文/命名空间中只有一个”。它们通常是:每个线程、进程、IP地址或群集一个,但也可以是每个处理器、机器、语言命名空间/类加载器/等、子网、Internet等。
另一个不太常见的错误是忽略单例生命周期。仅因为只有一个实例并不意味着一个Singleton是某种全能的“永恒存在”,而且通常也不是理想的(没有开始和结束的对象违反了代码中所有有用的假设,并且应该只在最危急的情况下使用)。如果您避免这些错误,单例模式仍然可能很麻烦,但是它已经准备好显着减轻大部分最严重的问题。 想象一下一个Java单例模式,明确定义为每个类加载器一次(这意味着需要线程安全策略),具有定义的创建和销毁方法以及生命周期,指示何时以及如何调用它们,并且其“instance”方法具有包保护,因此通常通过其他非全局对象访问。 仍然是潜在的麻烦源,但肯定会少得多。
可悲的是,我们没有教授如何使用单例模式的好例子。 我们教了一些不好的例子,让程序员使用一段时间,然后告诉他们这是一种糟糕的设计模式。

20

单例模式本身并不是坏的,但GoF设计模式有问题。唯一真正有效的论点是,GoF设计模式在测试方面不太适用,特别是如果测试是并行运行的。

只要在代码中应用以下方法,使用类的单个实例就是有效的构造:

  1. 确保将用作单例的类实现接口。这允许使用相同接口实现存根或模拟

  2. 确保单例是线程安全的。这是必须的。

  3. 单例应该简单明了,不要过于复杂。

  4. 在应用程序的运行时期间,需要将单例传递给给定对象时,请使用一个类工厂来构建该对象,并让类工厂将单例实例传递给需要它的类。

  5. 在测试期间并为确保确定性行为,请将单例类创建为单独的实例,作为实际类本身或实现其行为的存根/模拟,并将其原样传递给需要它的类。不要在测试期间使用创建需要单例的测试对象的类因子,因为它将传递它的单个全局实例,这违反了目的。

我们在解决方案中使用了单例模式,并取得了巨大的成功,这些单例模式是可测试的,确保在并行测试运行流中具有确定性行为。


+1,终于有一个回答解决了单例何时有效的问题。 - Pacerier

19

我希望能够讨论已接受答案中提到的四个问题,希望有人能解释一下我错在哪里。

  1. 为什么在代码中隐藏依赖关系是不好的?已经存在很多隐藏的依赖关系(C运行时调用、操作系统API调用、全局函数调用),而单例依赖关系很容易找到(搜索instance())。

    “将某些内容设置为全局变量以避免传递它是代码坏味道。”为什么传递某些内容以避免将其作为单例也是一种代码坏味道?

    如果您通过 10 个函数在调用堆栈中传递一个对象只是为了避免使用单例,那真的好吗?

  2. 单一职责原则:我认为这有点模糊,取决于您对职责的定义。一个相关的问题是,为什么要将此特定的“职责”添加到一个类中?

  3. 为什么将对象传递给类会使其与单例的使用相比更紧密地耦合?

  4. 为什么它会改变状态持续的时间?单例可以手动创建或销毁,因此仍然有控制权,并且可以使其寿命与非单例对象的寿命相同。

关于单元测试:

  • 并不是所有类都需要进行单元测试
  • 并不是所有需要进行单元测试的类都需要更改单例的实现
  • 如果它们需要进行单元测试并需要更改实现,那么将类从使用单例更改为通过依赖注入传递单例很容易。

1
所有那些隐藏的依赖关系?那也是不好的。隐藏的依赖总是很恶劣的。但在CRT和操作系统的情况下,它们已经存在,并且它们是完成任务的唯一方式。(尝试编写一个不使用运行时或操作系统的C程序。) 这种能力远远超过了它们的负面影响。我们代码中的单例模式就没有这种奢侈品;因为它们牢固地掌握在我们的控制和责任范围内,每个使用(读:每个额外的隐藏依赖关系)都应该被证明是完成任务的唯一合理方式。其中非常非常少的实际上是这样的。 - cHao
1
@cHao:1. 很好,你认识到“能力远远超过它们的负面影响”是好事。一个例子就是日志框架。如果强制通过依赖注入在函数调用层级中传递全局日志对象,从而使开发人员不愿记录日志,那么这是有害的。因此,使用单例模式。 3. 只有当你希望类的客户端通过多态来控制行为时,你的紧耦合点才是相关的。这通常并非如此。 - Jon
2
  1. 我不确定“无法手动销毁”是“只能有一个实例”的逻辑蕴含。如果这就是你定义单例的方式,那么我们谈论的不是同一件事情。 并非所有类都应该进行单元测试。这是一个商业决策,有时候上市时间或开发成本会优先考虑。
- Jon
1
如果您不想要多态性,那么您甚至不需要一个实例。您可以将其作为静态类,因为无论如何,您都将自己绑定到单个对象和单个实现-至少这样 API 不会欺骗您。 - cHao
我在谈论的是经过GoF认可的、大写S的“Singleton模式”。它包括一个非公共构造函数和一个公共的getInstance()方法。它要求只有一个实例存在。它管理着唯一的实例,并禁止第二个实例的存在——并通过创建另一个实例(并希望不会破坏OTI)来防止其他人覆盖其唯一性。如果你不是在谈论这个,那么你只是在谈论全局变量或服务定位器。 - cHao
显示剩余7条评论

18

文森特·哈斯顿提出了下列标准,我认为这些标准很合理:

只有当满足以下三个条件时才应该考虑使用单例模式:

  • 无法合理地分配单个实例的所有权
  • 希望进行惰性初始化
  • 没有其他方式提供全局访问

如果单个实例的所有权、初始化的时间和方式以及全局访问不是问题,那么单例模式就不够有趣。


14

从纯粹主义的角度来看,单例模式是不好的。

从实际角度来看,单例模式是在开发时间和复杂性之间做出的一种权衡

如果你知道你的应用程序不会有太大变化,那么单例模式完全可以使用。只需知道,如果您的要求以意想不到的方式改变(在大多数情况下还是可以接受的),您可能需要进行重构。

单例模式有时也会使单元测试变得更加复杂。


3
我的经验是,即使在短期内,从单例开始会对你造成很大的伤害,更不用说长期了。如果应用程序已经存在一段时间,并且可能已经有其他单例对象的存在,这种影响可能会减少一些。但如果从零开始?无论如何都要避免使用单例!如果想要一个对象的单个实例,请让工厂管理该对象的实例化。 - Sven
1
据我所知,Singleton 就是 一种工厂方法。因此,我不理解你的建议。 - tacone
3
“工厂” ≠ “一个无法从同一类中的其他有用内容中分离出来的工厂方法”。 - Sven

14

我不想评论关于好与坏的论点,但自从 Spring 出现以来,我就没有再使用它们了。使用依赖注入几乎消除了我对单例、服务定位器和工厂的要求。我发现这是一个更加高效和干净的环境,至少对于我所做的工作(基于Java的Web应用程序)而言。


4
默认情况下,Spring Bean 是单例的吗? - slim
3
是的,但我的意思是“编码”单例模式。我还没有“编写单例模式”(即按照设计模式使用私有构造函数等等),但是是的,我正在使用Spring框架中的单个实例。 - prule
5
所以,如果其他人这样做,那就可以(如果我之后也这样做的话),但如果其他人这样做,而我却不这样做,那就是邪恶的? - Dainius

13

假设该模式确实只用于模型中真正单一的某个方面,那么该模式本身并没有任何问题。

我认为人们对它的反感是由于它被过度使用了,而这又是因为它是最容易理解和实现的模式。


6
我认为反弹现象更多与实践正规化单元测试的人意识到它们难以处理有关。 - Jim Burger
2
不确定为什么这个回答得到了负分。虽然它不是最具描述性的答案,但他也没有错。 - Tim Frey

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