从一个装饰器说起:拆解 Python 的 @property

0 阅读8分钟

写过几年 Python 的人大概都遇到过这样的场景——一开始图省事,类里随手放了个公开属性,比如 self.radius = 5。后来产品经理跑过来说,半径不能是负数啊,得加个校验。这时候麻烦就来了,如果直接把属性改成方法 get_radius(),所有调用过 circle.radius 的老代码全都要跟着改。@property 这个装饰器,就是 Python 专门用来解决这类尴尬处境的工具。它让你能在不改变外部调用方式的前提下,悄悄给属性访问加上逻辑控制。下面就把它从原理到实战掰开揉碎讲一遍。


为什么 Python 需要 property

在 Java 或 C++ 这类语言里,程序员习惯性地把所有字段设为私有,然后写一堆 getX()setX() 方法,这是一种防御性设计,图的是以后想加校验逻辑随时能加,不用担心破坏接口。

Python 的哲学不太一样,讲究我们都是成年人,不搞那么多防御。所以 Python 程序员一开始通常就是直接暴露属性,简洁又直白。但问题也很明显——万一以后真需要加校验或者计算逻辑呢?总不能让全世界调用你这个类的代码都跟着改吧。

property 就是这个矛盾的调和方案。它长得像属性(用 obj.attr 访问,不用加括号),骨子里却是方法(访问的时候会自动执行一段函数逻辑)。鱼和熊掌,它都要。


核心概念一:描述符协议

想真正搞懂 property,绕不开一个更底层的机制,叫描述符协议(descriptor protocol)。这是 Python 对象模型里比较硬核的一块,但理解了它,property 的行为就完全不神秘了。

一个对象只要实现了下面这几个特殊方法中的任意一个,它就成了描述符

  • __get__(self, obj, objtype=None) 控制读取行为
  • __set__(self, obj, value) 控制赋值行为
  • __delete__(self, obj) 控制删除行为

如果一个描述符同时实现了 __get__ 和 (__set____delete__),它就叫数据描述符;如果只实现了 __get__,那叫非数据描述符(普通函数其实就是非数据描述符,这也是为什么方法能通过实例调用)。

property 类,本质上就是 Python 内置的一个数据描述符实现。你写下 @property 的时候,Python 在背后帮你造了一个 property 对象,塞进了类的字典里。

数据描述符有个很关键的特权——它的优先级高于实例的 __dict__。也就是说,即便你的实例字典里塞了同名的键,Python 查找属性时也会先绕道去问描述符,而不是直接读实例字典。这个优先级顺序,正是 property 能够拦截并接管属性访问的根本原因。


核心概念二:getter / setter / deleter 三件套

一个完整的 property 对象内部维护着三个可选的函数

方法名触发时机典型写法
fget读取属性时@property
fset赋值属性时@x.setter
fdel删除属性时@x.deleter

早期没有装饰器语法的时候,大家是这么写的

class Circle:
    def __init__(self, radius):
        self._radius = radius

    def get_radius(self):
        return self._radius

    def set_radius(self, value):
        if value <= 0:
            raise ValueError("半径必须为正数")
        self._radius = value

    radius = property(get_radius, set_radius)

property() 本身就是个内置类,接收 fget, fset, fdel, doc 四个参数(都是可选的)。装饰器语法出来之后,代码优雅了不少

class Circle:
    def __init__(self, radius):
        self.radius = radius   # 注意,这里已经会触发 setter 校验了

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("半径必须为正数")
        self._radius = value

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

这里的 area 是个只写了 getter、没有 setter 的只读计算属性,外部代码可以像访问普通字段一样写 c.area,但根本没法给它赋值,赋值会直接抛 AttributeError。圆的面积公式大家高中就学过

S=πr2S = \pi r^2

用 property 把这个计算逻辑包装成属性,调用起来比 c.get_area() 顺眼太多,语义上也更贴近数学习惯——面积本来就该是圆的一个属性,不是一个需要主动去做的动作


property 到底怎么被 Python 找到的

搞清楚属性查找的优先级顺序,能帮你理解很多容易踩坑的边界情况,比如为什么 property 定义在类上却能拦截实例的读写。下面这张图展示了 obj.attr 被访问时,Python 内部大致的判断流程

export_5bh41j.png

property 因为同时具备 __get____set__,属于数据描述符,所以它永远排在实例字典的前面被优先处理。这也解释了一个常见的困惑——为什么在 __init__ 里写 self.radius = radius,明明看起来是往实例字典里塞值,实际却触发了类上定义的 setter 方法。因为 Python 压根没给你机会直接写实例字典,descriptor 把这个动作拦截并接管了。


工程实践中怎么用

1. 数据校验,这是最常见的用法

上面圆的例子就是典型代表。任何需要在赋值时做合法性检查的场景,都很适合用 property 包一层,外部代码完全不需要知道内部多了这层逻辑。

2. 只读属性,保护内部状态

只定义 getter,不定义 setter,就得到一个外部无法直接修改的属性。这在做不可变对象或者保护关键内部状态时特别好用,比如银行账户的余额,你肯定不希望外部代码一行 account.balance = 999999 就能改出个亿万富翁。

3. 惰性计算与缓存

有些属性算起来挺费劲,比如要跑一遍复杂的统计或者查一次数据库,但结果在对象生命周期内基本不会变。这种情况可以结合标准库的 functools.cached_property,第一次访问时计算并缓存,之后直接读缓存

from functools import cached_property

class Report:
    def __init__(self, data):
        self.data = data

    @cached_property
    def summary(self):
        print("正在执行昂贵的统计计算…")
        return sum(self.data) / len(self.data)

第一次调用 report.summary 会真的跑一遍计算,之后再访问,直接从实例字典里取缓存值,不会重复计算。这个技巧在处理大数据集或者调用外部 API 的场景里,能省下不少性能开销。

4. 平滑升级 API,不破坏向后兼容

这大概是 property 存在的最原始动机。假设你维护的库里原本有个公开属性 temperature,用户用了好几年,突然某天你需要把摄氏度换算成从内部华氏度存储的值,这时候直接把属性改成方法会让所有用户的代码报错。但改成 property,接口签名一个字不变,用户完全无感知。

5. 配合继承,子类扩展父类的 property

有个容易被忽略的技巧,如果子类只想扩展 setter 的校验逻辑,不想把整个 property 重新定义一遍,可以借助父类 property 的 getter/setter/deleter 方法生成新的 property

class Base:
    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, v):
        self._value = v

class Checked(Base):
    @Base.value.setter
    def value(self, v):
        if v < 0:
            raise ValueError("不能为负")
        Base.value.fset(self, v)

这样子类既复用了父类的 getter,又叠加了自己的校验逻辑,代码不用重复写一遍。


容易踩的几个坑

赋值给只读属性报错——只写了 @property 没写对应的 .setter,一旦外部代码尝试赋值,会直接抛 AttributeError: can't set attribute。这其实是设计意图,但初学者经常一头雾水,得看清是不是真的想要只读语义。

property 与 __slots__ 的配合——如果类用了 __slots__ 来省内存,property 内部通常需要一个额外的私有存储位(比如 _radius),这个私有属性名字也得写进 __slots__ 里,否则会报错说没有这个属性。

别在 property 里塞重活——property 从语法上看起来像个廉价的属性访问,但它背后可能藏着一段复杂计算。如果每次访问都触发一次数据库查询或者网络请求,调用者根本意识不到自己正在做一件昂贵的事情。这种情况该用普通方法名字上带上明显的动词提示(比如 fetch_xxx()),或者用 cached_property 兜底。

不能和 @staticmethod@classmethod 叠加——property 描述的是实例级别的访问行为,跟静态方法和类方法的语义模型不兼容,硬凑在一起大概率报错或者行为诡异,没必要这么折腾。


小结

property 表面上是个语法糖,往深了看其实是 Python 描述符协议的一次优雅落地。它把方法调用属性访问两种截然不同的语法形式统一了起来,让开发者能在完全不改变外部接口的情况下,随时给属性访问加上校验、计算、缓存这些逻辑。

工程实践里,property 用得好能让代码既保持 Python 一贯的简洁直白,又不失必要的健壮性。用得不好——比如把很重的计算悄悄藏进一个看起来毫不起眼的属性访问里——反而会给团队里的其他人埋下不小的坑。归根结底,它是一件工具,好不好用取决于你是不是真的理解了它背后那套描述符查找的游戏规则。