12、描述符协议:深入理解属性访问机制

说实话,Python 里很多高级特性,底层都离不开描述符协议。

你可能每天都在用 @property@classmethod@staticmethod,但有没有想过——它们本质上是什么?

嗯,它们都是描述符。

什么是描述符协议?

描述符,说白了就是定义了 __get____set____delete__ 这三个特殊方法的对象。

当一个类属性被定义为描述符时,Python 在访问这个属性时,就不再是简单的字典查找了。它会调用描述符协议里的方法。

三个核心方法:

  • __get__(self, instance, owner) — 获取属性时触发
  • __set__(self, instance, value) — 设置属性时触发
  • __delete__(self, instance) — 删除属性时触发

参数说明:

  • self — 描述符实例本身
  • instance — 被访问属性的实例对象(如果是类调用,则为 None
  • owner — 拥有该属性的类

核心要点:描述符接管了属性的读取、写入、删除行为。你可以把它想象成一个「属性守卫」。

描述符的类型

类型 实现的方法 优先级
非数据描述符 __get__ 低(实例属性优先)
数据描述符 __get__ + __set__(或 __delete__ 高(覆盖实例属性)

这个区别很重要。我在项目中就踩过这个坑——

我曾经写了一个只实现了 __get__ 的描述符,以为它能拦截所有属性访问。结果实例上直接赋值后,描述符就失效了。后来才意识到,非数据描述符的优先级低于实例属性。

属性访问的完整查找顺序

当你写 obj.attr 时,Python 内部是怎么找的?

  1. 先找 type(obj).__mro__ 里的数据描述符
  2. 再找 obj.__dict__ 里的实例属性
  3. 最后找 type(obj).__mro__ 里的非数据描述符

你想想看,这个顺序意味着什么?

意味着数据描述符拥有最高优先级。哪怕实例字典里有同名属性,数据描述符也会覆盖它。

注意:如果以上三步都没找到,会触发 __getattr__。如果找到了但调用出错,会触发 __getattribute__ 的异常。

实战案例:类型检查描述符

好了,理论说完了。咱们直接上一个实战案例。

我经常需要在项目中做属性类型校验。比如用户年龄必须是 int,邮箱必须是字符串。用描述符来实现,干净又优雅。

class Typed:
    def __init__(self, name, expected_type):
        self.name = name
        self.expected_type = expected_type

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__.get(self.name)

    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"属性 '{self.name}' 期望类型为 {self.expected_type.__name__},"
                f"但传入的是 {type(value).__name__}"
            )
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        del instance.__dict__[self.name]


class Person:
    name = Typed("name", str)
    age = Typed("age", int)

    def __init__(self, name, age):
        self.name = name
        self.age = age


# 测试
p = Person("张三", 25)
print(p.name, p.age)  # 张三 25

try:
    p.age = "二十五"  # 类型错误
except TypeError as e:
    print(e)  # 属性 'age' 期望类型为 int,但传入的是 str

这个例子很直观。每个描述符实例负责一个属性,在 __set__ 里做类型检查。

我个人习惯把描述符定义成类属性,然后在 __init__ 里正常赋值。赋值时自动触发 __set__,检查就完成了。

小技巧:可以用 __set_name__ 方法自动获取属性名,这样就不用手动传 name 参数了。Python 3.6+ 支持。

描述符的应用场景

  • 类型校验 — 像上面那样,强制属性类型
  • 数据转换 — 比如自动把字符串转成 datetime
  • 延迟计算 — 属性首次访问时才计算,然后缓存
  • 访问控制 — 只读属性、日志记录、权限校验
  • ORM 框架 — Django、SQLAlchemy 的字段定义就是描述符

我记得有一次接手一个老项目,里面大量使用 @property 做校验。每个属性都要写 getter 和 setter,代码重复得很厉害。后来我重构成了描述符,代码量直接砍了一半。

描述符与 property 的关系

@property 本质上就是一个数据描述符。它帮你封装了 __get____set____delete__ 的实现。

但 property 只能用在单个属性上。如果你有多个属性需要同样的行为,描述符才是正解。

SVG 结构图:描述符协议核心逻辑

描述符协议核心逻辑 __get__ 获取属性时触发 __set__ 设置属性时触发 __delete__ 删除属性时触发 非数据描述符 仅实现 __get__ 数据描述符 实现 __get__ + __set__/__delete__ 属性访问查找顺序(obj.attr) ① 数据描述符(类 MRO 中) ② 实例属性(obj.__dict__) ③ 非数据描述符(类 MRO 中) 数据描述符优先级最高,非数据描述符优先级最低

避坑指南

我曾经在团队里遇到过一个问题:有人把描述符定义在实例上,而不是类上。结果描述符完全不生效。

记住,描述符必须定义在类级别。Python 只在类属性上查找描述符协议,实例属性不会触发。

还有一个坑:在 __set__ 里直接写 self.value = value 会导致无限递归。因为赋值又会触发 __set__

正确的做法是把值存到 instance.__dict__ 里,就像上面的例子那样。

总结

描述符协议是 Python 属性系统的核心机制。理解了它,你才能真正理解 @property@classmethod、甚至元类的工作原理。

说白了,描述符就是让你能「劫持」属性的访问行为。写框架、做校验、搞 ORM,都离不开它。

嗯,这一章的内容就到这里。希望你能动手写几个描述符试试,光看是学不会的。

公众号:蓝海资料掘金营,微信 deep3321