Python 描述符协议是什么?
描述符协议,面试里属于那种“你不会时觉得它很神秘,会了才发现它其实藏在 Python 底层每个角落”的知识点。我一般不从定义讲起,而是先抛一个你天天用但没意识到的例子。
🔍 先来感受它的存在¶
class MyClass:
def __init__(self, x):
self.x = x
obj = MyClass(10)
print(obj.x) # 你以为只是读属性,背后可能有描述符在干活
如果 MyClass 的类属性 x 是一个描述符对象,obj.x 就不再是简单的 dict 查找,而是触发了一段代码。这就是描述符协议:它定义了对象作为类属性时,被访问、修改、删除的行为。
🧬 协议的核心:三个魔法方法¶
任何一个类,只要实现了以下任意一个方法,它就是描述符:
| 方法 | 触发时机 | 返回值 |
|---|---|---|
| get(self, instance, owner) | 通过实例或类读取属性 | 属性的计算结果 |
| set(self, instance, value) | 给实例属性赋值 | None |
| delete(self, instance) | del obj.attr | None |
优先级规则:描述符分为两种,取决于它实现了哪些方法——
-
数据描述符:同时有
get和set(或delete) → 访问优先级最高,会覆盖实例字典。 -
非数据描述符:只有
get→ 实例字典的优先级高于它。
这个优先级机制是理解 property、classmethod、staticmethod 这些内置设施的关键。
🧩 最简单的例子:自己写一个类型检查描述符¶
class TypedField:
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}")
instance.__dict__[self.name] = value
class Person:
age = TypedField("age", int)
name = TypedField("name", str)
p = Person()
p.age = 30 # 正常
p.name = "Alice" # 正常
p.age = "30" # ❌ TypeError
这里的 age 和 name 是类属性,但每次对实例赋值,都通过 set 被拦截并校验。描述符让属性访问变成了方法调用,这就是它强大的地方。
🧠 为什么说它无处不在?¶
Python 内置的 property 本身就是用描述符实现的。你写:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError
self._radius = value
@property 实际上创建了一个数据描述符并挂到类上。当你 c.radius 时,调用的是描述符的 get;c.radius = 5 时,调用 set。
同理,classmethod 和 staticmethod 也是非数据描述符,所以你可以在实例上调用类方法(因为非数据描述符会被实例字典遮蔽,但实例并没有同名属性,所以走类查找触发描述符)。
🛠️ 两个我常用的实际场景¶
- 懒加载(Lazy Property)
有些属性计算昂贵且可能用不到,我们让第一次访问才计算,之后直接返回缓存:
class LazyProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
value = self.func(instance)
instance.__dict__[self.name] = value # 缓存到实例字典
return value
class DataSet:
@LazyProperty
def heavy_computation(self):
print("计算中...")
return sum(range(10**6))
这里 LazyProperty 是非数据描述符(只有 get),第一次访问触发计算并存入实例 dict,此后实例字典优先级高于描述符,直接返回缓存,不再重复计算。比在 init 里一股脑全算出来优雅得多。
- 限定访问次数或权限控制
可以给描述符加一个计数器,在
get里记录读取次数,超过限制就抛异常,实现简单的“阅后即焚”属性。
⚖️ 描述符 vs property vs getattr,选谁?¶
| 需求 | 推荐工具 | 理由 |
|---|---|---|
| 单个属性需要 getter/setter 逻辑 | @property | 简单直观,不需要新建类 |
| 多个属性复用同一逻辑(类型校验、日志) | 描述符 | 避免重复代码,逻辑内聚 |
| 拦截所有不存在的属性访问 | getattr | 它的触发时机在描述符和实例字典之后 |
| 需要同时拦截赋值和删除 | 数据描述符 | property 也能,但描述符更灵活 |
很多 Web 框架(如 Django ORM)的字段定义就是依赖描述符,因为字段类型需要在模型类上做元数据声明,并在实例上做读写拦截。
💬 面试常踩的坑 & 我的理解¶
很多人把描述符讲得很玄,其实它本质上就是 “属性访问的拦截器”,并且必须定义为类属性才生效,实例属性不行。
面试官如果追问“为什么描述符必须是类属性”,答案在 Python 属性查找链:实例 dict → 类 dict → 父类… 类属性里的描述符才有机会触发 get/set,实例属性直接绕过。
我常在心里把它比作地铁闸机:你自己直接走过去(实例属性)没问题,但如果你想走我管的那个门(类属性定义的描述符),就得刷码(调用 get),我不想让你过你就不去。
最后说一点很实用的:如果你要写框架或 ORM,描述符是绕不开的基石;如果只写业务逻辑,多半用 @property 就够了。但懂它,能让你看懂源码时不发怵,也能在 code review 时提出更优雅的抽象。