C++ 单例设计模式的替代方案

6

我不想重复讲述已经说过的话,但是在过去几天里,我看了很多关于单例模式使用的文章,它们互相矛盾。

我的问题并不是哪种方法更好,而是要根据我的用例来选择哪种方法。

我正在开发一个游戏项目。目前我正在编写的一些代码,我倾向于使用单例模式。

以下是使用案例:

  • 全局可访问的记录器。
  • OpenGL渲染管理器。
  • 文件系统访问。
  • 网络访问。
  • 等等。

需要说明的是,上面的其中一些需要在访问之间共享状态。例如,记录器包装了一个日志库,需要一个指向输出日志的指针,网络需要建立一个打开的连接等。

从我所了解的情况来看,建议避免使用单例模式,那么我们看看如何做到这一点。很多文章只是简单地说在顶部创建实例,并将其作为参数传递到任何需要的地方。虽然我同意这是可行的,但我的问题是,如何管理可能大量的参数?那么,我们可以将不同的实例封装在一种"上下文"对象中,并传递该对象,然后执行类似于context->log("Hello World")的操作。当然,这并不是太麻烦,但如果你有一个类似于以下的框架:

game_loop(ctx)
   ->update_entities(ctx)
        ->on_preupdate(ctx)
             ->run_something(ctx)
                 ->only use ctx->log() in some freak edge case in this function.
        ->on_update(ctx)
            ->whatever(ctx)
                 ->ctx->networksend(stuff)
   ->update_physics(ctx)
        ->ctx->networksend(stuff)
        //maybe ctx never uses log here.

您明白了...在某些领域中,“ctx”的某些方面从未被使用,但您仍然需要在每个地方传递它,以防您可能想要使用记录器调试某些内容或者在开发的后期,在代码的该部分实际上需要网络或其他功能。
我觉得上面的例子更适合于全局可访问的单例模式,但我必须承认,我来自C#/Java / JS背景,这可能会影响我的观点。我想采用C ++程序员的思维方式/最佳实践,但是像我说的那样,我似乎找不到一个直截了当的答案。我还注意到,建议只将“单例”作为参数传递的文章只给出非常简单的用例,任何人都会同意参数是更好的方法。
在这个游戏示例中,即使您不打算立即使用它,您可能仍希望在任何地方都可以访问日志记录。文件系统的东西可能到处都是,但在构建项目之前,很难说它何时/何处最有用。
所以我应该:
1.无论人们如何说它是“邪恶/不好”,都坚持使用单例模式来处理这些用例。
2.将所有内容封装在上下文对象中,并在每个地方都传递它。 (在我看来有点恶心,但如果这是“更被接受/更好”的方法,那就这样吧。)
3.完全不同的东西。 (真的不知道那可能是什么。)
如果选择1,则从性能角度考虑,应该切换到使用命名空间函数,并在匿名命名空间中隐藏“私有”变量/函数,就像大多数人在C中所做的那样? (我猜性能会略有提高,但然后我将被迫在一些这些上调用“init”和“destroy”方法,而不能让构造函数/析构函数为我执行这些操作,这仍然可能值得一试?)
现在我意识到这可能有点基于观点,但我希望在涉及更复杂/嵌套的代码库时仍然可以获得相对较好的答案。
编辑:经过更多的商议,我决定改用“服务定位器”模式。为了防止服务定位器的全局/单例出现,我使可能使用服务的任何内容都继承自要求在构造时传递服务定位器的抽象基类。
我还没有实现所有内容,因此我仍然不确定是否会遇到任何问题,仍然希望获得反馈,以确定这是否是单例/全局范围困境的合理替代方案。
我已经阅读过服务定位器也是一种反模式,但是,我找到的许多示例都使用静态和/或单例来实现它,也许像我描述的那样使用它会消除导致它成为反模式的方面?

考虑到这是一个个人项目,很可能你是自己在开发,我的建议是尝试两种方法并看看哪种适合。正如所指出的,这是一个有争议的话题 - 使用单例或传递上下文对象这两种方法都有优缺点。在团队环境中,由于存在冲突的论点,做出决定更加困难,但你有自由去尝试。 - Georgi Gerganov
日志工具通常被用作单例实际上有用的示例。而我个人不喜欢绝对的说法。没有所谓的“邪恶”模式。单例有许多缺点,但在某些情况下也可以很有用 - Yksisarvinen
@GeorgiGerganov 我发现学习编程语言最好的方法是通过个人项目,虽然我知道两种方式都可以,但我更想知道一个有经验的C++程序员在团队环境中更喜欢哪种方式。 - Hex Crown
在单例模式有用的情况下,更有经验的C++程序员会采用这种https://dev59.com/Q3NA5IYBdhLWcg3wVcJx#1008289方法,还是使用命名空间方法?编辑:我应该提到,性能是一个问题,但只要没有太多的权衡。 (我知道这是留给意见的,但再次,我对专业的C++开发人员在这种情况下会做什么感兴趣。) - Hex Crown
1个回答

9
每当你想使用单例模式时,请问自己以下问题:为什么必须确保在任何时刻都不存在该类的多个实例?因为单例模式的整个重点是确保永远不会有多个单例实例存在。这就是“单例”的含义:只有一个存在。这就是为什么它被称为单例模式的原因。这也是为什么该模式要求构造函数为私有的原因。单例模式的重点不是并且从来不是为了给你一个全局可访问的实例。全局访问点到唯一实例的事实只是单例模式的结果,而不是单例模式旨在实现的目标。如果你只想要一个全局可访问的实例,那么请使用全局变量。这正是全局变量的用途...

单例模式可能是最容易被误解的设计模式之一。如果网络连接的概念本质上只能同时存在一个网络连接,而且如果这个限制被违反了,世界将会末日降临,那么它是网络连接中固有的方面吗?如果答案是否定的,那么就没有理由将网络连接建模为单例模式。但不要只听我的话,可以通过查看《可重用的面向对象软件元素》(Design Patterns: Elements of Reusable Object-Oriented Software)的第127页来自己验证单例模式的描述...

关于你的例子:如果你最终不得不将大量参数传递到某个地方,那么首先告诉你一件事情:在那个地方有太多的责任。单例模式并没有改变这个事实。单例模式只是使这个事实变得更加难以理解,因为你不需要通过参数的形式把所有东西都传递进去,而是可以直接访问任何你想要的东西。但你仍然在访问这些东西。所以你代码的依赖关系是相同的。这些依赖关系只是不再以某个接口级别明确地表达出来,而是在雾中潜行。而且你永远不知道某个代码片段依赖于什么东西,直到你尝试拿走另一个东西后,你的构建才会中断。请注意,这个问题不是特定于单例模式的。这是与任何全局实体相关的问题...
因此,与其问如何最好地传递大量参数,不如问为什么这一段代码需要访问那么多内容?例如,您是否真的需要显式地将网络连接传递给游戏循环?游戏循环是否应该只知道物理世界对象,并且在创建时给定一个处理网络通信的对象,而该对象又在初始化时告诉它应该使用哪个网络连接?日志可以只是一个全局变量(或者说日志本身有什么东西会阻止它超过一个吗?)。或者也许每个线程都有自己的日志(可以是线程本地变量),这样你就可以按照线程所采取的控制流顺序从每个线程获取日志,而不是一些(最好)交错混乱的输出,对于这些输出,您可能需要编写一些工具,以使其至少有些意义……

关于性能,考虑到在游戏中,通常会有一些父对象,它们各自管理着小的子对象集合。关键性能问题通常会出现在需要对这种集合中的所有子对象执行操作的地方。相对于首先获取父对象本身的开销通常应该是可以忽略不计的...

PS:您可能想看看实体组件系统模式...


我计划实现实体组件系统模式,但是,我看到的大多数实现存在许多问题,例如过度指针追踪等。仍在尝试解决这个问题,以避免指针追踪/缓存未命中等问题,并希望提出一种将行为与状态分离的实现,因为我已经阅读过这可以极大地增加并行执行潜力。无论如何,ECS并不适用于游戏的所有方面,原始问题与它无关。 - Hex Crown

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