Python 基础体系 · 第 25/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 描述符与 property:属性访问、绑定方法和 ORM 基础
在 Python 中,obj.name 看起来只是一次字典查找,但实际结果可能来自实例字典、类字典、继承链、描述符协议,甚至数据库或其他外部资源。
描述符正是把“属性访问”变成可编程行为的机制。property、实例方法、classmethod、staticmethod、__slots__ 中的成员访问器,以及许多 ORM 的字段对象,都建立在描述符协议之上。Python 3.14 语言参考将描述符定义为实现了 __get__()、__set__() 或 __delete__() 之一,并且该对象被放在拥有它的类字典或父类字典中时,属性访问才会触发相应协议。(docs.python.org)
1. 先区分三个对象:属性名、属性值和拥有者
考虑下面的代码:
class User:
role = "member"
def __init__(self, name):
self.name = name
user = User("Alice")
这里有三个不同概念:
name、role是属性名,本质上是字符串键;"Alice"、"member"是属性值;user是实例,User是实例的类型。
对于普通实例属性:
user.name
通常可以近似理解为:
user.__dict__["name"]
而对于类属性:
User.role
通常来自:
User.__dict__["role"]
实例访问 user.role 时,如果实例字典中没有 role,Python 会继续从 User 及其父类的字典中查找。
但是,如果找到的类属性值实现了描述符协议,访问行为就不再是“直接取值”,而是由描述符对象控制。例如:
class Constant:
def __get__(self, instance, owner=None):
return 42
class Config:
answer = Constant()
config = Config()
print(config.answer) # 42
42 并没有存放在 config.__dict__ 或 Config.__dict__ 中。Config.__dict__["answer"] 存放的是 Constant 实例,config.answer 会调用它的 __get__() 动态产生结果。描述符必须作为类属性使用;如果把描述符实例放进某个对象的实例字典,它通常只会被当作普通对象,不会自动触发描述符协议。(docs.python.org)
可以用 vars() 观察类字典而不触发描述符:
print(vars(Config)["answer"])
print(config.answer)
前者得到描述符对象,后者得到 __get__() 返回的值。
2. 描述符协议:__get__、__set__ 和 __delete__
一个对象只要定义了以下方法中的至少一个,就可以成为描述符:
class Descriptor:
def __get__(self, instance, owner=None):
...
def __set__(self, instance, value):
...
def __delete__(self, instance):
...
这三个方法分别参与:
- 读取:
obj.attr - 写入:
obj.attr = value - 删除:
del obj.attr
2.1 __get__
__get__ 的典型签名是:
def __get__(self, instance, owner=None):
...
参数含义如下:
| 参数 | 含义 |
|---|---|
self |
描述符实例本身 |
instance |
通过哪个实例访问;通过类访问时为 None |
owner |
拥有该属性的类,通常是 type(instance) |
例如:
class DebugDescriptor:
def __get__(self, instance, owner=None):
print(f"instance={instance!r}, owner={owner.__name__}")
return "value"
class Example:
item = DebugDescriptor()
example = Example()
print(example.item)
print(Example.item)
输出形式类似:
instance=<__main__.Example object at 0x...>, owner=Example
value
instance=None, owner=Example
value
因此:
example.item
大致对应:
Example.__dict__["item"].__get__(example, Example)
而:
Example.item
大致对应:
Example.__dict__["item"].__get__(None, Example)
Python 语言参考明确区分了实例绑定和类绑定:实例访问会把实例作为 __get__() 的第二个参数传入,类访问则传入 None。(docs.python.org)
如果描述符需要同时支持实例访问和类访问,通常这样写:
class Field:
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__[self.name]
instance is None 分支的意义是:访问 Model.field 时,返回字段描述符本身,使框架能够检查字段元数据,而不是尝试读取某个不存在的实例数据。
2.2 __set__
__set__ 拦截赋值:
class PositiveNumber:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__[self.name]
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"{self.name} must be non-negative")
instance.__dict__[self.name] = value
class Product:
price = PositiveNumber()
product = Product()
product.price = 10
print(product.price)
product.price = -1
执行结果:
10
ValueError: price must be non-negative
赋值语句:
product.price = 10
并不是把 price 直接写入 product.__dict__,而是调用:
Product.__dict__["price"].__set__(product, 10)
描述符通常把公开属性名和实际存储名分开:
product.__dict__["price"]
或者:
product.__dict__["_price"]
这样可以在读取和写入之间插入校验、转换、日志、缓存、脏数据标记等逻辑。
2.3 __delete__
__delete__ 拦截:
del product.price
例如:
class ManagedValue:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
try:
return instance.__dict__[self.name]
except KeyError:
raise AttributeError(self.name) from None
def __set__(self, instance, value):
instance.__dict__[self.name] = value
def __delete__(self, instance):
try:
del instance.__dict__[self.name]
except KeyError:
raise AttributeError(self.name) from None
class Record:
value = ManagedValue()
record = Record()
record.value = 3
print(record.value) # 3
del record.value
try:
print(record.value)
except AttributeError as exc:
print(type(exc).__name__, exc)
输出:
3
AttributeError value
3. 数据描述符与非数据描述符
描述符的分类关键不在于它有没有 __get__(),而在于它是否定义了 __set__() 或 __delete__()。
3.1 数据描述符
如果描述符定义了 __set__() 或 __delete__(),它就是数据描述符:
class DataDescriptor:
def __get__(self, instance, owner=None):
return "from descriptor"
def __set__(self, instance, value):
print("assignment intercepted")
即使实例字典中有同名属性,数据描述符仍然优先:
class Example:
value = DataDescriptor()
example = Example()
example.__dict__["value"] = "from instance dict"
print(example.value)
输出:
from descriptor
因为 value 是数据描述符,实例字典中的同名键不能覆盖它。
只读 property 也是数据描述符。即使没有定义 setter,property 仍然拥有写入行为,只是该行为会抛出 AttributeError。Python 3.14 文档明确说明,property 通过数据描述符协议实现;属性实例访问会调用 getter,赋值调用 setter,删除调用 deleter。(docs.python.org)
3.2 非数据描述符
如果描述符只定义 __get__(),它就是非数据描述符:
class NonDataDescriptor:
def __get__(self, instance, owner=None):
return "from descriptor"
class Example:
value = NonDataDescriptor()
example = Example()
example.__dict__["value"] = "from instance dict"
print(example.value)
输出:
from instance dict
实例字典覆盖了非数据描述符。
非数据描述符的这一特性非常重要,因为普通函数就是非数据描述符。它既允许函数在类中表现为方法,也允许单个实例通过实例属性覆盖某个方法。
4. 属性查找优先级:为什么顺序不能记错
对于普通对象的属性读取,Python 的核心优先级可以概括为:
- 数据描述符;
- 实例字典;
- 非数据描述符;
- 普通类属性;
__getattr__()兜底。
可以把它写成如下形式。设:
obj是实例;cls = type(obj);name是属性名;cls_var是沿cls.__mro__查到的第一个同名类属性;descr_get是cls_var类型上的__get__方法。
则读取 obj.name 的主要逻辑近似为:
def lookup_attribute(obj, name):
cls = type(obj)
cls_var = find_first_in_mro(cls, name)
if cls_var is not MISSING:
descriptor_type = type(cls_var)
if (
hasattr(descriptor_type, "__get__")
and (
hasattr(descriptor_type, "__set__")
or hasattr(descriptor_type, "__delete__")
)
):
return cls_var.__get__(obj, cls)
if hasattr(obj, "__dict__") and name in obj.__dict__:
return obj.__dict__[name]
if cls_var is not MISSING and hasattr(type(cls_var), "__get__"):
return cls_var.__get__(obj, cls)
if cls_var is not MISSING:
return cls_var
raise AttributeError(name)
这不是完整的解释器实现,但准确体现了核心优先级。Python 官方描述符指南也给出了类似的纯 Python 模拟代码。(docs.python.org)
4.1 数据描述符压过实例字典
class PropertyLike:
def __get__(self, instance, owner=None):
return "descriptor value"
def __set__(self, instance, value):
instance.__dict__["x"] = value
class A:
x = PropertyLike()
a = A()
a.x = "instance value"
print(a.__dict__)
print(a.x)
输出:
{'x': 'instance value'}
descriptor value
这里赋值确实把数据放进了实例字典,但读取时数据描述符先被调用,因此实例字典中的值不可见。
这也是数据描述符适合实现校验字段、ORM 字段和只读属性的原因:使用者不能绕过描述符的正常访问路径来覆盖属性语义。
4.2 非数据描述符可以被实例覆盖
class A:
def method(self):
return "class method"
a = A()
a.method = lambda: "instance function"
print(a.method())
输出:
instance function
普通方法是非数据描述符,所以 a.__dict__["method"] 优先于类中的函数对象。
但是,这种覆盖只影响当前实例:
b = A()
print(a.method()) # instance function
print(b.method()) # class method
4.3 __getattr__() 不是普通查找的第四级
更准确地说,__getattr__() 不是 object.__getattribute__() 内部的一步。点号访问或 getattr() 在 __getattribute__() 抛出 AttributeError 后,才会尝试调用 __getattr__()。直接调用 object.__getattribute__(obj, name) 不会自动触发 __getattr__()。(docs.python.org)
class Fallback:
def __getattr__(self, name):
return f"generated: {name}"
obj = Fallback()
print(obj.missing)
print(object.__getattribute__(obj, "missing"))
结果是:
generated: missing
AttributeError: 'Fallback' object has no attribute 'missing'
这一区别在实现代理对象、懒加载对象或 ORM 模型时尤其重要。
5. __set_name__:描述符如何知道自己的字段名
描述符通常需要知道它被绑定到哪个属性名。例如:
class Logged:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = "_" + name
def __get__(self, instance, owner=None):
if instance is None:
return self
value = getattr(instance, self.private_name)
print(f"读取 {self.public_name} -> {value!r}")
return value
def __set__(self, instance, value):
print(f"写入 {self.public_name} <- {value!r}")
setattr(instance, self.private_name, value)
class User:
name = Logged()
age = Logged()
def __init__(self, name, age):
self.name = name
self.age = age
user = User("Alice", 30)
print(user.name)
print(user.age)
print(vars(user))
输出类似:
写入 name <- 'Alice'
写入 age <- 30
读取 name -> 'Alice'
Alice
读取 age -> 30
30
{'_name': 'Alice', '_age': 30}
在类创建过程中,type.__new__() 会扫描新类的类变量;如果某个对象定义了 __set_name__(),就调用:
descriptor.__set_name__(OwnerClass, "attribute_name")
该机制从 Python 3.6 起提供。(docs.python.org)
5.1 __set_name__ 的调用时机
下面的写法会自动调用 __set_name__():
class Model:
field = Logged()
但动态添加属性不会自动调用:
class Model:
pass
descriptor = Logged()
Model.field = descriptor
此时需要手动通知:
descriptor.__set_name__(Model, "field")
官方文档特别指出,__set_name__() 的自动通知发生在类创建时;类创建后再添加描述符,必须手动调用。(docs.python.org)
5.2 描述符实例通常由同一个类对象共享
class User:
name = Logged()
a = User("Alice", 20)
b = User("Bob", 30)
User.name 对应的是同一个 Logged 描述符实例。描述符本身应该保存“字段定义”,而不应该保存某一个具体用户的数据。
因此:
vars(User)["name"]
是共享的描述符对象,而:
vars(a)
vars(b)
分别保存两个实例的数据。
如果错误地把每个实例的数据存储在描述符对象自身:
class Broken:
def __set__(self, instance, value):
self.value = value
def __get__(self, instance, owner=None):
return self.value
那么不同实例之间会共享 self.value,后写入的实例会覆盖先写入的实例。这是描述符实现中最常见的状态归属错误之一。
6. function 到 bound method:方法绑定到底发生了什么
定义类方法时:
class Greeter:
def hello(self, name):
return f"Hello, {name}"
在类字典中,hello 存放的是一个函数对象:
greeter_function = vars(Greeter)["hello"]
print(type(greeter_function))
print(greeter_function)
函数对象本身实现了非数据描述符协议。访问类属性:
Greeter.hello
大致得到原始函数:
vars(Greeter)["hello"].__get__(None, Greeter)
访问实例属性:
greeter = Greeter()
greeter.hello
则大致得到:
vars(Greeter)["hello"].__get__(greeter, Greeter)
返回值是一个绑定方法,也就是把 greeter 预先绑定为第一个参数的可调用对象。官方描述符指南给出的等价模型包含两个关键引用:
class Method:
def __init__(self, function, instance):
self.__func__ = function
self.__self__ = instance
def __call__(self, *args, **kwargs):
return self.__func__(
self.__self__,
*args,
**kwargs
)
因此:
greeter.hello("Alice")
等价于:
Greeter.hello(greeter, "Alice")
或者更底层地理解为:
vars(Greeter)["hello"].__get__(greeter, Greeter)("Alice")
Python 方法和普通函数的核心差别,是实例会被自动放到参数列表前面。(docs.python.org)
可以直接验证:
class Greeter:
def hello(self, name):
return f"Hello, {name}"
greeter = Greeter()
bound = greeter.hello
unbound_like = Greeter.hello
print(bound.__self__ is greeter) # True
print(bound.__func__ is unbound_like) # True
print(bound("Alice"))
print(unbound_like(greeter, "Alice"))
输出:
True
True
Hello, Alice
Hello, Alice
这里常说的“未绑定方法”需要谨慎理解。在现代 Python 中,Greeter.hello 返回的是函数对象,而不是 Python 2 时代意义上的特殊未绑定方法对象;真正的绑定发生在通过实例访问时。
6.1 方法可以被实例属性覆盖
因为函数是非数据描述符,所以:
greeter.hello = lambda name: f"Intercepted {name}"
print(greeter.hello("Alice"))
输出:
Intercepted Alice
此时实例字典中的 hello 覆盖了类字典中的函数描述符。
这也解释了为什么把实例方法替换成测试替身是可行的:
service.fetch = lambda: []
但它同时意味着实例状态可能改变对象行为,不能把“类中定义了方法”理解为“实例一定只能使用这个方法”。
7. property:把方法接口伪装成属性接口
property 是内置的数据描述符。它允许我们把方法调用表达成属性访问:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("temperature below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
使用时:
temperature = Temperature(20)
print(temperature.celsius)
print(temperature.fahrenheit)
temperature.celsius = 30
print(temperature.fahrenheit)
输出:
20
68.0
86.0
调用关系是:
temperature.celsius
触发:
Temperature.__dict__["celsius"].__get__(temperature, Temperature)
最终调用 getter:
Temperature.celsius.fget(temperature)
而:
temperature.celsius = 30
触发:
Temperature.__dict__["celsius"].__set__(temperature, 30)
最终调用 setter。
7.1 装饰器语法的展开
下面两种写法在语义上等价:
class Account:
def get_balance(self):
return self._balance
def set_balance(self, value):
if value < 0:
raise ValueError("balance cannot be negative")
self._balance = value
balance = property(get_balance, set_balance)
以及:
class Account:
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("balance cannot be negative")
self._balance = value
property() 的构造参数是:
property(fget=None, fset=None, fdel=None, doc=None)
其中:
fget负责读取;fset负责赋值;fdel负责删除;doc是属性文档字符串。
如果没有显式传入 doc,通常会使用 getter 的文档字符串。(docs.python.org)
7.2 只读 property 为什么不能被实例字典绕过
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.141592653589793 * self.radius ** 2
circle = Circle(2)
circle.__dict__["area"] = "fake"
print(circle.area)
结果仍然是:
12.566370614359172
因为 area 是数据描述符,数据描述符优先于实例字典。
如果尝试:
circle.area = 100
会抛出:
AttributeError: property 'area' of 'Circle' object has no setter
这里的只读并不是“没有 __set__()”,而是 property.__set__() 存在,但在没有 setter 时主动抛出异常。因此它仍然属于数据描述符。
7.3 property 不等于缓存
下面的 getter 每次访问都会重新计算:
class Report:
@property
def total(self):
print("calculating")
return sum(range(100))
report = Report()
print(report.total)
print(report.total)
输出会包含两次:
calculating
property 只定义访问协议,不自动缓存结果。如果需要缓存,应明确使用实例字段、functools.cached_property 或其他缓存机制,并考虑失效策略。描述符指南将 property 和 cached_property 都列为建立在描述符基础上的工具,但二者的存储和生命周期语义不同。(docs.python.org)
8. 自定义描述符:从校验字段到可复用组件
property 适合少量、单独定义的属性;当多个字段共享相同的访问规则时,自定义描述符更合适。
例如实现类型校验:
class TypedField:
def __init__(self, expected_type):
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
self.storage_name = "_" + name
def __get__(self, instance, owner=None):
if instance is None:
return self
try:
return getattr(instance, self.storage_name)
except AttributeError:
raise AttributeError(
f"{owner.__name__}.{self.name} has not been initialized"
) from None
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} requires {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(instance, self.storage_name, value)
class User:
name = TypedField(str)
age = TypedField(int)
def __init__(self, name, age):
self.name = name
self.age = age
user = User("Alice", 30)
print(user.name, user.age)
user.age = "thirty"
赋值 user.age = "thirty" 时,数据流是:
- 根据
User的 MRO 找到age; - 发现
age是数据描述符; - 调用
TypedField.__set__(user, "thirty"); isinstance()检查失败;- 抛出
TypeError; _age不会被写入。
这种设计把验证规则放到了字段定义处,而不是分散到每个构造函数、更新方法和反序列化入口中。
不过,描述符并不能阻止所有绕过行为:
user.__dict__["_age"] = "thirty"
这里直接写入的是私有存储字段,不经过 age.__set__()。因此“描述符保证字段不变量”成立的前提是:所有代码都通过公开属性或明确的受控接口访问数据。Python 的访问控制主要依赖约定,而不是强制的内存隔离。
9. ORM 字段:类属性描述数据库,实例属性访问数据
ORM,即 Object-Relational Mapping,对象关系映射,试图把:
- 数据库中的表映射成 Python 类;
- 表中的列映射成类属性;
- 行映射成 Python 实例;
- SQL 查询和更新映射成属性读取与赋值。
描述符天然适合这个任务,因为它可以让类属性保存“字段定义”,而把真正的数据访问延迟到实例属性读取或写入时。
9.1 一个可运行的 SQLite 示例
下面的例子只演示描述符机制,不试图实现完整 ORM。运行环境是 Python 3.14 标准库中的 sqlite3。
import sqlite3
class Field:
def __init__(self, column=None):
self.column = column
def __set_name__(self, owner, name):
self.name = name
if self.column is None:
self.column = name
def __get__(self, instance, owner=None):
if instance is None:
return self
sql = (
f"SELECT {self.column} "
f"FROM {owner.table_name} "
f"WHERE {owner.primary_key} = ?"
)
row = instance.connection.execute(
sql,
(instance.key,),
).fetchone()
if row is None:
raise LookupError(
f"{owner.__name__} object with key {instance.key!r} "
"does not exist"
)
return row[0]
def __set__(self, instance, value):
sql = (
f"UPDATE {type(instance).table_name} "
f"SET {self.column} = ? "
f"WHERE {type(instance).primary_key} = ?"
)
cursor = instance.connection.execute(
sql,
(value, instance.key),
)
instance.connection.commit()
if cursor.rowcount == 0:
raise LookupError(
f"cannot update missing object with key {instance.key!r}"
)
class Movie:
table_name = "movies"
primary_key = "title"
director = Field()
year = Field()
def __init__(self, connection, title):
self.connection = connection
self.key = title
def main():
connection = sqlite3.connect(":memory:")
connection.execute(
"""
CREATE TABLE movies (
title TEXT PRIMARY KEY,
director TEXT NOT NULL,
year INTEGER NOT NULL
)
"""
)
connection.execute(
"""
INSERT INTO movies(title, director, year)
VALUES (?, ?, ?)
""",
("Jaws", "Steven Spielberg", 1975),
)
connection.commit()
jaws = Movie(connection, "Jaws")
print(jaws.director)
print(jaws.year)
jaws.year = 1976
print(jaws.year)
connection.close()
if __name__ == "__main__":
main()
预期输出:
Steven Spielberg
1975
1976
9.2 读取路径
执行:
jaws.director
具体过程如下:
-
Movie.__dict__["director"]找到Field描述符; -
Field定义了__set__(),因此它是数据描述符; -
数据描述符优先于
jaws.__dict__; -
调用:
Field.__get__(jaws, Movie) -
__get__()从jaws.key得到主键"Jaws"; -
构造查询并使用参数
(jaws.key,); -
数据库返回一行;
-
返回该行的第一列。
实例 jaws 并没有保存 director 的实际值。它只保存了:
{
"connection": connection,
"key": "Jaws",
}
因此,属性读取是一次潜在的数据库 I/O,而不是普通内存读取。
9.3 写入路径
执行:
jaws.year = 1976
会经历:
- 找到
Movie.year的Field; - 调用
Field.__set__(jaws, 1976); - 生成
UPDATE; - 使用
?参数绑定主键和值; - 提交事务;
- 如果没有匹配行,则抛出
LookupError。
这里使用参数化 SQL 而不是字符串插值,是为了避免把数据值直接拼接到 SQL 中。字段名和表名则不是普通参数值,SQLite 参数绑定不能替换标识符,因此示例假设它们来自类定义,而不是不可信用户输入。
9.4 ORM 描述符的状态关系
可以用下面的关系理解一个简单 ORM:
sequenceDiagram
participant C as 调用方
participant O as Movie 实例
participant D as Field 描述符
participant DB as SQLite 数据库
C->>O: movie.director
O->>D: __get__(movie, Movie)
D->>O: 读取 key 和 connection
D->>DB: SELECT director ... WHERE title = ?
DB-->>D: 返回一行
D-->>O: 返回 director
O-->>C: "Steven Spielberg"
C->>O: movie.year = 1976
O->>D: __set__(movie, 1976)
D->>DB: UPDATE movies SET year = ? WHERE title = ?
DB-->>D: rowcount
D->>DB: COMMIT
D-->>C: 完成或抛出异常
这里至少有三种状态:
- 模型状态:实例中的
key、数据库连接、加载标记; - 描述符状态:字段名、列名等类级元数据;
- 数据库状态:表中的真实值和事务状态。
描述符本身不应保存某个实例的 year 值,因为同一个 Field 会被所有 Movie 实例共享。实例标识和数据库连接属于实例状态,列名和表名属于字段定义状态,真实字段值属于数据库状态。
10. ORM 示例中的真实边界
10.1 每次读取都可能产生 N+1 查询
下面的代码:
for movie in movies:
print(movie.director)
如果 movie.director 每次都访问数据库,就可能产生:
- 1 次获取电影列表的查询;
- N 次获取导演的查询。
这就是常说的 N+1 查询问题。描述符只是把查询隐藏在属性访问后面,并没有自动消除数据库往返。
工程上通常需要在以下方案之间取舍:
- 一次性预加载字段;
- 在实例中缓存已读取值;
- 批量查询;
- 显式区分懒加载属性和已加载属性;
- 使用 ORM 自己的查询规划能力。
关键点是:movie.director 语法看起来像内存访问,但其真实成本由 __get__() 决定。
10.2 并发下的读写一致性
如果一个线程读取:
value = movie.year
另一个线程或进程同时更新数据库,第一次读取和第二次读取可能得到不同结果:
print(movie.year)
# 另一个事务更新 year
print(movie.year)
描述符不会自动提供快照隔离、锁、事务边界或缓存一致性。它只负责把属性访问转发到某个实现。事务隔离和并发控制仍然由数据库连接、事务配置和应用层策略决定。
10.3 写入成功不一定代表业务操作完成
示例中的 __set__() 直接执行并提交:
movie.year = 1976
这意味着赋值语句包含数据库 I/O 和事务提交。生产系统可能更希望:
movie.year = 1976
session.commit()
此时 __set__() 只修改内存中的待提交状态,或者记录脏字段;真正提交由工作单元统一完成。两种模型都可以使用描述符,但生命周期不同:
- 立即写入:实现简单,赋值后数据库通常立即可见,但难以组合多个修改;
- 延迟写入:事务边界更清晰,支持批量提交和回滚,但需要维护脏状态。
不能因为语法是 obj.field = value,就假设其事务语义等同于普通对象赋值。
10.4 删除、缺失行和空值必须区分
数据库查询可能出现三种情况:
- 找到一行,列值为普通值;
- 找到一行,列值为
NULL,对应 Python 的None; - 找不到主键对应的行。
因此不能简单写:
return row[0] if row else None
因为这样会把“记录不存在”和“记录存在但列值为 NULL”混为一谈。示例使用:
if row is None:
raise LookupError(...)
return row[0]
显式区分了两种状态。
11. 常见误解与诊断方法
11.1 “实现了 __get__ 就一定能拦截赋值”
错误。
只实现 __get__() 的是非数据描述符,只能参与读取;赋值时,实例通常可以直接写入同名实例属性。
class ReadOnlyLookup:
def __get__(self, instance, owner=None):
return "descriptor"
class Example:
value = ReadOnlyLookup()
example = Example()
example.value = "instance"
print(example.value)
输出:
instance
如果希望赋值也被拦截,必须实现 __set__() 或 __delete__(),哪怕 __set__() 只负责抛出异常。(docs.python.org)
11.2 “描述符放在实例中也会触发”
通常错误。
class Descriptor:
def __get__(self, instance, owner=None):
return 42
class Example:
def __init__(self):
self.value = Descriptor()
example = Example()
print(example.value)
输出是一个 Descriptor 实例,而不是 42。描述符协议由属性查找机制针对类字典中的对象触发;实例字典中的对象不会因为自己实现了 __get__() 就自动变成描述符。
11.3 “访问 Class.attr 一定返回原始描述符”
不一定。
class Descriptor:
def __get__(self, instance, owner=None):
if instance is None:
return self
return "instance value"
class Example:
value = Descriptor()
print(Example.value)
这个例子中因为 __get__() 主动返回 self,所以得到描述符本身。但描述符也可以在类访问时返回其他值:
class ClassAware:
def __get__(self, instance, owner=None):
if instance is None:
return f"defined on {owner.__name__}"
return "instance value"
类访问时传入 instance=None,描述符可以据此决定返回元数据、字段对象或其他类级结果。(docs.python.org)
11.4 如何绕过描述符观察原始对象
使用:
vars(Example)["value"]
而不是:
Example.value
因为 Example.value 会经过 type.__getattribute__(),可能触发类绑定;vars(Example) 返回类字典映射,可以直接查看原始条目。描述符指南也强调,通过 vars(some_class)[name] 间接访问时不会触发描述符。(docs.python.org)
11.5 重写 __getattribute__() 会改变描述符行为
描述符自动调用逻辑位于 object.__getattribute__()、type.__getattribute__() 和 super() 相关的属性访问机制中。如果自定义 __getattribute__() 时完全不调用父类实现,可能绕过描述符:
class Broken:
value = property(lambda self: 42)
def __getattribute__(self, name):
if name == "value":
return "bypassed"
return object.__getattribute__(self, name)
print(Broken().value)
输出:
bypassed
更危险的情况是递归:
class Recursive:
def __getattribute__(self, name):
return self.__dict__[name] # 访问 self.__dict__ 会再次进入这里
通常需要使用:
object.__getattribute__(self, "__dict__")
或者:
super().__getattribute__(name)
来避免不必要的递归,并保留标准属性查找行为。
12. 描述符、property 与普通方法的统一模型
可以把三者放在同一个抽象框架中:
| 语言特性 | 类字典中的对象 | 描述符类型 | 实例访问效果 |
|---|---|---|---|
| 普通类属性 | 42 |
不是描述符 | 返回 42 |
| 普通方法 | 函数对象 | 非数据描述符 | 产生绑定方法 |
property |
property 对象 |
数据描述符 | 调用 getter/setter |
| ORM 字段 | 自定义字段对象 | 通常是数据描述符 | 访问外部数据源 |
classmethod |
类方法描述符 | 非数据描述符 | 绑定类 |
staticmethod |
静态方法描述符 | 非数据描述符 | 不绑定实例 |
它们的共同点是:类字典里保存的不是最终结果,而是一个可以参与属性访问的对象。
区别在于:
- 普通类属性直接返回;
- 函数根据访问者绑定实例;
property把访问转发给 getter/setter;- ORM 字段把访问转发给数据库或会话;
classmethod把类作为第一个参数;staticmethod不自动添加实例或类参数。
描述符因此不是某个孤立的元编程技巧,而是 Python 对象模型中“类属性如何解释”的统一协议。(docs.python.org)
13. 什么时候使用 property,什么时候使用自定义描述符
如果只有一个类中的一个或几个属性需要封装逻辑,property 通常更直接:
class User:
@property
def display_name(self):
return f"{self.first_name} {self.last_name}"
如果多个类、多个字段需要共享规则,自定义描述符更适合:
class Positive:
...
然后复用:
class Product:
price = Positive()
stock = Positive()
class Order:
quantity = Positive()
选择时应先判断数据来源:
- 数据来自实例内存,适合
property或本地描述符; - 数据需要统一校验,适合可复用的数据描述符;
- 数据来自数据库、缓存或远程服务,可以使用 ORM 字段式描述符,但必须显式评估 I/O、事务、缓存和并发语义;
- 数据访问需要复杂查询规划时,单纯隐藏在属性语法后面可能降低可观察性,此时应考虑显式查询接口。
描述符能隐藏实现细节,但不能消除实现成本。尤其是 ORM 场景,属性访问可能触发异常、网络请求、数据库查询或事务提交;接口形式简单,不代表执行语义简单。
结语
理解描述符需要同时掌握四个层次:
- 协议层:
__get__()、__set__()、__delete__()定义了属性访问钩子; - 优先级层:数据描述符、实例字典、非数据描述符和普通类属性有严格顺序;
- 绑定层:函数作为非数据描述符,把
function转换成带有__self__的 bound method; - 抽象层:
property和 ORM 字段把方法调用、校验逻辑、缓存或数据库操作包装成属性访问。
其中最容易混淆的结论是:
- 只实现
__get__()的是非数据描述符,可能被实例字典覆盖; - 实现
__set__()或__delete__()的是数据描述符,会压过实例字典; property即使没有 setter,仍然是数据描述符;__set_name__()只会在类创建时自动调用;- 描述符对象通常由类实例共享,实例数据不能存储在描述符自身;
obj.attr的低成本外观不能证明它只是内存读取,ORM 中它完全可能是一次数据库操作。
掌握这些规则后,property、方法绑定、__slots__、字段校验器和 ORM 字段就不再是彼此孤立的特性,而是同一套属性访问机制在不同场景下的具体应用。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python MRO 与 super:C3 线性化、多继承和协作调用
- 下一篇:Python ABC 与 Protocol:名义子类型、结构化类型和接口设计
- 延伸:Python 对象模型:type、object、属性查找、身份与生命周期
- 延伸:Python 元类:类创建流程、prepare、注册和使用边界
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论