中EN
概念页

单例与服务定位器

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)

取舍与坑

代码草图(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(),也回避了惰性初始化时机不可控的问题。
  1. Game Programming Patterns — singleton
  2. Game Programming Patterns — service-locator
  3. Game Programming Patterns — singleton(全文版)
  4. Game Programming Patterns — service-locator(全文版)
  5. Robert Nystrom《Game Programming Patterns》(2014) singleton 章,gameprogrammingpatterns.com/singleton.html
  6. Robert Nystrom《Game Programming Patterns》(2014) service-locator 章,gameprogrammingpatterns.com/service-locator.html

相关页面

游戏开发子类沙盒Subclass Sandbox子类沙盒 (Subclass Sandbox) 让基类提供一组受保护的“提供的操作”和一个抽象的沙盒方法,派生类只用这些操作来实现沙盒方法。这样大量派生类(例如几十种超能力)只耦合到基类,不直接接触引擎的各个系统;代价是基类会越来越臃肿,变成脆弱基类。游戏开发事件队列Event Queue事件队列 (Event Queue) 用一个先进先出队列,把“发送消息或事件”和“处理它”在时间上解耦:发送方入队后立即返回,接收方在自己方便的时候再取出处理。它比观察者和命令模式多做的一件事是时间上的解耦,适合“推”与“拉”两端节奏不同的场合(例如音频请求),代价是全局可见、世界状态可能已经改变,以及可能出现隐蔽的反馈回环。游戏开发 · UE5子系统Subsystems子系统 (Subsystem) 是自动实例化、生命周期绑定在宿主对象上的类:Engine、Editor、GameInstance、World、LocalPlayer 五种。宿主创建时自动创建并 Initialize(),宿主关闭时 Deinitialize(),之后无引用即被 GC。它替代了「继承 GameInstance 塞管理器」的老做法,自带按类型的蓝图节点和 Python 访问,特别适合插件。官方编程页只列四种,World 子系统依据 5.8 Python API 补充。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发子系统启动与关闭Subsystem Start-Up & Shut-Down游戏引擎由许多相互依赖的子系统组成,启动时必须按依赖顺序初始化(B 依赖 A,则先启动 A),关闭时通常反序。C++ 全局/静态对象的构造与析构顺序不可预测,「首次调用时构造」的函数静态单例也无法控制析构顺序且时机不可控。GEA 推荐最朴素的方案:单例管理器的构造/析构函数什么也不做,提供显式的 startUp() / shutDown(),在 main() 里按顺序手动调用。游戏开发组件模式Component Pattern组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原