你的self.name value为何“绕道”了——Python 数据描述符的优先级霸权与实例属性消失之谜在 Python 面向对象编程中描述符是实现property、ORM 字段、类型校验等高级特性的核心机制。你可能已经知道当一个类的属性是一个描述符对象时通过实例访问该属性会触发__get__或__set__。但有一种描述符拥有更高的权力数据描述符即同时定义了__get__和__set__/__delete__的描述符。它会在属性查找时剥夺实例字典的话语权——即使你在实例的__dict__中已经存储了同名属性Python 仍然会优先使用数据描述符的__get__和__set__让你的实例属性仿佛透明。这一优先级规则是无数诡异现象的根源你明明在__init__里给self.name赋了值可读取时却触发了描述符的校验逻辑你想为实例临时添加一个属性来覆盖类属性却发现怎么也覆盖不掉而当你把描述符中的__set__删掉后一切又突然恢复正常。这些“幽灵属性”的背后正是数据描述符与非数据描述符截然不同的优先级在作祟。今天我们就来彻底解剖描述符协议的优先级链搞懂数据描述符如何凌驾于实例属性之上并学会利用这种霸权构建牢不可破的数据封装同时避免那些因优先级误判而酿成的调试噩梦。一、问题复现为什么self.__dict__里的值被无视了场景 1赋值后读取却触发了验证逻辑classPositiveNumber:def__get__(self,instance,owner):print(f访问{self.name})returninstance.__dict__.get(self.name,0)def__set__(self,instance,value):print(f设置{self.name}{value})ifvalue0:raiseValueError(必须为正数)instance.__dict__[self.name]valuedef__set_name__(self,owner,name):self.namenameclassProduct:pricePositiveNumber()pProduct()p.price100# 调用 __set__存储到 p.__dict__[price] 100print(p.price)# 调用 __get__从 p.__dict__[price] 读取 100print(p.__dict__)# {price: 100} ← 实例字典中有 price# 诡异开始p.__dict__[price]-999# 直接篡改实例字典跳过描述符print(p.__dict__[price])# -999print(p.price)# 依然触发 __get__返回的是 instance.__dict__.get(self.name) → -999!你天真地认为直接篡改实例字典就能绕开描述符但描述符的__get__依然会被调用因为描述符本身主动去读了instance.__dict__[self.name]所以你改字典的值也会被读取。但如果我们改写描述符让它不读取字典而是从另一个地方取值呢那样实例字典里的值就完全被无视了。这揭示了描述符本身可以完全控制数据来源。场景 2数据描述符“拦截”实例属性让你覆盖失败classDataDesc:def__get__(self,instance,owner):ifinstanceisNone:returnselfreturn42def__set__(self,instance,value):print(数据描述符 __set__ 被调用)# 什么都不存就是个黑洞classMyClass:attrDataDesc()objMyClass()obj.attr10# 触发 __set__打印消息但实例字典里什么都没有print(obj.attr)# 42而不是 10print(obj.__dict__)# {} ← 空的由于DataDesc定义了__set__它是一个数据描述符。Python 的属性查找规则会优先调用数据描述符的__get__而不去查找实例的__dict__。即使__set__什么都没存obj.attr仍然返回描述符规定的值实例字典被彻底无视。场景 3非数据描述符则被实例属性轻松覆盖classNonDataDesc:def__get__(self,instance,owner):ifinstanceisNone:returnselfreturn来自描述符classMyClass:attrNonDataDesc()objMyClass()print(obj.attr)# 来自描述符obj.attr实例属性# 没有 __set__因此直接在实例字典中创建 attrprint(obj.attr)# 实例属性 ← 覆盖了描述符print(obj.__dict__)# {attr: 实例属性}delobj.attr# 删除实例属性print(obj.attr)# 来自描述符 ← 描述符又回来了因为NonDataDesc只定义了__get__它是一个非数据描述符。实例字典中的同名属性会遮蔽非数据描述符删除实例属性后描述符重新生效。这与数据描述符的行为截然相反。二、底层原理属性查找的优先级链1. 描述符的分类数据描述符实现了__get__和__set__或__delete__的对象。即同时能读能写。非数据描述符仅实现了__get__的对象。如函数、classmethod、staticmethod等实际上都是描述符。内置的property是数据描述符因为它有__get__和__set__如果未定义 setter__set__会抛出AttributeError。2. 实例属性查找的完整顺序当执行obj.attr时Python 按照以下步骤查找属性数据描述符在类及其基类的__dict__中按 MRO 查找attr如果找到一个对象并且它是数据描述符则直接调用其__get__不再继续。实例字典在obj.__dict__中查找。非数据描述符和普通类属性如果在实例字典中没找到回到类及其基类的__dict__中按 MRO 查找。如果找到的对象是非数据描述符则调用其__get__如果是普通值直接返回该值。如果都没找到调用__getattr__如果定义了否则抛出AttributeError。关键点数据描述符的优先级高于实例字典。这就是为什么场景 2 中实例字典明明为空obj.attr却返回描述符的值而且你在实例字典中写入同名属性也不会生效——因为写操作本身也会触发数据描述符的__set__如果定义了导致你根本无法在实例字典中创建该属性除非绕过描述符直接操作obj.__dict__。如果你绕过了描述符直接通过obj.__dict__[attr] value在字典中插入了键那么读取时由于数据描述符优先级更高Python 依然会先执行数据描述符的__get__而忽略实例字典。所以实例字典中的同名值被永远遮蔽除非描述符的__get__自己主动去读取instance.__dict__。3. 为什么property即使没有 setter 也是数据描述符property的__set__和__delete__始终存在由property类内部实现。如果没有定义 setter__set__会引发AttributeError: cant set attribute。因此property总是数据描述符这就解释了为什么只读 property 不能被实例属性覆盖。4. 描述符必须为类属性才能生效描述符协议只在类属性中起作用。如果你把描述符实例赋值给实例属性如self.descriptor SomeDescriptor()它只是一个普通对象__get__/__set__不会自动调用。三、常见陷阱与迷惑行为陷阱 1试图用实例属性覆盖数据描述符classMyProp:def__get__(self,instance,owner):return描述符值def__set__(self,instance,value):passclassTest:attrMyProp()tTest()t.attr新值# 触发 __set__但不存储print(t.attr)# 依然是 描述符值t.__dict__[attr]强制写入print(t.attr)# 还是 描述符值实例字典被忽略数据描述符就像一块强力磁铁你的任何访问都被它吸走实例字典中的同名项永远翻不了身。陷阱 2误解property的 setter 删除后变成非数据描述符你无法动态删除property的 setter。一旦定义为property它就是数据描述符除非你替换整个类属性为一个非数据描述符对象。如果某个属性只需要偶尔可写更好的做法是在 setter 中抛出特定异常或使用其他机制而不是试图改为非数据描述符。陷阱 3在__init__中通过self.attr value触发数据描述符的__set__但描述符内部又操作了instance.__dict__导致无限递归classRecursiveDesc:def__set__(self,instance,value):# 错误直接使用 self 或 setattr 可能导致无限递归setattr(instance,self.name,value)# 再次触发 __set__在__set__中必须直接操作instance.__dict__而不能通过setattr或instance.attr 否则会循环调用。陷阱 4使用vars(obj)或obj.__dict__来查看属性以为能获取所有值如果数据描述符没有在__get__中返回instance.__dict__中的值那么vars(obj)只会显示实例字典中实际存储的键但通过obj.attr访问到的却是描述符计算出的值两者可能不一致容易造成调试错觉。陷阱 5混合使用数据描述符与__slots____slots__产生的描述符本身就是数据描述符它们定义了__get__和__set__如果与自定义数据描述符冲突会引发复杂的优先级问题通常应避免在同一个类中混合使用除非完全理解其交互。四、正确运用数据描述符的霸权模式 1实现不可变的只读属性classImmutableAttr:def__init__(self,default):self.defaultdefaultdef__set_name__(self,owner,name):self.namenamedef__get__(self,instance,owner):ifinstanceisNone:returnselfreturninstance.__dict__.get(self.name,self.default)def__set__(self,instance,value):raiseAttributeError(f{self.name}是不可变属性)classConfig:hostImmutableAttr(localhost)cConfig()print(c.host)# localhostc.hostremote# AttributeError由于ImmutableAttr是数据描述符任何对host的赋值都会触发__set__从而抛出异常且实例无法通过__dict__存储其他值即便存储了也会被__get__覆盖。模式 2创建缓存属性惰性求值但一旦计算就不能被修改classLazyAttribute:def__init__(self,func):self.funcfunc self.namefunc.__name__def__set_name__(self,owner,name):self.namenamedef__get__(self,instance,owner):ifinstanceisNone:returnself valueself.func(instance)# 计算后直接存储到实例字典中下次就不走描述符了# 但数据描述符的 __get__ 优先级更高如果我们还希望后续读取直接走实例字典就必须在存储后让 __get__ 能够发现实例字典已有值并返回。instance.__dict__[self.name]valuereturnvaluedef__set__(self,instance,value):raiseAttributeError(缓存属性不可直接赋值)注意这种设计下第一次访问计算值并存入__dict__但由于数据描述符优先级高第二次访问仍然会触发__get__。因此__get__需要检查self.name是否已经在instance.__dict__中如果有就直接返回避免重复计算。这样才能实现惰性且不可变的缓存。更优雅的做法是使用非数据描述符来实现可变的惰性缓存因为非数据描述符在实例属性存在时会被遮蔽天然支持“一次计算之后直接读实例字典”的模式。而数据描述符的惰性缓存需要手动在__get__中判断并绕过。模式 3类型校验字段ORM 常见classIntegerField:def__set_name__(self,owner,name):self.namenamedef__get__(self,instance,owner):ifinstanceisNone:returnselfreturninstance.__dict__.get(self.name,0)def__set__(self,instance,value):ifnotisinstance(value,int):raiseTypeError(f{self.name}必须是整数)instance.__dict__[self.name]value数据描述符保证了无论用户怎么尝试赋值都必须经过校验实例字典无法绕过。模式 4利用数据描述符实现“影子属性”不同于实例字典如果希望属性值完全由描述符控制而不依赖实例字典可以在描述符内部维护一个字典例如弱引用字典来存储每个实例的状态完全不触碰instance.__dict__。这样即使有恶意代码直接修改__dict__也不会影响属性行为。五、调试与排查技巧打印type(obj).__dict__和obj.__dict__快速识别属性是由描述符控制还是实例属性。使用inspect模块inspect.getattr_static(obj, attr)可以绕过描述符协议直接返回描述符对象或实例字典中的值用于诊断。检查描述符类型通过查看类属性是否有__get__和__set__方法判断是数据描述符还是非数据描述符。添加日志在描述符的__get__、__set__中打印调用信息跟踪属性访问顺序。测试覆盖对每一个描述符编写测试验证实例属性覆盖情况、直接修改字典的效果。Linter 建议目前没有专门针对描述符优先级问题的 Linter但可以通过 Code Review 确保数据描述符的__set__正确操作instance.__dict__避免递归。六、最佳实践总结数据描述符用于需要完全控制读写行为的场景类型校验、只读属性、计算属性需要缓存时注意优先级、数据库字段等。非数据描述符适用于可被实例属性覆盖的默认行为如方法函数是非数据描述符所以实例可以覆盖方法、某些惰性缓存实例属性可覆盖。永远在描述符的__set__和__delete__中直接操作instance.__dict__不要通过setattr或instance.attr赋值以避免递归调用。理解property是数据描述符即使没有定义 setter它也会阻止实例属性覆盖这是设计如此。如果需要实例属性能够临时“遮盖”描述符请使用非数据描述符或者设计显式的开关。在 ORM 等框架中大量使用数据描述符要特别注意它们对内存和性能的影响因为每次访问都经过方法调用。使用functools.cached_propertyPython 3.8作为非数据描述符的惰性属性它天然支持实例属性覆盖且缓存于实例字典性能优异优先级符合直觉。为自定义描述符编写清晰的文档说明其是数据描述符还是非数据描述符以及属性查找的预期行为。在单元测试中覆盖描述符与实例属性交互的各种边界特别是直接修改__dict__后的表现。七、结语Python 的描述符协议精心设计了一套属性查找的“等级制度”数据描述符就是这套制度中的最高统治者。它拥有越过实例字典直接干预属性访问的特权确保每一次读取和写入都必须经过它的审批。这种霸权让我们能够构建出严格的数据验证、透明的 ORM 映射和安全的只读属性但如果不清楚它的权力边界你就会发现自己的赋值如石沉大海属性覆盖总是失败实例字典形同虚设。记住数据描述符是门前站岗的守卫除非你绕过围墙直接进入后花园obj.__dict__否则任何试图让同名属性绕过守卫的尝试都会被他拦截。驾驭这种优先级规则你就能在 Python 的属性世界中游刃有余既利用它的强大又不被它的“霸道”所困。