你有没有想过,每次访问一个 Python 对象的属性(比如 student.name),Python 在背后执行的其实是一套“隐形流程”?把对象想象成一间公寓房间,属性就是房间里摆放的各种物品。Python 的 __getattribute__ 就像前台总机——你报出想取的物品,总台不会直接拉开储物柜,而是先翻看一份“管家名单”:如果这件物品配了专属管家(描述符),无论是取、存还是扔,都必须通过管家之手。这套“管家守则”就是描述符协议,由 __get__、__set__、__delete__ 三个钩子方法组成。
更妙的是,你早就一直在使用它:@property 是物业提供的标准管家模板,@classmethod 和 @staticmethod 则是两个特殊服务窗口。一旦看懂描述符,你就掌握了自己定义“存取规则”的能力——字段校验、ORM 映射、延迟计算,不过是管家守则的几种编排方式。本文会带你从公寓管家模型出发,彻底拆解 Python 属性系统的底层设计。
掌握描述符协议,等于拿到了 Python 数据模型的“钥匙”。无论做框架设计、ORM 封装还是代码洁癖级的属性控制,这都是绕不开的底层内功。
目录
可视化演示
展示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 对应的属性对象,如果它是描述符,就调用 __get__ 接管这次读取。- 实例访问表达式
a.name会被 Python 编译为属性查找,统一经过__getattribute__入口。 - MRO 查找沿着
type(a)的方法解析顺序,从当前类到父类再到object,找出名为name的属性对象。 - 描述符接管若找到的
name是描述符,则调用它的__get__(a, type(a)),返回值就是最终结果。
数据描述符:全职管家
管家也分全职和兼职。全职管家同时管存取,兼职引导员只负责指路。数据描述符就是“全职管家”:它实现了 __set__ 或 __delete__(通常与 __get__ 一起实现),因此任何一次赋值都逃不过它的眼睛。在 Python 术语里,数据描述符意味着这个属性“值得被全程监控”。
为什么 Python 要专门给数据描述符最高优先级?因为只要实现了 __set__,就能保证每次写入都先被检查。假如实例字典比管家优先,那么你刚写完 setter,实例上随手赋一个同名属性,控制就会瞬间失效——管家成了摆设。为了让“赋值检查”拥有绝对约束力,Python 规定:数据描述符优先于实例 __dict__。
这就是“实例遮蔽”问题的由来。很多人困惑:self.x = 1 明明把 x 放进了实例字典,为什么 property 依然拦截?答案就藏在这条优先级规则里——property 是数据描述符,你永远无法用普通赋值覆盖一个数据描述符。
所以,“全职管家”的设立不是为了抢活干,而是为了确保任何非法赋值都无法绕过检查。后面写类型校验时最关键的一步,就是把描述符做成数据描述符——有了 __set__,你才真正拥有对这个属性的永久控制权。
≻ 表示查找时的优先顺序:类型 MRO 中一旦出现数据描述符,无论实例字典里存了什么,读取都由 __get__ 接管;实例字典只在没有数据描述符时才被信任;非数据描述符排在最后。- 数据描述符最优先类型 MRO 中发现数据描述符后,直接调用它的
__get__(obj, cls)。 - 实例字典其次没有数据描述符时,才会查看
obj.__dict__中已存的值。 - 非数据描述符兜底前两处都落空,才轮到类型 MRO 中的非数据描述符。
非数据描述符:兼职引导员
兼职引导员只在房间本身找不到物品时才会被询问。普通函数就是这类角色:函数对象只实现了 __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__。理解了它,你就理解了“类里定义的函数为什么和普通函数不一样”。
__get__ 生成绑定方法,把实例作为第一个参数传入,这就是 self 自动出现的真正来源。- 函数对象类体里的
def f(self)编译后是一个function对象,它实现了__get__。 - 绑定过程访问
obj.f时,Python 调用function.__get__(obj, cls)生成一个绑定方法对象。 - 首参传入绑定方法被调用时,把
obj自动作为第一个参数传给f,于是self到手。
查找优先级:前台总机的决策
现在把视角升到整栋公寓的前台:当你请求一个属性,__getattribute__ 就像总机接线员,按一套固定剧本决定由谁来响应。这套剧本有四个分支,顺序完全不可打乱。理解它,你就彻底掌握了“属性到底在哪找”的全局地图。
第一步,沿类型 MRO 找数据描述符。前台先翻管家名册,看有没有全职管家负责这个物品——有,直接转给管家,流程结束。第二步,没有全职管家,去房间储物柜 instance.__dict__ 里找。第三步,储物柜也没有,再去翻兼职引导员手册(类型 MRO 中的非数据描述符)。第四步,全部落空,抛出 AttributeError。
这个顺序解释了三个常见现象:为什么 property 的 setter 一定能触发(它是数据描述符);为什么 instance.method = x 能覆盖方法的绑定(函数是非数据描述符);为什么读一个只实现了 __set__ 的字段得到的是描述符对象本身(数据描述符存在但无 __get__,读取时返回它自己)。
前台总机的这套剧本,决定了每一个属性的“命运”。你不需要记住 CPython 源码,只要记住“数据描述符 > 实例字典 > 非数据描述符”这个优先序,就掌握了 90% 的属性行为预测能力。
getattr 的官方语义:数据描述符、实例字典、非数据描述符、异常,四层决策从上到下执行。- 数据描述符分支先沿
A的 MRO 查找名为x的数据描述符,找到就调用它的__get__。 - 实例字典分支没有数据描述符时,直接看
a.__dict__是否存了这个键。 - 非数据描述符分支实例字典没有,再查类型 MRO 中的非数据描述符。
- 异常兜底四层都落空,抛出
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 装饰类体执行时,把紧跟的函数传给 property 构造器,形成描述符对象。
- setter 调用
@x.setter实际执行x = x.setter(fset)。 - 替换绑定新 property 对象被赋回
attr名字,类字典中的旧对象被替换。
类方法与静态方法:两个特殊窗口
公寓里还有两个特殊服务窗口。classmethod 是“业主委员会窗口”:无论你从哪间房(实例)还是从公寓前台(类)来,它都把第一个参数绑定为“业主委员会”(类本身)。staticmethod 是“公告栏”:谁来问都是同一张公告,不绑定任何东西。
从描述符视角看,两者都是非数据描述符。classmethod 包装函数后生成一个 classmethod 对象,其 __get__ 把持有它的类作为第一个参数传入并返回绑定方法;staticmethod 的 __get__ 则原样返回被包装的函数。它们都不实现 __set__,所以实例字典优先级更高——这些描述符只提供“默认绑定规则”,不强制拦截。
为什么要用描述符实现?因为 Python 想复用同一套属性查找机制:方法绑定本身就是一次属性查找的结果。Class.method 通过类查找得到绑定到类的 bound method,instance.method 通过实例查找同样得到绑定结果,只是绑定目标不同。这套统一规则,让“方法”和“属性”在描述符协议面前完全平等。
有了这两个窗口,类工厂、注册表模式、工具函数组织都变得顺手。记住它们的本质:一个是“绑定到类”的非数据描述符,一个是“不绑定”的非数据描述符。
__get__ 返回绑定到类的调用对象;staticmethod 的 __get__ 直接透传原函数。- classmethod 的 get传入
instance=None和拥有类cls,生成捕获cls的可调用对象。 - staticmethod 的 get无论 instance 还是 cls,都返回最初包装的那个函数。
- 非数据描述符属性二者都未实现
__set__,因此可被实例字典覆盖。
实战应用:从字段校验到 ORM
理解了协议,就可以自己“培训管家”了。最常见的两个模式:类型校验字段和延迟计算字段。前者用数据描述符实现,后者用非数据描述符实现。两者都只用几十行代码,就能写出媲美框架的体验。
类型校验字段的套路是:__set__ 中检查类型,不合格直接抛异常,合格则写入 instance.__dict__;__get__ 中从 instance.__dict__ 读回。由于数据描述符优先于实例字典,无论外部怎么赋值都会被拦截,这就是 ORM 字段校验的雏形——Django 的 Field、SQLAlchemy 的 Column 本质都是描述符。
延迟计算字段的套路是:用 @LazyField 装饰一个计算方法,第一次访问时执行该方法、把结果存进实例字典;后续访问因非数据描述符优先级低于实例字典,直接命中缓存。这个模式在特征工程中尤其好用:复杂特征只在第一次访问时计算一次。下面会给出完整代码。
培训自己的管家并不神秘:数据描述符用于一切需要“强制纪律”的场景,非数据描述符用于“默认行为但允许覆盖”的场景。掌握了这个分工,你就能在项目中写出既克制又优雅的属性层。
- set 校验先验证
value的类型,不符立即抛错,从源头阻止脏数据。 - 写入实例字典校验通过后,把值存入
instance.__dict__(存储位置由描述符决定)。 - get 读取从实例字典按名取回,实现“存哪读哪”的规则一致性。
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 演示
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)
def __set__(self, instance, value):赋值统一入口,所有写入先过这道关卡。if not isinstance(value, self.expected_type):类型不符直接抛TypeError,从源头拦截脏数据。instance.__dict__[self.name] = value绕过普通赋值,把值直接写进实例字典,存储位置由描述符掌控。@LazyField用非数据描述符包装total方法,让计算延迟到首次访问。if self.name not in instance.__dict__:首次访问才执行计算并缓存,之后直接命中实例字典。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. 描述符协议要求类实现的三个方法是哪组?
- A.
__init__/__new__/__del__ - B.
__get__/__set__/__delete__← 正确答案 - C.
__read__/__write__/__remove__ - D.
__getattr__/__setattr__/__delattr__
答案:B。描述符协议是“管家三件套”:__get__ 负责取、__set__ 负责存、__delete__ 负责删。A 是对象生命周期方法,C 是编造的接口名,D 是属性查找兜底与拦截方法,虽然名字接近,但不构成描述符协议。
Q2. 以下哪些对象属于数据描述符?
- A.property 实例← 正确答案
- B.同时实现
__get__和__set__的类实例← 正确答案 - C.普通的类函数属性
- D.staticmethod 包装的对象
答案:AB。数据描述符的核心是实现了 __set__ 或 __delete__。property 和一手抓存取的自定义描述符都是数据描述符。普通函数与 staticmethod 包装对象都只实现 __get__,属于非数据描述符,优先级低于实例 __dict__。
Q3. 属性查找的优先级从高到低正确排列是?
- A.非数据描述符 > 实例
__dict__> 数据描述符 - B.数据描述符 > 实例
__dict__> 非数据描述符← 正确答案 - C.数据描述符 > 非数据描述符 > 实例
__dict__ - D.实例
__dict__> 数据描述符 > 非数据描述符
答案:B。数据描述符最优先,就像全职管家优先于房间储物柜;实例 __dict__ 其次;非数据描述符最后兜底。C 把实例字典和非数据描述符的顺序搞反了,A 和 D 的整体顺序都错误。
Q4. Python 内置的 property 属于哪种描述符?
- A.属于非数据描述符
- B.属于数据描述符← 正确答案
- C.属于元类描述符
- D.属于弱引用描述符
答案:B。property 同时实现了 __get__、__set__、__delete__,因此是数据描述符,优先级高于实例字典。这就是为什么 property 的 setter 能在赋值时被触发,也解释了为什么无法用 instance.x = value 绕过 property。
Q5. 关于 property 的说法,正确的有哪些?
- 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 访问返回绑定方法的关键机制是?
- A.在类型 MRO 中直接找到方法并原样返回
- B.方法是非数据描述符,经由
__get__绑定实例← 正确答案 - C.每次访问时动态编译方法为字节码
- D.从实例
__dict__中找到并自动绑定
答案:B。类中的函数是 function 对象,只实现了 __get__,是非数据描述符。访问 instance.method 时,数据描述符和实例字典都未命中,最后由 function.__get__(instance, cls) 生成绑定方法。A 漏掉了绑定过程,C 与字节码无关,D 错误因为方法通常不在实例字典中。
Q7. classmethod 与 staticmethod 的共同本质是?
- A.本质都是数据描述符
- B.都会自动绑定实例自身
- C.本质都是非数据描述符← 正确答案
- D.都是不参与查找的函数
答案:C。两者都只包装函数并提供 __get__,不实现 __set__,属于非数据描述符。classmethod 的 __get__ 把第一个参数绑定为持有类,staticmethod 的 __get__ 原样返回原函数。A 错误,B 只对了一半(classmethod 绑类,staticmethod 不绑),D 违背事实。
Q8. 以下哪些操作会触发描述符的 __set__?
- 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. 描述符协议不适用于以下哪个场景?
- A.属性类型校验与只读保护
- B.构建 ORM 字段映射
- C.优化内存中的列表排序← 正确答案
- D.延迟计算与结果缓存
答案:C。描述符非常适合属性校验、ORM 字段映射、延迟计算与缓存。C 是算法与数据结构问题,与属性访问机制无关,也不是描述符协议的适用场景。
Q10. 编写字段校验描述符时,必须实现哪对方法?
- A.
__init__和__get__ - B.
__get__和__set__← 正确答案 - C.只实现
__set__就足够 - D.必须继承 property 类
答案:B。要实现拦截赋值并验证,必须让描述符成为数据描述符,即实现 __set__;同时实现 __get__ 负责读取值,否则读取会返回描述符对象本身。只实现 __set__ 会让读取行为变得怪异,而继承 property 反而难以写出灵活的校验逻辑。