1. 先弄明白“属性附加”的机制:为什么一道setattr下去就打架
我之前在一个项目里遇到过这样的场景:两个不同的插件模块,都要往同一个核心业务对象上挂一个名为meta的附加属性,A插件想存自己的状态标记,B插件也想存自己的状态标记。当时图省事,两边直接obj.meta = ...,结果A写完B写,后写的把先写的覆盖得干干净净,查了半天才发现问题出在Python属性存储的基本机制上。
1.1 实例属性到底存在哪里
Python里绝大多数对象都有一个__dict__字典,专门用来存放实例自己的属性。你执行obj.meta = 123的时候,本质上是在obj.__dict__['meta'] = 123里写入一个键值对。读的时候也简单,obj.meta会先查obj.__dict__,查不到再去类的继承链上找。
这个机制天然意味着:同一个对象身上,一个属性名只有一份存储。两个类都往同一个对象上塞同名属性,如果不做额外处理,那就是同一个坑位,谁后到谁占。想“彼此隔离”,本质上是让两个类各自的写入目标,别再指向同一个obj.__dict__键。
1.2 同名属性的覆盖过程
看一段最直接的复现:
class PluginA: def attach(self, obj, value): obj.meta = value class PluginB: def attach(self, obj, value): obj.meta = value obj = SimpleNamespace() PluginA().attach(obj, "A的数据") print(obj.__dict__) # {'meta': 'A的数据'} PluginB().attach(obj, "B的数据") print(obj.__dict__) # {'meta': 'B的数据'} A的丢了这里SimpleNamespace只是为了让示例简短,换成普通类也一样。关键在于两次obj.meta = ...写的是同一个键。很多人第一反应是“给对象加一个容器字典”,比如:
class PluginA: def attach(self, obj, value): if not hasattr(obj, '_plugin_data'): obj._plugin_data = {} obj._plugin_data['A'] = value这能行,但不够优雅,等于把“数据存哪”的约定暴露给了使用者,而且两个插件都得遵守同一个私有属性名,将来谁手一滑把_plugin_data覆盖了,又是一坑。
1.3 数据描述符与实例字典的优先级次序
真正值得利用的是描述符协议。Python的属性查找顺序里,数据描述符的优先级高于实例字典。也就是说,如果一个类里定义了实现了__get__和__set__的描述符属性,那么实例的obj.attr = value不会直接写进obj.__dict__,而是会走描述符的__set__方法。
这个优先级是我们做隔离的关键:可以把目标对象的类设计成带描述符的形态,让两个插件分别通过各自的描述符写入,两个描述符内部调用object.__setattr__往obj.__dict__里的不同私有键写数据。外部看属性名可能很相似,但实际存储键完全不同。
2. 方案一:利用描述符把同名属性写进不同的私有存储键
先明确一点:如果两个插件真的都要用obj.meta = ...这个形式,那光靠描述符还不够,因为同一个对象的同一个属性名只能绑定一个描述符。但我们可以让描述符接收入参,或者让两个插件各自持有不同的描述符实例,实际写入obj.__dict__的不同内部键。
2.1 描述符模板与set_name生命周期
描述符的标准写法是这样的。注意 Python 3.12 之前,__set_name__会在类创建时自动调用,把描述符所在的属性名告诉描述符;Python 3.13 又把这块逻辑挪到了type.__setattr__,但最终效果一致——我们依然可以用它记录属性名。
class IsolatedAttribute: def __init__(self, storage_key): self.storage_key = storage_key self.public_name = None def __set_name__(self, owner, name): self.public_name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.storage_key, None) def __set__(self, obj, value): # 直接写到私有键,不触发其他覆盖逻辑 obj.__dict__[self.storage_key] = value这里storage_key是真正落在对象__dict__里的键名,public_name只是给调用方看的。两个插件可以各建各的描述符实例,各自指向不同的storage_key,这样即便挂在同一个对象上,数据也互不干扰。
2.2 如何保证“同名属性”但存储互不干扰
实际操作中,我更推荐把描述符放在一个公共基类上,让两个插件类分别持有实例:
class BaseModel: plugin_a_data = IsolatedAttribute(storage_key="_isolated_a") plugin_b_data = IsolatedAttribute(storage_key="_isolated_b") class PluginA: def attach(self, obj, value): obj.plugin_a_data = value class PluginB: def attach(self, obj, value): obj.plugin_b_data = value obj = BaseModel() PluginA().attach(obj, "A的标记") PluginB().attach(obj, "B的标记") print(obj.__dict__) # {'_isolated_a': 'A的标记', '_isolated_b': 'B的标记'} print(obj.plugin_a_data) # A的标记 print(obj.plugin_b_data) # B的标记两个插件写入的属性名看着不一样,但问题是标题里明确说“相同属性”。如果业务上实在要求外部公开名必须一样,比如两边都叫meta,那就再包一层:
class BaseModel: meta = IsolatedAttribute(storage_key="_base_meta") class PluginA: def attach(self, obj, value): # 通过描述符写入,落点是 _base_meta obj.meta = value这样两个插件操作的公开属性名都是meta,但存储键换成了_base_meta这类私有键。如果再要细粒度隔离,就把storage_key也做成动态的,按插件名区分:
def make_plugin_data(plugin_name): return IsolatedAttribute(storage_key=f"_plugin_data_{plugin_name}") class BaseModel: a_data = make_plugin_data("A") b_data = make_plugin_data("B")A插件和B插件各自通过obj.a_data、obj.b_data读写,公开名不同但存储逻辑完全隔离。如果你偏执一点,要求两边连公开名都相同,那建议直接看下一节的路由表方案。
2.3 边界:__slots__类、vars()可见性
用描述符方案有几个边界条件要提前知道。首先,如果目标对象用了__slots__并且没有把__dict__加进去,那obj.__dict__根本不存在,上面的写法会直接抛AttributeError。解决办法是把存储位置从实例移到别的容器,或者给__slots__里加上'__dict__'。不过后者等于放弃了__slots__最重要的省内存优势,要权衡。
其次,obj.__dict__里能看到_isolated_a这种私有键,一些序列化工具(比如copy.copy、pickle)会把这些键一起带走。好处是数据完整,坏处是如果你希望“外界完全看不到附加数据”,描述符方案就不够隐蔽。
另外,描述符__get__里我用了obj.__dict__.get(...),没有处理“描述符被类直接访问”的情况(即objtype参数)。上面代码里已经做了obj is None时返回描述符自身,但如果你要支持通过类名访问默认值,需要再在__get__里加一层对objtype的判断。
3. 方案二:全局路由表+弱引用,最简单直接的隔离容器
如果说描述符方案是在“对象的属性空间”里做文章,那路由表方案就是完全绕开属性空间,把附加数据放到对象外部的一个容器里,用对象 ID 或对象的弱引用作为钥匙。这个方案最大的好处是:目标类不用做任何改造,普通实例拿到手就能挂数据。
3.1 用WeakValueDictionary按实例ID路由
我先说一个不算最优但很容易理解的写法:
import weakref class PluginRegistry: _registry = weakref.WeakValueDictionary() @classmethod def _table(cls, obj): # 每个对象有自己的子字典,再按插件名隔离 key = id(obj) if key not in cls._registry: cls._registry[key] = obj # 存的是对象本身,不是子表 return key @classmethod def set(cls, obj, plugin_name, value): key = id(obj) if key not in cls._registry: cls._registry[key] = obj # 真正的隔离数据还是挂到 obj 的一个私有属性上,确保对象不回收 if not hasattr(obj, '_plugin_sidecar'): object.__setattr__(obj, '_plugin_sidecar', {}) obj.__dict__['_plugin_sidecar'][plugin_name] = value @classmethod def get(cls, obj, plugin_name): sidecar = getattr(obj, '_plugin_sidecar', None) if sidecar is None: return None return sidecar.get(plugin_name)等等,这个写法有点绕,而且最后还是往对象上挂了属性。我需要把它改得更干净一些。真正干净的弱引用方案是这样的:用一个WeakKeyDictionary,键是目标对象,值是各插件的专属字典。但WeakKeyDictionary要求键可哈希,普通对象默认可哈希,没问题。
import weakref class PluginSidecar: def __init__(self): self._namespaces = weakref.WeakKeyDictionary() def put(self, obj, plugin_name, value): table = self._namespaces.get(obj) if table is None: table = {} self._namespaces[obj] = table table[plugin_name] = value def get(self, obj, plugin_name): table = self._namespaces.get(obj) if table is None: return None return table.get(plugin_name) sidecar = PluginSidecar() obj = SomeBusinessObject() sidecar.put(obj, "PluginA", "A的数据") sidecar.put(obj, "PluginB", "B的数据") print(sidecar.get(obj, "PluginA")) # A的数据 print(sidecar.get(obj, "PluginB")) # B的数据这个方案里,obj本身没有任何变化,数据全在sidecar的_namespaces里。WeakKeyDictionary会弱引用键,所以当obj不再被外部引用时,对应条目会被自动清掉,不用担心内存泄漏。
3.2 不可哈希对象的处理与id复用风险
有个容易踩的坑:如果业务对象重写了__eq__但没重写__hash__(或者直接把__hash__设为None),对象会变成不可哈希的,WeakKeyDictionary会直接报TypeError: unhashable type。
处理方式有两种。一种是干脆不用弱引用,改用id(obj)作为WeakValueDictionary的键。但要小心:id可以被复用。一个对象被回收之后,Python 可能把同一块内存地址分给下一个对象,这时旧路由表里残留的数据就到了新对象头上。所以用id时一定要想办法确认对象没被回收,最实用的做法是维护一个“id -> 弱引用对象”的映射,取数据时通过弱引用判断对象是否还活着:
import weakref class IdBasedSidecar: def __init__(self): self._entries = {} self._refs = weakref.WeakValueDictionary() def put(self, obj, plugin_name, value): ref = weakref.ref(obj) key = id(obj) entry = self._entries.get(key) if entry is None: entry = {} self._entries[key] = entry self._refs[key] = ref entry[plugin_name] = value def get(self, obj, plugin_name): ref = self._refs.get(id(obj)) if ref is None or ref() is not obj: # id 对应的对象已经换人了,清掉旧数据 self._entries.pop(id(obj), None) return None entry = self._entries.get(id(obj)) if entry is None: return None return entry.get(plugin_name)第二种办法是给对象包一个可哈希的壳子,用这个壳子做WeakKeyDictionary的键。但这种方案会让业务代码多一层封装,侵入性更大,一般只有在目标对象不可哈希、又不愿意改业务结构时才用。
3.3 对真实业务来说意味着什么
路由表方案我在一个实际项目里验证过。当时我在写一个轻量级的事件追踪工具,外部事件源会往同一个order对象上打各种标记,有的标记来自风控模块,有的来自优惠券模块,两边各自维护自己对这笔订单的判断结果。用WeakKeyDictionary之后,核心订单类完全不用感知“身上挂了什么”,模块间也不存在任何属性名冲突的隐患。
代价是访问速度稍微慢一点:普通属性访问走的是__dict__的一个字典查询,路由表方案要多走一次外层映射,再加上一次内层字典查询。在每秒几万次的访问量级下这个差距可以忽略,但如果是热路径上的高频读写,还是建议用描述符方案,少一层间接调用。
4. 方案三:通过元类__setattr__做写入拦截,让每个类各管各的
有时候我们拿到的业务对象是别人定义的,没法给它加描述符,也不好要求它可哈希。这时候可以在“操控方”这一侧做文章:给插件类定义一个元类,在元类的__setattr__里对特定名字的写入做拦截,转存到独立容器。
4.1 元类拦截的完整示例
场景设定:PluginA 类负责往目标对象上挂meta,PluginB 类也往同一个目标对象挂meta,二者互不可见。插件类本身是普通类,元类拦截的是插件实例收到setattr时的行为。
class IsolatedMeta(type): def __setattr__(cls, name, value): # 这里拦截的是给类本身设置属性,不是给实例设置 # 但我们可以借这个入口做全局路由 if name == "attach_meta": raise AttributeError("请使用 attach 方法") super().__setattr__(name, value)但这样拦的是类属性,不是插件实例往业务对象上挂数据。所以准确说,应该拦的是插件实例的setattr:
class PluginBase: def __init__(self, storage_name): self._storage_name = storage_name def __setattr__(self, name, value): if name == "meta": # 这里拿到的是插件对象自己的属性写入 # 但我们的目标是写入到“目标业务对象” raise TypeError("不要直接设置 meta,调用 attach_to(obj, value)") super().__setattr__(name, value) def attach_to(self, obj, value): # 通过路由容器写入 registry = get_registry() registry.put(obj, self._storage_name, value)注意这跟标题的“给同一实例附加相同属性”不完全是一个方向——标题说的是给同一个业务实例附加属性,而不是给插件类附加属性。但实际业务里,这个元类方案更像是一种“代码纪律约束”:通过拦截和报错,强制两边都走统一的attach_to方法,避免有人图省事直接写obj.meta = ...。这种方案适合团队协作场景,防的是人祸,而不是机制缺陷。
如果非要实现“元类层面的自动拦截”,可以这样组合:业务对象的类使用我们定义的元类,且业务对象没有__slots__,然后元类的__setattr__根据obj的某个身份标记做路由。
class BusinessMeta(type): def __setattr__(cls, name, value): # 类属性设置正常走 object.__setattr__(cls, name, value) class BusinessBase(metaclass=BusinessMeta): pass class RealBusinessObject(BusinessBase): pass但这里有个绕不过去的点:obj.meta = value会先查找obj.__class__的 MRO 中meta是不是数据描述符,如果不是,就直接写实例__dict__。元类的__setattr__拦不住实例属性写入,实例属性写入走的是type(obj).__setattr__或者说object.__setattr__,和类的元类不是一个层级。所以这条路本质上还是需要配一个描述符,否则没法把同一属性名引到不同存储。
4.2 描述符的隐藏陷阱:递归、MRO、属性名覆盖
真正把元类和描述符结合时,坑都在细节里。先看一个常见错误:
class IsolatedDescriptor: def __set__(self, obj, value): # 直接 obj.__dict__[self.key] = value 可以 # 但如果写成 setattr(obj, self.key, value) 会怎样? setattr(obj, self.key, value) # 危险在__set__里调用setattr(obj, self.key, value),如果self.key正好等于实例上另一个描述符的名字,会再次触发那个描述符的__set__,甚至无限递归。正确做法是用object.__setattr__或者直接操作obj.__dict__。
另外,如果在同一个类里两条描述符指向同一个公开属性名,后定义的那个会覆盖先定义的。比如:
class BaseModel: meta_a = IsolatedDescriptor("_a") meta_b = IsolatedDescriptor("_b") # 不会覆盖 meta_a,因为名字不同但如果写成:
class BaseModel: meta = IsolatedDescriptor("_a") meta = IsolatedDescriptor("_b") # 这个会覆盖上面的 meta,类体里同名绑定就是覆盖这样meta_a描述符直接从类里消失了。所以描述符方案里,公开名和存储键都需要刻意设计,不要以为隔离存储就能解决一切命名问题。
4.3 三种方案对比
| 维度 | 描述符方案 | 弱引用路由表 | 元类/拦截方案 |
|---|---|---|---|
| 目标类是否要改造 | 需要加描述符属性 | 完全不需要 | 需要业务类配合元类或方法 |
| 属性名是否可完全同名 | 可做到公开名相同,存储键不同 | 可完全同名,按插件名隔离 | 可完全同名,按调用方隔离 |
| 对未哈希对象支持 | 不受影响,走对象自身 | 不可哈希对象需特殊处理 | 不受影响 |
| 是否泄漏到vars() | 会看到私有键 | 看不到,数据完全在外面 | 看不到 |
| 内存清理 | 依赖对象自身 | 弱引用自动清 | 需手动清理或依赖外部容器 |
| 性能 | 最快,两次字典查询 | 慢一些,外层+内层两次映射 | 描述符方案性能优于拦截方案 |
如果让我给一个选型建议:
- 目标类是自己维护的核心模型,追求速度和明确归属,选描述符。
- 目标是第三方类、不可哈希或不想侵入业务类,选弱引用路由表。
- 目标是团队协作项目,与其让大家自律,不如用元类+描述符做硬约束,拦截不规范写法。
我在实际项目中基本只用前两种。第三种适合做框架设计,单独为“两个类给同一实例挂同名属性”这种场景上元类,有点大炮打蚊子。
5. 实盘验证:一次完整的隔离属性注入过程
前面讲了不少原理和方案,这里用一个完整示例把“描述符+kiss原则”串起来。以订单系统为例,有两个服务类:风控服务和优惠券服务,都要往订单对象上写一个名为risk_score的属性,但语义不同,彼此隔离。
5.1 定义带隔离描述符的基础订单类
class IsolatedScore: def __init__(self, storage_key): self.storage_key = storage_key self.name = None def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return None return obj.__dict__.get(self.storage_key, None) def __set__(self, obj, value): obj.__dict__[self.storage_key] = value class Order: risk_score = IsolatedScore(storage_key="_risk_score_fc") coupon_score = IsolatedScore(storage_key="_risk_score_coupon") def __init__(self, order_id): self.order_id = order_id这里_risk_score_fc和_risk_score_coupon是两个存储键,前一个给风控用,后一个给优惠券逻辑用。对外暴露的属性名分别是risk_score和coupon_score,如果你想两边都叫同一个名字,可以让两个服务各自持有不同描述符对象,用同样的__set_name__结果。
看到这里可能有人问:为什么不干脆给Order加两个普通属性,比如risk_score_fc和risk_score_coupon?其实在业务语义不明显时,普通属性确实够用。但描述符的价值在于:它把“写入到哪里”和“取出来怎样默认”封装了起来,将来如果改成存储到 Redis 或者其它外部缓存,只需改描述符内部实现,调用方代码不用动。
5.2 两个服务类分别写入
class RiskControlService: def evaluate(self, order, score): order.risk_score = score class CouponService: def evaluate(self, order, score): order.coupon_score = score order = Order("20250901-001") RiskControlService().evaluate(order, 85) CouponService().evaluate(order, 60) print(order.risk_score) # 85 print(order.coupon_score) # 60 print(order.__dict__) # {'order_id': '20250901-001', '_risk_score_fc': 85, '_risk_score_coupon': 60}两个服务操作不同描述符,写入互不干扰。而且因为描述符是数据描述符,即使有人直接在别处执行order.__dict__['risk_score'] = 999,读的时候依然走描述符,拿到的是storage_key对应值,避免了外部绕过。
5.3 实测中发现的三个坑
第一,序列化时的键名问题。json.dumps(order.__dict__)会把_risk_score_fc这种私有键一起输出,如果前端或者下游系统看到这个键名会困惑,可以在描述符里增加一个to_serializable方法,或者给订单类实现__getstate__,在序列化时过滤掉下划线开头的键。
第二,多线程环境下的写入原子性。obj.__dict__[self.storage_key] = value是一个赋值操作,CPython 的 GIL 保证了单条字节码层面的原子性,但在极端并发下,A线程刚写完,B线程又把整个__dict__替换了(如果有人执行obj.__dict__ = new_dict),那数据还是会被冲掉。我们项目里直接在入口服务加了分布式锁,不在属性层纠结并发。
第三,类方法访问描述符时的None处理。我在__get__里写了if obj is None: return None,这意味着Order.risk_score拿到的是None而不是描述符对象。如果团队里有人想通过类直接访问描述符做某些初始化判断,可能拿不到预期值。更稳妥的写法是return self,但这样会在Order.risk_score处暴露描述符对象,也不是所有场景都合适。这个取舍要根据你的实际使用方式来定。
6. 绕不开的边界情况与我的最终建议
聊到这里,标题的问题其实已经给出了三条可落地的路径。但在接手这个需求时,还有几件事值得先反问自己,否则选错方案会白折腾。
一是“隔离”的粒度。是A类和B类之间互不可见,还是A类自己写的多次值之间也不能覆盖?如果是后者,那就不要在属性层做文章了,应该用队列或历史表,因为属性天生只保存一个“当前值”。如果只是两个服务类之间互不覆盖,描述符和路由表方案都够用。
二是“相同属性”到底有多相同。如果业务模型里,A类往订单上写meta,B类也写meta,但两个meta的意义完全不同,我倾向于认为这是建模问题,而不是技术问题——你需要的不是一个同名属性,而是两个语义清晰的属性,比如risk_meta和coupon_meta。强行让它们同名只会增加后续阅读负担。
三是对象生命周期。路由表方案里,弱引用会自动清理,但如果你用了id(obj)做键的普通字典方案,又忘了清,那么短生命周期对象频繁创建销毁时,字典会不断膨胀。我在一个爬虫项目里就吃过这个亏:每分钟创建几千个临时对象,路由表只增不减,最后内存飙到了1.2G。后来换成WeakKeyDictionary,内存稳在了300M以内。
综合来说,我最推荐的做法是:优先看业务对象能不能加描述符,能加就加,代码最直观;不能加或不想侵入,就用WeakKeyDictionary路由表;元类方案只在需要硬性约束团队写法时才考虑。
如果你现在就要动手,我从实操角度给你一个最小实现模板:
import weakref class IsolatedSidecar: def __init__(self): self._data = weakref.WeakKeyDictionary() def set(self, obj, namespace, key, value): ns = self._data.get(obj) if ns is None: ns = {} self._data[obj] = ns entry = ns.setdefault(namespace, {}) entry[key] = value def get(self, obj, namespace, key, default=None): ns = self._data.get(obj) if ns is None: return default entry = ns.get(namespace, {}) return entry.get(key, default)这个IsolatedSidecar可以放在一个公共模块里导出,任何服务类都能调用,按namespace做隔离,都不需要为业务类添加任何描述符。等哪天你发现这个模板已经满足不了性能需求,再换成描述符方案也不迟。
我在实际项目里最后采用的是“描述符+业务侧明确命名”组合:核心对象加描述符,保证写入路径受控;同时要求每个服务模块必须自己声明storage_key,这样别人 review 代码时一眼就能看出每个字段是谁在写。隔离不是靠黑魔法,而是靠清晰的边界和可预期的行为。想通这一点,无论用哪种方案,坑都会少踩一大半。