重构旧项目那阵子,最烦的不是业务逻辑本身,而是散落在几十个类里的日志埋点和权限判断。复制粘贴到第三遍的时候我决定换个打法——用元编程把这些横切逻辑收拢到一处。改完之后代码量掉了大半,后面加新模型也不用再惦记着补样板。下面这几招是真正在项目里跑过的,不是教科书演示。
装饰器不止能包函数,还能改造整个类
函数装饰器大家都会写,但类装饰器在批量注入行为时更省事。比如给所有数据模型统一挂一个 get_or_create,逐个类手写既啰嗦又容易漏。类装饰器一次搞定:
def auto_get_or_create(cls):
def get_or_create(self, kwargs):
obj = cls.objects.filter(kwargs).first()
if not obj:
obj = cls(**kwargs)
obj.save()
return obj
cls.get_or_create = get_or_create
return cls
在类头上加一行 @auto_get_or_create 就能用。这里有个容易混淆的点:类装饰器是在类对象已经构建完成之后才动手改它,而元类介入的是类创建过程本身。前者适合”事后打补丁”,后者适合”出生即定型”。选错了手段,代码会别扭。
描述符做惰性缓存,顺手避开两个坑
描述符是元编程里最值得花时间啃的机制。我做过一个计算密集的数据管道,属性每次读取都要判断缓存是否失效,用普通 property 写出一堆重复样板。换成描述符后清爽很多:
class CachedProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
if self.name not in instance.__dict__:
instance.__dict__[self.name] = self.func(instance)
return instance.__dict__[self.name]
第一次访问触发计算,结果塞进实例字典,后续直接命中。数据管道跑下来快了约三倍。
但这段代码上线前得想清楚两件事。其一,如果宿主类定义了 slots,实例根本没有 dict,赋值那行会直接抛 AttributeError。要么去掉 slots,要么把缓存挪到 WeakKeyDictionary 里按实例索引。其二,CachedProperty 只实现了 get,属于非数据描述符,优先级低于实例属性——一旦有人手动给同名属性赋值,缓存就被静默覆盖,且不会失效。需要强一致的话,补一个 set 让它变成数据描述符,或者在 set 里清缓存。

元类负责”出生登记”,注册路由最典型
元类常被扣上过度设计的帽子,但在框架层做自动注册确实好用。写微服务框架时,所有继承 BaseService 的类都该把公开方法登记进路由表,靠人工维护迟早出错。元类在类定义那一刻就能完成收集:
class ServiceMeta(type):
def __new__(cls, name, bases, dct):
new_class = super().__new__(cls, name, bases, dct)
if hasattr(new_class, '__rpc_methods__'):
for attr in dct:
if callable(dct[attr]) and not attr.startswith('_'):
ServiceMeta._register_method(new_class, attr, dct[attr])
return new_class
团队新人写服务类,什么都不用配就自动接入。注意遍历的是 dct 而非 dir(new_class),前者只含本类新定义的成员,不会把父类方法重复注册一遍。
属性校验交给 set_name 和 init_subclass
数据校验如果每个字段手写断言,代码会迅速膨胀。用描述符拦截赋值,再配合 init_subclass 让子类自动获得校验能力,业务层就干净了:
class ValidatedAttribute:
def __set_name__(self, owner, name):
self.name = name
def __set__(self, instance, value):
if not isinstance(value, (int, float)):
raise TypeError(f'{self.name} must be numeric')
instance.__dict__[self.name] = value
set_name 是 Python 3.6 引入的钩子,类创建时由解释器自动调用,把属性名回传给描述符,省去了手动传名字的麻烦。低于 3.6 的版本没有这个机制,只能退回在 init 里显式赋值。另外 set_name 抛出的异常会被包装成 RuntimeError 向上冒泡,调试时别被这层包装迷惑。
动态生成类:能用 type() 就别用 exec
配置系统里根据 YAML 自动生成数据类,很多人第一反应是拼字符串丢给 exec。能跑,但命名空间污染和安全边界都难控。更稳的做法是 type() 三参形式或 types.new_class:
fields = {'host': str, 'port': int}
Config = type('Config', (), {k: None for k in fields})
需要更复杂的构建流程(比如处理 prepare)时用 types.new_class,它把元类解析、命名空间准备这些细节都封装好了。exec 不是不能用,但只该出现在输入完全可信、且确实没有替代路径的场景。把外部配置直接喂给 exec,等于把执行权交出去。
收尾
元编程的价值不在炫技,而在于把重复的、机械的、容易漏的代码收进运行时机制里。装饰器管横切注入,描述符管属性访问,元类管类创建期的登记,动态构建管批量生成。每样都有明确的适用边界,越界使用就是给自己埋雷。从小处动手,先挑一个重复最多的模块试,跑通了再铺开。
ID:7106