Python类装饰器实战:用装饰器在类定义时加工类(及C++注册表视角)
一、当"给类加功能"开始重复:注册表、计时、单例、校验
写过一点 Python 的人,都见过函数装饰器——@staticmethod、@property、@lru_cache 那一套。但还有一类更狠的:类装饰器。它不是贴在函数上,而是贴在类上:
1 |
|
区别在于:函数装饰器接收"一个函数"、返回"一个函数";类装饰器接收"一个类"、返回"一个类"(或它的替身)。它在 class 语句执行完、类对象刚诞生的那一刻被调用,让你有机会当场加工这个类——加方法、改属性、登记进注册表、甚至换成单例。
什么场景会用到?四个最常见的:
- 插件注册表:定义
class BM25Scorer,希望它自动进全局registry,不用在别处再写一行register(...)。 - 统一计时/日志:给类里所有方法自动包一层耗时统计。
- 单例化:让某个配置类无论
new几次都返回同一实例。 - 字段校验:类一定义就检查"必须声明
table_name",否则直接报错。
换句话说,凡是"类定义时就要做点事"的需求,类装饰器都比"写完类再手动调一个函数"优雅。下面看它到底怎么写。
二、类装饰器是什么:输入类、输出类,类定义后立刻加工
最朴素的类装饰器,长相和普通函数装饰器一模一样,只是参数类型换成 cls:
1 | _registry = {} |
注意三点:
register(name)是装饰器工厂——因为要传参数name,所以外层函数返回真正的装饰器deco。不带参数的话,装饰器本身就直接是def deco(cls): ...。deco(cls)接收的是已经创建好的类对象(不是实例)。return cls——最常见、也最安全的写法,是"就地加工后把原类还回去"。
再看一个"给类加方法"的装饰器:
1 | def add_repr(cls): |
cls.__repr__ = __repr__ 这一步,本质就是在类定义后动态往类体里塞方法——和你在类里手写 def __repr__ 完全等价,只不过时机被挪到了装饰器里。cls.__name__ 在闭包里捕获的是被装饰类的名字,所以换个类也不会串。
三、深入:带参装饰器、保留元信息、装饰器叠加、与 init_subclass 怎么选
(1) 带参数的类装饰器要套三层。 和最上面 register 一样,外层接参数、中层是真正的装饰器、内层加工类:
1 | def with_timeout(seconds): |
(2) 元信息陷阱:别让装饰器"调包"了类。 类装饰器最经典的坑,是返回了一个函数而不是类。比如下面这个"单例"装饰器:
1 | def singleton_bad(cls): |
Config 这个名字现在绑定的是 get_instance 函数,isinstance、子类化、pickle 序列化、类型注解全崩。
正确写法:就地改 __new__,把类本身还回去:
1 | def singleton(cls): |
一句话准则:类装饰器应返回"类",而非"包装函数"——除非你真的想要一个可调用对象。
(3) 多个装饰器自下而上叠加。 离类最近的先执行:
1 |
|
执行顺序是 add_repr(Point) → register("point")(结果):先挂 __repr__,再登记。想让 register 拿到"带 __repr__ 的版本",就把 register 放最外层,如上。
(4) 类装饰器 vs __init_subclass__:怎么选? 两者都能"类出现时自动做事",但机制不同:
1 | class PluginBase: |
| 维度 | 类装饰器 | __init_subclass__ |
|---|---|---|
| 触发时机 | 该类被 @ 显式装饰时 |
任何类继承该类时自动 |
| 可控性 | 想用才贴,精确控制单个类 | 所有子类强制触发,难局部豁免 |
| 是否改类身份 | 可(若返回新类) | 不改,只是钩子 |
| 适合 | 一次性加工、显式声明(如 @register) |
框架级 uniform 行为(所有插件都要注册) |
经验法则:要"显式、只对这一个类做",用装饰器;要"凡是我家子类都必须做",用 __init_subclass__。
四、实战:C++ 视角 + 前司 BM25 检索的"策略注册表"血案
C++ 等价物:没有类装饰器,只能手写注册表 + 工厂。 C++ 没有"类定义时自动执行一段代码"的语法糖,要搞插件注册,得自己维护一个 map<string, 工厂函数>:
1 |
|
差异很清楚:Python 的 @register("bm25") 让"类定义即注册",声明和登记同一行完成;C++ 得先定义类、再在别处手写一行 registry[...] = ...(工程上常靠宏或 CRTP 静态初始化来模拟,但都更绕)。装饰器把"注册"这件事,从"易漏的手动步骤"变成了"声明即生效的副作用"。
前司检索系统的真实血案。 我们做 OA 全文检索(基于 BM25 + 倒排索引)时,分词器、打分器、排序器都是可插拔的"策略"。早期是一坨 if type == "bm25": ... elif type == "tfidf": ...,新增一种策略要改三处。我们遂引入类装饰器 @register_strategy("bm25") 让每个策略类"自报家门"自动进注册表——代码清爽了。
但第一版 @register_strategy 犯了上面第二节的元信息坑:它为了"顺便给类加个 from_name() 类方法",内部返回了一个包装函数而非原类。结果在 Celery 异步任务里,分发器用 isinstance(obj, StrategyBase) 做类型判断直接 TypeError,任务批量失败——因为 StrategyBase 这个名字已被装饰器悄悄换成了函数。修法是把装饰器改成"就地给 cls 挂方法、return cls",类身份保住后 isinstance/序列化全部恢复正常。
这再次印证前司 BM25 那条老经验:装数据的容器、定义行为的类,身份都不能被偷偷调包——可变默认 = {} 会漏状态(dataclass 那篇讲过),类装饰器返回函数则会毁身份。两者的根因相通:装饰器的"副作用"必须作用在"原对象"上,而不是用替身把它换掉。
五、对比总结:函数装饰器 / 类装饰器 / 元类 / init_subclass
| 维度 | 函数装饰器 | 类装饰器 | 元类(metaclass) |
__init_subclass__ |
|---|---|---|---|---|
| 接收对象 | 函数 | 类 | 类(在创建前) | 子类(创建后) |
| 触发时机 | 函数定义时 | 类定义时 | 类创建时(更早) | 子类定义时 |
| 能否改类身份 | 否(返回函数) | 可(返回类/新类) | 可(完全控制类的诞生) | 否(只钩子) |
| 复杂度 | 低 | 低–中 | 高 | 中 |
| 典型用途 | 计时/缓存/权限 | 注册/单例/加方法/校验 | 深度定制类创建(ORM 字段收集) | 框架级子类统一行为 |
| 可读性 | 好 | 好 | 差(劝退新人) | 好 |
一句话本质:类装饰器是在"类已经造好"之后出场的那把手术刀——它比元类轻、比 __init_subclass__ 灵活、比"定义完再手动调函数"省心。但它也最容易被误用成"返回包装函数",从而悄悄调包类的身份。记住三条:① 想用才贴,精确控制单个类;② 除非有意,永远 return cls;③ 框架级"所有子类都要做"的事,交给 __init_subclass__。
小结:类装饰器接收"类对象"、返回"类对象",在
class语句执行后立刻加工类(加方法/挂属性/登记注册表/改构造),是"类定义即做事"的轻量手段;带参装饰器需工厂三层嵌套,return cls是铁律(否则会把类调包成函数,毁掉isinstance/序列化/子类化,前司 BM25 检索的注册表就因返回包装函数在 Celery 里批量TypeError);多装饰器自下而上叠加;与__init_subclass__的取舍是"显式单个用装饰器、框架级全体子类用钩子";C++ 无类装饰器,插件注册只能手写map<string, 工厂>再手动登记,Python 的@register让"声明即注册"省掉易漏的那一行——装数据的容器与定义行为的类,身份都不该被副作用偷偷换掉。

