恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Python设计模式之单例模式实例
首页
资讯中心
/
Python设计模式之单例模式实例
Python设计模式之单例模式实例
发布时间:2026/10/12 7:24:11
前言单例模式Singleton Pattern出自 1994 年的《设计模式》一书作者常被合称为「四人组」GoF。它的意图用一句话概括就是保证一个类只有一个实例并提供一个访问它的全局入口。听起来简单可它大概是所有设计模式里争议最大的一个——在很多现代工程实践里它被直接归入「反模式」名单。这里要先澄清一个常见误解单例不是「只实例化一次」这么简单。它同时承诺了三件事——唯一性、全局可访问、延迟或受控的创建。很多人写的所谓单例只做到了「有个全局变量」既没有受控创建也没有考虑并发和继承那其实不是单例模式只是把全局状态换了个壳。本文从设计模式的视角讲三件事GoF 定义的结构是什么、它本来是想解决什么问题、以及为什么它今天会被这么多人批评。至于「具体怎么用 Python 写出来」和「几种写法各有什么坑」属于更偏实现的话题本文只在必要处点到为止。一、意图与结构GoF 对单例的意图描述是确保一个类只有一个实例并提供一个全局访问点。它的结构里只有一个角色——单例类自己负责私有化创建入口防止外部随意 new。在 Python 里没有真正意义上的「私有构造」常见做法是改写__new__或用一个专门的工厂函数来拦截。内部持有一个类级别的实例引用。对外暴露一个静态/类方法返回那个实例第一次被调用时才创建。这个结构解决的问题是某些资源在一个进程里只应该有一份而且需要被很多地方访问。典型代表是配置、日志器、连接池。二、典型适用场景场景为什么「唯一」有意义配置对象全程序应读同一份配置不能各读各的日志器多个 logger 争抢同一个文件句柄会乱序、丢日志连接池池是多份反而会耗尽后端连接数硬件/设备句柄一个物理设备通常只允许一个控制通道进程内缓存多份缓存等于没有缓存命中率被稀释注意这些场景的共同点它们管的是「稀缺资源」或「全局一致的状态」。如果某个东西既不是稀缺资源也不需要全局一致那用单例就没有正当理由。对比一下如果你只是想要一个「带默认值的字典」那用模块级常量就够了不涉及任何创建控制如果你想要的是「全局变量的替代品」那单例只是给全局变量套了层类的外壳问题一点没解决。三、它长什么样唯一性与全局访问点单例承诺的两个特征——唯一和全局可访问——用一个模块级的对象就能表达清楚。下面这段代码不是「单例模式的实现」只是用来说明它想达到的效果程序里只有一份日志出口所有调用都往同一处写。# 适用于 Python 3.8_log_lines []def emit(level, msg):_log_lines.append(f[{level}] {msg})def dump():return list(_log_lines)emit(INFO, 启动完成)emit(WARN, 配置项缺失)print(dump()) # [[INFO] 启动完成, [WARN] 配置项缺失]print(len(_log_lines)) # 2两次调用写进了同一份列表推演一遍emit每次都在往同一个模块级列表里追加所以dump()能看到这两条记录。「唯一」体现在所有调用共享同一个列表「全局访问点」体现在任何模块只要导入emit就能写进同一处。这也顺带暴露了单例的本质所谓单例实质是一段被共享的可变状态——而共享可变状态正是它最大的风险来源。四、为什么它常被称为反模式批评集中在以下几点。第一它把全局可变状态合法化了。单例状态可以被任意位置的代码读写任何一处修改都可能影响别处。这类 bug 的排查难度极高因为「谁改的」在代码里看不出来。单例只是把「全局变量」这个老问题换了个更好听的名字。第二它隐藏依赖关系。一个函数内部直接Config().data[timeout]从签名上完全看不出它依赖配置。调用方无法从类型或参数上知道「这个方法需要什么才能工作」。相比之下把配置作为参数传进去依赖是一目了然的。第三它让测试难以隔离。单元测试需要「每个用例从干净状态开始」但单例的状态会跨用例保留。第一个用例改了配置第二个用例就拿到被污染的值。要隔离就得在setUp里手动重置单例内部状态——而这段重置代码本身又跟实现细节耦合。第四它经常被用来「省内存」。有人用一个单例缓存大量数据理由是「多个实例太浪费」。但真正的问题通常是设计没分层而不是实例太多。第五它假设了「进程内唯一」等于「全局唯一」。一旦用了多进程每个进程各有自己的一份模块状态彼此不再一致。这一点在使用多进程做 CPU 密集任务时尤其容易踩。# 适用于 Python 3.8import multiprocessing as mp_state []def child_append(tag):_state.append(tag) # 改的是子进程里那一份 _statereturn len(_state)if __name__ __main__:_state.append(main)with mp.Pool(1) as pool:pool.apply(child_append, (child,))print(_state) # [main]子进程的追加没有反映到父进程上面这段的重点不是并发技巧而是要说明单例的边界是进程。跨进程共享状态必须靠外部的共享机制数据库、缓存服务、队列单例给不了你。五、什么情况下仍然值得用单例并非一无是处。在下面这些条件下用它是合理的资源确实稀缺且多份会引发实质问题连接池、设备句柄。创建成本高而全局只需要一份。这个对象基本无状态或者状态是只读的比如一份加载后就不再变的配置。反过来如果这个对象是有状态的、会被频繁修改的或者你只是因为「传参太麻烦」而想图省事那应该优先考虑依赖注入把对象创建好当成参数传给需要它的地方。这样依赖显式、测试可替换、作用域可控。常见坑点把单例当成高级全局变量。❌ 用一个单例装各种互不相关的开关和计数器谁都能改。✅ 全局可变状态能免则免确实需要就限定职责并明确谁写谁读。单例里塞进过多职责。❌ 一个 AppContext 单例同时管配置、日志、数据库连接和缓存最后变成谁都不敢动的巨类。✅ 拆成多个职责单一的组件各自用依赖注入选定生命周期。依赖在函数内部隐式获取。❌ 函数体里直接Config()签名上看不出任何依赖。✅ 把依赖作为参数传进来或者至少存成实例属性让依赖显式可见。测试之间共享单例状态。❌ 用例 A 改了单例的配置用例 B 拿到被污染的值测试结果随机失败。✅ 用依赖注入在每个用例里传独立实例退而求其次也要在setUp里显式重置。以为单例能跨进程统一。❌ 用单例当「全局注册表」多进程任务里各进程各改各的最后结果对不上。✅ 明确单例只作用于单个进程跨进程共享要用外部服务。在模块导入时立刻创建实例。❌ 模块顶层直接instance Heavy()导入即触发耗时的连接或读盘还可能引发循环导入。✅ 延迟到第一次真正使用时再创建或干脆用依赖注入由调用方决定时机。为了「省内存」而给数据缓存套单例。❌ 把大对象塞进单例缓存以为省了内存其实只是让生命周期变得不可控。✅ 先确认内存真的有问题需要缓存就用带淘汰策略的显式缓存对象。认为单例必须由类来实现。❌ 写一大堆__new__逻辑只为得到一个「全局唯一的东西」。✅ Python 里一个模块本身就是天然的命名空间模块级对象天生唯一先考虑这个最简单的方式。总结维度单例的表现解决的问题稀缺资源或全局一致状态只需要一份结构单例类自己控制创建并对外提供访问点代价全局可变状态、依赖隐藏、测试难隔离作用域仅限单个进程更好的替代多数场景下是依赖注入合理的使用条件资源稀缺、基本无状态、确实全局唯一单例的核心风险不在「怎么写」而在「要不要写」。它把全局状态包装得很体面让人放松警惕。每次想用它之前先问一句我要的是唯一性还是只是懒得传参如果答案是后者依赖注入通常才是正确的方向。