ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Python单例模式五种写法:从模块级到元类,附防破坏指南

Python单例模式五种写法:从模块级到元类,附防破坏指南 单例模式大概是设计模式里最被人嫌弃、但又最高频被问到的模式了。我在面试时经常让人手写一个线程安全的单例十个里有六七个会翻车。很多人一说单例就想到Java的私有构造器和getInstance方法但在Python里实现路径完全不一样而且坑比想象中多得多。更麻烦的是写出来简单能不能扛住反射、反序列化、多线程这些破坏手段才是真正拉开差距的地方。这篇我打算把Python单例的5种常见写法全部拆开讲每种写法的原理、适用场景、隐藏风险都会说清楚。然后再从破坏者的视角展示如何把一张看似完美的单例撕开口子以及怎么把这些口子一个个补上。内容适合正在准备面试的人也适合那些用单例管理配置、连接池、日志对象但总感觉哪里不对劲的实战派。1. 为什么单例模式在Python里这么有争议但还是要学1.1 单例模式到底解决什么问题先抛开各种设计模式的理论直接用大白话说单例模式就是保证一个类在整个进程生命周期里只有一个实例对象并且提供一个统一的访问入口。那为什么要“只有一个实例”最常见的是资源类对象。比如数据库连接池假设你开100个连接放进池子里结果代码里到处都是new DatabasePool()每个地方都是一套独立的池子每个池子又去创建自己的连接数据库迟早被压垮。再比如全局配置管理器一个程序里如果同时存在多份配置对象某一处改了配置别处不知道排查起来能让人崩溃。还有日志记录器日志文件的句柄、格式化配置这些如果每个模块各自创建一份日志就会乱写、文件句柄泄漏。这些场景的共同特征是全局共享状态 创建成本高 多个访问方必须操作同一份数据。单例模式就为这种场景提供了简洁的约束——你拿到的永远是同一个对象。1.2 Python里的单例和Java/C完全不是一回事很多从Java转过来的同学会有个误区觉得单例就必须是私有构造器、静态方法、双重检查锁那一套。但Python没有私有构造器的概念也没有真正的私有成员所以那一套在Python里会显得非常别扭。更关键的是Python的模块机制本身就拥有类似单例的性质。一个模块在进程中只会被导入一次后续所有import拿到的都是同一个模块对象模块里的变量天然就是全局唯一的。这就意味着你根本不需要写一个类直接在模块里放一个对象就已经是单例了。这是Python和Java在单例实现上最本质的区别。Java必须靠类机制保证全局唯一而Python靠模块机制就已经能完成90%的需求。剩余那10%需要“类”这种形态的场景——比如传参、继承、类型判断——才需要用到装饰器、元类这些更复杂的写法。理解了这一点你就知道为什么同样一道“手写单例”的题在Java和Python里的正确答案完全不一样。2. 五种Python单例写法逐个拆解2.1 模块级单例Python特有的最简单方式先看最朴素的写法直接上代码# db_pool.py class DatabasePool: def __init__(self): self._connections [] def get_connection(self): if not self._connections: # 实际创建连接 self._connections.append(conn_1) return self._connections[-1] pool DatabasePool()然后其它模块里这样用from db_pool import pool conn pool.get_connection()这就是模块级单例。原理很简单Python解释器执行from db_pool import pool时会先去sys.modules里查有没有db_pool这个模块没有就执行一次模块代码有就直接用缓存里的。整个进程生命周期里db_pool模块的顶层代码只会执行一次所以pool只有一个对象。这种写法有几个很明显的优点。第一是极度简单没有任何魔法新同事一看就懂。第二是线程安全因为模块导入过程由解释器保证只执行一次不存在多线程并发创建的问题。第三是天然支持类型判断isinstance(pool, DatabasePool)完全正常。但缺点也很明显。模块级对象在导入时就创建了无法做到真正的“延迟加载”。如果这个对象初始化逻辑很重而你某个脚本只是顺手 import 一下就会白白付出初始化成本。另外如果你想控制这个对象的生命周期或者想在不同进程里做不同的初始化配置模块级单例就无能为力了。提示如果只是存配置项、常量、日志句柄这类全局对象优先用模块级单例。不要为了“设计模式”而去写一个花哨的单例类Python社区的哲学就是能简单绝不复杂。2.2 装饰器实现单例灵活控制实例缓存如果你需要“类”的形态但又不想每次手写重复的实例判断逻辑装饰器是个很自然的思路。核心想法是用一个字典缓存类的实例第二次调用时直接从缓存里拿。import functools import threading def singleton(cls): instances {} lock threading.Lock() functools.wraps(cls) def get_instance(*args, **kwargs): if cls not in instances: with lock: if cls not in instances: instances[cls] cls(*args, **kwargs) return instances[cls] return get_instance singleton class ConfigManager: def __init__(self, envdev): self.env env使用的时候要注意ConfigManager()这个调用实际上执行的是被包装后的get_instance函数。第一次传了envprod后续再传什么参数都会被忽略因为实例已经创建了这是符合单例语义的。这种写法有个很隐蔽的坑isinstance(config, ConfigManager)会返回False。原因是ConfigManager这个名字已经被替换成了函数函数不是类自然过不了isinstance检查。虽然用了functools.wraps把__name__、__doc__这些属性复制过来了但函数和类的类型差异是复制不过去的。如果你的业务代码有大量isinstance判断装饰器方案会让你踩坑踩得很难受。这时候可以考虑用元类方案类型信息能完整保留。2.3 基于元类的单例从类的创建端动手元类是很多人一听就发怵的东西但你只需要理解一句话类是元类的实例创建类的行为由元类控制创建实例的行为也由元类控制。单例要做的就是控制“创建实例”这个行为让它只执行一次。import threading class SingletonMeta(type): _instances {} _lock threading.Lock() def __call__(cls, *args, **kwargs): if cls not in cls._instances: with cls._lock: if cls not in cls._instances: instance super().__call__(*args, **kwargs) cls._instances[cls] instance return cls._instances[cls] class DatabasePool(metaclassSingletonMeta): def __init__(self, host): self.host host这里的关键是重写元类的__call__方法。DatabasePool()这个表达式实际上会先走到SingletonMeta.__call__由它来决定要不要真的调用DatabasePool.__new__和__init__。我们把实例缓存在_instances字典里用类对象作为key所以每个类可以拥有各自独立的单例。元类方案的优点非常突出。第一类型判断完全正常isinstance(DatabasePool(a), DatabasePool)是True。第二装饰器方案做不到的“子类之间互相独立”元类方案天然支持因为字典key是cls子类和父类是不同key。第三逻辑集中在元类里业务类只要指定metaclass不用写任何重复代码。这是生产环境里我比较推荐的一种写法。它不像装饰器那样破坏类型也不像__new__方案那样容易出现继承混乱算是在灵活性和安全性之间取了一个很好的平衡点。2.4 重写__new__实现单例最常见但隐藏陷阱多__new__是Python里真正创建实例的方法__init__只是实例创建后做初始化。所以很多人会想到直接重写__new__让实例只创建一次。import threading class Database: _instance None _lock threading.Lock() def __new__(cls, *args, **kwargs): if cls._instance is None: with cls._lock: if cls._instance is None: cls._instance super().__new__(cls) return cls._instance这段代码单独看是没问题的双重检查锁保证了多线程下不会创建出两个实例。但陷阱藏在继承关系里。假设你写了一个MySQLDatabase(Database)子类注意_instance是类属性子类如果没有重新定义_instance访问到的就是父类的同一个属性。第一次调用Database()时cls是Database把Database._instance赋值成Database实例。之后你调用MySQLDatabase()cls虽然是MySQLDatabase但cls._instance通过继承机制找到了Database._instance发现它不是None于是直接返回了父类的实例。这个“MySQLDatabase”实际上是个Database对象类型错了状态共享了单例也失效了。这种问题在元类方案里不会出现因为缓存字典是全局的、以类对象为key。而在__new__方案里如果你确实需要子类各自独立单例就得在每个子类里重新定义_instance None很容易忘忘掉就是事故。另外还有一个被经常忽略的问题__init__在每次调用Database()时都会执行。也就是说你拿到的确实是同一个实例但每次调用构造函数它的属性都会被重新初始化一遍。这个我在后面第5部分还会专门讲怎么解决。2.5 类方法类属性最直观但最脆弱的写法还有一种可能是很多人第一反应会写出来的方案用类属性存实例用类方法提供全局访问入口class Config: _inst None classmethod def get(cls): if cls._inst is None: cls._inst cls() return cls._inst这种写法直观到无需讲解但它的问题恰恰在于太直观了。首先它把“获取单例”的入口从Config()改成了Config.get()所有调用方都得遵守这个约定一旦有人直接Config()就会创建一个新对象单例被绕过。其次线程安全需要自己加锁否则多线程高并发时照样可能出现两个实例。最后_inst同样是继承链上共享的类属性子类不重定义就会拿到父类的实例。我把这种写法放在最后是希望你能通过对比明白一个道理单例模式的核心不在于“能拿到一个对象”而在于“所有入口都拿不到第二个对象”。最直观的写法往往只实现了前者没有堵住后者。装饰器、元类、__new__这些方案本质都是在入口处做拦截让“去构造函数”这条路本身就返回同一个对象。下面是这5种写法的快速对比方便你记忆写法类型判断线程安全延迟初始化子类独立性实现复杂度模块级单例正常天然安全不支持不涉及极低装饰器异常可加锁支持不涉及低元类正常可加锁支持天然独立中重写__new__正常需加锁支持需要手动处理中类方法类属性正常需加锁支持需要手动处理极低3. 单例模式的破坏方式不仅要会写更要会破面试的时候能在白板上写出一个元类单例只能算及格。真正的加分项是你能不能讲出这个单例在什么情况下会被破坏以及怎么防。下面这几个破坏手段都是我实际测试过、也见过别人踩坑的。3.1 反射与绕过直接调用最底层的创建逻辑Python里没有真正的私有成员这意味着只要拿到了类对象你几乎可以调用它的一切。对于用__new__实现单例的类最直接的破坏方式就是绕过__init__直接创建裸实例db object.__new__(Database) print(db.host) # AttributeError: Database object has no attribute host虽然这个对象因为没走__init__而缺少属性但如果你只是想要“第二个实例”它已经是了。更危险的是通过copy.copy或者pickle复制能复制出一个状态相同但身份不同的对象。对于元类单例type(db)(args...)并不会绕过元类的__call__因为type(db)返回的正是元类本身调用它还是会走到SingletonMeta.__call__。所以元类方案在反射攻击面前相对稳健但也不是完全无懈可击因为对象可以被复制、可以被反序列化。3.2 反序列化从字节流中重建第二个实例pickle是Python的序列化库可以把一个对象变成字节流之后从字节流恢复。问题来了pickle.loads恢复对象的时候走的是专门的还原逻辑不一定会调用类的构造函数。你用元类把__call__拦得再死pickle也可能用底层API直接创建实例。实践一下import pickle class Database(metaclassSingletonMeta): def __init__(self, host): self.host host db1 Database(localhost) data pickle.dumps(db1) db2 pickle.loads(data) print(db1 is db2) # False第二段代码运行完db2就是一个全新的对象单例被成功破坏。这在很多人的认知之外但危害却很真实。如果你的单例对象被缓存在Redis或者消息队列里经过一次序列化传输再还原就会出现两个“单例”。这也是为什么涉及缓存、消息、分布式场景时单例模式的生命周期需要格外小心。3.3 多线程并发不加锁的“双例”事故前面提到过模块级单例天然线程安全但类级别的单例如果不加锁在高并发下就会出问题。看这个反面教材import threading import time class BadSingleton: _inst None def __new__(cls): if cls._inst is None: time.sleep(0.1) cls._inst super().__new__(cls) return cls._inst def create(): obj BadSingleton() print(id(obj)) t1 threading.Thread(targetcreate) t2 threading.Thread(targetcreate) t1.start() t2.start() # 运行结果大概率打印出两个不同的id有些人可能会说Python不是有GIL吗GIL保证同一时刻只有一个线程执行Python字节码为什么还会出问题这里要注意GIL保证的是单条字节码指令的原子性不是整段逻辑的原子性。if cls._inst is None是一条判断cls._inst super().__new__(cls)是另一条赋值在这两条指令之间线程完全可能被切换。结果就是两个线程都通过了is None判断各自创建了一个实例。要解决这个问题就得在判断和赋值之间加锁并且配合二次判断也就是著名的双重检查锁Double-Checked Locking。这个模式下第一次判断是为了避免每次调用都去拿锁第二次判断是在拿到锁之后重新确认防止多个线程同时等待锁导致的重复创建。4. 防护方案从“防君子”到“防小人”很多人写单例的时候心里默认使用者都是按照文档、按照约定来写代码的。但现实是总有人会写出pickle.loads、总有人会在多线程里调用你的单例、总有人会去继承你的单例类。所以好的单例封装就要把这些口子一个一个堵上。4.1 用元类加锁实现线程安全的单例我最推荐的方案就是元类加双重检查锁。前面第2部分写过基本版这里再把关键点拎出来解释一下为什么这段代码是可靠的import threading class SingletonMeta(type): _instances {} _lock threading.Lock() def __call__(cls, *args, **kwargs): if cls not in cls._instances: with cls._lock: if cls not in cls._instances: instance super().__call__(*args, **kwargs) cls._instances[cls] instance return cls._instances[cls]首次判断cls not in cls._instances是快速路径实例已经存在时连锁都不用拿性能开销极小。只有实例不存在时才会进入加锁逻辑。拿到锁之后再做一次判断是因为可能有多个线程同时在快速路径上通过了检查都在等这把锁如果第二次不判断每个线程都会执行一遍super().__call__单例就破了。有人会问这个元类锁是全局的会不会因为不同类之间的锁竞争影响性能事实上单例类在整个项目里数量有限创建实例的频次也极低这个锁几乎不会被争抢性能完全不用操心。4.2 禁用反序列化让pickle无法还原新对象针对3.2的破坏方式防护手段也很明确。在单例类里明确禁止反序列化还原可以重写__reduce_ex__或直接抛异常class Database(metaclassSingletonMeta): def __init__(self, host): self.host host def __reduce_ex__(self, protocol): raise NotImplementedError(Singleton cannot be deserialized)这样一旦有人尝试pickle.dumps(db)立刻会抛出异常从源头阻止了通过反序列化创建第二个实例的可能。如果你的单例对象必须要支持序列化传输也可以考虑另一种思路在__reduce_ex__里返回一个函数让它反序列化时调用Database.get_instance()从而把还原结果重新指向全局唯一实例。但这依赖调用方的环境存在这个单例类不一定总是可行。4.3 彻底封死继承链单例类的继承问题前面提过多次。如果你真的希望某个单例类不允许任何子类可以在元类里做限制。思路是在元类创建新类的时候检查这个新类是否继承自某个不允许继承的类如果是就直接抛异常class SingletonMeta(type): _instances {} _lock threading.Lock() def __new__(metacls, name, bases, namespace): for base in bases: if isinstance(base, SingletonMeta): raise TypeError(f{base.__name__} is a singleton and cannot be subclassed) return super().__new__(metacls, name, bases, namespace) def __call__(cls, *args, **kwargs): # 同前面 ...这段代码在定义子类的时候就会直接报TypeError让继承行为在编译期就结束。不过在业务项目里这种“封死继承”的做法有些激进因为有时你只是希望子类共享基类的单例逻辑并不是要完全禁止继承。所以这个手段适合那些对安全性和一致性要求极高的系统模块比如配置中心、权限管理器。5. 常见问题与实战排查5.1 为什么我的单例在测试框架里总是失效我见过不少人抱怨“我写的单例本地跑得好好的一到pytest里面执行每个测试用例拿到的对象都不一样。”听起来像是单例失效其实是测试框架的运行机制在捣乱。pytest在默认设置下每个测试文件运行在同一个进程里但不同测试之间如果需要隔离很可能用了pytest-forked、pytest-xdist这些插件它们会把不同的测试分发给不同的进程运行。单例的语义只在单进程内成立跨进程就各是各的了这是任何单例实现都无法改变的事。另外即使进程没变如果测试用例A修改了单例对象的属性测试用例B继承了这份被修改的状态就会出现非常诡异的相互影响。这种问题不是单例的“错”它的设计初衷就是全局共享但在测试环境下这就是灾难。我在真实项目中给出的方案是给单例类加一个_reset_for_test()方法专门用于测试环境重置实例classmethod def _reset_for_test(cls): cls._instances.pop(cls, None)然后在测试的setup_method或fixture里调用它。这个方法带上下划线前缀明确表示“仅测试使用”避免业务代码误调。5.2 单例模式到底该不该用一线开发的真实建议聊到这里你会发现单例的坑真的不少线程安全要处理、反序列化要防、继承要管、测试要隔离。那结论是不是“别用单例”也不至于。我的建议是三句话。第一如果只是全局共享一份不可变数据直接用模块级常量别写类。比单例模式更简单的东西永远是更好的选择。第二如果需要类对象且创建成本高元类单例是首选。它的类型语义最完整子类扩展也相对清晰配合双重检查锁和反序列化防护能应对绝大多数场景。第三如果全局状态是“可变”的而且变化频繁这时候要考虑的不是怎么把单例写得更安全而是这个单例设计本身合不合理。比如一个全局用户会话到处改来改去与其用单例还不如显式传递、依赖注入让数据流变得可追踪。单例模式的本质是把“依赖”隐藏了。你看到Config()就知道它是单例可用起来的时候你其实不会注意到它背后还有一个全局状态。这种隐藏有时候是便利有时候是隐患。5.3 一个小技巧用初始化标志防止重复初始化最后分享一个小技巧就是处理“同一个对象被反复调用构造函数”的问题。无论你选元类还是__new__只要别人调用Database()Python都一定会执行__init__。即使返回的是同一个实例属性也会被重新赋值这在某些场景下是灾难。解决方案是在__init__里加一个初始化的标志位class Database(metaclassSingletonMeta): def __init__(self, host): if hasattr(self, _initialized): return self.host host self._initialized True这里用hasattr而不是if self._initialized是因为第一次调用时属性还不存在。第一次初始化后_initialized设为True后续调用直接return保证初始化逻辑只执行一次。这个小技巧看起来简单但能避免不少定位困难的怪异bug因为重复初始化的表现往往不是报错而是静默覆盖状态。我在实际项目里见过最隐蔽的一个问题就是一个日志模块的单例因为在高并发下被反复初始化导致日志格式每次都被重置文件句柄反复打开关闭最后线上日志缺失了好几段。排查了很久才发现是__init__重复执行导致的当时加上这个初始化标志就解决了。这也是为什么我说写单例的时候不只是要管住实例的创建还要管住实例的初始化。
返回列表