Python 描述符实战:你天天用的 @property,底层原来是个描述符

写了几年 Python,几乎每个人都知道 @property,但很少有人知道——它底层就是一个描述符(descriptor)。而描述符,正是 Python 里 property、classmethod、staticmethod、甚至普通方法,共享的同一个底层机制。

在这里插入图片描述

一、先说结论:描述符是 Python 属性系统的地基

我先抛一个问题:@property@classmethod@staticmethod,这三个东西看起来八竿子打不着,但它们有没有共同点?

答案是:它们都是描述符

描述符(descriptor)是 Python 的一个底层协议——任何定义了 __get____set____delete__ 中任意一个方法的对象,就是一个描述符。它能在属性被访问的瞬间"劫持"这个访问,执行自定义逻辑。

这篇文章我把描述符协议、@property 的底层拆解、数据描述符 vs 非数据描述符的优先级规则、以及自定义描述符的实战,一次性讲清楚。核心结论先放这:理解描述符,你才算真正理解了 Python 的属性访问机制——这也是从"会用 Python"到"懂 Python 底层"的一道分水岭。

收藏提示①:文末的自定义描述符模板可以直接复用,做类型校验、惰性属性时抄过去改就行。

二、环境信息

版本
Python3.13.12
说明描述符协议是 Python 语言级特性,2.2 起就存在,本文示例适用所有现代 Python 3.x

三、什么是描述符:一个三方法的协议

描述符协议极其简单,就三个方法:

class Descriptor:
    def __get__(self, obj, objtype=None):
        # 访问属性时调用:obj.attr
        ...

    def __set__(self, obj, value):
        # 赋值时调用:obj.attr = value
        ...

    def __delete__(self, obj):
        # 删除时调用:del obj.attr
        ...

一个对象只要实现了其中任意一个方法,它就成了描述符。关键在它必须作为类属性存在,而不是实例属性。

先看一个最简单的描述符,感受一下"劫持属性访问"是什么意思:

class Upper:
    """把赋进去的字符串自动转成大写"""
    def __get__(self, obj, objtype=None):
        return obj._value

    def __set__(self, obj, value):
        obj._value = value.upper()   # 赋值时自动转大写

class Person:
    name = Upper()   # 注意:描述符是「类属性」

p = Person()
p.name = "patrick"
print(p.name)   # PATRICK  —— 赋值时被自动转成了大写

p.name = "patrick" 这行赋值,没有走默认的"存进实例 __dict__",而是被 Upper.__set__ 劫持了,把字符串转成大写再存。这就是描述符的本质——在属性访问的必经之路上插一脚

四、property 就是个描述符

这是最能让人"哦原来如此"的一步。你平时这么用 @property

class Person:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not value:
            raise ValueError("名字不能为空")
        self._name = value

这段代码,底层等价于:

class Person:
    def __init__(self, name):
        self._name = name

    def get_name(self):
        return self._name

    def set_name(self, value):
        if not value:
            raise ValueError("名字不能为空")
        self._name = value

    name = property(get_name, set_name)   # property 返回的就是一个描述符对象

property(...) 返回的对象,实现了 __get____set__——所以它就是一个描述符@property 只是它的语法糖。同理,@classmethod@staticmethod,甚至普通的函数对象,全都是描述符。

五、数据描述符 vs 非数据描述符:优先级才是核心

描述符分两档,这一档之差,决定了属性查找时谁说了算:

类型实现的方法优先级(相对实例 __dict__典型代表
数据描述符__get__ + __set__/__delete__高于实例字典@property、ORM 字段
非数据描述符只实现 __get__低于实例字典函数、@classmethod@staticmethod

属性查找的完整顺序(CPython object.__getattribute__ 的逻辑)是这样的:

obj.attr
 ├─ 1. 找数据描述符 → 找到就调用 __get__,直接返回
 ├─ 2. 查实例 __dict__ → 有同名键就返回
 ├─ 3. 找非数据描述符 → 找到就调用 __get__
 ├─ 4. 返回类变量
 └─ 5. 都没有 → 触发 __getattr__(兜底)

收藏提示②:这个优先级顺序是描述符的全部精髓——数据描述符能覆盖实例属性,非数据描述符会被实例属性覆盖。这也是为什么 @property 能"锁死"属性、而普通方法可以被实例同名属性顶掉。

在这里插入图片描述

上图:obj.attr 的完整查找顺序,从上到下依次是数据描述符 → 实例字典 → 非数据描述符 → 类变量 → __getattr__。记住这张图,属性"为什么没生效"的问题就再也不用靠猜了。

在这里插入图片描述

上图:左边数据描述符(红色,能覆盖实例属性),右边非数据描述符(橙色,会被实例属性顶掉)。一档之差,行为完全不同。

六、自定义描述符实战:类型校验 + 惰性属性

理解了原理,就能自己造轮子了。下面两个是描述符最经典的两个应用场景。

场景一:字段类型校验(模仿 ORM)

class TypedField:
    """一个带类型校验的数据描述符"""
    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __get__(self, obj, objtype=None):
        if obj is None:          # 类级别访问,返回描述符本身
            return self
        return obj.__dict__.get(self.name)

    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f"期望 {self.expected_type},得到 {type(value)}")
        obj.__dict__[self.name] = value

    def __set_name__(self, owner, name):   # 自动拿到属性名
        self.name = name

class Account:
    owner = TypedField(str)     # owner 必须是 str
    balance = TypedField(float) # balance 必须是 float

acc = Account()
acc.owner = "Patrick"     # ok
acc.balance = 100.0       # ok
acc.balance = "一百块"     # TypeError: 期望 <class 'float'>,得到 <class 'str'>

场景二:惰性属性(访问时才计算,只算一次)

class LazyProperty:
    """非数据描述符:首次访问时计算并缓存"""
    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = self.func(obj)
        obj.__dict__[self.func.__name__] = value   # 缓存到实例字典
        return value

class Report:
    @LazyProperty
    def heavy_data(self):
        print("计算中……")     # 只会打印一次
        return sum(i*i for i in range(10_000_000))

r = Report()
r.heavy_data   # 第一次:计算中……
r.heavy_data   # 第二次:直接返回缓存,不再计算

收藏提示③:这两个模板——TypedField(类型校验)和 LazyProperty(惰性属性)——是描述符最高频的两个用途,直接收藏,以后做数据校验或性能优化时抄来改。

七、踩坑记录:这两个坑,几乎人人踩

  1. 描述符必须定义成类属性:如果你在 __init__ 里写 self.name = Descriptor(),那只是普通的实例属性赋值,描述符协议完全不会触发。这是新手最常见的坑——描述符要写在 class 体里,作为类属性。
  2. 数据 vs 非数据搞混,属性"覆盖"行为不一样:想用描述符锁死一个属性(不让实例覆盖),就必须用数据描述符(实现 __set__)。如果只实现 __get__(非数据描述符),实例一旦 self.attr = xxx,描述符就失效了。

八、写在最后

描述符这个东西,学之前觉得神秘,学完之后你会发现——Python 的属性系统其实就建立在这么简单的一个三方法协议上。property、classmethod、staticmethod、方法绑定,全都是它的具体应用。

理解它最大的收获,不是"多会一个特性",而是看懂了 Python 对象模型的一层:当你下次遇到"为什么这个属性赋值没生效""为什么 @property 能锁死属性"这类问题时,脑子里会自动浮现那张优先级查找表,而不是靠猜。

需要说明的是,描述符是语言级特性,不受版本影响,示例代码在 Python 3.x 全部适用。如果你正从"会用 Python"往"理解 Python"进阶,描述符是绕不过去的一站。

收藏提示④:如果这篇帮你把 Python 属性机制看懂了,收藏 + 点赞,下次纠结"属性为什么没生效"时直接回来查那张优先级表。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值