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 内部是怎么找的?
- 先找
type(obj).__mro__里的数据描述符 - 再找
obj.__dict__里的实例属性 - 最后找
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 结构图:描述符协议核心逻辑
避坑指南
我曾经在团队里遇到过一个问题:有人把描述符定义在实例上,而不是类上。结果描述符完全不生效。
记住,描述符必须定义在类级别。Python 只在类属性上查找描述符协议,实例属性不会触发。
还有一个坑:在 __set__ 里直接写 self.value = value 会导致无限递归。因为赋值又会触发 __set__。
正确的做法是把值存到 instance.__dict__ 里,就像上面的例子那样。
总结
描述符协议是 Python 属性系统的核心机制。理解了它,你才能真正理解 @property、@classmethod、甚至元类的工作原理。
说白了,描述符就是让你能「劫持」属性的访问行为。写框架、做校验、搞 ORM,都离不开它。
嗯,这一章的内容就到这里。希望你能动手写几个描述符试试,光看是学不会的。