Hone logo
Hone
Problems

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:

  1. Descriptor Protocol: Your CachedProperty class must implement the descriptor protocol, specifically the __get__ method.
  2. 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.
  3. 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.
  4. 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.
  5. 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_attribute will retrieve the value directly from instance.__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 the CachedProperty descriptor 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 CachedProperty class 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 instance argument in __get__ will be None when 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 instance argument will be the object itself. This is where you'll interact with instance.__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 accessing func.__name__. For simplicity, func.__name__ is usually sufficient for this exercise.
Loading editor...
python