Python Topic

Python Decorators & Closures

1. Higher-Order Functions

Purpose: Functions that take other functions as arguments or return functions as results.

Functions as Arguments:

python

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

def shout(func, name):
    """A higher-order function that takes a function as argument."""
    result = func(name)
    return result.upper() + "!"

def whisper(func, name):
    """Another higher-order function."""
    result = func(name)
    return result.lower() + "..."

# Using higher-order functions
print(shout(greet, "Alice"))    # HELLO, ALICE!!
print(whisper(greet, "Bob"))    # hello, bob...

Functions as Return Values:

python

def create_multiplier(factor):
    """Returns a new function that multiplies by the given factor."""
    def multiplier(x):
        return x * factor
    return multiplier

# Create specialized functions
double = create_multiplier(2)
triple = create_multiplier(3)

print(double(5))  # 10
print(triple(5))  # 15

# You can create any multiplier you want
times_ten = create_multiplier(10)
print(times_ten(7))  # 70

Practical Example:

python

def apply_operation(numbers, operation):
    """Apply an operation to all numbers in a list."""
    return [operation(num) for num in numbers]

def square(x):
    return x ** 2

def cube(x):
    return x ** 3

numbers = [1, 2, 3, 4, 5]

squared_numbers = apply_operation(numbers, square)
cubed_numbers = apply_operation(numbers, cube)

print(f"Squared: {squared_numbers}")  # [1, 4, 9, 16, 25]
print(f"Cubed: {cubed_numbers}")      # [1, 8, 27, 64, 125]

2. Closures

Purpose: Functions that "remember" the environment in which they were created, even after that environment is gone.

Basic Closure:

python

def outer_function(message):
    """Outer function that creates a closure."""
    def inner_function():
        # inner_function remembers 'message' from outer scope
        print(message)
    return inner_function

# Create closures
hello_closure = outer_function("Hello, World!")
goodbye_closure = outer_function("Goodbye, World!")

# Call the closures
hello_closure()    # Hello, World!
goodbye_closure()  # Goodbye, World!

Closure with Parameters:

python

def power_factory(exponent):
    """Create a power function with a fixed exponent."""
    def power(base):
        return base ** exponent
    return power

# Create specialized power functions
square = power_factory(2)
cube = power_factory(3)
sqrt = power_factory(0.5)

print(square(5))   # 25
print(cube(3))     # 27
print(sqrt(16))    # 4.0

Closure with Mutable State:

python

def counter_factory():
    """Create a counter that remembers its state."""
    count = 0  # This is remembered by the closure
    
    def counter():
        nonlocal count  # Needed to modify the outer variable
        count += 1
        return count
    
    return counter

# Create independent counters
counter1 = counter_factory()
counter2 = counter_factory()

print(counter1())  # 1
print(counter1())  # 2
print(counter1())  # 3

print(counter2())  # 1 (independent state)
print(counter2())  # 2

Practical Closure Example:

python

def create_authenticator(valid_tokens):
    """Create an authentication function for specific tokens."""
    attempts = 0
    max_attempts = 3
    
    def authenticate(token):
        nonlocal attempts
        if attempts >= max_attempts:
            return "Blocked: Too many failed attempts"
        
        if token in valid_tokens:
            attempts = 0  # Reset on success
            return "Access granted"
        else:
            attempts += 1
            remaining = max_attempts - attempts
            return f"Access denied. {remaining} attempts remaining"
    
    return authenticate

# Create authenticators for different systems
admin_auth = create_authenticator({"admin123", "superuser456"})
user_auth = create_authenticator({"user789", "guest000"})

print(admin_auth("wrong"))      # Access denied. 2 attempts remaining
print(admin_auth("admin123"))   # Access granted
print(user_auth("user789"))     # Access granted

3. Function Decorators

Purpose: Modify or enhance functions without changing their source code.

Basic Decorator Syntax:

python

def my_decorator(func):
    """A simple decorator that adds functionality."""
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

# Using the decorated function
say_hello()
# Output:
# Something is happening before the function is called.
# Hello!
# Something is happening after the function is called.

What @decorator Actually Does:

python

# This:
@my_decorator
def say_hello():
    print("Hello!")

# Is equivalent to:
def say_hello():
    print("Hello!")
say_hello = my_decorator(say_hello)

Decorators for Functions with Arguments:

python

def smart_divide(func):
    """Decorator that handles division by zero."""
    def wrapper(a, b):
        if b == 0:
            return "Error: Division by zero!"
        return func(a, b)
    return wrapper

@smart_divide
def divide(a, b):
    return a / b

print(divide(10, 2))  # 5.0
print(divide(10, 0))  # Error: Division by zero!

Decorators with *args and **kwargs:

python

def logger(func):
    """Decorator that logs function calls."""
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper

@logger
def add(a, b):
    return a + b

@logger
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(add(5, 3))
# Output:
# Calling add with args: (5, 3), kwargs: {}
# add returned: 8
# 8

print(greet("Alice", greeting="Hi"))
# Output:
# Calling greet with args: ('Alice',), kwargs: {'greeting': 'Hi'}
# greet returned: Hi, Alice!
# Hi, Alice!

4. Advanced Decorators

Decorators with Arguments:

python

def repeat(times):
    """Decorator that repeats function execution."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            for i in range(times):
                print(f"Execution {i + 1}:")
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Alice")
# Output:
# Execution 1:
# Hello, Alice!
# Execution 2:
# Hello, Alice!
# Execution 3:
# Hello, Alice!

Multiple Decorators:

python

def bold(func):
    def wrapper():
        return "<b>" + func() + "</b>"
    return wrapper

def italic(func):
    def wrapper():
        return "<i>" + func() + "</i>"
    return wrapper

@bold
@italic
def hello():
    return "Hello, World!"

print(hello())  # <b><i>Hello, World!</i></b>

# Equivalent to:
# hello = bold(italic(hello))

Class-based Decorators:

python

class Timer:
    """Decorator as a class to measure execution time."""
    
    def __init__(self, func):
        self.func = func
    
    def __call__(self, *args, **kwargs):
        import time
        start = time.time()
        result = self.func(*args, **kwargs)
        end = time.time()
        print(f"{self.func.__name__} executed in {end - start:.4f} seconds")
        return result

@Timer
def slow_function():
    import time
    time.sleep(1)
    return "Done"

print(slow_function())
# Output:
# slow_function executed in 1.0012 seconds
# Done

5. Practical Examples

Example 1: Authentication and Authorization System

python

def require_auth(role="user"):
    """Decorator to require authentication and specific role."""
    def decorator(func):
        def wrapper(user, *args, **kwargs):
            if not user.get('authenticated', False):
                return "Error: Authentication required"
            
            if user.get('role') != role and role != "any":
                return f"Error: {role} role required"
            
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

# User database (simulated)
users = {
    'alice': {'name': 'Alice', 'role': 'user', 'authenticated': True},
    'bob': {'name': 'Bob', 'role': 'admin', 'authenticated': True},
    'eve': {'name': 'Eve', 'role': 'user', 'authenticated': False}
}

class UserSystem:
    @require_auth(role="any")
    def view_profile(self, user):
        return f"Viewing profile of {user['name']}"
    
    @require_auth(role="user")
    def edit_profile(self, user):
        return f"{user['name']} is editing their profile"
    
    @require_auth(role="admin")
    def delete_user(self, user):
        return f"{user['name']} is deleting a user (admin action)"

# Test the system
system = UserSystem()

print(system.view_profile(users['alice']))   # Viewing profile of Alice
print(system.edit_profile(users['alice']))   # Alice is editing their profile
print(system.delete_user(users['alice']))    # Error: admin role required

print(system.delete_user(users['bob']))      # Bob is deleting a user (admin action)
print(system.view_profile(users['eve']))     # Error: Authentication required

Example 2: Caching Decorator (Memoization)

python

def cache(func):
    """Decorator that caches function results."""
    cache_dict = {}
    
    def wrapper(*args):
        if args in cache_dict:
            print(f"Cache hit for {args}")
            return cache_dict[args]
        
        print(f"Computing for {args}...")
        result = func(*args)
        cache_dict[args] = result
        return result
    
    return wrapper

@cache
def fibonacci(n):
    """Compute nth Fibonacci number (inefficient without caching)."""
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

@cache
def factorial(n):
    """Compute factorial."""
    if n == 0:
        return 1
    return n * factorial(n - 1)

# Test caching
print("Fibonacci(5):", fibonacci(5))
print("\nFactorial(5):", factorial(5))
print("\nFibonacci(5) again:", fibonacci(5))  # From cache

Example 3: Rate Limiting Decorator

python

import time

def rate_limit(max_calls, period):
    """Decorator to limit function calls per time period."""
    def decorator(func):
        calls = []
        
        def wrapper(*args, **kwargs):
            now = time.time()
            
            # Remove calls outside the current period
            calls[:] = [call_time for call_time in calls if now - call_time < period]
            
            if len(calls) >= max_calls:
                oldest_call = calls[0]
                wait_time = period - (now - oldest_call)
                return f"Rate limit exceeded. Try again in {wait_time:.1f} seconds"
            
            calls.append(now)
            return func(*args, **kwargs)
        
        return wrapper
    return decorator

@rate_limit(max_calls=3, period=10)  # 3 calls per 10 seconds
def api_call(endpoint):
    return f"Calling {endpoint}... Success!"

# Test rate limiting
for i in range(5):
    result = api_call(f"/users/{i}")
    print(f"Attempt {i + 1}: {result}")
    time.sleep(1)

Example 4: Validation Decorator

python

def validate_input(**validators):
    """Decorator to validate function arguments."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            # Combine args and kwargs for validation
            from inspect import signature
            sig = signature(func)
            bound_args = sig.bind(*args, **kwargs)
            bound_args.apply_defaults()
            all_args = bound_args.arguments
            
            # Validate each argument
            for arg_name, validator in validators.items():
                if arg_name in all_args:
                    value = all_args[arg_name]
                    if not validator(value):
                        raise ValueError(f"Invalid value for {arg_name}: {value}")
            
            return func(*args, **kwargs)
        return wrapper
    return decorator

# Validator functions
def is_positive(x):
    return x > 0

def is_non_empty_string(s):
    return isinstance(s, str) and len(s.strip()) > 0

def is_valid_email(email):
    return '@' in email and '.' in email

@validate_input(
    age=is_positive,
    name=is_non_empty_string,
    email=is_valid_email
)
def create_user(name, age, email):
    return f"User created: {name}, {age}, {email}"

# Test validation
try:
    print(create_user("Alice", 25, "alice@example.com"))  # Valid
    print(create_user("", 25, "alice@example.com"))       # Invalid name
except ValueError as e:
    print(f"Error: {e}")

try:
    print(create_user("Bob", -5, "bob@example.com"))      # Invalid age
except ValueError as e:
    print(f"Error: {e}")

Example 5: Retry Decorator

python

import random
import time

def retry(max_attempts=3, delay=1, backoff=2, exceptions=(Exception,)):
    """Decorator to retry function on exception."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            attempts = 0
            current_delay = delay
            
            while attempts < max_attempts:
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    attempts += 1
                    if attempts == max_attempts:
                        raise e
                    
                    print(f"Attempt {attempts} failed: {e}. Retrying in {current_delay}s...")
                    time.sleep(current_delay)
                    current_delay *= backoff
            
            return func(*args, **kwargs)  # Final attempt
        return wrapper
    return decorator

@retry(max_attempts=3, delay=1, exceptions=(ValueError,))
def unreliable_operation():
    """Simulate an operation that might fail."""
    if random.random() < 0.7:  # 70% chance of failure
        raise ValueError("Temporary failure")
    return "Operation successful!"

# Test retry mechanism
for i in range(3):
    try:
        result = unreliable_operation()
        print(f"Run {i + 1}: {result}")
    except Exception as e:
        print(f"Run {i + 1}: Failed after all retries - {e}")
    print()

6. Preserving Function Metadata

Using functools.wraps:

python

from functools import wraps

def logger(func):
    @wraps(func)  # Preserves function metadata
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logger
def say_hello(name):
    """Greets a person by name."""
    return f"Hello, {name}!"

print(say_hello.__name__)  # say_hello (without @wraps, it would be 'wrapper')
print(say_hello.__doc__)   # Greets a person by name.

Key Takeaways:

  1. Higher-Order Functions: Functions that take or return other functions

  2. Closures: Functions that remember their creation environment

  3. Decorators: Functions that modify other functions

  4. @decorator syntax: Syntactic sugar for function wrapping

  5. Use functools.wraps: To preserve function metadata

 

Comments (0)

No comments yet. Be the first to comment!

LecturreAI Assistant

Hello! I'm LecturreAI

What would you like to learn today?