良好的面向对象设计 - 单例设计模式

19
突然间,我陷入了一个OO危机。在过去的几年里,我已经相当好地利用了Singleton对象,并在许多地方使用它们。
例如,在设计MVC Java应用程序时,我会创建一个Singleton 'SystemRegistry'类来存储模型和视图类(我只处理简单的应用程序,没有出现需要多个视图的情况)。
当我创建我的模型和视图对象(它们不是单例,只是普通对象)时,我会做一些类似的事情:
SystemRegistry.getInstance().setModel(model);

在我的控制器类中(它们基本上是不同GUI项目的事件处理程序),我将按以下方式访问视图或模型:
SystemRegistry.getInstance().getView();

我不会在我的应用程序模型部分使用 SystemRegistry 类,但有时会在视图中使用它来访问(但很少甚至从不修改)模型中的信息。
从我所读到的(尤其是Steve Yegge的文章),这似乎是设计我的应用程序的一种不良方式。有什么更好的代码结构设计思路吗?
另外,我设计类的另一个方面(可能与单例模式有关,也可能不相关)是使用“管理器类型”类。例如,我创建了一个非常简单的基于OpenGL的游戏引擎,主要类是GameEngine。它是存储一堆管理器并处理主循环等的总体类。存储在此类中的一些管理器包括: ObjectManager、RenderingManager、LightingManager、EventManager(包括输入)、HUDManager、FrameRateManager、WindowManager等。可能还有一些其他的管理器。
基本上,这些类处理游戏引擎的不同方面。名称相当直观,因此您应该能够很好地了解它们的用途。
现在,这是一个可重复使用的基础,我可以在不同的项目中使用它而无需理想情况下进行更改。
在每个新游戏中,我将创建一个 GameEngine 的实例作为类范围变量(大多数游戏逻辑存储在单个类中),并设置不同的管理器(例如,从文件加载窗口坐标或光照细节,设置 FPS 等)。要在 ObjectManager 中注册对象,我会这样做:
Player player = new Player();
gameEngine.getObjectManager().addObject(player);

这个对象将存储在ObjectManager类的向量中,并且在每个帧时,当GameEngine调用ObjectManager drawObjects()方法时将被绘制。

在看完Singleton模式的文章后,我可能有点偏执(并且可能没有足够的时间来理解它),但我开始怀疑我设计的GameEngine方式是否正确(缺乏更好的词语),并且是否刚好避免了Singleton模式共享的相同陷阱。

对我的帖子进行任何评论将不胜感激。

编辑:感谢答复。 非常感激。 如果可能的话,我希望有人能在上面发布的两个项目场景方面给我一些提示。 我如何避免使用Singleton / Managers?

对于第一个问题,依赖注入是正确的响应吗? 我是否应该让视图访问模型(可能更多是MVC的响应)? 视图是否受益于实现接口(以便可以插入多个不同的视图)?

在第二种情况下,还有哪些方式可以构建应用程序? 抱怨仅仅是使用管理器类而不是更具体的名称吗? 还是,在某些情况下,类可以进一步分解(例如ObjectHolder,ObjectDrawer,ObjectUpdater)?

6个回答

10

好的,为什么你写单例模式?如果你理解了单例模式的问题所在,那么你就知道未来要注意什么。

为什么你会认为单例模式是解决任何问题的好方法?因为一本书这样说了吗?不要盲目相信书籍。书籍需要像其他人一样证明他们的观点。如果我告诉你,如果你把显示器倒过来,你的代码看起来会更好,那么我需要举出证明。即使我是某种类型的编程大神,即使全世界数百万开发者每天都崇拜我,如果我不能证明我的建议,如果我不能让你说“好的,这很有道理,我理解为什么你推荐这样做,并且我想不到更好的方法”,那么我的建议仍然毫无价值。

对于某些设计模式书籍也是如此。他们写道“设计模式非常棒”,“单例是一种设计模式,因此它也很棒”。那又怎样呢?他们能证明这个观点吗(至少在单例的情况下不能),所以忽略它。

如果有人建议你使用单例模式,逻辑是相同的:这些人是否真正提出了使用单例模式的好理由?

如果你自己想到了一个理由,那么今天你能看到它的问题所在吗?你忘记考虑了什么?

避免将来犯太多错误的唯一方法是从已经犯过的错误中学习。单例或管理类是如何逃脱你的防线的?当你第一次了解它们时,你应该注意什么?或者说,当你在代码中自由使用它们时,你应该注意什么?有哪些警告信号本应使你想知道“这些单例是否真的是正确的选择?”作为一个程序员,你必须相信自己的头脑。你必须相信自己会做出明智的决策。唯一的方法就是从犯错的时候吸取教训。因为我们都经常犯错。我们所能做的最好的事情就是确保我们不会反复犯同样的错误。
关于管理类,它们的问题在于每个类应该只有一个职责。"管理类"的职责是什么?它……嗯……管理东西。如果你不能定义一个简洁的职责范围,那么这个类就是错误的。这些对象需要什么样的管理?"管理"它们意味着什么?标准库已经提供了用于存储一组对象的容器类。然后,你需要一些代码来负责绘制存储在那里的所有对象。但那不一定是存储对象的相同对象。还有什么需要管理的?找出"管理"这些对象意味着什么,然后你就知道需要哪些类来管理。最可能的情况是不是单个的独立任务,而是几种不同的职责,应该将它们分成不同的类。
关于单例模式,我不会再重复之前说过的话了,所以这里有一个链接
最后一条建议:
放弃面向对象编程(OOP)。真正的好代码并不等同于OOP。有时候类是完成任务的好工具,有时候它们只会让所有东西变得混乱,并将最简单的代码埋藏在无尽的抽象层后面。
研究其他编程范式。如果你在使用C++,你需要了解泛型编程。STL和Boost是如何利用泛型编程来编写比等效的OOP代码更加干净、更好、更高效的解决方案的很好的例子。
而且,无论使用哪种语言,从函数式编程中都可以学到很多有价值的经验教训。
有时候,普通的过程式编程就是你需要的简单好用的工具。
“好设计”的关键不是试图进行“好的OO设计”。如果你这样做,你就会局限于这一范式。这就像试图用锤子建造房屋一样。我并不是说这不可能,但如果你也允许自己使用其他工具,就可以用更好的方式建造更好的房屋。
至于你的编辑:
引用块中的第一个问题,DI是否是正确的回答?我是否应该让视图访问模型(这可能更多是MVC的回答)?视图是否受益于实现接口(以便可以插入多个不同的视图)?
DI本来是避免单例模式的一种选择。但是不要忘了老式的低技术选项:仅需手动传递对需要知道有关“前”单例的对象的引用。
您的渲染器需要知道游戏对象列表。(或者真的吗?也许它只有一个需要提供对象列表的单一方法。也许渲染器类本身不需要它)因此,渲染器的构造函数应该接收到对该列表的引用。就这样。当 X 需要访问 Y 时,在构造函数或函数参数中将其传递给它。与 DI 相比,这种方法的好处在于使依赖关系变得十分明确。您知道渲染器知道可渲染对象列表,因为您可以看到引用被传递到构造函数中。而且您必须把这个依赖性写出来,这让您有很好的机会停下来问一下“这是必要的吗?”您还有清除依赖性的动力:这意味着输入较少!
使用单例或 DI 时会产生许多不必要的依赖关系。这两个工具都很容易在不同模块之间传播依赖关系,所以您就这样做了。然后,您最终得到的设计是您的渲染器知道键盘输入,而输入处理程序必须知道如何保存游戏。
DI 的另一个可能的缺点是技术问题。并非所有语言都有好的 DI 库可用。有些语言几乎不可能编写一个强大而通用的 DI 库。
在第二种情况下,还有什么其他的应用程序结构方式?抱怨只是使用Manager类而不是更具体的名称吗?或者,在某些情况下,类可以进一步分解(例如ObjectHolder,ObjectDrawer,ObjectUpdater)?
我认为重新命名它们是一个好的开始。像我上面说的,"管理"对象意味着什么?如果我管理这些对象,我应该做什么? 你提到的三个类听起来像是一个很好的划分。当然,在深入设计这些类时,您可能会想知道为什么需要一个ObjectHolder。标准库容器/集合类难道不是这样做的吗?你需要一个专门的类来"持有对象",还是可以简单地使用List<GameObject>?
所以我认为你的两个选项实际上归结为同一件事。如果你可以给类一个更具体的名称,那么你可能不需要将其拆分成多个较小的类。但是,如果你无法想出一个能清楚地说明类应该做什么的单个名称,那么它可能需要被拆分成多个类。
想象一下,你将代码放置了半年。当你回来时,你会知道"ObjectManager"类的目的吗?可能不会,但你会对"ObjectRenderer"有一个相当好的想法。它渲染对象。

4
“Screw OOP”是一股清新的风。太多人自动而无条件地将“OOP”等同于“好”。Bjarne Stroustrup曾多次谈及此事。 - fredoverflow
我不明白你所说的“与 DI 相比,这种方法的好处在于你可以让依赖关系变得非常明确”。这正是 DI 所做的;你的类在构造函数参数、setter 方法或(呃)私有变量中指定它们的依赖关系。你可以在没有 DI 框架的情况下做到这一点,但这可能会很棘手。手动完成通常会导致 A 类在构造函数中引入 B 类,而 B 类的唯一用途是将其传递给 C 类的构造函数。 - NamshubWriter
@NamshubWriter:我的观点是,使用一个DI框架后,类会注册它所依赖的内容,当然,这些内容会自动出现。你不需要从创建它们的地方传递到需要它们的地方,因此,你没有办法像通过将它们作为参数传递来传播依赖关系时那样“感知”依赖关系。DI使得很难看出依赖关系来自哪里。而且它也没有清晰的方法跟踪某个对象被用作依赖项的位置。这一切都取决于一个全局状态的黑匣子,它自由地传递依赖项。 - jalf
但是为了避免你误解我的意思,我并不是说“永远不要使用DI框架”,只是普通的参数传递也有其优点。DI框架实际上并不会像单例那样造成危害 - jalf
下次请不要将您回答问题的唯一部分作为指向一个不稳定网站的链接,而且您也无心维护。SO并不是自我推销的地方。已经被踩了。 - Miguel Bartelsman

4

史蒂夫·耶吉是正确的。Google甚至更进一步:他们有一个单例检测器来帮助识别它们。

我认为单例存在几个问题:

  1. 难以测试。
  2. 有状态的单例必须小心处理线程安全,很容易成为瓶颈。

但有时你确实只需要一个。我只是不要过度使用单例模式。


+1 但是如果你只需要一个,依赖注入就是最好的选择,而现代 DI 框架在复杂性方面只增加了很少的负担。 - helpermethod
没错。Spring有单例模式,但它们与自己编写的不同。 - duffymo
即使你“必须只有一个”,这仍然不意味着你需要一个单例。即使没有 DI,将一个参数传递给构造函数通常并不像一开始看起来的那么麻烦。 - jalf

4
关于您最初的问题已经有了解答,现在是关于您编辑后的跟进问题。
不要将只有一个类实例与单例模式混淆。在您的情况下,拥有一个管理所有事物的管理器类是可以的。对于整个应用程序只有一个实例也完全没问题。但是使用管理器的类不应该依赖于恰好存在一个全局对象(无论是通过类的静态变量还是由所有人访问的全局变量)。为了缓解这种情况,使它们在构造时需要一个管理器对象的引用。他们都可以接收到同一个对象的引用,他们不会在意。这有两个很好的副作用。首先,您可以更好地测试您的类,因为您可以轻松地注入一个模拟管理器对象。第二个好处是,如果您以后决定这样做,您仍然可以使用多个管理器对象(例如在多线程场景中),而无需更改客户端。

1

最近我看了一下我的一个项目,发现有很多“管理器”类(大多数是单例)。似乎你的担忧是两方面的 - 首先,是否要使用全局状态(导致管理器类型类),其次,单例模式是否是全局状态的最佳选择。

我认为这并不是非黑即白的。有时您确实应该有仅一个实例,并且应该可以从程序的任何地方访问的实体,在这种情况下,全局状态解决方案是有意义的。如果非全局替代方法涉及创建实体实例并通过多层方法和构造函数(以及类构造函数的层次结构)传递引用,那么这将非常方便。

另一方面,需要考虑全局状态的弊端。它会隐藏依赖关系。一旦引入全局变量,函数就不再是黑盒子,其输入不再仅限于它们的参数。如果您有可由多个类在任意顺序中的多个线程中访问的可变全局状态,则可能会出现调试噩梦。所有这些都会使理解/测试变得更加困难。

在选择全局状态时,有时静态类比单例更好(还有其他选项)。首先,它可以节省额外的GetInstance()调用...对我来说,这只是更自然的感觉。但值得注意的是,单例自动允许延迟加载,如果全局实体的初始化相当耗费资源,则这一点非常重要。此外(在这里有些牵强),如果您需要提供全局行为的变体,您可以简单地子类化您的单例。

0
我决定是否使用单例模式的方法是回答这个问题:“如果我在同一运行时两次实例化我的应用程序,这是否有意义?”,如果答案是肯定的,那么我就使用它。

0

1
你能给我一个需要“确保只存在一个实例”的例子吗?只有在你真正需要那个保证时,才能确保良好的设计。而且我想不出任何需要这样做的情况。 - jalf
1
如果建立第二个实例是有意义的,那它就不应该是单例模式。只有当仅需要一个实例时,才可使用单例模式。 - koen
2
如果只需要一个实例,那么你就只创建一个实例。问题解决了。这样,只要只有一个实例是有意义的,那么你就确切地拥有了一个实例。当你意识到哎呀,在这里有两个实例会很方便时,你不需要重写代码。你只需要创建第二个实例。从你的代码中人为地移除灵活性和通用性是没有任何好处的。 - jalf
1
@koen:我的理想单例实现可以给我带来冰淇淋。但是,通常理解的单例不会带来冰淇淋,很遗憾。所以让我们坚持“单例”通常意味着什么。当人们说“单例”时,通常指的是《设计模式》一书中Gang of Four描述的单例。这样的单例保证了全局可访问性。我不知道个人希望单例意味着什么,如果你适当地重新定义了这个术语,那么是的,我所有的反对都是无效的。但这有点...愚蠢。 - jalf
@NamshubWriter:正如我刚刚向@koen指出的那样,单例最广泛使用的定义“用于获取类的实例的静态方法,保证每个进程中只存在一个实例”。如果您重新定义它以表示其他内容,那么很难争论任何事情。是的,如果您的单例不是我们说单例时所指的内容,那么通常的批评可能不适用。但是也许您应该看一下OP的问题,这似乎明确指的是GoF单例设计模式,而不是您认为的单例。 - jalf
显示剩余18条评论

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