Python中@property与@cached_property的实现
作者:青衫客36
在 Python 中,属性不仅仅是简单的数据字段,它们还可以是动态计算的值,并且可以封装读写逻辑、懒加载、缓存控制,甚至权限检查。本文将探讨 Python 中与属性控制相关的四个关键装饰器:
- @property
- @x.setter
- @x.deleter
- @cached_property
一、@property—— 将方法变成属性
基本用法
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@property
def area(self):
return 3.1416 * self._radius ** 2
上述代码中,radius 和 area 都是方法,但通过 @property 装饰后,可以像普通属性一样使用:
c = Circle(5) print(c.radius) # 5 print(c.area) # 78.54
优势
| 方法调用形式 | 属性访问形式 | 好处 |
|---|---|---|
| obj.get_value() | obj.value | 语义清晰、接口简洁 |
二、@x.setter和@x.deleter—— 为属性添加写入与删除能力
🧩 写入控制:@x.setter
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("Name cannot be empty")
self._name = value
@name.setter 允许我们对 name 属性进行赋值时添加校验逻辑:
p = Person("Alice")
p.name = "Bob" # 正常
p.name = "" # 抛出异常
🧹 删除控制:@x.deleter
class Secret:
def __init__(self):
self._token = "abc123"
@property
def token(self):
return self._token
@token.deleter
def token(self):
print("Token deleted")
del self._token
s = Secret() del s.token # 调用自定义的删除逻辑
三、@cached_property—— 懒加载 + 缓存
在处理高成本计算时,我们通常希望第一次调用时计算,之后读取缓存结果。这正是 @cached_property 的应用场景。
示例:懒加载计算属性
from functools import cached_property
class Expensive:
@cached_property
def result(self):
print("Calculating...")
return sum(i * i for i in range(10**6))
obj = Expensive()
print(obj.result) # 第一次:执行计算
print(obj.result) # 第二次:返回缓存结果
四、cached_property的底层原理
源码如下:
class cached_property:
def __init__(self, func):
self.func = func
self.attrname = None
self.__doc__ = func.__doc__
self.lock = RLock()
def __set_name__(self, owner, name):
if self.attrname is None:
self.attrname = name
elif name != self.attrname:
raise TypeError(
"Cannot assign the same cached_property to two different names "
f"({self.attrname!r} and {name!r})."
)
def __get__(self, instance, owner=None):
if instance is None:
return self
if self.attrname is None:
raise TypeError(
"Cannot use cached_property instance without calling __set_name__ on it.")
try:
cache = instance.__dict__
except AttributeError: # not all objects have __dict__ (e.g. class defines slots)
msg = (
f"No '__dict__' attribute on {type(instance).__name__!r} "
f"instance to cache {self.attrname!r} property."
)
raise TypeError(msg) from None
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
with self.lock:
# check if another thread filled cache while we awaited lock
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
val = self.func(instance)
try:
cache[self.attrname] = val
except TypeError:
msg = (
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
f"does not support item assignment for caching {self.attrname!r} property."
)
raise TypeError(msg) from None
return val
cached_property实现了第一次调用时执行函数,并将结果存入 __dict__,之后每次访问直接从缓存中获取。
然而,笔者在debug中发现self.attrname会变成函数名,这是为什么呢?
原来从 Python 3.6 开始,描述符类可以实现 __set_name__(self, owner, name) 方法,这个方法在类定义时被自动调用,用于告诉描述符它被赋值到哪个类属性上。因为 cached_property 是一个描述符,并实现了 set_name 方法。
__set_name__(self, owner, name) 中的参数:
- owner: 拥有这个属性的类(类本身)
- name: 被绑定到类上的属性名(字符串)
当 @cached_property 被装饰函数时(如 @cached_property def result(self): …),
Python 会在类创建期间(class 定义执行时)自动调用这个 __set_name__,把方法名(如 “result”)作为 name 参数传进来,从而完成 self.attrname = “result” 的绑定。
为什么这么设计?
这为 cached_property 提供了两个关键能力:
- 动态识别属性名,避免手动硬编码;
- 支持多个 cached_property 实例在一个类中使用,每个都知道自己的名字。
能力 1:动态识别属性名 ——避免手动写死名字
当我们用 @cached_property 装饰一个方法时,Python 自动把这个方法的名字(属性名)告诉它,让它知道“我是绑定在 xxx 上的”。
就像别人告诉你:“你现在的代号叫 result,记住它。”
这样一来,cached_property 就知道以后把缓存结果存在 obj.__dict__["result"] 里,而不是自己去写死 'result' 这个字符串。
如果没有这个能力,我们需要手动这样写:
class cached_property:
def __init__(self, name, func):
self.func = func
self.attrname = name # 必须手动传入
...
然后使用时也得这么写:
class MyClass:
result = cached_property("result", lambda self: 42)
不仅麻烦,而且容易出错(比如名字不一致),所以 Python 自动通过 __set_name__() 把名字传进去,非常方便且安全。
能力 2:支持多个cached_property实例在一个类中使用,每个都知道自己的名字
假设有以下一个类:
class User:
@cached_property
def profile(self): ...
@cached_property
def permissions(self): ...
这时 Python 会自动调用 __set_name__() 两次:
绑定 profile → attrname = "profile"
绑定 permissions → attrname = "permissions"
每个 cached_property 实例都知道它叫啥,它不会混淆。也就是说:
self.attrname = "profile"存 profile 的结果在__dict__["profile"]self.attrname = "permissions"存 permissions 的结果在__dict__["permissions"]
如果没有这个能力会怎么样?
- 我们就必须手动传名字(见上面示例)
- 容易写错,比如两个地方都写
cached_property("result", ...) - 系统就会误把
profile的缓存存在了result里
打个比方,cached_property 就像快递员,快递员一上岗(类定义时),老板(Python 解释器)告诉他:“你负责投递 result”,他以后就知道要把东西塞进instance.__dict__["result"],而不是瞎塞,每个快递员有自己对应的“地址标签”,不会互相搞错。
| 能力 | 举例 | 通俗比喻 |
|---|---|---|
| 动态识别属性名 | 自动绑定 方法 | Python 帮你贴了“标签” |
| 多实例支持 | 类中定义多个 @cached_property | 每个快递员有独立投递地址 |
🔒 安全保护机制
在标准库实现中,还有如下防御逻辑:
elif name != self.attrname:
raise TypeError(
"Cannot assign the same cached_property to two different names "
f"({self.attrname!r} and {name!r})."
)
防止将同一个 cached_property 实例赋值到多个属性名:
shared = cached_property(lambda self: 123)
class Foo:
a = shared
b = shared # ❌ 抛出异常
执行时会触发:
TypeError: Cannot assign the same cached_property to two different names ('a' and 'b')
为什么会报错?因为 __set_name__() 是在类定义时自动调用的:
def __set_name__(self, owner, name):
if self.attrname is None:
self.attrname = name # 第一次绑定成功
elif name != self.attrname:
raise TypeError(...) # 第二次绑定,不一致就报错
为什么不能这么做?因为cached_property 的核心机制是:
instance.__dict__[self.attrname] = self.func(instance)
如果 self.attrname 是模糊不定的、反复变,那它根本不知道应该往哪里缓存结果 —— 会导致缓存覆盖、冲突、错乱。
正确做法:为每个属性写一个新的实例
class Foo:
@cached_property
def a(self):
return 1
@cached_property
def b(self):
return 2
每次 @cached_property 都会创建一个新的实例,这就没问题了。
总结
| 装饰器 | 是否只读 | 是否缓存 | 是否可写 | 是否可删除 | 用途 |
|---|---|---|---|---|---|
| @property | ✅ | ❌ | ❌ | ❌ | 普通只读属性(动态计算) |
| @x.setter | ❌ | ❌ | ✅ | ❌ | 提供 setter 逻辑 |
| @x.deleter | ❌ | ❌ | ❌ | ✅ | 提供属性删除逻辑 |
| @cached_property | ✅ | ✅ | ❌ | ✅ (用 del) | 高成本、只读缓存型属性(如加载模型) |
Python 的属性系统远比它表面上的 obj.x 更加强大。@property 提供了优雅的接口封装,@setter/@deleter 实现了对属性的写删控制,而 @cached_property 则将惰性计算与缓存机制优雅地融合到属性系统之中。
到此这篇关于Python中@property与@cached_property的实现的文章就介绍到这了,更多相关Python @property @cached_property内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
