Python 3 Fundamentals Week 21 - Decorators 課程筆記

Decorators

Decorators allow programmers to modify the functionality of a function by wrapping it in another function.

Key concepts

  • Closures: Functions that capture the enclosing environment.
  • Higher-Order Functions: Functions that can take other functions as arguments or return them.
  • Reassigning Symbols: Assigning a function to an existing name.

Why Use Decorators?

def greet(name):
	return f'Hello! {name}'

def add(x, y):
	return x + y

To log function calls, you can create a logging decorator.

def log(fn):
	def inner(*args, **kwargs):
		print(f'Calling {fn}...')  
		result = fn(*args, **kwargs)
		return result
	return inner

add_logged = log(add)
greet_logged = log(greet) 

add_logged(3, 55)

Output

Calling <function add at 0x0000011EE58EAA60>...
58

Reassign the function name to avoid changing all calls.

add = log(add)
greet = log(greet) 

add(3, 55)

Output

Calling <function add at 0x0000011EE58EAA60>...
58

The log function is called the decorator, which takes the original function as an argument and returns a modified version of it.

Decorator Syntax

Instead of manually assigning the decorator.

def add(x, y):
	return x + y

add = log(add)

Use the @ syntax.

@ log
def add(x, y):
	return x + y

Coding

Advanced Logging with logging Module

import logging
from time import perf_counter

# Configure our logger
logging.basicConfig(
    format='%(asctime)s %(levelname)s: %(message)s',
    level=logging.DEBUG
)
logger = logging.getLogger('Custom Log')

def log(func):
    def inner(*args, **kwargs):
        start = perf_counter()
        result = func(*args, **kwargs)
        end = perf_counter()
        logger.debug(f'called={func.__name__}, elapsed={end-start}')
        return result
    return inner

@log
def add(a, b, c):
    return a + b + c

@log
def greet(name):
    return f'Hello {name}!'

@log
def join(data, *, item_sep=',', line_sep='\n'):
    return line_sep.join([item_sep.join(str(item) for item in row) for row in data])     

add(10, 20, 30)
join([range(10) for _ in range(10)])

Output

# 2024-05-15 00:33:14,536 DEBUG: called=add, elapsed=1.1000010999850929e-06
# 60
# 2024-05-15 00:34:25,534 DEBUG: called=join, elapsed=2.600000152597204e-05
# '0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9\n0,1,2,3,4,5,6,7,8,9'

LRU Cache

Understanding Caching

Caching stores recent or frequently used data in memory for faster access.

Caching is useful when:

  • The function is deterministic (calls with the same set of arguments return the same result).
  • Re-calculating the function is costly.

Basic Caching Concept

def add(a, b, c):
	return a + b + c

cache = {}
def func(a, b, c):
	key = (a, b, c)
	if key in cache:
		return cache[key]
	print('key is not in cache')
	result = add(a, b, c)
	cache[key] = result  
	return result
	
cache
# {}

func(1, 2, 3)  # First call, adds to cache 
# key is not in cache
# 6

cache
# {(1, 2, 3): 6}

func(1, 2, 3)  # Second call, retrieves from cache
# 6

LRU Cache

An LRU (Least Recently Used) cache evicts the least recently accessed items first when full.

Implementing LRU Cache in Python

from funtools import lru_cache

Syntax

@lru_cache(maxsize=128)
  • maxsize (Optional): It sets the size of your cache. By default, it’s set to 128. If maxsize is set to None, the LRU feature will be disabled, and the cache can grow without any limitations.

Restriction

The arguments passed to the function must be hashable values since the arguments will be stored as keys.

@lru_cache
def my_func(x):
    print('calling my_func')
    return x

Calling with hashable arguments

my_func((10, 20))
# calling my_func
# (10, 20)

Calling with unhashable arguments

my_func([10, 20])
# ---------------------------------------------------------------------------
# TypeError                                 Traceback (most recent call last)
# ...
# TypeError: unhashable type: 'list'

Coding

# Create an lru_cache decorator with cache size 2
from functools import lru_cache

@lru_cache(maxsize=2)
def add(a, b):
    print('add called')
    return a + b

add(2, 3)  
# add called
# 5

add.cache_info()
# CacheInfo(hits=0, misses=1, maxsize=2, currsize=1)

add(5, 6) 
# add called
# 11

add.cache_info()
# CacheInfo(hits=0, misses=2, maxsize=2, currsize=2)

add(2, 3)  
# 5

add.cache_info()
# CacheInfo(hits=1, misses=2, maxsize=2, currsize=2)

add(4, 5)
# add called
# 9

add.cache_info()
# CacheInfo(hits=1, misses=3, maxsize=2, currsize=2)

add(5, 6) 
# add called
# 11

add.cache_info()
# CacheInfo(hits=1, misses=4, maxsize=2, currsize=2)

Calculating Fibonacci Numbers

Fibonacci numbers:  
	0, 1, 1, 2, 3, 5, 8, 13, 21, ...
def fib(n):
    print(f'fib({n}) called...')
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

# if we call fib(0), it just returns 0.
fib(0)
# fib(0) called...
# 0

# if we call fib(1), it just returns 1.
fib(1)
# fib(1) called...
# 1

# If we call fib(2) it will call fib(0) and fib(1) which return 0 and 1.
fib(2)
# fib(2) called...
# fib(1) called...
# fib(0) called...
# 1

# If we call fib(3) it will call fib(2) and fib(1) -> fib(1) will just return 1, but fib(2) calls fib(0) and fib(1).
fib(3)
# fib(3) called...
# fib(2) called...
# fib(1) called...
# fib(0) called...
# fib(1) called...
# 2


# Apply that LRU cache
@lru_cache
def fib(n):
    print(f'fib({n}) called...')
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

fib(2)
# fib(2) called...
# fib(1) called...
# fib(0) called...
# 1

fib(3)
# fib(3) called...
# 2