别再只把 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__ 的 instance 是 None。通常描述符会在这种情况下返回自身,方便 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_price 或 quantity 改变,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 的场景:
- 需要对赋值做校验,例如年龄、价格、状态码。
- 属性是由其他字段计算出来的,例如订单总价、面积、全名。
- 想保持 API 向后兼容:原本是公开字段,后来需要加逻辑。
- 需要懒加载或缓存昂贵计算结果。
- 希望隐藏内部存储结构,例如从
_first_name和_last_name暴露full_name。
不适合使用 property 的场景:
- 计算非常耗时,却看起来像普通字段,容易误导调用者。
- getter 或 setter 有明显副作用,例如发网络请求、写数据库。
- 逻辑过于复杂,应该使用显式方法名表达意图。
- 只是为了“看起来高级”,没有实际封装收益。
一个温和但重要的建议是:属性访问应该给人“便宜、稳定、无惊喜”的感觉。如果一次 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 对象具有 fget、fset 和 fdel 属性,对应构造时传入的访问器函数;getter、setter、deleter 方法可作为装饰器使用,并会创建设置了相应访问器函数的 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)


100

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



