单例为什么容易变成负债
单例模式保证某个类型在进程内只有一个实例,并提供全局访问点。问题不在唯一性本身,而在全局访问点:依赖被藏进方法体内部,调用方签名上看不出它需要什么,测试也无法替换。唯一实例往往是合理的需求(配置、连接池、注册表),不合理的是用静态方法把它取出来。
问题与替代
| 隐藏依赖 | 依赖不出现在构造签名或参数里,阅读者需要翻实现 |
|---|---|
| 无法替换 | 测试桩与模拟对象插不进去,只能改全局状态 |
| 测试污染 | 实例被跨用例复用,执行顺序不同结果不同 |
| 推荐替代 | 用依赖注入容器注册单实例,再注入到使用方 |
典型使用场景
- 应用配置:确实只需一份,但应通过构造参数传入而不是在方法里取值
- 数据库连接池:实例唯一合理,交由容器或框架管理生命周期
- 全局注册表与插件表:启动时装配完成,之后只读访问
- 缓存客户端:需要共享实例时用容器单例,避免各处自行 new 造成连接数翻倍
- 日志器:语言内置日志模块已提供全局实例,不必再做一层包装
从取单例改为注入
从取单例改为注入
# 改造前:依赖藏在方法体里,签名看不出任何信息
class OrderService:
def create(self, order):
cfg = Config.get_instance() # 全局访问点
# 改造后:依赖出现在构造签名,容器保证只有一份实例
class OrderService:
def __init__(self, cfg: Config): # 谁提供由容器决定
self.cfg = cfg症状与对应改法
| 观察到的症状 | 根因 | 改法 |
|---|---|---|
| 单测必须重置全局对象 | 实例被跨用例复用 | 改为注入,每次用例传独立实例 |
| 并行测试互相干扰 | 可变全局状态 | 把状态移入实例或改为不可变 |
| 改一处要全局回归 | 依赖图不可见 | 让依赖出现在签名,用静态分析审视调用 |
| 两个模块各自初始化 | 没有统一生命周期管理 | 交给容器统一注册与释放 |
| 启动顺序敏感、偶发空指针 | 静态初始化时机不可控 | 改为显式装配,在入口处完成初始化 |
过渡手法:把单例藏到接口后面,调用方只依赖接口,实现由容器装配。这样既保留进程内唯一的语义,又让测试可以注入替身,不必一次改完所有调用点。
单例持有可变状态时风险最高:任何一处写入都会影响所有使用者,跨测试用例污染只表现为偶发失败。若必须共享,优先让实例本身不可变,或明确写出并发访问约束。
自查清单
- 构造签名是否完整表达了这个类需要的协作对象
- 测试能否在不触碰全局状态的前提下替换依赖
- 实例的生命周期由谁负责创建与释放,是否有明确归属
- 如果明天要开两份不同配置的实例,现有代码会不会崩