别再只把 `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)
一、从最熟悉的写法开始
先看一个常见例子:
classUser:def__init__(self,age):self._age=age@propertydefage(self):"""用户年龄"""returnself._age@age.setterdefage(self,value):ifnotisinstance(value,int):raiseTypeError("age 必须是整数")ifvalue<0:raiseValueError("age 不能为负数")self._age=value@age.deleterdefage(self):raiseAttributeError("age 不允许删除")u=User(18)print(u.age)# 调用 getteru.age=20# 调用 setterprint(u.age)delu.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:
classMyProperty:def__init__(self,fget=None,fset=None,fdel=None,doc=None):self.fget=fget self.fset=fset self.fdel=fdel self.__doc__=docorgetattr(fget,"__doc__",None)def__get__(self,instance,owner=None):ifinstanceisNone:returnselfifself.fgetisNone:raiseAttributeError("unreadable attribute")returnself.fget(instance)def__set__(self,instance,value):ifself.fsetisNone:raiseAttributeError("can't set attribute")self.fset(instance,value)def__delete__(self,instance):ifself.fdelisNone:raiseAttributeError("can't delete attribute")self.fdel(instance)defgetter(self,fget):returntype(self)(fget,self.fset,self.fdel,self.__doc__)defsetter(self,fset):returntype(self)(self.fget,fset,self.fdel,self.__doc__)defdeleter(self,fdel):returntype(self)(self.fget,self.fset,fdel,self.__doc__)官方描述符指南也给出了类似的纯 Python 等价实现,用来说明内置property()如何基于描述符协议工作。(Python documentation)
现在我们试着用它:
classProduct:def__init__(self,price):self._price=pricedefget_price(self):returnself._pricedefset_price(self,value):ifvalue<0:raiseValueError("价格不能为负数")self._price=value price=MyProperty(get_price,set_price)p=Product(99)print(p.price)# 99p.price=120print(p.price)# 120这段代码揭示了一个关键事实:@property并不是魔法,它大致等价于:
price=property(get_price,set_price)装饰器写法只是让代码更自然、更聚合。
三、属性访问时,Python 到底做了什么?
当你写下:
u.agePython 并不是简单地去实例字典里找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)
你可以亲手验证:
classDemo:@propertydefvalue(self):return42d=Demo()print(d.value)# 42print(Demo.value)# <property object at ...>print(Demo.__dict__["value"])# <property object at ...>为什么Demo.value返回的是 property 对象本身?因为当通过类访问时,传入__get__的instance是None。通常描述符会在这种情况下返回自身,方便 introspection 和调试。
四、为什么实例属性覆盖不了property?
这是property最容易被忽略、却非常重要的细节。
看这段代码:
classPerson:@propertydefname(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”那么简单
很多人写只读属性:
classConfig:@propertydefversion(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保护对象不变量
一个类最怕什么?不是属性多,而是属性之间失去一致性。
比如订单金额:
classOrder:def__init__(self,unit_price,quantity):self.unit_price=unit_price self.quantity=quantity@propertydeftotal(self):returnself.unit_price*self.quantity这里total不应该被存储,因为它是派生值。如果你把它存在实例字典里:
self.total=unit_price*quantity后续只要unit_price或quantity改变,total就可能过期。用property可以保证每次访问都基于最新状态计算。
再看一个更完整的例子:
classAccount:def__init__(self,balance):self._balance=0self.balance=balance@propertydefbalance(self):returnself._balance@balance.setterdefbalance(self,value):ifnotisinstance(value,(int,float)):raiseTypeError("余额必须是数字")ifvalue<0:raiseValueError("余额不能为负")self._balance=valuedefwithdraw(self,amount):self.balance=self.balance-amount account=Account(100)account.withdraw(30)print(account.balance)# 70account.withdraw(100)# ValueError: 余额不能为负注意withdraw内部仍然使用self.balance = ...,而不是直接改self._balance。这是一条很实用的规则:类内部也尽量走同一套校验入口,否则 setter 就会形同虚设。
七、常见陷阱:递归调用
初学者最常见的错误是这样写:
classUser:@propertydefname(self):returnself.name@name.setterdefname(self,value):self.name=value这会无限递归。
原因很简单:self.name会再次触发property.__get__或property.__set__。正确做法是使用内部存储名,例如_name:
classUser:def__init__(self,name):self.name=name@propertydefname(self):returnself._name@name.setterdefname(self,value):ifnotvalue:raiseValueError("name 不能为空")self._name=value约定俗成地,_name表示内部实现细节,name表示对外公开接口。
八、property与缓存:别把昂贵计算重复做
有些属性计算成本很高,例如读取文件、解析配置、执行统计:
classReport:def__init__(self,rows):self.rows=rows@propertydefsummary(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)每次访问都会重新计算。如果数据不会变,可以手动缓存:
classReport:def__init__(self,rows):self.rows=rows self._summary_cache=None@propertydefsummary(self):ifself._summary_cacheisNone:print("首次计算 summary...")self._summary_cache={"count":len(self.rows),"max":max(self.rows),"min":min(self.rows),}returnself._summary_cachedefadd_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 给出的答案:先写简单代码,等需要控制时,再无痛升级为受管属性。
# 第一版classUser:def__init__(self,name):self.name=name后来需要校验:
# 第二版classUser:def__init__(self,name):self.name=name@propertydefname(self):returnself._name@name.setterdefname(self,value):ifnotvalue.strip():raiseValueError("name 不能为空")self._name=value外部调用代码完全不变:
user.name="Alice"这就是property最优雅的地方:它保护了未来的演进空间,也保护了今天的简洁表达。
十一、调试与自省:如何看见 property 的真实形态?
你可以直接查看类字典:
classBook:@propertydeftitle(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)
