你有没有想过,每次访问一个 Python 对象的属性(比如 student.name),Python 在背后执行的其实是一套“隐形流程”?把对象想象成一间公寓房间,属性就是房间里摆放的各种物品。Python 的 __getattribute__ 就像前台总机——你报出想取的物品,总台不会直接拉开储物柜,而是先翻看一份“管家名单”:如果这件物品配了专属管家(描述符),无论是取、存还是扔,都必须通过管家之手。这套“管家守则”就是描述符协议,由 __get__、__set__、__delete__ 三个钩子方法组成。 更妙的是,你早就一直在使用它:@property 是物业提供的标准管家模板,@classmethod 和 @staticmethod 则是两个特殊服务窗口。一旦看懂描述符,你就掌握了自己定义“存取规则”的能力——字段校验、ORM 映射、延迟计算,不过是管家守则的几种编排方式。本文会带你从公寓管家模型出发,彻底拆解 Python 属性系统的底层设计。

岗位关联

掌握描述符协议,等于拿到了 Python 数据模型的“钥匙”。无论做框架设计、ORM 封装还是代码洁癖级的属性控制,这都是绕不开的底层内功。

目录
  1. 可视化演示
  2. 描述符协议:三个钩子
  3. 数据描述符:全职管家
  4. 非数据描述符:兼职引导员
  5. 查找优先级:前台总机的决策
  6. property实现:标准管家套餐
  7. 类方法与静态方法:两个特殊窗口
  8. 实战应用:从字段校验到 ORM
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示get/set/delete三大方法调用流程、数据与非数据描述符优先级规则及property描述符本质,切换模式深入理解Python属性访问底层机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

描述符协议三大方法 get(self,obj,owner):属性读取时调用,返回计算值或描述符自身 set(self,obj,val):属性赋值时调用,可做类型校验或自定义存储 obj.x → get | obj.x=v → set | del obj.x → delete 描述符协议是Python属性访问的底层拦截机制

描述符协议:三个钩子

想象你住在一栋“类公寓”里,房间里的每一件物品都是一个属性。有些物品相当贵重,物业绝不会让住户直接打开保险柜。于是他们派了一位管家:你想取金条(__get__),管家核对你的身份;你想存进新金条(__set__),管家检查来路;你想扔掉它(__delete__),管家记录去向。任何动作都必须经过管家。描述符协议,就是这套“管家守则”的 Python 翻译。

在 Python 中,描述符首先是一个普通的类,只不过这个类额外实现了 __get__、__set__ 或 __delete__ 中的至少一个方法。把描述符类的实例挂在另一个类的类属性位置上,事情就变了:对 instance.field 的读写不再直接命中实例字典,而是优先转交给描述符处理。换句话说,描述符是“类属性层面的魔法”——它本身不存储任何业务数据,只负责定义“存取规则”。

三个方法各有分工。__get__(self, instance, owner) 在读取属性时触发,instance 是访问者实例,owner 是拥有该字段的类;如果通过类来访问,instance 为 None。__set__(self, instance, value) 在赋值时触发;__delete__(self, instance) 在 del 该属性时触发。只实现了 __get__ 的称为非数据描述符,还实现了 __set__ 或 __delete__ 的称为数据描述符——这一区别直接决定属性查找的优先级。

有了这套守则,物品的存取就能“有章可循”:取之前登记,存之前检查,扔之前确认。你不需要发明新语法,只要遵循协议,Python 就会把每一次属性访问交到你的手中——这就是描述符最迷人的地方。

a.name⇒MRO 中查找 name⇒d.__get__(a, type(a))\texttt{a.name} \Rightarrow \texttt{MRO 中查找 name} \Rightarrow \texttt{d.\_\_get\_\_(a, type(a))}
这条链路表示:访问 a.name 不只是从字典取键,而是沿方法解析顺序(MRO)找到 name 对应的属性对象,如果它是描述符,就调用 __get__ 接管这次读取。
  1. 实例访问表达式 a.name 会被 Python 编译为属性查找,统一经过 __getattribute__ 入口。
    a.name→type(a).__getattribute__(a, ’name’)\texttt{a.name} \to \texttt{type(a).\_\_getattribute\_\_(a, 'name')}
  2. MRO 查找沿着 type(a) 的方法解析顺序,从当前类到父类再到 object,找出名为 name 的属性对象。
    MRO:type(a)→父类→⋯→object\texttt{MRO}: \texttt{type(a)} \to \texttt{父类} \to \cdots \to \texttt{object}
  3. 描述符接管若找到的 name 是描述符,则调用它的 __get__(a, type(a)),返回值就是最终结果。
    d.__get__(a, type(a))→最终属性值\texttt{d.\_\_get\_\_(a, type(a))} \to \text{最终属性值}

数据描述符:全职管家

管家也分全职和兼职。全职管家同时管存取,兼职引导员只负责指路。数据描述符就是“全职管家”:它实现了 __set__ 或 __delete__(通常与 __get__ 一起实现),因此任何一次赋值都逃不过它的眼睛。在 Python 术语里,数据描述符意味着这个属性“值得被全程监控”。

为什么 Python 要专门给数据描述符最高优先级?因为只要实现了 __set__,就能保证每次写入都先被检查。假如实例字典比管家优先,那么你刚写完 setter,实例上随手赋一个同名属性,控制就会瞬间失效——管家成了摆设。为了让“赋值检查”拥有绝对约束力,Python 规定:数据描述符优先于实例 __dict__。

这就是“实例遮蔽”问题的由来。很多人困惑:self.x = 1 明明把 x 放进了实例字典,为什么 property 依然拦截?答案就藏在这条优先级规则里——property 是数据描述符,你永远无法用普通赋值覆盖一个数据描述符。

所以,“全职管家”的设立不是为了抢活干,而是为了确保任何非法赋值都无法绕过检查。后面写类型校验时最关键的一步,就是把描述符做成数据描述符——有了 __set__,你才真正拥有对这个属性的永久控制权。

数据描述符≻实例 __dict__≻非数据描述符\text{数据描述符} \succ \text{实例}\ \texttt{\_\_dict\_\_} \succ \text{非数据描述符}
≻ 表示查找时的优先顺序:类型 MRO 中一旦出现数据描述符,无论实例字典里存了什么,读取都由 __get__ 接管;实例字典只在没有数据描述符时才被信任;非数据描述符排在最后。
  1. 数据描述符最优先类型 MRO 中发现数据描述符后,直接调用它的 __get__(obj, cls)。
    数据描述符发现→desc.__get__(obj, cls)\text{数据描述符发现} \to \texttt{desc.\_\_get\_\_(obj, cls)}
  2. 实例字典其次没有数据描述符时,才会查看 obj.__dict__ 中已存的值。
    obj.__dict__.get(’name’)\texttt{obj.\_\_dict\_\_.get('name')}
  3. 非数据描述符兜底前两处都落空,才轮到类型 MRO 中的非数据描述符。
    非数据描述符→desc.__get__(obj, cls)\text{非数据描述符} \to \texttt{desc.\_\_get\_\_(obj, cls)}

非数据描述符:兼职引导员

兼职引导员只在房间本身找不到物品时才会被询问。普通函数就是这类角色:函数对象只实现了 __get__,没有 __set__,所以它是非数据描述符。当一个 def 函数出现在类的属性位置时,instance.method 的访问会调用 function.__get__(instance, cls),生成一个把 instance 作为第一个参数传入的绑定方法——这就是“方法自动获得 self”的底层真相。

也许你会问:为什么函数不实现 __set__?因为函数希望被“放回”实例字典后还能被替换。如果你给实例动态赋值 instance.f = 某个普通对象,非数据描述符优先级低于实例字典,于是新值胜出,函数被覆盖。这种“灵活替换”正是非数据描述符的设计意图:它提供默认行为,但不垄断属性。

staticmethod 和 classmethod 都属于这一族。staticmethod 的 __get__ 原样返回函数,不绑定任何东西;classmethod 的 __get__ 返回绑定到类的绑定方法。它们都不实现 __set__,因此都能被实例属性“礼貌地让位”。注意一个容易混淆的点:@staticmethod 包装后的对象不再是一个普通函数,而是一个 staticmethod 描述符对象。

兼职引导员虽然职务低,却是 Python 面向对象机制的“齿轮”:方法绑定、类方法、静态方法全部依赖这一个小小的 __get__。理解了它,你就理解了“类里定义的函数为什么和普通函数不一样”。

function.__get__(obj, cls)→bound method:f(obj,⋯ )\texttt{function.\_\_get\_\_(obj, cls)} \to \text{bound method}: f(\texttt{obj}, \cdots)
函数对象通过 __get__ 生成绑定方法,把实例作为第一个参数传入,这就是 self 自动出现的真正来源。
  1. 函数对象类体里的 def f(self) 编译后是一个 function 对象,它实现了 __get__。
    def f→function 对象 (含 __get__)\texttt{def f} \to \texttt{function 对象} \ (\text{含}\ \texttt{\_\_get\_\_})
  2. 绑定过程访问 obj.f 时,Python 调用 function.__get__(obj, cls) 生成一个绑定方法对象。
    f.__get__(obj, cls)→bound method\texttt{f.\_\_get\_\_(obj, cls)} \to \text{bound method}
  3. 首参传入绑定方法被调用时,把 obj 自动作为第一个参数传给 f,于是 self 到手。
    bound method(obj)→f(obj, ...)\text{bound method(obj)} \to \texttt{f(obj, ...)}

查找优先级:前台总机的决策

现在把视角升到整栋公寓的前台:当你请求一个属性,__getattribute__ 就像总机接线员,按一套固定剧本决定由谁来响应。这套剧本有四个分支,顺序完全不可打乱。理解它,你就彻底掌握了“属性到底在哪找”的全局地图。

第一步,沿类型 MRO 找数据描述符。前台先翻管家名册,看有没有全职管家负责这个物品——有,直接转给管家,流程结束。第二步,没有全职管家,去房间储物柜 instance.__dict__ 里找。第三步,储物柜也没有,再去翻兼职引导员手册(类型 MRO 中的非数据描述符)。第四步,全部落空,抛出 AttributeError。

这个顺序解释了三个常见现象:为什么 property 的 setter 一定能触发(它是数据描述符);为什么 instance.method = x 能覆盖方法的绑定(函数是非数据描述符);为什么读一个只实现了 __set__ 的字段得到的是描述符对象本身(数据描述符存在但无 __get__,读取时返回它自己)。

前台总机的这套剧本,决定了每一个属性的“命运”。你不需要记住 CPython 源码,只要记住“数据描述符 > 实例字典 > 非数据描述符”这个优先序,就掌握了 90% 的属性行为预测能力。

getattr(a, x)={d.__get__(a, A)∃ d∈数据描述符a.__dict__[x]实例字典命中d.__get__(a, A)∃ d∈非数据描述符AttributeError全部未命中\texttt{getattr(a, x)} = \begin{cases}d.\texttt{\_\_get\_\_(a, A)} & \exists\, d \in \text{数据描述符}\\\texttt{a.\_\_dict\_\_[x]} & \text{实例字典命中}\\d.\texttt{\_\_get\_\_(a, A)} & \exists\, d \in \text{非数据描述符}\\\texttt{AttributeError} & \text{全部未命中}\end{cases}
这是 getattr 的官方语义:数据描述符、实例字典、非数据描述符、异常,四层决策从上到下执行。
  1. 数据描述符分支先沿 A 的 MRO 查找名为 x 的数据描述符,找到就调用它的 __get__。
    ∃ d∈数据描述符→d.__get__(a, A)\exists\, d \in \text{数据描述符} \to d.\texttt{\_\_get\_\_(a, A)}
  2. 实例字典分支没有数据描述符时,直接看 a.__dict__ 是否存了这个键。
    a.__dict__∋x→a.__dict__[x]\texttt{a.\_\_dict\_\_} \ni x \to \texttt{a.\_\_dict\_\_[x]}
  3. 非数据描述符分支实例字典没有,再查类型 MRO 中的非数据描述符。
    ∃ d∈非数据描述符→d.__get__(a, A)\exists\, d \in \text{非数据描述符} \to d.\texttt{\_\_get\_\_(a, A)}
  4. 异常兜底四层都落空,抛出 AttributeError。
    未命中→AttributeError\text{未命中} \to \texttt{AttributeError}

property实现:标准管家套餐

物业替你预装了一位标准管家——property。它是 Python 内置的类,实例化后得到数据描述符对象。property 内部保存三件事:读取函数 fget、写入函数 fset、删除函数 fdel。当访问属性时,property 的 __get__ 调用 fget;赋值时,__set__ 调用 fset;删除时,__delete__ 调用 fdel。

平时我们写的 @property 装饰器,本质是 attr = property(attr) 的语法糖:把函数包装成 property 对象,并放回类字典。而 @price.setter 也并非新语法,它调用的是 property 对象的 setter 方法,该方法返回一个新的 property 实例(新实例复用了原 fget,只替换 fset),然后把类字典中的 price 替换成这个新实例。

这也是 property 的一个隐藏细节:它自己不存业务值,真正的值通常放在 self._price 或 instance.__dict__ 里。property 只负责“接线”——把属性读写转发给你写好的函数。你可以用它把属性变成只读(只给 fget)、带缓存、带单位换算等任何形态。

把 property 看成一键生成的管家套餐,你就不会再对它感到神秘。它和你手写的描述符类做的事一模一样,只是由官方替你打磨好了而已。

@property⇒attr = property(fget)@x.setter⇒x = x.setter(fset)\texttt{@property} \Rightarrow \texttt{attr = property(fget)} \qquad \texttt{@x.setter} \Rightarrow \texttt{x = x.setter(fset)}
装饰器语法实质是普通函数调用与重新赋值,property.setter 返回新实例而非原地修改。
  1. @property 装饰类体执行时,把紧跟的函数传给 property 构造器,形成描述符对象。
    attr=property(fget)\texttt{attr} = \texttt{property(fget)}
  2. setter 调用@x.setter 实际执行 x = x.setter(fset)。
    x.setter(fset)→新 property 对象\texttt{x.setter(fset)} \to \text{新 property 对象}
  3. 替换绑定新 property 对象被赋回 attr 名字,类字典中的旧对象被替换。
    x←新 property 对象\texttt{x} \gets \text{新 property 对象}

类方法与静态方法:两个特殊窗口

公寓里还有两个特殊服务窗口。classmethod 是“业主委员会窗口”:无论你从哪间房(实例)还是从公寓前台(类)来,它都把第一个参数绑定为“业主委员会”(类本身)。staticmethod 是“公告栏”:谁来问都是同一张公告,不绑定任何东西。

从描述符视角看,两者都是非数据描述符。classmethod 包装函数后生成一个 classmethod 对象,其 __get__ 把持有它的类作为第一个参数传入并返回绑定方法;staticmethod 的 __get__ 则原样返回被包装的函数。它们都不实现 __set__,所以实例字典优先级更高——这些描述符只提供“默认绑定规则”,不强制拦截。

为什么要用描述符实现?因为 Python 想复用同一套属性查找机制:方法绑定本身就是一次属性查找的结果。Class.method 通过类查找得到绑定到类的 bound method,instance.method 通过实例查找同样得到绑定结果,只是绑定目标不同。这套统一规则,让“方法”和“属性”在描述符协议面前完全平等。

有了这两个窗口,类工厂、注册表模式、工具函数组织都变得顺手。记住它们的本质:一个是“绑定到类”的非数据描述符,一个是“不绑定”的非数据描述符。

cls.m=f.__get__(None, cls)→bound method(cls,⋯ )\texttt{cls.m} = \texttt{f.\_\_get\_\_(None, cls)} \to \text{bound method}(cls, \cdots)
static.__get__(obj, cls)→原函数\texttt{static.\_\_get\_\_(obj, cls)} \to \text{原函数}
classmethod 的 __get__ 返回绑定到类的调用对象;staticmethod 的 __get__ 直接透传原函数。
  1. classmethod 的 get传入 instance=None 和拥有类 cls,生成捕获 cls 的可调用对象。
    f.__get__(None, cls)→bound method\texttt{f.\_\_get\_\_(None, cls)} \to \text{bound method}
  2. staticmethod 的 get无论 instance 还是 cls,都返回最初包装的那个函数。
    sm.__get__(obj, cls)→f原始\texttt{sm.\_\_get\_\_(obj, cls)} \to f_{\text{原始}}
  3. 非数据描述符属性二者都未实现 __set__,因此可被实例字典覆盖。
    两类对象∈非数据描述符\text{两类对象} \in \text{非数据描述符}

实战应用:从字段校验到 ORM

理解了协议,就可以自己“培训管家”了。最常见的两个模式:类型校验字段和延迟计算字段。前者用数据描述符实现,后者用非数据描述符实现。两者都只用几十行代码,就能写出媲美框架的体验。

类型校验字段的套路是:__set__ 中检查类型,不合格直接抛异常,合格则写入 instance.__dict__;__get__ 中从 instance.__dict__ 读回。由于数据描述符优先于实例字典,无论外部怎么赋值都会被拦截,这就是 ORM 字段校验的雏形——Django 的 Field、SQLAlchemy 的 Column 本质都是描述符。

延迟计算字段的套路是:用 @LazyField 装饰一个计算方法,第一次访问时执行该方法、把结果存进实例字典;后续访问因非数据描述符优先级低于实例字典,直接命中缓存。这个模式在特征工程中尤其好用:复杂特征只在第一次访问时计算一次。下面会给出完整代码。

培训自己的管家并不神秘:数据描述符用于一切需要“强制纪律”的场景,非数据描述符用于“默认行为但允许覆盖”的场景。掌握了这个分工,你就能在项目中写出既克制又优雅的属性层。

TypedField: __set__→校验并写 inst.__dict__;__get__→从 inst.__dict__ 读取\text{TypedField}:\ \texttt{\_\_set\_\_} \to \text{校验并写}\ \texttt{inst.\_\_dict\_\_};\quad \texttt{\_\_get\_\_} \to \text{从}\ \texttt{inst.\_\_dict\_\_}\ \text{读取}
数据描述符负责“强制校验”,非数据描述符负责“按需计算”,一个管写入纪律,一个管读取效率。
  1. set 校验先验证 value 的类型,不符立即抛错,从源头阻止脏数据。
    isinstance(value, expected)→通过或抛 TypeError\texttt{isinstance(value, expected)} \to \text{通过或抛}\ \texttt{TypeError}
  2. 写入实例字典校验通过后,把值存入 instance.__dict__(存储位置由描述符决定)。
    inst.__dict__[name]=value\texttt{inst.\_\_dict\_\_[name]} = value
  3. get 读取从实例字典按名取回,实现“存哪读哪”的规则一致性。
    inst.__dict__.get(name)\texttt{inst.\_\_dict\_\_.get(name)}

AI 应用场景

ORM 框架的字段映射与数据校验

阶段:数据准备与治理阶段

解决的问题:AI 训练管线对数据质量极其敏感,脏数据一旦进入模型就会导致精度下降甚至训练崩溃。传统手写 if type(x) is float 的校验散落在各处,极易漏判。用描述符把校验收敛到字段定义处,赋值即拦截,让非法数据在源头被拒。

具体案例:某特征工程库为每个特征字段定义 FloatField、IntField 等描述符类。加载 CSV 时逐行写入行对象,类型不匹配立即抛错并附带字段名,数据工程师能立刻定位坏行。模型上线后,线上请求的特征同样经过该类校验,杜绝了脏数据进入推理管线。

训练超参配置的只读与范围控制

阶段:模型训练配置与管理

解决的问题:训练脚本中,learning_rate、batch_size 等超参数一旦被中途意外修改,整轮训练结果就失效。传统做法靠约定“别改它”,容易踩坑。用 property 把关键超参设为只读,或限定在合法区间,误操作直接抛错。

具体案例:某 MLOps 平台的 TrainingConfig 类用 property 把 learning_rate 设计成只读属性,训练循环中所有写入尝试都会触发 AttributeError;batch_size 则通过描述符校验必须为正整数。团队再也没发生过“调参改了配置却不知道为什么”的排查事故。

特征延迟计算与结果缓存

阶段:特征工程

解决的问题:复杂特征的聚合计算代价很高,如果每条样本都重复计算,特征工程就会成为性能瓶颈。用非数据描述符实现 lazy property,让特征第一次访问时才计算并缓存到实例字典,后续访问直接命中缓存。

具体案例:某用户画像系统中,user_embedding 是一个需要跑权重聚合的复杂特征。通过 @LazyField 装饰,单用户首次访问耗时 200ms,之后每次访问仅耗时 0.01ms。全量离线特征构建时间从 6 小时缩短到 40 分钟。

Python 演示

python58 行
class ValidatedField:    """带类型校验的数据描述符"""     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__}, 收到 {type(value).__name__}"            )        instance.__dict__[self.name] = value  class LazyField:    """非数据描述符: 延迟计算并缓存到实例字典"""     def __init__(self, func):        self.func = func        self.name = func.__name__     def __get__(self, instance, owner):        if instance is None:            return self        if self.name not in instance.__dict__:            instance.__dict__[self.name] = self.func(instance)        return instance.__dict__[self.name]  class Order:    price = ValidatedField("price", float)    quantity = ValidatedField("quantity", int)     def __init__(self, price, quantity):        self.price = price        self.quantity = quantity     @LazyField    def total(self):        print("计算 total 中...")        return self.price * self.quantity  if __name__ == "__main__":    my_order = Order(9.5, 4)    print("第一次访问 total:", my_order.total)    print("第二次访问 total:", my_order.total)     try:        my_order.price = "昂贵"    except TypeError as exc:        print("类型校验拦截:", exc)
目的:演示数据描述符(ValidatedField)做类型强制校验、非数据描述符(LazyField)做延迟计算缓存。
  1. def __set__(self, instance, value):赋值统一入口,所有写入先过这道关卡。
  2. if not isinstance(value, self.expected_type):类型不符直接抛 TypeError,从源头拦截脏数据。
  3. instance.__dict__[self.name] = value绕过普通赋值,把值直接写进实例字典,存储位置由描述符掌控。
  4. @LazyField用非数据描述符包装 total 方法,让计算延迟到首次访问。
  5. if self.name not in instance.__dict__:首次访问才执行计算并缓存,之后直接命中实例字典。
  6. print("第二次访问 total:", my_order.total)非数据描述符优先级低于实例字典,第二次不再触发 __get__。

常见误区

误区`@property` 只是“让方法伪装成属性”的魔法语法,跟 `__get__` 那些底层方法没关系,我平时用 property 也完全不用理解描述符。

正解property 本身就是描述符协议最标准的实践。它底层是一个 C 实现的描述符类,分别用 __get__ 调 fget、__set__ 调 fset、__delete__ 调 fdel。你手写一个同时实现这三个方法的类,行为与 property 完全一致。区别只是官方把它封装成了装饰器语法,看起来像“魔法”而已。

为何常见:property 用起来太自然,没人会去查看它的 CPython 源码,于是误以为它是独立语法。

误区属性不都存在实例的 `__dict__` 里吗?那实例字典应该是第一优先级,描述符只能在实例没这个属性时才起作用吧?

正解数据描述符的优先级高于实例 __dict__。只要类型 MRO 里有数据描述符,实例字典中同名属性完全被忽略。property 是数据描述符,所以你永远无法用 instance.x = 1 覆盖一个 property;自定义校验字段时也是靠这条规则强制拦截赋值。只有非数据描述符(如普通函数)的优先级低于实例字典。

为何常见:平时查属性时“实例字典有值就直接返回”的经验太深刻,容易把局部现象当成全局规律。

误区函数放在类里,访问 `instance.method` 就会自动绑定 self,这没什么好深究的,可能就是解释器特殊处理了函数。

正解这是描述符协议的直接体现。类中的 def 产生的是 function 对象,它只实现了 __get__,属于非数据描述符。访问 instance.method 时,Python 调用 function.__get__(instance, cls) 生成绑定方法。如果实例字典里有同名属性,绑定根本不会发生——因为非数据描述符优先级低于实例字典。这个行为完全由协议推导,不是魔法。

为何常见:方法绑定是 Python 最顺滑的体验之一,使用者几乎不会察觉背后有一次描述符调用。

误区我写了一个描述符类,实现了 `__get__`,所以它就是万能的属性管家,读写都能拦截。

正解只实现 __get__ 的是非数据描述符,优先级低于实例 __dict__。只要实例上被赋了同名属性,你的 __get__ 就再也不被调用了。要实现强制的读写控制,必须同时实现 __set__(或 __delete__),让它升级为数据描述符。只实现 __set__ 而不实现 __get__,读取会直接返回描述符对象本身,行为同样诡异。

为何常见:初学描述符时最容易先只写 __get__,因为“能读到值”就以为成功了,忽略了赋值路径。

误区`@property` 和 `@price.setter` 是两个不同的装饰器魔法,Python 专门为 property 发明了配套语法。

正解@price.setter 是对 property 实例的普通方法调用。property 对象有一个 setter 方法,它返回一个新的 property 实例(复用原 fget,替换 fset),然后通过装饰器赋值把新实例绑回 price 这个名字。整个过程就是普通的装饰器链:先 property(fget),再 x = x.setter(fset)。没有任何特殊语法。

为何常见:装饰器叠加看起来像独立语法,且 setter 方法名与 property 强绑定,容易误以为是语言级新特性。

面试题

Q1. 描述符协议要求类实现的三个方法是哪组?

单选题 · easy

  • A.__init__/__new__/__del__
  • B.__get__/__set__/__delete__← 正确答案
  • C.__read__/__write__/__remove__
  • D.__getattr__/__setattr__/__delattr__

答案:B。描述符协议是“管家三件套”:__get__ 负责取、__set__ 负责存、__delete__ 负责删。A 是对象生命周期方法,C 是编造的接口名,D 是属性查找兜底与拦截方法,虽然名字接近,但不构成描述符协议。

Q2. 以下哪些对象属于数据描述符?

多选题 · mid

  • A.property 实例← 正确答案
  • B.同时实现 __get__ 和 __set__ 的类实例← 正确答案
  • C.普通的类函数属性
  • D.staticmethod 包装的对象

答案:AB。数据描述符的核心是实现了 __set__ 或 __delete__。property 和一手抓存取的自定义描述符都是数据描述符。普通函数与 staticmethod 包装对象都只实现 __get__,属于非数据描述符,优先级低于实例 __dict__。

Q3. 属性查找的优先级从高到低正确排列是?

单选题 · mid

  • A.非数据描述符 > 实例 __dict__ > 数据描述符
  • B.数据描述符 > 实例 __dict__ > 非数据描述符← 正确答案
  • C.数据描述符 > 非数据描述符 > 实例 __dict__
  • D.实例 __dict__ > 数据描述符 > 非数据描述符

答案:B。数据描述符最优先,就像全职管家优先于房间储物柜;实例 __dict__ 其次;非数据描述符最后兜底。C 把实例字典和非数据描述符的顺序搞反了,A 和 D 的整体顺序都错误。

Q4. Python 内置的 property 属于哪种描述符?

单选题 · easy

  • A.属于非数据描述符
  • B.属于数据描述符← 正确答案
  • C.属于元类描述符
  • D.属于弱引用描述符

答案:B。property 同时实现了 __get__、__set__、__delete__,因此是数据描述符,优先级高于实例字典。这就是为什么 property 的 setter 能在赋值时被触发,也解释了为什么无法用 instance.x = value 绕过 property。

Q5. 关于 property 的说法,正确的有哪些?

多选题 · mid

  • A.property 实例是数据描述符← 正确答案
  • B.类访问时 property 返回自身← 正确答案
  • C.@price.setter 会产生新 property← 正确答案
  • D.property 可以替代所有装饰器

答案:ABC。property 内置为数据描述符;通过类访问时 instance 为 None,property 的 __get__ 返回自身;@price.setter 调用 setter 方法产生全新 property 实例并重新绑定到类字典。D 夸大了 property 的能力,它只服务属性访问,不能替代函数装饰器、类装饰器等。

Q6. instance.method 访问返回绑定方法的关键机制是?

单选题 · hard

  • A.在类型 MRO 中直接找到方法并原样返回
  • B.方法是非数据描述符,经由 __get__ 绑定实例← 正确答案
  • C.每次访问时动态编译方法为字节码
  • D.从实例 __dict__ 中找到并自动绑定

答案:B。类中的函数是 function 对象,只实现了 __get__,是非数据描述符。访问 instance.method 时,数据描述符和实例字典都未命中,最后由 function.__get__(instance, cls) 生成绑定方法。A 漏掉了绑定过程,C 与字节码无关,D 错误因为方法通常不在实例字典中。

Q7. classmethod 与 staticmethod 的共同本质是?

单选题 · hard

  • A.本质都是数据描述符
  • B.都会自动绑定实例自身
  • C.本质都是非数据描述符← 正确答案
  • D.都是不参与查找的函数

答案:C。两者都只包装函数并提供 __get__,不实现 __set__,属于非数据描述符。classmethod 的 __get__ 把第一个参数绑定为持有类,staticmethod 的 __get__ 原样返回原函数。A 错误,B 只对了一半(classmethod 绑类,staticmethod 不绑),D 违背事实。

Q8. 以下哪些操作会触发描述符的 __set__?

多选题 · mid

  • A.instance.attr = value← 正确答案
  • B.setattr(instance, attr, value)← 正确答案
  • C.instance.__dict__[attr] = value
  • D.调用默认 __setattr__(attr, value)← 正确答案

答案:ABD。赋值语句和 setattr 最终都走 object.__setattr__,它会检查类型 MRO 中是否有数据描述符并调用 __set__。直接改写 instance.__dict__ 是绕过管家自己撬锁,不触发 __set__。D 虽然是显式调用 __setattr__,但默认实现同样遵循该规则,所以也触发。

Q9. 描述符协议不适用于以下哪个场景?

单选题 · easy

  • A.属性类型校验与只读保护
  • B.构建 ORM 字段映射
  • C.优化内存中的列表排序← 正确答案
  • D.延迟计算与结果缓存

答案:C。描述符非常适合属性校验、ORM 字段映射、延迟计算与缓存。C 是算法与数据结构问题,与属性访问机制无关,也不是描述符协议的适用场景。

Q10. 编写字段校验描述符时,必须实现哪对方法?

单选题 · hard

  • A.__init__ 和 __get__
  • B.__get__ 和 __set__← 正确答案
  • C.只实现 __set__ 就足够
  • D.必须继承 property 类

答案:B。要实现拦截赋值并验证,必须让描述符成为数据描述符,即实现 __set__;同时实现 __get__ 负责读取值,否则读取会返回描述符对象本身。只实现 __set__ 会让读取行为变得怪异,而继承 property 反而难以写出灵活的校验逻辑。