6. Singleton
6. Singleton:拆开唯一实例约束与全局访问便利这两个独立问题,通过可复位因果实验和反例证据验收。
6. Singleton
学习目标
- 能拆开唯一性与全局访问两个问题
- 能对比单例与依赖注入
- 能评估单例的使用代价
为什么"6. Singleton"从问题证据开始
游戏里总有些"全局唯一"的东西:音频管理器、日志器、配置表。最直觉的做法是写一个类,把实例存在静态字段里,所有人通过 AudioManager::instance() 拿它——这就是单例。它今天能跑,但长期看,你为"方便"付了三笔账:隐藏依赖(任何代码都能悄悄用它,测试无从注入替代)、初始化失控(惰性初始化的时机不受你控制)、耦合变紧(全局状态让系统之间互相纠缠)。
本页把"无模式基线"定义为"全局变量 + 手工唯一性检查"的代码,然后引入单例作为候选机制,验证它是否真的让"唯一实例约束"与"全局访问便利"同时成立。通过条件是两个问题分别有解——而不是把"唯一"和"好找"绑死在一个类里。
🔮 先预测:两个惰性单例互相引用,初始化顺序会怎样?
来源、版本与独立重写边界
本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。
本章机制与术语
理解 Singleton 需要四个核心概念:
- ↡:限制某个类最多创建一个实例——静态成员 + 私有构造实现。
- ↡:从任何地方都能拿到该实例——
getInstance()静态方法。 - ↡:实例在第一次被访问时才创建,而不是程序启动时——便利但时机不受控。
- ↡:把依赖显式传给使用者,替代"到处都能拿"的全局访问。
四个概念共同约束"拆开唯一实例约束与全局访问便利这两个独立问题":任何结论都必须回到"唯一性是否成立、访问是否显式、初始化是否可控"三个可观察量。
Singleton — 把"唯一"与"好找"拆成两个问题
第 1 / 5 步 · ① 中心:唯一实例 GameManager(静态 instance + 私有构造)
把'只有一个'(静态成员)与'到处能拿'(全局变量)分开——你常常只需要前者。
💡 对着动画看:动画左侧是唯一实例辐射图——六个子系统沿虚线汇聚到中心的 GameManager,这就是"全局访问点"的直观形态。注意右侧两个盒子:① 唯一性约束 和 ② 全局访问 被分开讨论。读"为什么后悔用它"一节时,想想六个系统各自独立持有实例引用(依赖注入)是否更好。
官方结构逐项深读
6. Singleton
Singleton 的经典实现三件套:私有构造(防外部 new)、静态实例(唯一性)、静态 getInstance()(全局访问)。作者的核心批评是:它把两个独立的问题绑在一个模式里——"只允许一个实例"(合理需求)和"从任何地方都能访问"(可疑需求)。前者可以用类静态成员解决,后者才是全局变量的恶名来源。
The Singleton Pattern
模式结构:构造私有化 → 静态实例字段 → 静态工厂方法 getInstance()(首次调用时惰性创建,之后直接返回)。AudioManager::getInstance().play(...) 随处可用。这个结构让"唯一"和"好找"同时成立,但也同时引入了全局状态——你必须为"全局"这个副作用买单。
Restricting a class to one instance
"限制一个类只有一个实例"本身是正当需求:比如音频设备只能初始化一次。解法不必是单例——类静态成员 + 私有构造即可,甚至一个普通类加"只能创建一次"的运行时检查也行。重点是:唯一性约束可以在类内部实现,不需要全局访问点。这是"拆开两个问题"的第一半。
Providing a global point of access
"从任何地方都能访问"是可疑需求:它的本质是全局变量。全局变量让依赖关系隐藏——你无法从函数签名看出它依赖什么,测试时无法注入替身,重构时不知道谁在用。如果"方便访问"是目的,有更好的手段:作为参数传入、放在容器/上下文里、或让使用者显式获取。这是"拆开两个问题"的第二半。
Why We Use It
为什么单例在游戏里如此流行?三个现实理由:跨系统共享(音频/日志/配置被所有系统使用)、避免到处传参(参数链太长代码难看)、性能(一次静态查找远快于消息传递或查表)。这些理由都真实,但它们证明的是"需要共享访问",而不是"需要全局变量"——作者强调,两者常常被混为一谈。
Why We Regret Using It
后悔的理由同样真实:测试困难(无法在测试间隔离单例状态)、并发问题(多线程初始化竞态)、隐藏依赖(改一处静默影响全局)、初始化顺序(惰性创建让启动顺序不可预测)。一个看似省事的模式,长期维护成本可能超过它省的传参功夫——这是"用前权衡"而非"永远不用"的态度。
It's a global variable
作者直言:单例就是披着漂亮外衣的全局变量。全局变量的所有缺点——隐式耦合、难以测试、状态污染——单例一个不少。唯一差别是单例"延迟创建"且"类型限制更严"。认清这一点,才能理性评估:我真的需要全局变量吗?还是需要的是"共享一个实例,但访问走显式通道"?
It solves two problems even when you just have one
单例的"缝合怪"本质:你明明只需要"唯一性"(如音频设备),单例却强制你同时接受"全局访问";你明明只需要"好访问"(如日志),单例却强制你同时接受"唯一性"。两个问题捆绑销售,你常常只想要其中一个。这就是"拆开两个问题"的价值——按需购买,不买套餐。
Lazy initialization takes control away from you
惰性初始化看似贴心(不用管创建时机),实则把初始化顺序的决定权从你手里拿走:实例第一次被访问时创建,而"第一次访问"可能发生在任何代码路径、任何时间点。若初始化依赖其他系统(如日志系统要先就绪),惰性创建会在错误时机触发,产生难查的初始化竞态。显式初始化(启动时创建)虽然啰嗦,但顺序可控。
What We Can Do Instead
替代方案的谱系:从"普通类 + 每帧从某处获取实例"到"依赖注入容器"到"静态类(全静态方法,无实例)"。关键是根据真实需求选择:只要唯一性 → 静态成员即可;只要好访问 → 显式传参或注入;两者都要且接受代价 → 才考虑单例。作者的态度是:默认不用单例,遇到真实证据再上。
See if you need the class at all
第一步反问:这个类真的需要存在吗? 音频管理器可能是"音频系统的全局状态"的代名词——也许你只需要几个自由函数加一个音频设备句柄。很多"管理器"类是把本应属于模块的函数硬塞进一个假对象。砍掉类,问题自然消失。这是比任何模式都便宜的解法。
To limit a class to a single instance
如果确实要唯一性,写法的优先级:类静态成员(简单直接)> 单例(带全局访问)> 其他。静态成员方案:私有构造 + 静态实例字段 + 静态访问方法,但不提供全局可访问的 getInstance()——只在需要的地方显式传递。这样唯一性保留,全局访问的代价为零。
To provide convenient access to an instance
如果确实要方便访问,写法的优先级:依赖注入(显式、可测)> 服务定位器(按需查表)> 全局变量(最后手段)。依赖注入在创建对象时把依赖塞进去,测试时可换替身;服务定位器用名字或类型查表,比全局变量可控(可以注入、可以替换);纯全局变量是最后的选择。三种都比单例的"隐式全局"清晰。
What's Left for Singleton
单例并非一无是处:在平台桥接(系统 API 天然单例)、资源配置(全局只读配置)、纯工具服务(无状态的日志/调试)等场景,单例或静态类是可接受的务实选择。作者的落点:把单例当作有代价的工具,而不是默认姿势——用它之前,先回答"我到底要唯一性还是要好访问,能不能分开要"。
可迁移实现或计算骨架
initial state -> 识别需求:唯一性 or 全局访问 or 两者
fault injection -> 把两个需求绑进一个全局单例
pass condition -> 唯一性成立、访问显式、初始化顺序可控
reset -> initial state对应实践要点:先回答"唯一性"与"好访问"是否分离;唯一性用类静态成员,访问用依赖注入或显式传参;只有平台桥接/全局只读配置才考虑单例。该骨架只保存实验合同;真实项目还要固定初始化顺序清单、测试注入路径与并发访问规则,并保留基线实现以便回退。
本章练习与节点验证矩阵
练习
问题 1:需求拆解。
操作:在动画中区分"唯一性"与"全局访问"两个盒子,对每个系统判断它需要哪个
问题 2:依赖注入实验。
操作:把动画中一个子系统从 getInstance() 改为构造注入
问题 3:初始化顺序。
操作:构造两个惰性单例互相引用(A 初始化时访问 B)
术语复核
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 唯一实例
- 全局访问点
- 惰性初始化
练习答案参考
练习判据即答案:每个练习的"验证判据"列给出了通过标准——先自己动手,再对照判据核验。
术语复核与本章回顾
掌握"6. Singleton"意味着能从"全局变量 + 手工唯一性检查"出发,解释唯一实例、全局访问点、惰性初始化与依赖注入四者的关系,再用"唯一性是否成立、访问是否显式、初始化是否可控"三个可观察量推翻或保留实现。若三个判据不能同时满足,本章仍未通过。
一句话回顾:单例把"只能有一个"和"到处能拿"打包出售——多数时候你只需要前者,用静态成员;后者用依赖注入,别用全局变量。