Python property底层原理:描述符协议是怎么工作的
深入剖析 Python 中 property 的底层实现机制,解析描述符协议(__get__、__set__、__delete__)的工作流程、资料描述符与实例字典的优先级判定规则,并通过纯 Python 模拟实现与常见陷阱排查加深理解。
在 Python 中,@property 装饰器常用于将方法封装为类似普通属性的访问形式,以实现数据校验或惰性计算。但 property 并不是编译期的特殊语法糖,而是一个实现了描述符协议(Descriptor Protocol)的内置类。当通过实例读取一个被 property 修饰的属性时,Python 底层的 object.__getattribute__ 会截获这次访问,并在类字典中发现该属性属于实现了 __set__ 的资料描述符(Data Descriptor),进而直接将控制权转交给其 __get__ 方法。理解这一机制,是掌握 Python 对象模型查找优先级、解耦复杂属性逻辑与规避递归死循环的关键。
一、从表象到实质:普通属性访问与 property 的本质差异
在 Python 对象模型中,普通实例属性和方法在底层有着截然不同的存储与读取方式。直观上看,访问普通变量与访问被 property 修饰的方法毫无区别:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
rect = Rectangle(4, 5)
print(rect.width) # 示例输出: 4
print(rect.area) # 示例输出: 20
当执行 rect.width 时,Python 默认在实例的内部属性字典 rect.__dict__ 中查找键 'width',直接返回对应的值。然而,当执行 rect.area 时,rect.__dict__ 内部其实根本不存在键 'area':
print(rect.__dict__)
# 示例输出: {'width': 4, 'height': 5}
既然实例字典中没有 area,Python 是如何返回 20 的?原因在于 area 作为类属性挂载在 Rectangle 自身,并且它是一个描述符对象。当点号操作符 . 触发属性求值时,Python 并没有简单地返回这个类属性对象,而是自动调用了该对象内部的协议方法。
二、描述符协议的核心规范:三大协议方法
Python 官方文档中将“描述符”定义为:任何定义了 __get__()、__set__() 或 __delete__() 中至少一个方法的对象。当这样的对象作为类属性存在时,通过实例或类对该属性进行的访问、赋值或删除操作,都会被重定向到这三个方法中。
| 协议方法签名 | 触发时机 | 参数含义解释 |
|---|---|---|
__get__(self, instance, owner=None) |
读取属性时触发,如 obj.attr 或 Class.attr |
self 为描述符本身;instance 为访问属性的实例(类访问时为 None);owner 为属主类 |
__set__(self, instance, value) |
给属性赋值时触发,如 obj.attr = val |
instance 为所属实例;value 为待赋予的目标数值 |
__delete__(self, instance) |
删除属性时触发,如 del obj.attr |
instance 为所属实例,用于清理实例对应的私有状态 |
当执行 rect.area 时,Python 内部传入的参数是 instance=rect, owner=Rectangle;但如果直接通过类访问 Rectangle.area,此时不存在实例,Python 传入的参数则是 instance=None, owner=Rectangle。这正是为什么直接打印 Rectangle.area 时,返回的是 <property object at ...> 本身——因为 property.__get__ 内部判定了若 instance is None,就直接返回 self。
三、Python 属性查找的完整决策链路
要深刻理解描述符为何能够截获属性读写,必须厘清 Python 在处理 obj.attr 时的底层解析机制。根据 CPython 源码中 object.__getattribute__ 的实现逻辑,属性查找遵循着严格且确定的优先级:
整个属性求值流程分为五个步骤:
- 扫描属主类层次:在对象所属的类
type(obj)及其方法解析顺序(MRO)链条上查找名为attr的类属性。 - 资料描述符判定(最高优先级):如果在类及其基类中找到了
attr,且该对象是一个资料描述符(Data Descriptor,即实现了__set__或__delete__),则直接中断后续步骤,调用其__get__(obj, type(obj))并返回结果。 - 实例字典查找:若类中没有此属性,或该属性并非资料描述符,Python 才会去检查实例自身的字典
obj.__dict__。如果存在键attr,直接返回其实例值。 - 非资料描述符与普通类属性:若实例字典中不存在该键,Python 再次回到步骤 1 找到的类属性:若它是一个非资料描述符(仅实现了
__get__),则调用其__get__;若只是普通类变量,直接返回其值。 - 最终未命中异常:如果上述位置均未找到,且类中定义了
__getattr__,则调用该方法兜底;否则直接抛出AttributeError。
四、手写一个纯 Python 版本的 SimpleProperty
为了直观验证上述原理,我们使用纯 Python 代码模拟标准库 property 的内部逻辑。通过实现一个完整的描述符类,可以彻底看清 @property 与 @x.setter 在背后做了什么:
class SimpleProperty:
"""纯 Python 模拟描述符协议驱动的 property 类"""
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __get__(self, instance, owner=None):
# 类级别访问时,返回描述符自身
if instance is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
# 实例访问时,将 instance 传递给底层被装饰的方法
return self.fget(instance)
def __set__(self, instance, value):
# 只要定义了 __set__,即成为资料描述符,优先级高于实例字典
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(instance, value)
def __delete__(self, instance):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(instance)
def setter(self, fset):
# 复制并返回包含新 setter 的全新 SimpleProperty 对象
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
现在,我们使用这个手写的 SimpleProperty 来修饰一个圆类 Circle:
class Circle:
def __init__(self, radius):
self._radius = radius
@SimpleProperty
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("半径不能为负数")
self._radius = value
c = Circle(10)
print(c.radius) # 示例输出: 10
c.radius = 25
print(c.radius) # 示例输出: 25
print(Circle.radius) # 示例输出: <__main__.SimpleProperty object at 0x...>
上述代码的执行结果与 Python 内置的 property 完全一致。这也揭示了 @radius.setter 的语法本质:在定义 @radius.setter 时,其实是将原有的 radius 描述符对象与新的校验函数重新包装,生成了一个包含 fset 的新描述符,并最终替换了 Circle 类字典中的同名属性。
五、深度辨析:资料描述符与非资料描述符的优先级混淆
初学者在学习 Python 对象模型时,常会产生一个看似合理的误解:“实例字典 obj.__dict__ 的查找优先级高于类属性”。在很多情况下这个说法确实成立,例如普通实例变量确实会遮蔽类变量:
class Item:
tag = "class_default"
it = Item()
it.tag = "instance_override"
print(it.tag) # 示例输出: instance_override
然而,一旦涉及到 property,这个“常识”便被打破了。来看一个极具代表性的实验:如果我们强行通过 __dict__ 向实例字典写入一个与 property 同名的键,会发生什么?
c = Circle(10)
# 绕过 setter,直接在实例字典中写入键 'radius'
c.__dict__['radius'] = 999
# 再次访问 c.radius
print(c.radius)
# 示例输出: 10(依然返回 10,根本没有被 999 覆盖!)
为什么 999 被完全忽略了?原因正在于描述符的分类机制:
- 资料描述符(Data Descriptor):定义了
__set__()或__delete__()的描述符。property即使没有显式编写 setter,其底层 C 结构也默认实现了__set__(并在调用时引发只读异常)。因此,property是标准的资料描述符。Python 语言规范明确规定:资料描述符的优先级严格高于实例字典。 - 非资料描述符(Non-data Descriptor):仅定义了
__get__()的描述符。最典型的例子是 Python 的普通函数与方法。函数对象只实现了__get__,因此如果向实例字典中注入一个与方法同名的键,该键会成功遮蔽类中的方法。
这种设计保证了面向对象封装的完整性:类设计者通过 property 设立的安全屏障与类型校验,不会因为外部代码在实例字典上随意赋值而被意外瓦解。
六、实战注意事项与常见陷阱排查
掌握了描述符协议后,我们在日常编写 property 时常见的几类疑难问题与反直觉现象也就迎刃而解了:
1. setter 中的递归死循环(Maximum Recursion Depth)
初学者最容易犯的一个错误,是在 setter 内部再次对同名属性赋值:
class BadCircle:
def __init__(self, radius):
self.radius = radius
@property
def radius(self):
return self.radius # 错误:递归调用 __get__
@radius.setter
def radius(self, value):
self.radius = value # 错误:递归调用 __set__,导致 RecursionError
原因解释:在类内部执行 self.radius = value 时,同样会被 __getattribute__ 拦截并重新路由给 radius.__set__,形成无限自我递归。正确规范:必须将实际底层数据存储在带下划线的不同名称中(例如 self._radius)。
2. 为什么不能在 __init__ 中定义描述符?
有些开发者试图在构造函数中动态挂载 property:self.area = property(...),随后发现该 property 完全失效,只返回了一个 property 对象。
原因解释:回到步骤 1 的判定流程,Python 的属性拦截器只会去遍历 type(obj) 及其 MRO 类字典,根本不会在实例本身的 __dict__ 中寻找描述符方法。描述符协议的成立,必须依托于“类属性”这一宿主形态。
七、总结
Python 中的 property 并非孤立的语法特性,而是建立在描述符协议之上的标准应用典范。其核心运行规律可总结为三点:
- 宿主位置:描述符必须以类属性的形式定义,由
type(obj).__getattribute__在属性查找的最前沿进行截获与分流。 - 资料优先:因为
property实现了__set__协议,它作为资料描述符的优先级严格凌驾于实例自身的obj.__dict__之上,确保了数据封装与访问拦截的不可绕过性。 - 解耦机制:描述符对象本身作为单例常驻于类中,通过在
__get__和__set__中动态接收具体的instance对象,实现了单个描述符对成千上万个实例数据的通用托管。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













