跳转至

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

优先级规则:描述符分为两种,取决于它实现了哪些方法——

  • 数据描述符:同时有 getset(或 delete) → 访问优先级最高,会覆盖实例字典。

  • 非数据描述符:只有 get → 实例字典的优先级高于它。

这个优先级机制是理解 propertyclassmethodstaticmethod 这些内置设施的关键。


🧩 最简单的例子:自己写一个类型检查描述符

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

这里的 agename 是类属性,但每次对实例赋值,都通过 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 时,调用的是描述符的 getc.radius = 5 时,调用 set。 同理,classmethodstaticmethod 也是非数据描述符,所以你可以在实例上调用类方法(因为非数据描述符会被实例字典遮蔽,但实例并没有同名属性,所以走类查找触发描述符)。


🛠️ 两个我常用的实际场景

  1. 懒加载(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 里一股脑全算出来优雅得多。

  1. 限定访问次数或权限控制 可以给描述符加一个计数器,在 get 里记录读取次数,超过限制就抛异常,实现简单的“阅后即焚”属性。

⚖️ 描述符 vs property vs getattr,选谁?

需求 推荐工具 理由
单个属性需要 getter/setter 逻辑 @property 简单直观,不需要新建类
多个属性复用同一逻辑(类型校验、日志) 描述符 避免重复代码,逻辑内聚
拦截所有不存在的属性访问 getattr 它的触发时机在描述符和实例字典之后
需要同时拦截赋值和删除 数据描述符 property 也能,但描述符更灵活

很多 Web 框架(如 Django ORM)的字段定义就是依赖描述符,因为字段类型需要在模型类上做元数据声明,并在实例上做读写拦截。


💬 面试常踩的坑 & 我的理解

很多人把描述符讲得很玄,其实它本质上就是 “属性访问的拦截器”,并且必须定义为类属性才生效,实例属性不行。 面试官如果追问“为什么描述符必须是类属性”,答案在 Python 属性查找链:实例 dict → 类 dict → 父类… 类属性里的描述符才有机会触发 get/set,实例属性直接绕过。

我常在心里把它比作地铁闸机:你自己直接走过去(实例属性)没问题,但如果你想走我管的那个门(类属性定义的描述符),就得刷码(调用 get),我不想让你过你就不去。

最后说一点很实用的:如果你要写框架或 ORM,描述符是绕不开的基石;如果只写业务逻辑,多半用 @property 就够了。但懂它,能让你看懂源码时不发怵,也能在 code review 时提出更优雅的抽象。