Descriptor:Python 面向对象真正的隐藏核心——从 A().x == 42 到 ORM Field 设计
很多 Python 开发者已经熟练使用 property、实例方法、classmethod、ORM 模型字段,甚至每天都在写 Django、SQLAlchemy、Pydantic 风格的业务代码,却未必真正理解它们背后的共同机制。
这个机制,就是 Descriptor——描述符协议。
先看一段极其简单的代码:
class D:
def __get__(self, obj, owner):
return 42
class A:
x = D()
print(A().x)
运行结果:
42
问题来了:
x 明明只是类 A 中保存的一个 D() 对象,为什么访问 A().x 时,拿到的不是 D 的实例,而是 42?
更值得追问的是:
- data descriptor 和 non-data descriptor 到底谁优先?
property属于哪一种 descriptor?- 普通函数为什么放进类里以后,就能自动变成 bound method?
- ORM 中的
User.name、Product.price为什么能够自动完成校验、类型转换甚至数据库读写? - 如果我们自己设计一个 ORM Field,descriptor 应该怎么用?
如果这些问题能彻底想明白,你对 Python 面向对象的理解会从“会用 class”,进入“理解 Python 对象模型”的阶段。
一、Descriptor 到底是什么?
Descriptor 并不是某个特殊的类,而是一套 对象协议。
只要一个对象实现了下面这些特殊方法中的一个或多个,它就可能成为 descriptor:
__get__(self, instance, owner)
__set__(self, instance, value)
__delete__(self, instance)
最简单的 descriptor:
class D:
def __get__(self, instance, owner):
print("调用了 __get__")
return 42
然后把它作为类属性:
class A:
x = D()
访问:
a = A()
print(a.x)
Python 并不是简单地执行:
A.__dict__["x"]
然后把 D() 返回给你。
它会发现:
A.__dict__["x"]
这个对象实现了:
__get__
于是触发 descriptor 协议,大致相当于:
A.__dict__["x"].__get__(a, A)
因此:
a.x
最终得到:
42
这就是:
A().x == 42
的根本原因。
二、真正需要理解的是属性查找顺序
Descriptor 最容易学错的地方,是只记住:
“实现
__get__就会自动调用。”
这句话不够准确。
真正重要的问题是:
当实例属性、类属性和 descriptor 同名时,到底听谁的?
例如:
class D:
def __get__(self, obj, owner):
return 42
class A:
x = D()
a = A()
a.__dict__["x"] = 100
print(a.x)
你觉得结果是什么?
答案:
100
为什么不是 42?
因为这个 D 只有:
__get__
它属于 non-data descriptor。
Python 的实例属性查询顺序,简化以后可以理解为:
1. data descriptor
2. instance.__dict__
3. non-data descriptor / 普通类属性
4. 父类继续查找
5. __getattr__
这是理解 descriptor 最关键的一条规则。
可以记成:
Data Descriptor
↓
实例属性
↓
Non-data Descriptor
↓
普通类属性
三、Data Descriptor 与 Non-data Descriptor
1. Non-data Descriptor
如果一个 descriptor 只定义:
__get__
而没有定义:
__set__
__delete__
通常称为:
non-data descriptor。
例如:
class D:
def __get__(self, obj, owner):
return 42
测试:
class A:
x = D()
a = A()
print(a.x)
a.x = 100
print(a.x)
print(a.__dict__)
输出类似:
42
100
{'x': 100}
发生了什么?
第一次:
a.x
实例字典没有 x,因此找到:
A.__dict__["x"]
触发:
D.__get__()
得到 42。
但执行:
a.x = 100
之后,100 被直接写入:
a.__dict__["x"]
此时实例属性优先于 non-data descriptor,因此再次执行:
a.x
得到的是:
100
四、什么是 Data Descriptor?
只要 descriptor 实现了:
__set__
或者:
__delete__
它通常就属于 data descriptor。
例如:
class D:
def __get__(self, obj, owner):
return 42
def __set__(self, obj, value):
print("设置值:", value)
使用:
class A:
x = D()
a = A()
a.__dict__["x"] = 100
print(a.x)
结果仍然是:
42
即使:
a.__dict__["x"]
真的存在。
因为:
data descriptor
优先级高于:
instance.__dict__
所以:
a.x
依然优先调用:
D.__get__()
这也是一个非常容易在面试中出现的问题:
data descriptor 和实例属性谁优先?
答案是:
data descriptor > instance.__dict__
而:
instance.__dict__ > non-data descriptor
五、用伪代码理解 Python 的属性查找
为了理解本质,可以把:
obj.x
想象成 Python 内部进行了类似下面的工作。
注意,这只是帮助理解的简化版:
def object_getattribute(obj, name):
cls = type(obj)
# 查找类属性
cls_var = find_name_in_mro(cls, name)
# 1. data descriptor
if hasattr(type(cls_var), "__get__"):
if (
hasattr(type(cls_var), "__set__")
or hasattr(type(cls_var), "__delete__")
):
return cls_var.__get__(obj, cls)
# 2. instance dictionary
if hasattr(obj, "__dict__"):
if name in obj.__dict__:
return obj.__dict__[name]
# 3. non-data descriptor
if hasattr(type(cls_var), "__get__"):
return cls_var.__get__(obj, cls)
# 4. 普通类属性
if cls_var is not None:
return cls_var
raise AttributeError(name)
于是整个逻辑就清楚了:
obj.x
│
├─ 类中有 data descriptor? ──→ __get__
│
├─ obj.__dict__ 有 x? ───────→ 返回实例属性
│
├─ 类中有 non-data descriptor?→ __get__
│
├─ 普通类属性? ──────────────→ 返回类属性
│
└─ 否则 AttributeError
这套规则不仅解释 descriptor。
它实际上解释了 Python 大量 OO 行为。
六、property 属于哪一种 Descriptor?
答案:
property是 data descriptor。
来看最常见的代码:
class User:
def __init__(self, age):
self._age = age
@property
def age(self):
return self._age
使用:
user = User(18)
print(user.age)
本质上:
@property
def age(self):
...
约等于:
def age(self):
return self._age
age = property(age)
所以:
User.__dict__["age"]
并不是普通函数,而是一个:
property
对象。
你甚至可以验证:
print(type(User.__dict__["age"]))
结果:
<class 'property'>
而 property 实现了 descriptor 协议。
因此:
user.age
会触发类似:
User.__dict__["age"].__get__(user, User)
最终调用 getter。
七、没有 setter 的 property 为什么仍然是 data descriptor?
这是更深入的一层。
例如:
class User:
@property
def name(self):
return "Jack"
没有:
@name.setter
是不是就变成 non-data descriptor 了?
不是。
它仍然属于 data descriptor。
例如:
u = User()
u.name = "Tom"
会抛出:
AttributeError
原因是 property 类型本身具备 descriptor 的赋值处理能力;没有配置 setter 时,它不会退化成普通实例赋值,而是拒绝赋值。
因此它仍然可以阻止:
instance.__dict__
简单覆盖这个属性。
这也是 property 能很好实现封装的重要原因。
八、真正精彩的问题:function 为什么会变成 bound method?
来看 Python 最普通的一段代码:
class User:
def hello(self):
print("Hello")
然后:
u = User()
u.hello()
我们每天都这么写。
但有一个非常值得思考的问题:
你定义的是:
def hello(self):
Python 为什么知道:
u.hello()
应该自动把:
u
传给 self?
答案仍然是:
Descriptor。
九、Python 函数本身就是 Descriptor
先观察:
class User:
def hello(self):
print("Hello")
print(User.__dict__["hello"])
你得到的是一个普通 function:
<function User.hello at ...>
函数对象实现了:
__get__
因此:
User.__dict__["hello"]
实际上是一个:
non-data descriptor
于是执行:
u.hello
时,本质类似:
User.__dict__["hello"].__get__(u, User)
这个 __get__ 不再返回原始函数,而是产生一个:
bound method
验证一下:
u = User()
print(User.hello)
print(u.hello)
你会发现:
User.hello
接近:
<function User.hello ...>
而:
u.hello
则接近:
<bound method User.hello of <User object ...>>
这就是 绑定方法。
十、手动模拟 bound method
我们可以自己实现一个迷你版本:
class Function:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
if instance is None:
return self.func
def bound(*args, **kwargs):
return self.func(instance, *args, **kwargs)
return bound
使用:
def hello(self, name):
print(f"{self} says hello to {name}")
class User:
say = Function(hello)
调用:
u = User()
u.say("Tom")
实际上:
u.say
触发:
Function.__get__(u, User)
返回了一个已经记住:
instance = u
的函数。
因此:
u.say("Tom")
等价思想就是:
hello(u, "Tom")
Python 内置 function 的实现当然更加完善、高效,但底层思想就是 descriptor binding。
理解这一点之后,self 就不再神秘了:
self并不是 Python 的关键字,也没有什么魔法。真正发生的是函数 descriptor 在属性访问阶段完成了实例绑定。
十一、为什么函数设计成 Non-data Descriptor?
这是 Python 对象模型一个非常优雅的设计。
普通函数只提供 __get__,因此是 non-data descriptor。
而:
instance.__dict__ > non-data descriptor
意味着你可以在实例级别覆盖方法:
class A:
def hello(self):
return "class method"
a = A()
a.hello = lambda: "instance method"
print(a.hello())
输出:
instance method
为什么?
因为:
a.__dict__["hello"]
覆盖了类中的函数 descriptor。
如果 function 是 data descriptor,就无法这么自然地进行实例级覆盖。
这体现了 Python 一贯强调的动态性。
十二、Descriptor 最经典的工程场景:字段系统
理解 descriptor 以后,一个问题自然出现:
我们为什么需要自己写 descriptor?
实际项目中非常典型的答案,就是:
字段管理。
比如我们希望实现:
class User:
age = IntegerField()
name = StringField()
然后:
user = User()
user.age = 18
user.name = "Jack"
系统自动实现:
- 类型检查
- 范围检查
- 数据存储
- 默认值
- 字段元数据
- ORM 映射
- 序列化
- 数据库列生成
这正是 descriptor 最擅长解决的问题。
十三、实现一个真正可用的 ORM Field
先从基础 Field 开始:
class Field:
def __set_name__(self, owner, name):
self.name = name
self.storage_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.storage_name, None)
def __set__(self, instance, value):
setattr(instance, self.storage_name, value)
这里又出现了一个非常实用的特殊方法:
__set_name__
当 descriptor 被放到类里时:
class User:
name = Field()
Python 创建 User 类以后,会告诉这个 Field:
你在 User 中的名字叫 name
于是:
Field.__set_name__(User, "name")
会被调用。
这比以前手工写:
name = Field("name")
优雅很多。
十四、给 ORM Field 增加类型验证
进一步设计:
class Field:
expected_type = object
def __set_name__(self, owner, name):
self.name = name
self.storage_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.storage_name, None)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} 必须是 "
f"{self.expected_type.__name__}"
)
setattr(instance, self.storage_name, value)
class IntegerField(Field):
expected_type = int
class StringField(Field):
expected_type = str
现在模型就可以写成:
class User:
name = StringField()
age = IntegerField()
测试:
user = User()
user.name = "Alice"
user.age = 30
print(user.name)
print(user.age)
输出:
Alice
30
如果:
user.age = "30"
则直接抛错:
TypeError: age 必须是 int
一个最小字段系统已经出现了。
十五、为什么数据不能直接存在 descriptor 自己身上?
很多初学者第一次写 descriptor,会这样实现:
class Field:
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
self.value = value
看起来没问题。
实际上有严重 Bug。
例如:
class User:
age = Field()
u1 = User()
u2 = User()
u1.age = 18
u2.age = 30
print(u1.age)
你可能期待:
18
但得到的可能是:
30
为什么?
因为:
User.age
对应的 descriptor 只有一个。
也就是说:
u1
u2
u3
共享的是同一个:
Field()
因此实例数据绝不能简单存储在:
self.value
中。
正确方式应该存到具体 instance 身上:
instance.__dict__
或者使用私有存储名:
_age
_name
例如:
instance.__dict__[self.storage_name] = value
这是一条设计 ORM descriptor 时非常重要的原则:
Descriptor 管理“字段行为”,instance 保存“字段数据”。
十六、进一步增加范围限制
我们继续强化:
class IntegerField:
def __init__(self, *, min_value=None, max_value=None):
self.min_value = min_value
self.max_value = max_value
def __set_name__(self, owner, name):
self.name = name
self.storage_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.storage_name, None)
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} 必须是 int")
if self.min_value is not None:
if value < self.min_value:
raise ValueError(
f"{self.name} 不能小于 {self.min_value}"
)
if self.max_value is not None:
if value > self.max_value:
raise ValueError(
f"{self.name} 不能大于 {self.max_value}"
)
setattr(instance, self.storage_name, value)
使用:
class User:
age = IntegerField(
min_value=0,
max_value=150,
)
现在:
user = User()
user.age = 25
print(user.age)
正常。
而:
user.age = -10
会立即失败。
这就是 descriptor 的工程价值:
把横切于多个模型的字段逻辑,从业务类中抽离出来。
十七、Descriptor 如何进一步演化成 ORM?
真正的 ORM 当然比上面的例子复杂得多。
假设:
class User(Model):
id = IntegerField(primary_key=True)
name = StringField(max_length=50)
age = IntegerField()
Field 不仅可以承担实例数据访问,还可以保存字段元信息:
class Field:
def __init__(
self,
*,
column_type,
nullable=True,
primary_key=False
):
self.column_type = column_type
self.nullable = nullable
self.primary_key = primary_key
def __set_name__(self, owner, name):
self.name = name
于是:
User.__dict__["id"]
不仅表示:
实例的 id 属性
还可以描述数据库 Schema:
name = id
type = INTEGER
primary_key = True
然后 metaclass 或 __init_subclass__ 就可以扫描:
User.__dict__
收集所有 Field:
fields = {}
for name, value in User.__dict__.items():
if isinstance(value, Field):
fields[name] = value
最终生成:
CREATE TABLE user (
id INTEGER PRIMARY KEY,
name VARCHAR(50),
age INTEGER
);
这就是很多 ORM 框架设计思想的一个缩影:
Descriptor
│
├── 控制实例属性访问
│
├── 类型验证
│
├── 数据转换
│
└── 字段元数据
│
↓
Model Metadata
│
↓
SQL / Schema
descriptor 负责“一个字段如何表现”。
metaclass 或模型构建系统负责:
“整个类包含哪些字段,以及这些字段如何共同形成模型。”
十八、还可以利用 Descriptor 实现 Lazy Loading
ORM 中另一个典型场景是延迟加载。
假设:
order.user
对应数据库中的外键。
我们不希望加载 Order 时立刻查询 User,而希望第一次访问:
order.user
时再查数据库。
就可以使用 descriptor:
class LazyField:
def __init__(self, loader):
self.loader = loader
def __set_name__(self, owner, name):
self.name = name
self.cache_name = f"_{name}_cache"
def __get__(self, instance, owner):
if instance is None:
return self
if hasattr(instance, self.cache_name):
return getattr(instance, self.cache_name)
value = self.loader(instance)
setattr(
instance,
self.cache_name,
value,
)
return value
例如:
def load_user(order):
print("查询数据库...")
return {
"id": order.user_id,
"name": "Alice",
}
class Order:
user = LazyField(load_user)
def __init__(self, user_id):
self.user_id = user_id
使用:
order = Order(1001)
print("订单创建完成")
print(order.user)
print(order.user)
输出类似:
订单创建完成
查询数据库...
{'id': 1001, 'name': 'Alice'}
{'id': 1001, 'name': 'Alice'}
可以看到:
第一次:
order.user
执行查询。
第二次:
order.user
直接使用缓存。
这已经接近真实 ORM 中 relation lazy loading 的核心思想。
十九、Descriptor、__getattribute__ 和 __getattr__ 的关系
这里需要再澄清一个经常混淆的问题。
访问:
obj.x
首先进入的是:
obj.__getattribute__("x")
Descriptor 协议实际上就是属性访问机制的一部分。
而:
__getattr__
角色不同。
只有正常查找失败后,它才作为兜底机制被调用。
例如:
class A:
def __getattr__(self, name):
return f"{name} 不存在"
调用:
a = A()
print(a.hello)
输出:
hello 不存在
因此可以这样理解:
obj.x
│
↓
__getattribute__
│
├─ descriptor
├─ instance.__dict__
├─ class / MRO
│
↓
查找失败
│
↓
__getattr__
如果你在设计代理对象、ORM、RPC Client、动态配置系统,这几层机制经常会一起出现。
二十、实战中什么时候应该自己写 Descriptor?
Descriptor 很强,但并不代表所有属性都应该用 descriptor。
如果需求只是:
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
直接 property 通常最好。
当你发现同一种属性行为需要被多个字段、多个类重复使用时,descriptor 才开始真正发挥优势。
典型场景包括:
- ORM Field
- 类型检查字段
- 参数范围验证
- Lazy Loading
- 缓存属性
- 数据转换
- 单位转换
- 权限控制
- 属性访问日志
- 序列化字段
- API Schema Field
例如:
class Product:
price = PositiveFloat()
stock = PositiveInteger()
class Account:
balance = PositiveFloat()
如果不用 descriptor,你可能需要给每个属性重复写:
@property
和:
@xxx.setter
而 descriptor 可以把规则抽象一次,然后重复复用。
这才是它真正的工程价值。
二十一、几个高频坑一定要避开
1. Descriptor 必须通常定义在类上
下面这种写法:
a.x = D()
不会因为 D 实现 __get__,就自然拥有和类级 descriptor 完全相同的效果。
Descriptor 协议的核心场景是:
class A:
x = D()
也就是 descriptor 作为类属性参与属性查找。
2. 不要把实例数据放在 descriptor 对象自身
错误:
self.value = value
正确思路:
instance.__dict__[self.name] = value
否则多个实例可能共享状态。
3. instance is None 要处理
访问:
User.age
时,通常:
instance is None
因此一个成熟 descriptor 经常写成:
def __get__(self, instance, owner):
if instance is None:
return self
...
这样:
User.age
还能拿到 Field 本身。
这对 ORM 尤其重要,因为 ORM 经常希望:
User.age
不仅仅是一个字段,而是进一步构造 SQL 表达式,例如概念上的:
User.age > 18
最终转换成:
age > 18
descriptor 因此同时承担了:
实例访问语义
和:
类访问语义
这是一种非常强大的 API 设计技巧。
二十二、最后彻底回答三个追问
回到文章最开始的问题。
问题一:data descriptor 和 non-data descriptor 谁优先?
核心顺序:
Data Descriptor
↓
instance.__dict__
↓
Non-data Descriptor
↓
普通类属性
所以:
data descriptor > 实例属性 > non-data descriptor
这条规则值得直接记住。
问题二:property 属于哪一种?
答案:
Data Descriptor
即使只定义 getter,没有 setter,它依旧不会简单退化成普通 non-data descriptor。
问题三:function 为什么会变成 bound method?
因为:
Python function 本身实现了 descriptor protocol。
函数放进类后成为类属性。
访问:
obj.method
时触发 function 的:
__get__
从而把:
obj
绑定进去,形成:
bound method
因此:
obj.method(arg)
本质上可以理解为:
Class.method(obj, arg)
正是 descriptor,让 self 的自动绑定成为可能。
二十三、从 Descriptor 重新理解 Python OO
学 Python 面向对象时,我们通常先看到的是:
class
self
@property
method
inheritance
但学到 descriptor 以后,你会发现很多看似互不相关的机制,其实共享同一个底层模型:
@property
│
├──── Descriptor
│
普通实例方法 ──┤
│
ORM Field ─────┤
│
Lazy Attribute ┤
│
字段验证 ──────┘
这也是为什么我认为:
Descriptor 是 Python OO 世界里最值得深入理解的隐藏核心之一。
它并不是一个天天需要手写的高级语法。
真正重要的是,一旦理解 descriptor,你就能解释 Python 为什么会这样工作。
你会知道:
为什么:
A().x
不是简单地“从对象中取出 x”。
你会知道:
为什么:
obj.method()
可以自动传递 self。
你会知道:
为什么:
@property
能够控制属性读取和写入。
你也会开始看懂 ORM、字段验证框架、缓存系统和许多 Python 库内部那些原本看起来像“魔法”的设计。
真正高级的 Python 编程,并不是使用更多魔法,而是理解魔法背后的协议,然后在正确的地方使用它。
如果你正在学习 Python 源码、ORM 架构、框架设计或者元编程,Descriptor 很适合作为一个分水岭:在它之前,你主要是在“使用 Python”;理解它以后,你开始真正进入 Python 对象模型内部。🚀
最后留三个值得继续思考的问题:
- 如果同时重写
__getattribute__,是否可能破坏 descriptor 的默认行为? classmethod和staticmethod又分别是怎样借助 descriptor 实现的?- 如果让
User.age > 18自动生成 SQL,应该如何扩展上面的Field?
当你能独立设计出一个支持字段验证、默认值、Lazy Loading、类级查询表达式和模型元数据收集的小型 ORM 时,Descriptor 就不再只是一个知识点,而会真正成为你手里的架构工具。

3003

被折叠的 条评论
为什么被折叠?



