I’ve been recently working with a concept similar to a Lazy Property and discovered that newer Python versions offer the @cached_property decorator (as documented on functools.cached_property). This decorator transforms a class method into a property. The value of this property is calculated once, then stored as a regular attribute for the lifetime of the instance. It essentially functions like the @property decorator but with added caching.
It’s worth noting that @cached_property operates differently than @property. While a standard property prevents attribute writes without a defined setter, a @cached_property allows them.
The @cached_property decorator is only called during attribute lookups when an attribute with the same name doesn’t exist. When executed, it writes to the attribute with the matching name. Subsequent reads and writes of this attribute take priority, making it behave like a regular attribute.
To grasp the differences in behavior between @property and @cached_property, this discussion provides valuable insights. Although it refers to a previous implementation by Werkzeug, the core principles still apply to the functools version. Understanding Python’s complex attribute lookup process is crucial, as partially discussed in my post about descriptors and thoroughly explained at here.
In essence:
- Instance attributes usually take precedence over class attributes. However, this isn’t always absolute, as class attributes can sometimes overshadow instance attributes. This is where descriptors come in.
- Data descriptors, which define the
__set__() and/or __delete__() methods (usually both, particularly __set__()), override instance attributes. - Non-data descriptors only define the
__get__() method and are overshadowed by instance attributes of the same name.
Let’s illustrate with some code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| @dataclass
class Book:
name: str
@property
def short_name(self):
return self.name[0:3]
def _calculate_isbn(self):
print("calculating ISBN")
return f"ISBN{datetime.now():%Y-%m-%d_%H%M%S}"
@cached_property
def isbn(self):
return self._calculate_isbn()
print("- Normal Property")
b1 = Book("Gattaca")
try:
b1.short_name = "AAA"
except BaseException as ex:
print(f"Exception: {ex}")
#Exception: can't set attribute 'short_name'
try:
del b1.short_name
except BaseException as ex:
print(f"Exception: {ex}")
#Exception: can't delete attribute 'short_name'
#I can set an attribute in the dictionary with that same name
b1.__dict__["short_name"] = "short"
print(f"b1.__dict__['short_name']: {b1.__dict__['short_name']}")
# but the normal attribute lookup will get the property
print(f"b1.short_name: {b1.short_name}")
#I can delete it from the dictionary
del b1.__dict__["short_name"]
"""
- Normal Property
Exception: can't set attribute 'short_name'
Exception: can't delete attribute 'short_name'
b1.__dict__['short_name']: short
b1.short_name: Gat
"""
|
The @property decorator creates a Data descriptor with __get__(), __set__(), and __delete__() methods, regardless of whether .setter and .deleter are defined. Without them, __set__() and __delete__() raise exceptions.
Now, let’s examine @cached_property:
1
2
3
4
5
6
7
8
9
10
11
| print("\n- Cached Property")
b1 = Book("Atomka")
print(f"b1: {b1.isbn}")
# the first access has set the value in the instance dict
print(f"b1.__dict__: {b1.__dict__}")
"""
calculating ISBN
b1: ISBN2023-10-24_210718
b1.__dict__: {'name': 'Atomka', 'isbn': 'ISBN2023-10-24_222659'}
"""
|
The @functools.cached_property creates a Non-data descriptor with only a __get__() method. When the property is accessed for the first time, it creates an attribute with the same name in the instance. This instance attribute then shadows the class property in subsequent lookups.
Being able to delete the cached attribute from the instance, effectively “clearing the cache,” is a useful feature:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # force the property to be recalculated
print("delete to force the property to be recalculated")
del b1.isbn
print(f"b1.__dict__: {b1.__dict__}")
# it gets recalculated
print(f"b1: {b1.isbn}")
"""
delete to force the property to be recalculated
b1.__dict__: {'name': 'Atomka'}
calculating ISBN
b1: ISBN2023-10-24_210718
"""
|
Note that you can manually set the attribute’s value in the instance (both before and after the first cached read), bypassing the “get calculation”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| # we can manually set the attribute, overwriting what had been calculated, maybe this is not so good
print("manually setting b1")
b1.isbn = "BBB"
print(f"b1: {b1.isbn}")
# force the property to be recalculated
print("delete to force the property to be recalculated")
del b1.isbn
print(f"b1: {b1.isbn}")
b3 = Book("Ange Rouge")
# manually set the value before its first read, so skipping the "calculation"
print("manually setting b3")
b3.isbn = "CCC"
print(f"b3: {b3.isbn}")
"""
manually setting b1
b1: BBB
delete to force the property to be recalculated
calculating ISBN
b1: ISBN2023-10-24_222659
manually setting b3
b3: CCC
"""
|
Attempting to delete the attribute from the instance before it’s cached results in an exception. Deletion behaves like setting: if it’s not a Data descriptor, set and delete operations are attempted on the instance. Conversely, you can delete the property from the class, but this will lead to issues with new instances:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| # if I have not cached the value yet and try to delete it from the instance I get an exception
b2 = Book("Syndrome E")
try:
del b2.isbn
except Exception as ex:
print(f"Exception: {ex}")
#Exception: isbn
# I can delete the property itself from the class
print("del Book.isbn")
del Book.isbn
# so now I still have the one that was cached in the instance
print(f"b1: {b1.isbn}")
# but I no longer have the cached property in the class
b2 = Book("Syndrome E")
try:
print(f"b2: {b2.isbn}")
except Exception as ex:
print(f"Exception: {ex}")
#Exception: 'Book' object has no attribute 'isbn'
"""
Exception: isbn
del Book.isbn
b1: ISBN2023-10-24_222659
Exception: 'Book' object has no attribute 'isbn'
"""
|