Tuesday 24 October 2023

Python Lazy Property

Recently I've needed a sort of Lazy Property and found out that recent versions of Python feature the functools.cached_property decorator. From the documentation

Transform a method of a class into a property whose value is computed once and then cached as a normal attribute for the life of the instance. Similar to property(), with the addition of caching.

The mechanics of cached_property() are somewhat different from property(). A regular property blocks attribute writes unless a setter is defined. In contrast, a cached_property allows writes.

The cached_property decorator only runs on lookups and only when an attribute of the same name doesn’t exist. When it does run, the cached_property writes to the attribute with the same name. Subsequent attribute reads and writes take precedence over the cached_property method and it works like a normal attribute.

To understand how the property and cached_property can have such different behaviour this discussion is a good help. It's about a previous implementation by werkzeurg but it should be mainly valid for the funtools version. First of all we have to be aware of the complex attribute lookup process in python (that I partially mentioned in my post about descriptors. It's well explained here

instance attributes take precedence over class attributes – with a caveat. Contrary to what a quite lot of people think, this is not always the case, and sometimes class attributes shadow the instance attributes. Enter descriptors.

- Data descriptors (overriding) – they define the __set__() and/or the __delete__() method (but normally __set__() as well) and take precedence over instance attributes.

- Non-data descriptors (non-overriding) – they define only the __get__() method and are shadowed by an instance attribute of the same name.

Let's see some code:


@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
"""

So a normal @property decorator creates a Data descriptor. It has __get__, __set__ and __delete__ regardless of whether you define or not the .setter and .deleter. If you have not defined them, __set__ and __delete__ will throw an exception.

And now with the cached_property:


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'}
"""

So a @funtools.cached_property creates a Non-data descriptor. It only has __get__, so because of that when it creates an attribute with that same name (the first time you access the property) this instance attribute will shadow the property in the class in the next lookups.

It's very nice that you can delete that attribute that has been cached in the instance, and this way "clear the cache".


# 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
"""

Notice that we can set (force) the value in the instance (both before and after it's been read-cached for the first time), skipping the "get calculation".


# 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
"""

If you try to delete the attribute from the instance before it has been cached you'll get an exception, so deleting works the same as setting, if it's not a Data descriptor the set and delete will be tried in the instance. On the other side, you can delete the property from the class, which will cause problems in new instances:


# 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'
"""

No comments:

Post a Comment