Python 描述符进阶:数据描述符和非数据描述符到底有什么区别?

Python 描述符进阶:数据描述符和非数据描述符到底有什么区别?

在 Python 编程中,很多高级特性看似神秘,其实都藏在对象模型的细节里。比如你每天都会写的:

obj.name
obj.method()
obj.age = 18

这些再普通不过的属性访问,背后并不只是“从对象里拿一个值”这么简单。Python 会沿着一套清晰的属性查找规则,判断这个属性来自实例、类、父类,还是一个特殊对象。

这个特殊对象,就是描述符。

如果说装饰器让函数拥有了可扩展能力,元类让类的创建过程可以被定制,那么描述符协议则让属性访问拥有了“可编程”的能力。它是 property、实例方法、staticmethodclassmethod、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. 数据描述符;
  2. 非数据描述符。

它们的区别非常简单,但影响非常深远。

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__。任何对 agescore 的赋值都会被它拦截。

这个模式非常适合:

  • 表单字段校验;
  • 配置项校验;
  • 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__

结果可能是每次访问都重新计算,缓存效果被破坏。

这说明:描述符不是实现方法越多越好。你要根据需求选择数据描述符或非数据描述符。

如果你要强制控制赋值,选择数据描述符。

如果你希望实例可以覆盖结果,选择非数据描述符。


十一、属性查找流程图

可以用下面这个流程图理解:

访问 obj.attr

类或父类中是否存在 attr?

attr 是否是数据描述符?

调用数据描述符 __get__

obj.__dict__ 中是否有 attr?

返回实例属性

attr 是否是非数据描述符?

调用非数据描述符 __get__

返回普通类属性

是否定义 __getattr__?

调用 __getattr__

抛出 AttributeError

在工程实践中,你不一定每天都手写描述符,但只要你理解这张图,很多 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 必须在 165535 之间

这个案例展示了数据描述符在项目中的真实价值:把校验、默认值、错误提示等逻辑封装起来,让业务类保持干净。


十五、描述符与框架设计思维

如果你用过 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对象模型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值