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__.attr4. __getattr__? → 调用 obj.__getattr__("attr")5. 抛出 AttributeErrorobj.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)。这意味着:
- 你需要自己管理底层存储(通常用
_attr命名约定) - 如果 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.53981633974483c.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 # ❌ ValueErrorp.quantity = 99999 # ❌ ValueError3.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 = "这是一篇关于..." * 50article.view_count = 0article.is_published = False
article.view_count = -1 # ❌ ValueErrorarticle.title = "x" * 300 # ❌ ValueError这就是 Django ORM 和 SQLAlchemy 字段定义的基本思路。
四、ORM 框架的核心秘密
4.1 SQLAlchemy 的描述符魔法
SQLAlchemy 的 Column 就是一个描述符。当你定义:
from sqlalchemy import Column, Integer, Stringfrom 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" 时,实际发生的事情是:
- Python 检测到
User.name是一个描述符(实现了__set__) - 调用
Column.__set__(user, "Alice") - SQLAlchemy 在内部将
"Alice"标记为”脏数据”(dirty) - 当你调用
session.commit()时,SQLAlchemy 生成UPDATE users SET name = 'Alice' WHERE id = ...
当你读取 user.name 时:
- 调用
Column.__get__(user, User) - 如果该实例已加载到内存,返回缓存值
- 如果未加载(懒加载),触发 SQL 查询
SELECT name FROM users WHERE id = ... - 返回值
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 = 30p2.save(conn)
# 验证更新p3 = Person.get(conn, 1)print(f"更新后: {p3.name}, {p3.age}") # 李四, 30这个迷你 ORM 展示了描述符在框架中的三个关键作用:
- 声明式 API:用户只需声明字段类型,框架自动处理底层逻辑
- 脏数据追踪:
__set__中自动标记哪些字段被修改,UPDATE 只更新变更的列 - 透明访问:用户代码像操作普通属性一样操作 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() 正确处理了 propertysuper() 能够正确处理 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 文档:描述符 HOWTO
- Python 文档:property() 内置函数
- 本系列文章:《Python functools 深度解析》《Python 高级类型提示实战》
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!