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, objtypeNone): 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_keyf_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 类负责往目标对象上挂metaPluginB 类也往同一个目标对象挂meta二者互不可见。插件类本身是普通类元类拦截的是插件实例收到setattr时的行为。class IsolatedMeta(type): def __setattr__(cls, name, value): # 这里拦截的是给类本身设置属性不是给实例设置 # 但我们可以借这个入口做全局路由 if name attach_meta: raise AttributeError(请使用 attach 方法) super().__setattr__(name, value)但这样拦的是类属性不是插件实例往业务对象上挂数据。所以准确说应该拦的是插件实例的setattrclass 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(metaclassBusinessMeta): 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, objtypeNone): 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类往订单上写metaB类也写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, defaultNone): 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 代码时一眼就能看出每个字段是谁在写。隔离不是靠黑魔法而是靠清晰的边界和可预期的行为。想通这一点无论用哪种方案坑都会少踩一大半。
