Python 描述符与 property:ORM 框架的核心秘密

3827 字
19 分钟
Python 描述符与 property:ORM 框架的核心秘密

你可能用过 @property 把方法伪装成属性,但你是否想过:为什么 obj.attr = value 这样一行简单的赋值语句,背后可以触发数据校验、延迟加载、类型转换甚至远程 RPC 调用?

答案就是 描述符协议(Descriptor Protocol)——Python 属性访问机制的底层基础设施。它是 @property@classmethod@staticmethod 的共同基础,更是 SQLAlchemy、Django ORM、Pydantic 等流行框架的核心秘密武器。

这篇文章不会停留在”@property 怎么用”的层面。我会带你深入到描述符协议的底层逻辑,理解 Python 属性查找的完整链路,并亲手实现一个迷你 ORM 框架。读完后,你会理解那些”魔法”背后其实是清晰的协议设计。

一、描述符协议:三个方法控制属性访问#

1.1 什么是描述符?#

描述符是一个实现了特定协议的类。这个协议包含三个特殊方法:

class Descriptor:
def __get__(self, obj, objtype=None):
"""读取属性时调用"""
pass
def __set__(self, obj, value):
"""设置属性时调用"""
pass
def __delete__(self, obj):
"""删除属性时调用"""
pass

核心规则:当一个类的类属性是一个实现了上述任意方法的对象时,Python 的属性访问机制就会”委托”给这个对象来处理。

class MyDescriptor:
def __get__(self, obj, objtype=None):
print(f"__get__ 被调用: obj={obj}, objtype={objtype}")
return "descriptor value"
def __set__(self, obj, value):
print(f"__set__ 被调用: obj={obj}, value={value}")
class MyClass:
attr = MyDescriptor() # 类属性是描述符实例
obj = MyClass()
print(obj.attr) # 触发 __get__
obj.attr = 42 # 触发 __set__
# 输出:
# __get__ 被调用: obj=<__main__.MyClass object at 0x...>, objtype=<class '__main__.MyClass'>
# descriptor value
# __set__ 被调用: obj=<__main__.MyClass object at 0x...>, value=42

注意:obj.attr = 42 并没有改变 MyClass.attr 的值,而是调用了描述符的 __set__ 方法。这就是描述符的”拦截”能力。

1.2 数据描述符 vs 非数据描述符#

描述符分为两类,区别在于是否实现了 __set__

类型实现的方法优先级典型例子
数据描述符__get__ + __set__(或 __delete__@property(有 setter 时)
非数据描述符__get__@classmethod@staticmethod、普通方法

优先级差异体现在属性查找顺序中。这是理解描述符的关键。

1.3 Python 属性查找的完整链路#

当你写 obj.attr 时,Python 按以下顺序查找:

obj.attr 的查找顺序(读取):
1. 数据描述符? → 调用 obj.__class__.attr.__get__(obj, obj.__class__)
2. 实例 __dict__? → 返回 obj.__dict__["attr"]
3. 非数据描述符 / 类属性? → 调用或返回 obj.__class__.attr
4. __getattr__? → 调用 obj.__getattr__("attr")
5. 抛出 AttributeError
obj.attr = value 的赋值顺序(写入):
1. 数据描述符? → 调用 obj.__class__.attr.__set__(obj, value)
2. 否则 → 写入 obj.__dict__["attr"] = value

关键洞察:数据描述符的优先级高于实例 __dict__。这意味着即使你在实例上”覆盖”了属性名,描述符仍然优先。

class DataDesc:
def __get__(self, obj, objtype=None):
return "from descriptor"
def __set__(self, obj, value):
obj.__dict__["_stored"] = value
class NonDataDesc:
def __get__(self, obj, objtype=None):
return "from non-data descriptor"
class MyClass:
data_desc = DataDesc() # 数据描述符
non_data_desc = NonDataDesc() # 非数据描述符
obj = MyClass()
# 数据描述符:实例 __dict__ 无法覆盖
obj.__dict__["data_desc"] = "hacked"
print(obj.data_desc) # 仍然是 "from descriptor"(数据描述符优先)
# 非数据描述符:实例 __dict__ 可以覆盖
obj.__dict__["non_data_desc"] = "hacked"
print(obj.non_data_desc) # "hacked"(实例 __dict__ 优先)

这个优先级差异解释了为什么 @property(数据描述符)能可靠地拦截属性访问,而普通方法(非数据描述符)可以被实例属性覆盖。

二、property 装饰器:描述符的最佳实践#

2.1 @property 本质上是一个描述符#

property 不是魔法,它就是一个实现了 __get____set____delete__ 的内置描述符类:

>>> type(property)
<class 'type'>
>>> property.__get__
<slot wrapper '__get__' of 'property' object>
>>> property.__set__
<slot wrapper '__set__' of 'property' object>
>>> property.__delete__
<slot wrapper '__delete__' of 'property' object>

你写的这段代码:

class Person:
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError("年龄不能为负")
self._age = value

等价于:

class Person:
def get_age(self):
return self._age
def set_age(self, value):
if value < 0:
raise ValueError("年龄不能为负")
self._age = value
age = property(get_age, set_age) # 手动创建 property 描述符

property(fget, fset, fdel, doc) 接收四个参数,分别是 getter、setter、deleter 函数和文档字符串。装饰器语法只是让代码更优雅。

2.2 property 的隐藏陷阱:继承与实例 dict#

一个常见的坑是:property 的值存储在哪里?

class Person:
def __init__(self, name):
self._name = name # 存到实例 __dict__
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
p = Person("Alice")
print(p.__dict__) # {'_name': 'Alice'}

property 本身不存储值,它只是转发到另一个属性(_name)。这意味着:

  1. 你需要自己管理底层存储(通常用 _attr 命名约定)
  2. 如果 getter 返回 self.attr,会导致无限递归
# ❌ 无限递归的典型案例
class Bad:
@property
def value(self):
return self.value # 触发 property 的 __get__ → 又调用 self.value → 无限递归
@value.setter
def value(self, v):
self.value = v # 触发 property 的 __set__ → 又调用 self.value = v → 无限递归
# ✅ 正确做法
class Good:
@property
def value(self):
return self._value # 读取底层存储
@value.setter
def value(self, v):
self._value = v # 写入底层存储

2.3 只读 property 与延迟加载#

只读属性只需定义 getter,不定义 setter:

class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@property
def area(self):
"""只读计算属性"""
import math
return math.pi * self._radius ** 2
c = Circle(5)
print(c.area) # 78.53981633974483
c.area = 100 # AttributeError: can't set attribute 'area'

延迟加载(Lazy Loading)——property 的经典应用场景:

import json
class Config:
def __init__(self, filepath):
self._filepath = filepath
self._data = None # 初始为 None
@property
def data(self):
"""首次访问时才读取文件"""
if self._data is None:
print("读取配置文件...")
with open(self._filepath) as f:
self._data = json.load(f)
return self._data
# 使用
config = Config("config.json")
print("尚未访问 data")
# 此时文件未被读取
print(config.data) # 触发读取
print(config.data) # 使用缓存,不再读取

三、手写描述符:从字段校验到 ORM 核心#

3.1 类型校验描述符#

让我们实现一个能校验类型的字段描述符:

class TypedField:
"""类型校验描述符"""
def __init__(self, field_type):
self.field_type = field_type
self.storage_name = None # 后续由 __set_name__ 填充
def __set_name__(self, owner, name):
"""Python 3.6+:类创建时自动调用,告知描述符自己的属性名"""
self.storage_name = f"_field_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self # 通过类访问时返回描述符本身
return getattr(obj, self.storage_name, None)
def __set__(self, obj, value):
if not isinstance(value, self.field_type):
raise TypeError(
f"{self.storage_name} 必须是 {self.field_type.__name__},"
f"实际是 {type(value).__name__}"
)
setattr(obj, self.storage_name, value)
class User:
name = TypedField(str)
age = TypedField(int)
score = TypedField(float)
user = User()
user.name = "Alice" # ✅
user.age = 30 # ✅
user.score = 95.5 # ✅
user.age = "thirty" # ❌ TypeError

__set_name__ 是 Python 3.6 引入的钩子,在类创建时自动调用。它解决了描述符的命名问题——描述符需要知道自己被赋给了哪个属性名,以便为每个属性生成唯一的底层存储 key。

3.2 范围校验描述符#

class ValidatedField:
"""支持范围校验的描述符"""
def __init__(self, validator=None, default=None):
self.validator = validator
self.default = default
self.storage_name = None
def __set_name__(self, owner, name):
self.storage_name = f"_validated_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage_name, self.default)
def __set__(self, obj, value):
if self.validator and not self.validator(value):
raise ValueError(f"{self.storage_name} 的值 {value!r} 未通过校验")
setattr(obj, self.storage_name, value)
# 使用
class Product:
price = ValidatedField(
validator=lambda v: v > 0,
default=0.0
)
quantity = ValidatedField(
validator=lambda v: 0 <= v <= 10000,
default=0
)
p = Product()
p.price = 99.9 # ✅
p.quantity = 500 # ✅
p.price = -10 # ❌ ValueError
p.quantity = 99999 # ❌ ValueError

3.3 组合多个描述符:声明式模型定义#

描述符的真正威力在于组合。你可以像定义数据库表一样定义 Python 类:

class StringField(TypedField):
def __init__(self, max_length=255):
super().__init__(str)
self.max_length = max_length
def __set__(self, obj, value):
super().__set__(obj, value)
if len(value) > self.max_length:
raise ValueError(
f"{self.storage_name} 长度不能超过 {self.max_length}"
)
class IntegerField(TypedField):
def __init__(self, min_value=None, max_value=None):
super().__init__(int)
self.min_value = min_value
self.max_value = max_value
def __set__(self, obj, value):
super().__set__(obj, value)
if self.min_value is not None and value < self.min_value:
raise ValueError(f"{self.storage_name} 不能小于 {self.min_value}")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"{self.storage_name} 不能大于 {self.max_value}")
class Article:
title = StringField(max_length=200)
content = StringField(max_length=10000)
view_count = IntegerField(min_value=0, max_value=1000000000)
is_published = TypedField(bool)
article = Article()
article.title = "Python 描述符详解"
article.content = "这是一篇关于..." * 50
article.view_count = 0
article.is_published = False
article.view_count = -1 # ❌ ValueError
article.title = "x" * 300 # ❌ ValueError

这就是 Django ORM 和 SQLAlchemy 字段定义的基本思路。

四、ORM 框架的核心秘密#

4.1 SQLAlchemy 的描述符魔法#

SQLAlchemy 的 Column 就是一个描述符。当你定义:

from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import DeclarativeBase
class User(DeclarativeBase):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String(50))
age = Column(Integer)

Column 对象实现了 __get____set__。当你写 user.name = "Alice" 时,实际发生的事情是:

  1. Python 检测到 User.name 是一个描述符(实现了 __set__
  2. 调用 Column.__set__(user, "Alice")
  3. SQLAlchemy 在内部将 "Alice" 标记为”脏数据”(dirty)
  4. 当你调用 session.commit() 时,SQLAlchemy 生成 UPDATE users SET name = 'Alice' WHERE id = ...

当你读取 user.name 时:

  1. 调用 Column.__get__(user, User)
  2. 如果该实例已加载到内存,返回缓存值
  3. 如果未加载(懒加载),触发 SQL 查询 SELECT name FROM users WHERE id = ...
  4. 返回值

4.2 Django ORM 的描述符实现#

Django 的 models.Field 同样使用了描述符协议。但 Django 采用了一种更巧妙的方式——描述符的值存储在实例的 __dict__ 中,key 是字段名本身

# Django 内部的大致逻辑
class Field:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.attname)
def __set__(self, obj, value):
obj.__dict__[self.attname] = value

这看起来简单,但 Django 在 Model.__init__ 中做了大量工作来确保 __dict__ 的 key 与描述符的 attname 对齐。

4.3 手写迷你 ORM:理解全链路#

让我们实现一个极简的 ORM,展示描述符如何与数据库操作衔接:

import sqlite3
class Field:
"""字段描述符"""
def __init__(self, db_type, primary_key=False):
self.db_type = db_type
self.primary_key = primary_key
self.name = ""
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, f"_value_{self.name}", None)
def __set__(self, obj, value):
setattr(obj, f"_value_{self.name}", value)
if hasattr(obj, "_dirty"):
obj._dirty.add(self.name)
class ModelMeta(type):
"""元类:收集所有 Field 描述符"""
def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
fields = {}
for attr_name, attr_value in namespace.items():
if isinstance(attr_value, Field):
fields[attr_name] = attr_value
cls._fields = fields
return cls
class Model(metaclass=ModelMeta):
"""模型基类"""
_table_name = ""
def __init__(self, **kwargs):
self._dirty = set()
for key, value in kwargs.items():
setattr(self, key, value)
self._dirty.clear() # 初始赋值不标记为脏
@classmethod
def create_table(cls, conn):
"""生成 CREATE TABLE 语句"""
columns = []
for name, field in cls._fields.items():
col_def = f"{name} {field.db_type}"
if field.primary_key:
col_def += " PRIMARY KEY AUTOINCREMENT"
columns.append(col_def)
sql = f"CREATE TABLE IF NOT EXISTS {cls._table_name} ({', '.join(columns)})"
conn.execute(sql)
def save(self, conn):
"""INSERT 或 UPDATE"""
if not hasattr(self, "_value_id") or self._value_id is None:
# INSERT
fields = [(n, f) for n, f in self._fields.items() if not f.primary_key]
names = ", ".join(n for n, _ in fields)
placeholders = ", ".join("?" for _ in fields)
values = [getattr(self, n) for n, _ in fields]
sql = f"INSERT INTO {self._table_name} ({names}) VALUES ({placeholders})"
cursor = conn.execute(sql, values)
if self._fields.get("id"):
setattr(self, "id", cursor.lastrowid)
else:
# UPDATE
if not self._dirty:
return
sets = ", ".join(f"{n} = ?" for n in self._dirty)
values = [getattr(self, n) for n in self._dirty]
values.append(getattr(self, "id"))
sql = f"UPDATE {self._table_name} SET {sets} WHERE id = ?"
conn.execute(sql, values)
self._dirty.clear()
@classmethod
def get(cls, conn, obj_id):
"""根据 ID 查询"""
cursor = conn.execute(
f"SELECT * FROM {cls._table_name} WHERE id = ?", (obj_id,)
)
row = cursor.fetchone()
if row is None:
return None
columns = [desc[0] for desc in cursor.description]
kwargs = dict(zip(columns, row))
return cls(**kwargs)
# === 使用示例 ===
class Person(Model):
_table_name = "persons"
id = Field("INTEGER", primary_key=True)
name = Field("TEXT")
age = Field("INTEGER")
# 创建数据库
conn = sqlite3.connect(":memory:")
Person.create_table(conn)
# 插入
p = Person(name="张三", age=28)
p.save(conn)
print(f"插入后 ID: {p.id}") # 1
# 查询
p2 = Person.get(conn, 1)
print(f"查询: {p2.name}, {p2.age}") # 张三, 28
# 更新
p2.name = "李四"
p2.age = 30
p2.save(conn)
# 验证更新
p3 = Person.get(conn, 1)
print(f"更新后: {p3.name}, {p3.age}") # 李四, 30

这个迷你 ORM 展示了描述符在框架中的三个关键作用:

  1. 声明式 API:用户只需声明字段类型,框架自动处理底层逻辑
  2. 脏数据追踪__set__ 中自动标记哪些字段被修改,UPDATE 只更新变更的列
  3. 透明访问:用户代码像操作普通属性一样操作 ORM 对象

五、高级技巧与避坑指南#

5.1 描述符的性能考量#

描述符每次属性访问都会触发方法调用。在热点路径上,这可能成为性能瓶颈:

import timeit
class WithDescriptor:
def __init__(self):
self._x = 0
@property
def x(self):
return self._x
class WithoutDescriptor:
def __init__(self):
self.x = 0
wd = WithDescriptor()
wod = WithoutDescriptor()
t1 = timeit.timeit(lambda: wd.x, number=1_000_000)
t2 = timeit.timeit(lambda: wod.x, number=1_000_000)
print(f"描述符: {t1:.3f}s, 直接访问: {t2:.3f}s, 倍数: {t1/t2:.1f}x")
# 典型结果:描述符 0.080s, 直接访问 0.020s, 倍数: 4.0x

结论:描述符比直接属性访问慢 3-5 倍。但在绝大多数应用中,这个差距可以忽略不计(微秒级别)。只有在极端性能敏感的场景(如数值计算循环)中才需要关注。

5.2 slots 与描述符的冲突#

使用 __slots__ 时,实例没有 __dict__,描述符的存储策略需要调整:

class SlottedClass:
__slots__ = ("_x",)
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self._x = value
s = SlottedClass()
s.x = 42 # ✅ 正常工作,因为 _x 在 __slots__ 中

但如果描述符尝试使用 obj.__dict__ 存储值,就会报错:

class BadDescriptor:
def __set__(self, obj, value):
obj.__dict__["_x"] = value
class SlottedWithBad:
__slots__ = ()
x = BadDescriptor()
s = SlottedWithBad()
s.x = 42 # AttributeError: 'SlottedWithBad' object has no attribute '__dict__'

5.3 描述符的命名冲突#

同一个描述符实例被多个类属性引用时,__set_name__ 只会在第一个类创建时调用。如果你的描述符使用类级别的共享状态,需要小心:

class SharedStateDesc:
counter = 0 # 类级别共享(危险!)
def __set_name__(self, owner, name):
SharedStateDesc.counter += 1
print(f"__set_name__ for {owner.__name__}.{name}")
class A:
x = SharedStateDesc()
class B:
y = SharedStateDesc()
# __set_name__ for A.x
# __set_name__ for B.y
# SharedStateDesc.counter == 2

正确做法:使用实例级别的存储(如上面的 _field_{name} 模式),避免类级别共享状态。

5.4 描述符与 super() 的交互#

当子类继承父类的 property 时,描述符的行为可能令人困惑:

class Parent:
def __init__(self):
self._value = 10
@property
def value(self):
return self._value
class Child(Parent):
@property
def value(self):
return super().value * 2
c = Child()
print(c.value) # 20 ✅ super() 正确处理了 property

super() 能够正确处理 property,因为 super() 返回的代理对象会触发描述符协议。但如果你需要在子类的 setter 中调用父类 setter,写法略有不同:

class Parent:
def __init__(self):
self._value = 10
@property
def value(self):
return self._value
@value.setter
def value(self, v):
if v < 0:
raise ValueError("不能为负")
self._value = v
class Child(Parent):
@Parent.value.setter
def value(self, v):
if v > 1000:
raise ValueError("不能超过 1000")
# 调用父类 setter
Parent.value.fset(self, v)
c = Child()
c.value = 500 # ✅
c.value = -1 # ❌ 父类校验
c.value = 2000 # ❌ 子类校验

使用 Parent.value.fset(self, v) 显式调用父类的 setter 函数。这是处理 property 继承的标准模式。

六、对比总结:属性控制机制一览#

Python 提供了多种控制属性访问的方式,适用场景各不相同:

机制实现方式数据/非数据适用场景
__getattr__魔术方法非数据动态属性、代理模式
__getattribute__魔术方法数据全局拦截(慎用)
@property内置描述符数据计算属性、只读属性
自定义描述符__get__/__set__数据/非数据ORM、校验框架
实例 __dict__ 直接存储普通属性

选择建议

  • 简单计算属性 → @property
  • 需要复用校验逻辑 → 自定义描述符
  • 动态属性代理 → __getattr__
  • ORM/框架开发 → 描述符 + 元类

七、总结#

描述符协议是 Python 属性系统的基石。理解它,你就能理解:

  • @property 为什么能拦截属性访问
  • @classmethod@staticmethod 如何改变方法签名
  • SQLAlchemy/Django ORM 的字段定义为什么看起来像”声明”
  • 框架如何实现脏数据追踪、延迟加载、类型校验

描述符不是日常编码的必需品,但它是框架开发的利器。当你需要编写可复用的属性控制逻辑时,描述符是最 Pythonic 的解决方案。

延伸阅读

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Python 描述符与 property:ORM 框架的核心秘密
https://boke.hackerdream.xyz/posts/python-descriptor-property/
作者
晴天
发布于
2026-05-31
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
155
分类
24
标签
387
总字数
345,424
运行时长
0
最后活动
0 天前

目录