Python 描述符进阶:数据描述符和非数据描述符到底有什么区别?
在 Python 编程中,很多高级特性看似神秘,其实都藏在对象模型的细节里。比如你每天都会写的:
obj.name
obj.method()
obj.age = 18
这些再普通不过的属性访问,背后并不只是“从对象里拿一个值”这么简单。Python 会沿着一套清晰的属性查找规则,判断这个属性来自实例、类、父类,还是一个特殊对象。
这个特殊对象,就是描述符。
如果说装饰器让函数拥有了可扩展能力,元类让类的创建过程可以被定制,那么描述符协议则让属性访问拥有了“可编程”的能力。它是 property、实例方法、staticmethod、classmethod、ORM 字段、表单校验、缓存属性等机制背后的重要基础。
上一篇我们理解了什么是描述符协议,这一篇继续深入一个更关键的问题:
数据描述符和非数据描述符有什么区别?
这个问题看似细节,实际却是理解 Python 属性访问优先级的核心。很多框架的“魔法”,都建立在这个区别之上。
一、先回顾:什么是描述符?
在 Python 中,只要一个对象实现了下面三个方法中的任意一个,它就可以被称为描述符:
__get__(self, instance, owner)
__set__(self, instance, value)
__delete__(self, instance)
描述符通常作为类属性存在,用来控制实例属性的读取、赋值或删除。
class SimpleDescriptor:
def __get__(self, instance, owner):
return "来自描述符的值"
class User:
name = SimpleDescriptor()
u = User()
print(u.name)
输出:
来自描述符的值
这里的 name 并不是普通字符串,而是一个描述符对象。当访问 u.name 时,Python 发现 User.name 实现了 __get__,于是调用它的 __get__ 方法。
描述符真正强大的地方在于:它不是简单保存数据,而是可以控制属性访问行为。
二、数据描述符与非数据描述符的定义
描述符分为两类:
- 数据描述符;
- 非数据描述符。
它们的区别非常简单,但影响非常深远。
1. 数据描述符
如果一个描述符实现了 __set__ 或 __delete__ 方法,它就是数据描述符。通常它也会实现 __get__。
class DataDescriptor:
def __get__(self, instance, owner):
return "读取数据描述符"
def __set__(self, instance, value):
print("写入数据描述符")
这里 DataDescriptor 是数据描述符,因为它实现了 __set__。
即使 __set__ 只是抛出异常,它仍然是数据描述符:
class ReadOnly:
def __get__(self, instance, owner):
return "只读属性"
def __set__(self, instance, value):
raise AttributeError("该属性不可修改")
这也是 property 实现只读属性的常见方式。
2. 非数据描述符
如果一个描述符只实现了 __get__,没有实现 __set__ 和 __delete__,它就是非数据描述符。
class NonDataDescriptor:
def __get__(self, instance, owner):
return "读取非数据描述符"
非数据描述符只能控制读取行为,不能拦截赋值和删除。
Python 中非常典型的非数据描述符是普通函数。类中的方法之所以会自动绑定 self,就是因为函数对象实现了 __get__。
class User:
def say_hello(self):
print("hello")
u = User()
print(User.say_hello)
print(u.say_hello)
大致输出:
<function User.say_hello at 0x...>
<bound method User.say_hello of <__main__.User object at 0x...>>
通过类访问时,它是普通函数;通过实例访问时,它变成了绑定方法。这背后就是非数据描述符在工作。
三、最核心区别:属性查找优先级不同
数据描述符和非数据描述符最大的区别,不在于方法数量,而在于它们和实例属性发生冲突时,谁优先。
当执行:
obj.attr
Python 的属性查找顺序大致如下:
数据描述符
↓
实例字典 obj.__dict__
↓
非数据描述符
↓
类属性
↓
__getattr__
这条规则非常重要,可以直接记下来。
它意味着:
- 数据描述符优先级高于实例属性;
- 非数据描述符优先级低于实例属性;
- 实例属性可以覆盖非数据描述符;
- 实例属性不能覆盖数据描述符。
我们通过代码来看。
四、代码实验:数据描述符不会被实例属性覆盖
class DataDesc:
def __get__(self, instance, owner):
return "来自数据描述符"
def __set__(self, instance, value):
print(f"数据描述符拦截赋值:{value}")
class Demo:
attr = DataDesc()
obj = Demo()
obj.__dict__["attr"] = "来自实例字典"
print(obj.__dict__)
print(obj.attr)
输出:
{'attr': '来自实例字典'}
来自数据描述符
虽然实例字典里已经有了 attr,但访问 obj.attr 时,Python 仍然优先调用数据描述符的 __get__。
这就是数据描述符的“强势”之处:只要类属性中存在同名数据描述符,它就会优先于实例属性。
再看赋值:
obj.attr = 100
输出:
数据描述符拦截赋值:100
这次赋值并没有直接写入 obj.__dict__,而是被 __set__ 拦截了。
这也是为什么 property 可以控制属性赋值:
class Product:
def __init__(self, price):
self._price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value <= 0:
raise ValueError("价格必须大于 0")
self._price = value
price 本质上就是一个数据描述符。
五、代码实验:非数据描述符会被实例属性覆盖
现在看非数据描述符。
class NonDataDesc:
def __get__(self, instance, owner):
return "来自非数据描述符"
class Demo:
attr = NonDataDesc()
obj = Demo()
print(obj.attr)
obj.__dict__["attr"] = "来自实例字典"
print(obj.attr)
输出:
来自非数据描述符
来自实例字典
第一次访问时,实例字典里没有 attr,所以调用非数据描述符的 __get__。
第二次访问时,实例字典里有了 attr,于是 Python 直接返回实例字典中的值,不再调用非数据描述符。
这就是非数据描述符的特点:它可以提供默认行为,但允许实例覆盖。
这个设计非常优雅,因为它让某些属性可以“第一次由类提供,后续由实例接管”。
六、对比总结:一张表看懂区别
| 对比维度 | 数据描述符 | 非数据描述符 |
|---|---|---|
| 必要方法 | 实现 __set__ 或 __delete__ | 只实现 __get__ |
| 是否能拦截读取 | 可以 | 可以 |
| 是否能拦截赋值 | 可以 | 不可以 |
| 是否能拦截删除 | 可以,如果实现 __delete__ | 不可以 |
| 与实例属性冲突时 | 数据描述符优先 | 实例属性优先 |
| 常见例子 | property、ORM 字段、校验字段 | 普通实例方法、缓存属性 |
| 适合场景 | 类型校验、只读属性、字段管理 | 延迟计算、默认绑定、可覆盖属性 |
最值得记住的是这一句:
数据描述符像“强规则”,非数据描述符像“默认值”。
七、为什么实例方法是非数据描述符?
在 Python 中,函数对象作为类属性时,会表现为非数据描述符。
class Person:
def greet(self):
return "hello"
p = Person()
print(p.greet())
当访问 p.greet 时,Python 实际上调用了函数对象的 __get__,返回一个绑定了实例 p 的方法对象。
可以用下面的伪代码理解:
method = Person.__dict__["greet"].__get__(p, Person)
method()
由于普通函数是非数据描述符,所以它可以被实例属性覆盖。
class Person:
def greet(self):
return "hello"
p = Person()
p.greet = lambda: "hi"
print(p.greet())
输出:
hi
实例属性 p.greet 覆盖了类中的方法。这在某些测试、Mock、动态替换行为中很有用。
但也要谨慎使用,因为过度动态修改实例方法,会降低代码可读性。
八、实战案例一:用数据描述符实现字段校验
假设我们在做一个用户系统,需要保证:
- 用户名必须是字符串;
- 年龄必须是整数;
- 年龄不能小于 0;
- 分数必须在 0 到 100 之间。
如果把校验逻辑全部写在 __init__ 中,代码很快会变得臃肿。更好的方式是使用数据描述符。
class IntegerField:
def __init__(self, min_value=None, max_value=None):
self.min_value = min_value
self.max_value = max_value
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} 必须是整数")
if self.min_value is not None and value < self.min_value:
raise ValueError(f"{self.name} 不能小于 {self.min_value}")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"{self.name} 不能大于 {self.max_value}")
instance.__dict__[self.name] = value
使用:
class Student:
age = IntegerField(min_value=0, max_value=150)
score = IntegerField(min_value=0, max_value=100)
def __init__(self, age, score):
self.age = age
self.score = score
s = Student(18, 95)
print(s.age)
print(s.score)
s.score = 120
最后一行会抛出异常:
ValueError: score 不能大于 100
这里 IntegerField 是数据描述符,因为它实现了 __set__。任何对 age 或 score 的赋值都会被它拦截。
这个模式非常适合:
- 表单字段校验;
- 配置项校验;
- ORM 模型字段;
- API 入参对象;
- 数据清洗流程。
九、实战案例二:用非数据描述符实现 cached_property
非数据描述符最经典的用途之一,是实现缓存属性。
有些属性计算成本较高,比如读取文件、统计数据、解析配置、执行复杂计算。我们希望第一次访问时计算,之后直接复用结果。
class cached_property:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
print(f"计算 {self.name}")
value = self.func(instance)
instance.__dict__[self.name] = value
return value
使用:
class Report:
def __init__(self, numbers):
self.numbers = numbers
@cached_property
def total(self):
return sum(self.numbers)
r = Report([1, 2, 3, 4, 5])
print(r.total)
print(r.total)
print(r.__dict__)
输出:
计算 total
15
15
{'numbers': [1, 2, 3, 4, 5], 'total': 15}
为什么第二次没有打印“计算 total”?
因为 cached_property 只实现了 __get__,它是非数据描述符。
第一次访问时,描述符计算结果,并把结果写入:
instance.__dict__["total"] = 15
第二次访问时,实例字典中的 total 优先级高于非数据描述符,所以 Python 直接返回缓存值。
这正是非数据描述符的巧妙应用:允许实例属性覆盖描述符结果。
十、如果 cached_property 是数据描述符会怎样?
我们稍微改一下,给它加上 __set__:
class BadCachedProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
print(f"计算 {self.name}")
value = self.func(instance)
instance.__dict__[self.name] = value
return value
def __set__(self, instance, value):
instance.__dict__[self.name] = value
现在它变成了数据描述符。即使实例字典中已经存在同名属性,访问时仍然优先调用 __get__。
结果可能是每次访问都重新计算,缓存效果被破坏。
这说明:描述符不是实现方法越多越好。你要根据需求选择数据描述符或非数据描述符。
如果你要强制控制赋值,选择数据描述符。
如果你希望实例可以覆盖结果,选择非数据描述符。
十一、属性查找流程图
可以用下面这个流程图理解:
在工程实践中,你不一定每天都手写描述符,但只要你理解这张图,很多 Python 行为都会变得清晰。
十二、常见坑点:数据到底应该存在哪里?
很多初学者第一次写描述符时,会把值存在描述符对象自己身上。
错误示例:
class BadField:
def __init__(self):
self.value = None
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
self.value = value
看起来没问题,但实际很危险。
class User:
age = BadField()
a = User()
b = User()
a.age = 18
b.age = 30
print(a.age)
print(b.age)
输出:
30
30
为什么?
因为 age = BadField() 是类属性,整个 User 类只有一个 BadField 描述符对象。所有实例共享它。
正确做法是把每个实例的数据存进实例自己的 __dict__:
class GoodField:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
instance.__dict__[self.name] = value
这样每个实例都有自己的数据,互不影响。
十三、结合 __set_name__ 写更优雅的描述符
__set_name__ 会在类创建时自动调用,用于告诉描述符:你被绑定到了哪个属性名上。
class Field:
def __set_name__(self, owner, name):
print(f"{owner.__name__}.{name} 被绑定")
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
instance.__dict__[self.name] = value
class User:
name = Field()
age = Field()
输出:
User.name 被绑定
User.age 被绑定
以前很多描述符写法需要手动传字段名:
name = Field("name")
现在更推荐用 __set_name__:
name = Field()
它减少了重复,也避免字段名写错。
十四、项目实践:迷你配置系统
假设我们要写一个应用配置类,要求:
host必须是字符串;port必须是 1 到 65535 的整数;debug必须是布尔值。
我们可以构建一套描述符字段。
class TypedField:
expected_type = object
def __init__(self, *, default=None):
self.default = default
self.name = None
def __set_name__(self, owner, name):
self.name = name
def validate(self, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} 必须是 {self.expected_type.__name__}"
)
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, self.default)
def __set__(self, instance, value):
self.validate(value)
instance.__dict__[self.name] = value
class StringField(TypedField):
expected_type = str
class BoolField(TypedField):
expected_type = bool
class PortField(TypedField):
expected_type = int
def validate(self, value):
super().validate(value)
if not 1 <= value <= 65535:
raise ValueError("port 必须在 1 到 65535 之间")
使用:
class AppConfig:
host = StringField(default="127.0.0.1")
port = PortField(default=8000)
debug = BoolField(default=False)
config = AppConfig()
print(config.host)
print(config.port)
print(config.debug)
config.port = 9000
config.debug = True
config.port = 70000
最后一行会抛出异常:
ValueError: port 必须在 1 到 65535 之间
这个案例展示了数据描述符在项目中的真实价值:把校验、默认值、错误提示等逻辑封装起来,让业务类保持干净。
十五、描述符与框架设计思维
如果你用过 Django ORM,可能见过类似写法:
class User(models.Model):
name = models.CharField(max_length=50)
age = models.IntegerField()
如果你用过表单库,也可能见过:
class RegisterForm:
username = StringField(required=True)
password = StringField(required=True)
这些写法都体现了一种思想:用类属性声明字段,用描述符管理字段行为。
当然,成熟框架内部远比我们这里复杂,还会结合元类、类型注解、反射、数据库映射、验证器、序列化等机制。但理解描述符后,你再看这些框架,就不会只觉得“神奇”,而能看见它背后的设计路径。
这也是学习 Python 进阶知识的意义:不是为了炫技,而是为了看懂优秀框架为什么这样设计,并在自己的项目中写出更稳、更优雅的代码。
十六、最佳实践:什么时候用数据描述符,什么时候用非数据描述符?
适合使用数据描述符的场景:
需要强制控制属性赋值
需要字段类型校验
需要范围校验
需要只读属性
需要拦截删除行为
需要构建模型字段或表单字段
适合使用非数据描述符的场景:
只需要控制读取行为
允许实例覆盖属性
需要实现缓存属性
需要延迟计算
需要模拟方法绑定行为
可以这样判断:
如果你的规则是“必须经过我”,用数据描述符。
如果你的规则是“我提供默认能力,但实例可以接管”,用非数据描述符。
十七、单元测试建议
描述符通常隐藏在属性访问背后,所以更需要测试。
下面是一个简单测试示例:
def test_integer_field_valid_value():
class User:
age = IntegerField(min_value=0, max_value=150)
user = User()
user.age = 18
assert user.age == 18
def test_integer_field_invalid_type():
class User:
age = IntegerField(min_value=0, max_value=150)
user = User()
try:
user.age = "18"
except TypeError as e:
assert "age 必须是整数" in str(e)
def test_instances_are_independent():
class User:
age = IntegerField(min_value=0)
a = User()
b = User()
a.age = 18
b.age = 30
assert a.age == 18
assert b.age == 30
重点测试这些内容:
- 正常赋值是否成功;
- 错误类型是否抛异常;
- 边界值是否正确;
- 多个实例之间是否互不影响;
- 通过类访问描述符时是否安全;
- 实例属性是否会覆盖非数据描述符。
十八、性能与可维护性思考
描述符很强大,但不要滥用。
如果只是一个简单的计算属性,property 更清晰:
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
如果只是普通数据存储,直接用实例属性即可:
self.name = name
如果多个字段共享复杂校验规则,或者你正在设计可复用框架组件,描述符才真正值得出场。
好的 Python 编程不是把所有高级语法都用上,而是知道每种工具适合解决什么问题。成熟的工程代码,往往不是最炫的,而是最容易理解、最稳定、最方便扩展的。
十九、总结:区别不只是语法,而是控制权
数据描述符和非数据描述符的区别,可以浓缩成三句话:
第一,数据描述符实现了 __set__ 或 __delete__,非数据描述符通常只实现 __get__。
第二,数据描述符优先级高于实例字典,非数据描述符优先级低于实例字典。
第三,数据描述符适合强约束,非数据描述符适合默认行为与延迟计算。
理解这一区别后,你会发现很多 Python 机制都豁然开朗:
property为什么能拦截赋值;- 实例方法为什么能自动绑定
self; cached_property为什么能缓存结果;- ORM 字段为什么能像普通属性一样使用;
- 框架为什么喜欢用类属性声明规则。
Python 的优雅,不只是语法简洁,更在于它把复杂能力藏进了一套统一而开放的对象协议中。描述符就是其中最值得深入理解的一环。
当你真正掌握数据描述符和非数据描述符后,你写的就不只是 Python 代码,而是在和 Python 对象模型协作。
互动思考
你在项目中是否遇到过属性校验、延迟加载、缓存计算或字段声明的场景?
如果让你实现一个配置系统、ORM 模型或表单校验工具,你会选择 property、数据描述符,还是非数据描述符?
欢迎在评论区分享你的实践经验。技术成长从来不是孤独的路,每一次交流,都是我们重新理解代码的一次机会。
SEO 关键词建议
Python编程、Python教程、Python实战、Python最佳实践、Python描述符、数据描述符、非数据描述符、Python属性查找、Python高级编程、Python对象模型。


750

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



