Python描述符与property机制深度解析:把"属性访问"做成一等公民
一、引言:obj.x 背后到底发生了什么
写 Python 的人几乎每天都在敲 obj.x,但很少有人追问一句:这一行点号访问,解释器到底干了什么?
直觉上它像 C++ 的 obj.x 字段访问——直接按偏移量取内存。但 Python 里 x 既不是编译期固定的偏移,也不保证每次都返回同一个东西。下面这段代码会让你知道它有多"活":
1 | class C: |
输出:
1 | 1 |
注意:我们没有碰任何方法,只是写了 c.__dict__,c.x 的返回值就变了。这说明 obj.x 不是"取字段",而是一次有协议、可拦截、有查找顺序的访问。这套协议,就是描述符(Descriptor)。
本文顺着"点号访问 → 描述符协议 → property 语法糖 → 优先级陷阱"递进,并用 C++ 视角对照,最后给你两个真实会踩的坑。
二、描述符协议:属性的读写被"对象"接管
描述符,本质是一个**"实现了特定方法的对象",它被挂在类的属性**上,从而接管该类实例对该属性的读写行为。协议只需要三个魔术方法里的任意组合:
1 | class Desc: |
输出:
1 | -> __get__ 被调用 |
核心规则:描述符必须是一个类属性(C.x = Desc()),而不是实例属性。解释器在 c.x 时,发现 C.x 是个带 __get__ 的对象,就把这次访问"分发"给它的方法。你写的 c.x = 10,也不再是简单的字典赋值,而是 C.x.__set__(c, 10)。
由此引出两个关键分类:
- 数据描述符(data descriptor):实现了
__set__或__delete__(通常两者之一即可,标准以"有__set__或__delete__"界定)。 - 非数据描述符(non-data descriptor):只实现了
__get__(如classmethod、staticmethod,以及下面要讲的property的只读形态)。
这个分类,直接决定了属性查找的优先级——也就是下面最坑的地方。
三、property 不过是描述符的语法糖(C++ 对照)
property 是日常用得最多的描述符,它本身就是用描述符协议实现的。下面两段等价:
1 | class Circle: |
@property 只是把 r = property(getter, setter) 这个类属性赋值用装饰器糖衣包了起来。property 对象内部实现了 __get__ 和 __set__,所以它是一个数据描述符。
C++ 视角对照:C++ 没有"统一的属性访问拦截协议"。想做到"读 x 时自动校验/计算",通常两条路:
- 手写
get_x()/set_x()成员函数:每个属性一套,名字靠约定,编译器不强制——漏写set也不会报错,全靠人盯。 - 上框架元系统:如 Qt 的
Q_PROPERTY,靠moc代码生成另搞一套属性元数据;C# 有语言级property(get/set 访问器),比 C++ 强,但同样没有"描述符可被复用、组合、继承"的协议感。
Python 的高明之处在于:它把"属性的读写行为"做成了一个一等公民对象(可以单独定义、复用、组合、继承),并且由解释器在每次点号访问时自动分发。这是静态类型语言靠"写一对函数"很难优雅表达的动态协议。property 只是这个协议最甜的那层糖。
四、优先级陷阱:三个让校验/只读失效的坑
描述符最反直觉的,是属性查找的优先级顺序。把它画成一张图就清楚了:
1 | obj.x 的查找顺序(以 type(obj) 为起点): |
记住这条:数据描述符压过实例字典;但非数据描述符会被实例字典压过。两个真实踩坑都源于此:
坑一:描述符挂在 __init__ 里,等于没挂。
曾做一个带字段类型校验的配置类(类似 pydantic 的 field),早期这么写:
1 | class Config: |
self.value = TypeCheck(int) 把校验器存进了实例字典,而解释器只在类层面找描述符。点号访问时 C.value 不是描述符,c.value 就老老实实读写 __dict__——校验彻底失效。修复只有一条路:提到类属性 class Config: value = TypeCheck(int)。
坑二:只读描述符只写 __get__,挡不住误写。
想做个"只读"字段,只实现了 __get__:
1 | class ReadOnly: |
因为 x 是非数据描述符,优先级低于 c.__dict__,c.x = 999 默默在实例字典里塞了同名的 x,后续 c.x 在第 2 步就被实例字典截走,描述符再也没机会出场。要真正只读,必须实现 __set__ 并在里面抛 AttributeError——那样它就变成数据描述符,优先级压过实例字典,写入会被拦截。
五、对比与小结
把描述符相关的几样东西放一起看:
| 机制 | 本质 | 是否数据描述符 | 典型用途 | C++ 里最接近 |
|---|---|---|---|---|
自定义 Desc 含 __set__ |
完整接管读写 | 是 | 校验、只读、惰性加载 | 手写 get/set + 约定 |
property |
描述符的语法糖 | 是(有 __set__) |
把方法当属性、加校验 | C# property / Qt Q_PROPERTY |
classmethod |
非数据描述符 | 否 | 绑定类的方法 | static 成员函数 |
staticmethod |
非数据描述符 | 否 | 不绑定实例/类的方法 | 普通函数 |
实例属性 self.x=1 |
普通字典项 | — | 存数据 | 成员变量 |
一句话本质:描述符是 Python 把"属性访问"从"取内存偏移"升级成"可调用的协议"的机制;property 是这个协议最常用的一层糖衣,而优先级规则(数据描述符 > 实例字典 > 非数据描述符)决定了你的校验和只读到底生不生效。
收尾三条实战建议:
- 描述符永远挂在类上,别在
__init__里挂——这是 90% "为什么没生效"的根因。 - 想只读,就实现
__set__并抛异常;只写__get__的"只读"会被实例字典轻松绕过。 - 字段级校验/转换、ORM 列定义、lazy property,都是描述符的主场;当你发现自己在
__init__里写一堆if not isinstance(...)时,就该想想能不能抽成一个描述符类复用。

