Python property底层原理:描述符协议是怎么工作的

Owen
Owen
发布时间:2026-11-03 | 4人浏览过
原创

深入剖析 Python 中 property 的底层实现机制,解析描述符协议(__get__、__set__、__delete__)的工作流程、资料描述符与实例字典的优先级判定规则,并通过纯 Python 模拟实现与常见陷阱排查加深理解。

进阶原理与机制 主题:描述符协议与属性分发 适用版本:Python 3.6+
Python property底层原理:描述符协议是怎么工作的
导读图: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 为所属实例,用于清理实例对应的私有状态
核心细节:instance 与 owner 的传参差异

当执行 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__ 的实现逻辑,属性查找遵循着严格且确定的优先级:

Python property底层原理:描述符协议是怎么工作的
图 1:Python 实例属性访问判定流程(点号 obj.attr 内部调度推导)

整个属性求值流程分为五个步骤:

  1. 扫描属主类层次:在对象所属的类 type(obj) 及其方法解析顺序(MRO)链条上查找名为 attr 的类属性。
  2. 资料描述符判定(最高优先级):如果在类及其基类中找到了 attr,且该对象是一个资料描述符(Data Descriptor,即实现了 __set__ 或 __delete__),则直接中断后续步骤,调用其 __get__(obj, type(obj)) 并返回结果。
  3. 实例字典查找:若类中没有此属性,或该属性并非资料描述符,Python 才会去检查实例自身的字典 obj.__dict__。如果存在键 attr,直接返回其实例值。
  4. 非资料描述符与普通类属性:若实例字典中不存在该键,Python 再次回到步骤 1 找到的类属性:若它是一个非资料描述符(仅实现了 __get__),则调用其 __get__;若只是普通类变量,直接返回其值。
  5. 最终未命中异常:如果上述位置均未找到,且类中定义了 __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 property底层原理:描述符协议是怎么工作的
图 2:@property 声明期绑定与运行时属性分流映射机制

五、深度辨析:资料描述符与非资料描述符的优先级混淆

初学者在学习 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__,因此如果向实例字典中注入一个与方法同名的键,该键会成功遮蔽类中的方法。
Python property底层原理:描述符协议是怎么工作的
图 3:资料描述符与非资料描述符在属性访问中的优先级梯队

这种设计保证了面向对象封装的完整性:类设计者通过 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底层原理:描述符协议是怎么工作的
图 4:Python property 与描述符协议全景认知与排坑地图

七、总结

Python 中的 property 并非孤立的语法特性,而是建立在描述符协议之上的标准应用典范。其核心运行规律可总结为三点:

  1. 宿主位置:描述符必须以类属性的形式定义,由 type(obj).__getattribute__ 在属性查找的最前沿进行截获与分流。
  2. 资料优先:因为 property 实现了 __set__ 协议,它作为资料描述符的优先级严格凌驾于实例自身的 obj.__dict__ 之上,确保了数据封装与访问拦截的不可绕过性。
  3. 解耦机制:描述符对象本身作为单例常驻于类中,通过在 __get__ 和 __set__ 中动态接收具体的 instance 对象,实现了单个描述符对成千上万个实例数据的通用托管。
相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表