Implement a Cached Property Decorator in Python
Many object-oriented programs have computationally expensive methods that are called frequently. To avoid redundant calculations, we often want to cache the result of such a method so that it's computed only once. This challenge asks you to create a Python descriptor that acts as a cached property, mimicking the behavior of functools.cached_property.
Problem Description
You need to implement a descriptor class named CachedProperty. This descriptor should behave like a regular attribute when accessed for the first time, but it should store (cache) the result of the property's getter method and return the cached value on subsequent accesses.
Key Requirements:
- Descriptor Protocol: Your
CachedPropertyclass must implement the descriptor protocol, specifically the__get__method. - First Access Computation: On the first access to the property on an instance of a class using
CachedProperty, the decorated method should be called, and its return value should be stored. - Subsequent Access Caching: On all subsequent accesses to the same property on the same instance, the stored (cached) value should be returned directly, without re-executing the decorated method.
- Instance Binding: The cache should be specific to each instance of the class. If you have multiple instances of the same class, each instance should maintain its own separate cache for the property.
- Deletion Handling (Optional but Recommended): Consider how to handle deletion of the cached property. A reasonable approach would be to remove the cached value, allowing the property to be recomputed on the next access.
Expected Behavior:
When CachedProperty is used as a decorator on a method within a class:
- The first time the method is accessed via an instance (e.g.,
instance.cached_attribute), the method will execute, and its return value will be stored in the instance's__dict__. - Subsequent accesses to
instance.cached_attributewill retrieve the value directly frominstance.__dict__without executing the original method. - If the cached value is deleted from the instance (e.g.,
del instance.cached_attribute), the next access should recompute the value.
Edge Cases:
- Class Access: Accessing the cached property directly on the class (e.g.,
MyClass.cached_attribute) should return theCachedPropertydescriptor instance itself, not trigger the method execution. - Multiple Instances: Ensure that changes to the cached property on one instance do not affect other instances.
Examples
Example 1: Simple Cached Property
import time
class ExpensiveCalculation:
def __init__(self, value):
self.value = value
self._calculation_count = 0
# Assume CachedProperty is implemented elsewhere and imported
# from my_decorators import CachedProperty
# For demonstration, we'll assume it's defined here:
class CachedProperty:
def __init__(self, func):
self.func = func
self.attr_name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self # Return descriptor itself when accessed on class
# Check if value is already cached
if self.attr_name in instance.__dict__:
print(f"Returning cached value for {self.attr_name}")
return instance.__dict__[self.attr_name]
# Compute and cache the value
print(f"Computing value for {self.attr_name}...")
value = self.func(instance)
instance.__dict__[self.attr_name] = value
return value
def __set__(self, instance, value):
# Allow setting, but it might overwrite cached value
instance.__dict__[self.attr_name] = value
def __delete__(self, instance):
# Remove cached value
if self.attr_name in instance.__dict__:
del instance.__dict__[self.attr_name]
print(f"Cache cleared for {self.attr_name}")
@CachedProperty
def computed_value(self):
self._calculation_count += 1
print(f"Executing expensive_computation. Count: {self._calculation_count}")
time.sleep(0.1) # Simulate expensive work
return self.value * 2
# --- Demonstration ---
obj1 = ExpensiveCalculation(10)
print("--- First Access (obj1) ---")
result1 = obj1.computed_value
print(f"Result: {result1}\n")
# Expected Output:
# Computing value for computed_value...
# Executing expensive_computation. Count: 1
# Result: 20
print("--- Second Access (obj1) ---")
result2 = obj1.computed_value
print(f"Result: {result2}\n")
# Expected Output:
# Returning cached value for computed_value
# Result: 20
print("--- Third Access (obj1) ---")
result3 = obj1.computed_value
print(f"Result: {result3}\n")
# Expected Output:
# Returning cached value for computed_value
# Result: 20
obj2 = ExpensiveCalculation(20) # Another instance
print("--- First Access (obj2) ---")
result4 = obj2.computed_value
print(f"Result: {result4}\n")
# Expected Output:
# Computing value for computed_value...
# Executing expensive_computation. Count: 1
# Result: 40
print("--- Second Access (obj2) ---")
result5 = obj2.computed_value
print(f"Result: {result5}\n")
# Expected Output:
# Returning cached value for computed_value
# Result: 40
Example 2: Deleting the Cached Property
# Continuing from Example 1's class definition...
print("--- Deleting Cached Property (obj1) ---")
del obj1.computed_value
print("\n")
# Expected Output:
# Cache cleared for computed_value
print("--- Accessing after Deletion (obj1) ---")
result6 = obj1.computed_value
print(f"Result: {result6}\n")
# Expected Output:
# Computing value for computed_value...
# Executing expensive_computation. Count: 2
# Result: 20
Constraints
- The
CachedPropertyclass must be implemented entirely in Python. - The solution should not rely on external libraries like
functools(you are reimplementing its functionality). - The descriptor should be memory-efficient. Caching the computed value directly on the instance is the intended approach.
- The
__get__,__set__, and__delete__methods of the descriptor should handle their respective operations correctly.
Notes
- Remember that descriptors work by intercepting attribute access on an instance. The
__get__(self, instance, owner)method is crucial here. - The
instanceargument in__get__will beNonewhen the attribute is accessed on the class itself (e.g.,MyClass.my_property). You should handle this case by returning the descriptor instance. - When accessed on an instance, the
instanceargument will be the object itself. This is where you'll interact withinstance.__dict__to store and retrieve cached values. - Consider using the
__set_name__method (available in Python 3.6+) to automatically get the attribute name, or store it manually during__init__by accessingfunc.__name__. For simplicity,func.__name__is usually sufficient for this exercise.