别再只把 `property` 当装饰器:一文看懂 Python 属性访问的底层机制

别再只把 property 当装饰器:一文看懂 Python 属性访问的底层机制

很多 Python 初学者第一次见到 @property,都会觉得它像一个“语法糖”:把方法伪装成属性,让 obj.get_name() 变成更优雅的 obj.name。但当你写过足够多的业务代码、框架代码或 SDK,就会发现:property 不是装饰器那么简单,它站在 Python 对象模型最核心的位置——描述符协议 descriptor protocol

理解 property 的底层机制,不只是为了“炫技”。它能帮助你写出更稳定的类接口,避免属性覆盖、递归调用、缓存失效等隐蔽问题,也能让你读懂 Django ORM、SQLAlchemy、Pydantic、dataclass、cached_property 等高级工具背后的共同思想。

Python 官方文档明确说明,property(fget=None, fset=None, fdel=None, doc=None) 会返回一个 property 属性对象;访问、赋值和删除该属性时,会分别触发 getter、setter 和 deleter。(Python documentation) 而从更底层看,property() 是通过描述符协议实现的,并且属于数据描述符 data descriptor。(Python documentation)


一、从最熟悉的写法开始

先看一个常见例子:

class User:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        """用户年龄"""
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, int):
            raise TypeError("age 必须是整数")
        if value < 0:
            raise ValueError("age 不能为负数")
        self._age = value

    @age.deleter
    def age(self):
        raise AttributeError("age 不允许删除")


u = User(18)

print(u.age)     # 调用 getter
u.age = 20       # 调用 setter
print(u.age)

del u.age        # 调用 deleter,抛出异常

表面上看,age 像普通属性;实际上,每一次 u.age 都不是直接读取 u.__dict__['age'],而是触发了 User.__dict__['age'] 这个 property 对象的 __get__ 方法。

这正是 property 的价值:对外保留属性访问的简洁性,对内保留方法调用的控制力


二、property 的本质:它是一个描述符对象

Python 中,只要一个对象实现了以下任意方法,它就可以被称为描述符:

__get__(self, instance, owner)
__set__(self, instance, value)
__delete__(self, instance)

官方描述符指南指出,描述符允许对象自定义属性的查找、存储和删除行为。(Python documentation) property 正是描述符的典型应用:它把“属性访问”转发给你定义的函数。

我们可以用纯 Python 模拟一个简化版 property

class MyProperty:
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc or getattr(fget, "__doc__", None)

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(instance)

    def __set__(self, instance, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(instance, value)

    def __delete__(self, instance):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(instance)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

官方描述符指南也给出了类似的纯 Python 等价实现,用来说明内置 property() 如何基于描述符协议工作。(Python documentation)

现在我们试着用它:

class Product:
    def __init__(self, price):
        self._price = price

    def get_price(self):
        return self._price

    def set_price(self, value):
        if value < 0:
            raise ValueError("价格不能为负数")
        self._price = value

    price = MyProperty(get_price, set_price)


p = Product(99)
print(p.price)   # 99
p.price = 120
print(p.price)   # 120

这段代码揭示了一个关键事实:@property 并不是魔法,它大致等价于:

price = property(get_price, set_price)

装饰器写法只是让代码更自然、更聚合。


三、属性访问时,Python 到底做了什么?

当你写下:

u.age

Python 并不是简单地去实例字典里找 age。大致流程可以理解为:

u.age
  ↓
调用 object.__getattribute__(u, "age")
  ↓
查找 type(u).__dict__["age"]
  ↓
发现它是 property,并实现了 __get__
  ↓
调用 property.__get__(u, User)
  ↓
内部再调用你写的 age(self)

官方数据模型文档说明,如果是实例绑定,a.x 会被转换为类似 type(a).__dict__['x'].__get__(a, type(a)) 的调用;如果是类绑定,A.x 则会变成类似 A.__dict__['x'].__get__(None, A) 的调用。(Python documentation)

你可以亲手验证:

class Demo:
    @property
    def value(self):
        return 42


d = Demo()

print(d.value)                 # 42
print(Demo.value)              # <property object at ...>
print(Demo.__dict__["value"])  # <property object at ...>

为什么 Demo.value 返回的是 property 对象本身?因为当通过类访问时,传入 __get__instanceNone。通常描述符会在这种情况下返回自身,方便 introspection 和调试。


四、为什么实例属性覆盖不了 property

这是 property 最容易被忽略、却非常重要的细节。

看这段代码:

class Person:
    @property
    def name(self):
        return "Alice"


p = Person()
p.__dict__["name"] = "Bob"

print(p.__dict__)  # {'name': 'Bob'}
print(p.name)      # Alice

明明实例字典里已经有了 "name": "Bob",为什么 p.name 仍然返回 "Alice"

原因是:property数据描述符。官方文档说明,只要描述符定义了 __set__()__delete__(),它就是数据描述符;数据描述符的优先级高于实例字典。property() 被实现为数据描述符,因此实例不能覆盖它的行为。(Python documentation)

属性查找优先级可以简化记忆为:

数据描述符
  > 实例 __dict__
  > 非数据描述符
  > 类属性
  > __getattr__

这解释了为什么 property 常被用来做校验、延迟计算和兼容性封装:只要类上定义了 property,实例层面很难绕过它。


五、只读属性不是“没有 setter”那么简单

很多人写只读属性:

class Config:
    @property
    def version(self):
        return "1.0.0"

然后测试:

c = Config()
print(c.version)

c.version = "2.0.0"

会得到:

AttributeError: property 'version' of 'Config' object has no setter

这里不是 Python 禁止修改字符串,也不是实例没有 __dict__,而是 property.__set__ 被调用了,但发现没有 fset,于是抛出异常。

这也意味着:只读 property 依然是数据描述符。即使你没有显式写 setter,内置 property 对象仍然有 __set__ 逻辑,只是该逻辑会报错。


六、工程实践:用 property 保护对象不变量

一个类最怕什么?不是属性多,而是属性之间失去一致性。

比如订单金额:

class Order:
    def __init__(self, unit_price, quantity):
        self.unit_price = unit_price
        self.quantity = quantity

    @property
    def total(self):
        return self.unit_price * self.quantity

这里 total 不应该被存储,因为它是派生值。如果你把它存在实例字典里:

self.total = unit_price * quantity

后续只要 unit_pricequantity 改变,total 就可能过期。用 property 可以保证每次访问都基于最新状态计算。

再看一个更完整的例子:

class Account:
    def __init__(self, balance):
        self._balance = 0
        self.balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("余额必须是数字")
        if value < 0:
            raise ValueError("余额不能为负")
        self._balance = value

    def withdraw(self, amount):
        self.balance = self.balance - amount


account = Account(100)
account.withdraw(30)
print(account.balance)  # 70

account.withdraw(100)   # ValueError: 余额不能为负

注意 withdraw 内部仍然使用 self.balance = ...,而不是直接改 self._balance。这是一条很实用的规则:类内部也尽量走同一套校验入口,否则 setter 就会形同虚设。


七、常见陷阱:递归调用

初学者最常见的错误是这样写:

class User:
    @property
    def name(self):
        return self.name

    @name.setter
    def name(self, value):
        self.name = value

这会无限递归。

原因很简单:self.name 会再次触发 property.__get__property.__set__。正确做法是使用内部存储名,例如 _name

class User:
    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("name 不能为空")
        self._name = value

约定俗成地,_name 表示内部实现细节,name 表示对外公开接口。


八、property 与缓存:别把昂贵计算重复做

有些属性计算成本很高,例如读取文件、解析配置、执行统计:

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

    @property
    def summary(self):
        print("正在计算 summary...")
        return {
            "count": len(self.rows),
            "max": max(self.rows),
            "min": min(self.rows),
        }


r = Report([3, 1, 9])
print(r.summary)
print(r.summary)

每次访问都会重新计算。如果数据不会变,可以手动缓存:

class Report:
    def __init__(self, rows):
        self.rows = rows
        self._summary_cache = None

    @property
    def summary(self):
        if self._summary_cache is None:
            print("首次计算 summary...")
            self._summary_cache = {
                "count": len(self.rows),
                "max": max(self.rows),
                "min": min(self.rows),
            }
        return self._summary_cache

    def add_row(self, value):
        self.rows.append(value)
        self._summary_cache = None

这类设计要特别注意缓存失效。只要原始数据变化,就必须清空缓存,否则属性返回的就是陈旧结果。


九、什么时候应该用 property

适合使用 property 的场景:

  1. 需要对赋值做校验,例如年龄、价格、状态码。
  2. 属性是由其他字段计算出来的,例如订单总价、面积、全名。
  3. 想保持 API 向后兼容:原本是公开字段,后来需要加逻辑。
  4. 需要懒加载或缓存昂贵计算结果。
  5. 希望隐藏内部存储结构,例如从 _first_name_last_name 暴露 full_name

不适合使用 property 的场景:

  1. 计算非常耗时,却看起来像普通字段,容易误导调用者。
  2. getter 或 setter 有明显副作用,例如发网络请求、写数据库。
  3. 逻辑过于复杂,应该使用显式方法名表达意图。
  4. 只是为了“看起来高级”,没有实际封装收益。

一个温和但重要的建议是:属性访问应该给人“便宜、稳定、无惊喜”的感觉。如果一次 obj.status 会偷偷调用远程接口,那它更适合叫 obj.fetch_status()


十、进阶理解:property、封装与 Python 哲学

在 Java、C# 等语言里,getter/setter 很常见:

user.getName();
user.setName("Alice");

Python 更鼓励直接、清晰的属性访问:

user.name = "Alice"
print(user.name)

有人担心:直接暴露字段,以后要加校验怎么办?

property 正是 Python 给出的答案:先写简单代码,等需要控制时,再无痛升级为受管属性。

# 第一版
class User:
    def __init__(self, name):
        self.name = name

后来需要校验:

# 第二版
class User:
    def __init__(self, name):
        self.name = name

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

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

外部调用代码完全不变:

user.name = "Alice"

这就是 property 最优雅的地方:它保护了未来的演进空间,也保护了今天的简洁表达


十一、调试与自省:如何看见 property 的真实形态?

你可以直接查看类字典:

class Book:
    @property
    def title(self):
        return "Python Internals"


print(Book.__dict__["title"])
print(Book.__dict__["title"].fget)
print(Book.__dict__["title"].fset)
print(Book.__dict__["title"].fdel)

官方内置函数文档说明,property 对象具有 fgetfsetfdel 属性,对应构造时传入的访问器函数;gettersetterdeleter 方法可作为装饰器使用,并会创建设置了相应访问器函数的 property 副本。(Python documentation)

从 Python 3.13 开始,property 对象还拥有可在运行时修改的 __name__ 属性。(Python documentation) 这对调试、框架元编程和文档生成都有帮助。


十二、总结:property 是 Python 对象模型的一扇窗

如果只从语法层面看,property 是一个让方法变属性的装饰器;如果从对象模型看,它是描述符协议的经典实现;如果从工程实践看,它是一种让 API 保持简洁、稳定、可演进的封装手段。

请记住三个核心点:

1. property 本质上是描述符对象
2. property 是数据描述符,优先级高于实例 __dict__
3. getter / setter / deleter 分别控制读取、赋值和删除

当你真正理解 property,你会重新看待 Python 的“优雅”。它不是少写几行代码,而是在简单表象之下,保留足够强大的扩展能力。

下一次写类时,不妨问自己三个问题:

  • 这个属性是否需要校验?
  • 这个属性是否应该由其他字段计算得出?
  • 这个字段未来是否可能从“直接存储”演进为“受控访问”?

如果答案是肯定的,property 也许就是最自然、最 Pythonic 的选择。

关键词建议:Python编程、Python教程、Python实战、Python最佳实践、property底层机制、Python描述符、Python面向对象。

参考资料:Python 官方内置函数文档、Python 数据模型文档、Python Descriptor HowTo Guide。(Python documentation)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

铭渊老黄

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

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

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

打赏作者

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

抵扣说明:

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

余额充值