Decorator and Cache

Revisit the important concept of decorator in Python.

Decorator is so beautiful and functional that it saves time and makes the codes succinct. The timer decorator is a classical example; skip detailed explanation of timer decorator here since it’s cliché.

I would like to dive into Python’s functools module, which comes with

  1. lru_cache
  2. partial
  3. reduce
  4. cmp_to_key
  5. total_ordering
  6. wraps
  7. singledispatch
  8. cached_property
  9. update_wrapper
  10. identity
  11. cache

It’s worth detailing use of cache and wraps!

First, wraps preserve the meta data of the original funciton

from functools import wraps

def my_decorator(func):
    @wraps(func)  # This preserves the original function's metadata
    def wrapper(*args, **kwargs):
        print(f"Calling function: {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper

@my_decorator
def add(a, b):
    """Adds two numbers."""
    return a + b

# Using the decorated function
result = add(3, 4)
print(f"Result: {result}")

# Checking the function's metadata
print(f"Function name: {add.__name__}")
print(f"Function docstring: {add.__doc__}")

Cache to avoid repeated calculations. here is a code illustration

from functools import cache
import time

@cache
def slow_fib(n):
    """Compute the nth Fibonacci number (inefficiently)."""
    time.sleep(1)  # Simulate a slow calculation
    if n < 2:
        return n
    return slow_fib(n - 1) + slow_fib(n - 2)

# Test the caching behavior
start_time = time.time()
print(slow_fib(10))  # First call, should take time
print(f"First call took: {time.time() - start_time:.2f} seconds")

start_time = time.time()
print(slow_fib(10))  # Second call, should be instant due to caching
print(f"Second call took: {time.time() - start_time:.2f} seconds")

start_time = time.time()
print(slow_fib(9))   # This call will be slow because it's not cached yet
print(f"Third call took: {time.time() - start_time:.2f} seconds")

start_time = time.time()
print(slow_fib(9))   # This call will be instant due to caching
print(f"Fourth call took: {time.time() - start_time:.2f} seconds")
55
First call took: 10.00 seconds
55
Second call took: 0.00 seconds
34
Third call took: 1.00 seconds
34
Fourth call took: 0.00 seconds

How does cache decorator built-in in Python accomplish this? it’s due to Memoization

Retrieval of Cached Results: On subsequent calls with the same arguments, the decorator first checks if the result is already in the cache. If it is, it returns the cached value instead of recalculating.

Storage of Results: When a function decorated with @cache is called, it stores the result in a dictionary (or a similar structure) using the function’s arguments as keys.

In actual practice, caching can improve the performance of web applications by reducing the need for repeated expensive computations or database queries. This pattern is commonly found in many codebases that require optimization for speed and efficiency.

from flask import Flask, jsonify
from functools import cache
import time

app = Flask(__name__)

# Simulate a slow database query or computation
@cache
def get_expensive_data(query):
    time.sleep(2)  # Simulate a delay
    # In a real scenario, you'd fetch data from a database or perform a complex calculation
    return {"data": f"Result for {query}"}

@app.route('/data/<query>')
def fetch_data(query):
    result = get_expensive_data(query)
    return jsonify(result)

if __name__ == '__main__':
    app.run(debug=True)

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.