一、当"给类加功能"开始重复:注册表、计时、单例、校验

写过一点 Python 的人,都见过函数装饰器——@staticmethod、@property、@lru_cache 那一套。但还有一类更狠的:类装饰器。它不是贴在函数上,而是贴在类上:

1
2
3
@some_decorator
class MyClass:
...

区别在于:函数装饰器接收"一个函数"、返回"一个函数";类装饰器接收"一个类"、返回"一个类"(或它的替身)。它在 class 语句执行完、类对象刚诞生的那一刻被调用,让你有机会当场加工这个类——加方法、改属性、登记进注册表、甚至换成单例。

什么场景会用到?四个最常见的:

  • 插件注册表:定义 class BM25Scorer,希望它自动进全局 registry,不用在别处再写一行 register(...)。
  • 统一计时/日志:给类里所有方法自动包一层耗时统计。
  • 单例化:让某个配置类无论 new 几次都返回同一实例。
  • 字段校验:类一定义就检查"必须声明 table_name",否则直接报错。

换句话说,凡是"类定义时就要做点事"的需求,类装饰器都比"写完类再手动调一个函数"优雅。下面看它到底怎么写。

二、类装饰器是什么:输入类、输出类,类定义后立刻加工

最朴素的类装饰器,长相和普通函数装饰器一模一样,只是参数类型换成 cls:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_registry = {}

def register(name):
def deco(cls):
_registry[name] = cls # 把类登记进全局字典
return cls # 关键:把原类原样返回
return deco

@register("bm25")
class BM25Scorer:
def score(self, text):
return 0.0

print(_registry)
# {'bm25': <class '__main__.BM25Scorer'>}

注意三点:

  1. register(name) 是装饰器工厂——因为要传参数 name,所以外层函数返回真正的装饰器 deco。不带参数的话,装饰器本身就直接是 def deco(cls): ...。
  2. deco(cls) 接收的是已经创建好的类对象(不是实例)。
  3. return cls——最常见、也最安全的写法,是"就地加工后把原类还回去"。

再看一个"给类加方法"的装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
def add_repr(cls):
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__ # 直接往类上挂方法
return cls

@add_repr
class Point:
def __init__(self, x, y):
self.x, self.y = x, y

print(Point(1, 2)) # Point(x=1, y=2)

cls.__repr__ = __repr__ 这一步,本质就是在类定义后动态往类体里塞方法——和你在类里手写 def __repr__ 完全等价,只不过时机被挪到了装饰器里。cls.__name__ 在闭包里捕获的是被装饰类的名字,所以换个类也不会串。

三、深入:带参装饰器、保留元信息、装饰器叠加、与 init_subclass 怎么选

(1) 带参数的类装饰器要套三层。 和最上面 register 一样,外层接参数、中层是真正的装饰器、内层加工类:

1
2
3
4
5
6
7
8
9
10
11
def with_timeout(seconds):
def deco(cls):
cls._timeout = seconds # 把配置写进类属性
return cls
return deco

@with_timeout(30)
class Fetcher:
pass

print(Fetcher._timeout) # 30

(2) 元信息陷阱:别让装饰器"调包"了类。 类装饰器最经典的坑,是返回了一个函数而不是类。比如下面这个"单例"装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def singleton_bad(cls):
instances = {}
def get_instance(*a, **k): # ← 这是一个函数
if cls not in instances:
instances[cls] = cls(*a, **k)
return instances[cls]
return get_instance # ← 把类"调包"成了函数!

@singleton_bad
class Config:
def __init__(self, path):
self.path = path

c = Config("a.json")
print(c.path) # a.json (实例本身没问题)
print(Config) # <function get_instance ...> ← Config 已不是类!
print(isinstance(c, Config)) # TypeError: isinstance() arg 2 must be a type

Config 这个名字现在绑定的是 get_instance 函数,isinstance、子类化、pickle 序列化、类型注解全崩。

正确写法:就地改 __new__,把类本身还回去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def singleton(cls):
_instances = {}
def __new__(cls_, *a, **k):
if cls_ not in _instances:
_instances[cls_] = object.__new__(cls_) # 只控制"是否新建"
return _instances[cls_]
cls.__new__ = __new__ # 只覆盖构造逻辑,类身份不变
return cls # ← 仍是类

@singleton
class Config:
def __init__(self, path):
self.path = path

c1 = Config("a.json")
c2 = Config("b.json")
print(c1 is c2, c1.path) # True a.json (第二次 new 被忽略,沿用首次实例)

一句话准则:类装饰器应返回"类",而非"包装函数"——除非你真的想要一个可调用对象。

(3) 多个装饰器自下而上叠加。 离类最近的先执行:

1
2
3
4
5
@register("point")
@add_repr
class Point:
def __init__(self, x, y):
self.x, self.y = x, y

执行顺序是 add_repr(Point) → register("point")(结果):先挂 __repr__,再登记。想让 register 拿到"带 __repr__ 的版本",就把 register 放最外层,如上。

(4) 类装饰器 vs __init_subclass__:怎么选? 两者都能"类出现时自动做事",但机制不同:

1
2
3
4
5
6
7
8
9
class PluginBase:
_registry = {}
def __init_subclass__(cls, name=None, **kw):
super().__init_subclass__(**kw)
if name:
PluginBase._registry[name] = cls

class Foo(PluginBase, name="foo"): # 继承即自动注册,无需装饰器
pass
维度 类装饰器 __init_subclass__
触发时机 该类被 @ 显式装饰时 任何类继承该类时自动
可控性 想用才贴,精确控制单个类 所有子类强制触发,难局部豁免
是否改类身份 可(若返回新类) 不改,只是钩子
适合 一次性加工、显式声明(如 @register) 框架级 uniform 行为(所有插件都要注册)

经验法则:要"显式、只对这一个类做",用装饰器;要"凡是我家子类都必须做",用 __init_subclass__。

四、实战:C++ 视角 + 前司 BM25 检索的"策略注册表"血案

C++ 等价物:没有类装饰器,只能手写注册表 + 工厂。 C++ 没有"类定义时自动执行一段代码"的语法糖,要搞插件注册,得自己维护一个 map<string, 工厂函数>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <map>
#include <string>
#include <functional>
#include <memory>

struct Scorer { // 抽象基类
virtual double score(const std::string&) = 0;
virtual ~Scorer() = default;
};

// 全局注册表:名字 -> 工厂
std::map<std::string, std::function<std::unique_ptr<Scorer>()>> registry;

struct BM25Scorer : Scorer {
double score(const std::string&) override { return 0; }
};

// 必须在别处手动注册一次!
registry["bm25"] = [] { return std::make_unique<BM25Scorer>(); };

差异很清楚: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 让"声明即注册"省掉易漏的那一行——装数据的容器与定义行为的类,身份都不该被副作用偷偷换掉。