单例与服务定位器
Singleton & Service Locator
单例 (Singleton) 同时承诺两件事:一个类只有一个实例,并且提供全局访问点。Nystrom 认为它在游戏里弊大于利,因为单例本质上就是全局变量。服务定位器 (Service Locator) 是更灵活的“近亲”:它同样给出全局访问点,但调用者只依赖抽象服务接口,具体实现由定位器在运行时或编译期决定,还可以退化为空服务。两者都应当慎用,首选仍是直接把对象传进去。
单例:意图与“为什么会用它”
GoF 定义:“确保一个类只有一个实例,并为它提供全局访问点。”经典写法是私有构造函数加静态 instance(),第一次访问时惰性创建;C++11 保证函数内局部静态变量的初始化只执行一次,因此 static FileSystem* instance = new FileSystem(); 这种写法是线程安全的。它的好处是:没人用就不创建;在运行时初始化,可以使用配置文件等运行期信息,也避免了静态成员初始化顺序的问题;可以子类化,比如按平台选择具体的文件系统实现。(Game Programming Patterns — singleton)
单例:为什么会后悔
- 它就是全局变量:让代码更难推理(凌晨三点在百万行代码里 grep 那一处错误赋值);鼓励耦合(新人一个
#include就把物理和音频连在一起了。控制对实例的访问,就是控制耦合);对并发不友好。单例对这些问题一个也没解决,因为它只是封装在类里的全局状态。(Game Programming Patterns — singleton) - 两个问题绑在一起:人们几乎总是为了“方便访问”才用单例,却顺带接受了“只能有一个”的限制。日志单例到项目后期想按在线、UI、音频、玩法拆成多个文件时,每一处
Log::instance().write(...)都要改。(Game Programming Patterns — singleton) - 惰性初始化夺走控制权:音频系统初始化要几百毫秒,如果在第一次播放声音时才初始化,就可能在激烈战斗中掉帧;游戏还要控制内存在堆上的布局以免碎片。所以很多游戏改成静态实例,但这样就失去了多态,也无法释放,本质上已经是静态类了,不如直接写静态函数。(Game Programming Patterns — singleton)
替代方案
- 先看看这个类是否需要存在:很多单例是“管理器”(
BulletManager之类),把它的行为移回被管理的类即可,需要的管理器数量是零。(Game Programming Patterns — singleton) - 只想限制为一个实例:允许任何人构造,但用静态标志在构造第二个实例时断言失败。这样保证单实例而不提供全局访问,代价是检查发生在运行时。(Game Programming Patterns — singleton)
- 只想方便访问:总原则是变量的作用域越窄越好。书中依次列出:直接传进去(例如渲染函数的
context参数);从基类获取(GameObject::getLog(),即 子类沙盒);挂在已有的全局对象上(只让Game全局可见,Log、FileSystem、AudioPlayer都从它取,以后支持多个Game实例时它们不受影响);以及服务定位器。挂在Game上的代价是更多代码耦合到Game,作者建议混合使用:已经认识Game的代码从它取,其他代码用别的办法。(Game Programming Patterns — singleton) - 作者自己的做法:他从未在游戏里用过完整的 GoF 单例;要保证单实例时通常用静态类,不行再用运行时静态标志检查。人们常说“单例比静态类更容易日后改成非静态”,但他在实践中没见过:大家都在一行里写
Foo::instance().bar(),改起来照样要动每个调用点。(Game Programming Patterns — singleton)
服务定位器:机制
服务 (service) 是一组操作的抽象接口;服务提供者 (service provider) 是具体实现;定位器负责找到合适的提供者,同时向调用者隐藏提供者的具体类型和查找过程。书中的比喻是电话簿:大家按名字查到你当前的地址,搬家时只需通知电话公司。(Game Programming Patterns — service-locator)
书中示例的几个要点(Game Programming Patterns — service-locator):
- 最简单的定位器靠外部代码在启动时
provide()注册提供者(作者称这是依赖注入 (dependency injection) 的一种)。调用方只认识抽象Audio,连定位器本身也不耦合具体类,唯一知道ConsoleAudio的是初始化代码。 - 服务接口不知道定位器的存在,所以这个模式可以套在现有的、并非为它设计的类上;单例则会影响“服务”类本身的设计。
- 空服务 (Null Service):注册之前调用会得到
NULL并崩溃,这是一种“时间耦合 (temporal coupling)”。改为返回一个什么都不做的NullAudio,并改为返回引用,暗示总能拿到有效对象;空值检查放在provide()而不是getAudio()里,代价是要尽早调用initialize()。不注册提供者也就成了临时关掉某个系统的手段(例如开发时关掉声音)。 - 日志装饰器 (Decorator):
LoggedAudio包装另一个提供者,转发调用并记录日志;需要时把它换进定位器,只对关心的系统打开日志。它和空服务可以叠加:关掉声音的同时仍记录本该播放的声音。
下图表示调用方只依赖抽象接口,定位器在背后决定交出哪个提供者;没有注册时交出空服务。
flowchart LR Caller["游戏代码"] -->|"Locator::getAudio()"| Loc["服务定位器"] Loc --> I["Audio 抽象接口"] I -."实现".-> Real[ConsoleAudio] I -."实现".-> Log["LoggedAudio(装饰器,记录日志)"] I -."实现".-> Null["NullAudio(空服务,什么也不做)"]
图中调用方从不 new 具体类;把注册的提供者换成空服务就能临时静音,换成带日志的包装就能调试。
服务定位器:设计决策(Game Programming Patterns — service-locator)
| 决策 | 选项 | 优点 | 缺点 |
|---|---|---|---|
| 如何定位 | 外部代码注册 | 快且简单;能控制提供者如何构造(例如联网输入提供者需要对方 IP);运行中可替换 | 依赖外部代码先完成注册 |
| 编译期绑定(预处理宏) | 最快;能编译通过就保证可用 | 换服务要重新编译 | |
| 运行时配置(配置文件 + 反射) | 不重编即可换;非程序员可改;同一代码支持多种配置 | 复杂;查找要花时间 | |
| 找不到怎么办 | 返回 NULL | 每个调用点自己决定 | 每处都要检查,漏一处就崩溃 |
| 断言停机 | 调用方不用处理,并且明确了这是定位器的 bug | 大团队里可能让所有人停工 | |
| 返回空服务 | 调用方不用处理,游戏继续跑,便于关掉有问题的服务 | 无意中缺失的服务难以排查 | |
| 作用域 | 全局 | 全代码库共用同一服务 | 失去对使用位置的控制 |
| 限于某个基类及其派生类 | 控制耦合 | 不相关的类各自要重复取用 |
作者说最常见的是断言(游戏发售前已充分测试,运行在可靠硬件上),大团队则建议加上空服务;只在单一领域使用的服务(如网络)限定在类层级内,日志之类广泛使用的服务做成全局。(Game Programming Patterns — service-locator)
取舍与坑
- 服务定位器把依赖推迟到运行时才接线,读代码时更难看出依赖关系。(Game Programming Patterns — service-locator)
- 服务不知道是谁在用它,必须在任何上下文中都能正确工作。只该在模拟阶段使用、不能在渲染期间使用的类,不适合做成服务。(Game Programming Patterns — service-locator)
- 书中提到的现成例子:Unity 的
GetComponent()把本模式与 组件模式 结合;XNA 的Game类内置GameServices用于注册和查找服务。(Game Programming Patterns — service-locator) - 何时用?尽量少用。用得不好时,它带着单例的全部包袱,运行时性能还更差。先考虑把对象直接传进去;日志、内存管理这类不该出现在模块公开 API 里的系统,以及音频设备这种本质上只有一个的环境性设施,才值得用。(Game Programming Patterns — service-locator)
代码草图(C++)
按书中示例整理:带空服务的定位器、日志装饰器,以及单例章中“只保证单实例、不提供全局访问”的写法。
class Audio {
public:
virtual ~Audio() {}
virtual void playSound(int soundID) = 0;
virtual void stopSound(int soundID) = 0;
virtual void stopAllSounds() = 0;
};
class NullAudio : public Audio {
public:
virtual void playSound(int soundID) { /* 什么也不做 */ }
virtual void stopSound(int soundID) { /* 什么也不做 */ }
virtual void stopAllSounds() { /* 什么也不做 */ }
};
class Locator {
public:
static void initialize() { service_ = &nullService_; }
static Audio& getAudio() { return *service_; }
static void provide(Audio* service) {
if (service == NULL) service_ = &nullService_; // 退回空服务
else service_ = service;
}
private:
static Audio* service_;
static NullAudio nullService_;
};
// 启动时注册:唯一知道具体类的地方
ConsoleAudio* audio = new ConsoleAudio();
Locator::provide(audio);
// 日志装饰器:包装现有服务并换进去
class LoggedAudio : public Audio {
public:
LoggedAudio(Audio& wrapped) : wrapped_(wrapped) {}
virtual void playSound(int soundID) { log("play sound"); wrapped_.playSound(soundID); }
virtual void stopSound(int soundID) { log("stop sound"); wrapped_.stopSound(soundID); }
virtual void stopAllSounds() { log("stop all sounds"); wrapped_.stopAllSounds(); }
private:
void log(const char* message) { /* 记录日志... */ }
Audio& wrapped_;
};
void enableAudioLogging() {
Audio* service = new LoggedAudio(Locator::getAudio());
Locator::provide(service);
}
// 单例章:只保证单实例,不提供全局访问(运行时检查)
class FileSystem {
public:
FileSystem() { assert(!instantiated_); instantiated_ = true; }
~FileSystem() { instantiated_ = false; }
private:
static bool instantiated_;
};
bool FileSystem::instantiated_ = false;
UE 中的对应
- 子系统 (Subsystem) → 子系统:子系统是自动实例化、生命周期绑定在宿主(Engine、Editor、GameInstance、World、LocalPlayer)上的类,通过宿主的
GetSubsystem<T>()按类型取得。它替代了“继承 GameInstance、往里塞管理器”的老做法。对照本页:它不是“到处可 new 的全局单例”,而是把访问点挂在已有的宿主对象上,并按宿主划定作用域与生命周期,接近 Nystrom 推荐的“挂在已有的全局对象上”和“限定作用域的服务定位器”。宿主创建时自动Initialize()、关闭时Deinitialize(),也回避了惰性初始化时机不可控的问题。
出处
- Game Programming Patterns — singleton
- Game Programming Patterns — service-locator
- Game Programming Patterns — singleton(全文版)
- Game Programming Patterns — service-locator(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) singleton 章,gameprogrammingpatterns.com/singleton.html
- Robert Nystrom《Game Programming Patterns》(2014) service-locator 章,gameprogrammingpatterns.com/service-locator.html