一、引言:obj.x 背后到底发生了什么

写 Python 的人几乎每天都在敲 obj.x,但很少有人追问一句:这一行点号访问,解释器到底干了什么?

直觉上它像 C++ 的 obj.x 字段访问——直接按偏移量取内存。但 Python 里 x 既不是编译期固定的偏移,也不保证每次都返回同一个东西。下面这段代码会让你知道它有多"活":

1
2
3
4
5
6
7
8
class C:
def __init__(self):
self.x = 1

c = C()
print(c.x) # 1,来自实例 __dict__
c.__dict__['x'] = 99
print(c.x) # 99,直接改底层字典就变了

输出:

1
2
1
99

注意:我们没有碰任何方法,只是写了 c.__dict__c.x 的返回值就变了。这说明 obj.x 不是"取字段",而是一次有协议、可拦截、有查找顺序的访问。这套协议,就是描述符(Descriptor)

本文顺着"点号访问 → 描述符协议 → property 语法糖 → 优先级陷阱"递进,并用 C++ 视角对照,最后给你两个真实会踩的坑。

二、描述符协议:属性的读写被"对象"接管

描述符,本质是一个**"实现了特定方法的对象",它被挂在类的属性**上,从而接管该类实例对该属性的读写行为。协议只需要三个魔术方法里的任意组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Desc:
def __get__(self, instance, owner):
print(" -> __get__ 被调用")
return 42

def __set__(self, instance, value):
print(" -> __set__ 被调用,拒绝写入")
raise AttributeError("只读,不许写")

def __delete__(self, instance):
print(" -> __delete__ 被调用")

class C:
x = Desc() # 关键:描述符必须挂在【类】上

c = C()
print(c.x) # 走 __get__
try:
c.x = 10 # 走 __set__,被拒
except AttributeError as e:
print(e)

输出:

1
2
3
4
  -> __get__ 被调用
42
-> __set__ 被调用,拒绝写入
只读,不许写

核心规则:描述符必须是一个类属性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__(如 classmethodstaticmethod,以及下面要讲的 property 的只读形态)。

这个分类,直接决定了属性查找的优先级——也就是下面最坑的地方。

三、property 不过是描述符的语法糖(C++ 对照)

property 是日常用得最多的描述符,它本身就是用描述符协议实现的。下面两段等价:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Circle:
def __init__(self, r):
self._r = r

@property # getter
def r(self):
return self._r

@r.setter # setter
def r(self, v):
if v <= 0:
raise ValueError("半径必须为正")
self._r = v

c = Circle(3)
print(c.r) # 3
c.r = 5 # 走 setter
print(c.r) # 5
c.r = -1 # ValueError: 半径必须为正

@property 只是把 r = property(getter, setter) 这个类属性赋值用装饰器糖衣包了起来。property 对象内部实现了 __get____set__,所以它是一个数据描述符

C++ 视角对照:C++ 没有"统一的属性访问拦截协议"。想做到"读 x 时自动校验/计算",通常两条路:

  1. 手写 get_x() / set_x() 成员函数:每个属性一套,名字靠约定,编译器不强制——漏写 set 也不会报错,全靠人盯。
  2. 上框架元系统:如 Qt 的 Q_PROPERTY,靠 moc 代码生成另搞一套属性元数据;C# 有语言级 property(get/set 访问器),比 C++ 强,但同样没有"描述符可被复用、组合、继承"的协议感

Python 的高明之处在于:它把"属性的读写行为"做成了一个一等公民对象(可以单独定义、复用、组合、继承),并且由解释器在每次点号访问时自动分发。这是静态类型语言靠"写一对函数"很难优雅表达的动态协议。property 只是这个协议最甜的那层糖。

四、优先级陷阱:三个让校验/只读失效的坑

描述符最反直觉的,是属性查找的优先级顺序。把它画成一张图就清楚了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
obj.x 的查找顺序(以 type(obj) 为起点):
┌─────────────────────────────────────────────────────────┐
│ 1. 在 type(obj) 的 MRO 里找名为 x 的【数据描述符】 │
│ (实现了 __set__ / __delete__)→ 命中则调用 desc.__get__│
├─────────────────────────────────────────────────────────┤
│ 2. 否则查 obj.__dict__['x'](实例自己的属性) │
├─────────────────────────────────────────────────────────┤
│ 3. 否则查 type(obj) 及其基类中的【非数据描述符】 │
│ (只有 __get__)→ 命中则调用 desc.__get__ │
├─────────────────────────────────────────────────────────┤
│ 4. 否则查 type(obj).__dict__['x'](普通类属性 / 方法) │
├─────────────────────────────────────────────────────────┤
│ 5. 都没有 → 触发 __getattr__(若定义),否则 AttributeError│
└─────────────────────────────────────────────────────────┘

口诀:数据描述符 > 实例 __dict__ > 非数据描述符 > 类属性

记住这条:数据描述符压过实例字典;但非数据描述符会被实例字典压过。两个真实踩坑都源于此:

坑一:描述符挂在 __init__ 里,等于没挂。

曾做一个带字段类型校验的配置类(类似 pydantic 的 field),早期这么写:

1
2
3
4
5
class Config:
def __init__(self):
self.value = TypeCheck(int) # 想用描述符校验,却挂成了实例属性
c = Config()
c.value = "不是数字" # 直接写进 __dict__,校验器根本没参与

self.value = TypeCheck(int) 把校验器存进了实例字典,而解释器只在类层面找描述符。点号访问时 C.value 不是描述符,c.value 就老老实实读写 __dict__——校验彻底失效。修复只有一条路:提到类属性 class Config: value = TypeCheck(int)

坑二:只读描述符只写 __get__,挡不住误写。

想做个"只读"字段,只实现了 __get__

1
2
3
4
5
6
7
8
9
10
11
12
class ReadOnly:
def __init__(self, val): self._v = val
def __get__(self, inst, owner): return inst._readonly_store

class C:
x = ReadOnly(10) # 非数据描述符(只有 __get__)

c = C()
c._readonly_store = 10
print(c.x) # 10,走 __get__
c.x = 999 # 没有 __set__,于是写进了 c.__dict__['x']
print(c.x) # 999!读取被实例字典截胡,只读形同虚设

因为 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(...) 时,就该想想能不能抽成一个描述符类复用。