No history yet

Functional Programming Mastery

Functions as First-Class Citizens

In Python, functions are more than just reusable blocks of code. They are , which means you can treat them just like any other variable. You can assign them to other variables, pass them as arguments to other functions, and even return them from functions.

Consider a simple function that adds two numbers. You can assign this function to a new variable and then call it using the new name. Both names will point to the same function object in memory.

def add(a, b):
    """This function adds two numbers."""
    return a + b

# Assign the function to a new variable
sum_numbers = add

# Call the function using the new variable
result = sum_numbers(5, 3)
print(result)  # Output: 8

# Check the function's identity and name
print(add.__name__)         # Output: add
print(sum_numbers.__name__)   # Output: add

This ability to treat functions as data is the foundation for more advanced techniques. A function that takes another function as an argument or returns a function is called a higher-order function. This pattern allows for flexible and reusable code.

Closures and Scope

What happens when you define a function inside another function? The inner function gets access to the variables in the outer function's scope. This is standard lexical scoping.

A closure is a bit more special. It's a function object that remembers values in the enclosing scope even if they are not present in memory. This happens when the outer function finishes executing but the inner function is returned and still holds a reference to that scope.

def outer_function(msg):
    message = msg

    def inner_function():
        print(message)

    return inner_function

# Create a closure
hello_func = outer_function("Hello")

# The outer_function has finished, but hello_func still remembers `message`
hello_func()  # Output: Hello

In this example, hello_func is a closure. Even though outer_function has completed, hello_func

Closures allow functions to carry around a persistent state, effectively creating private variables that can only be accessed by the inner function.

Decorators for Cleaner Code

Decorators provide a clean syntax for using higher-order functions. A decorator is a function that takes another function, adds some functionality, and returns the modified function without permanently altering the original function's code. This adheres to the (Don't Repeat Yourself) by abstracting away common setup or teardown logic.

Let's say you have several functions and you want to log when each one is called. Instead of adding print statements to every function, you can write a decorator.

def log_function_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling function: {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished function: {func.__name__}")
        return result
    return wrapper

@log_function_call
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")

# Output:
# Calling function: greet
# Hello, Alice!
# Finished function: greet

The @log_function_call syntax is just syntactic sugar for greet = log_function_call(greet). It's a clean and readable way to wrap greet with the logging functionality from wrapper.

Advanced Decorator Patterns

Sometimes you need to pass arguments to the decorator itself. To do this, you need one more layer of nesting: a function that accepts the arguments and returns a decorator. This is often called a decorator factory.

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                func(*args, **kwargs)
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def say_whee():
    print("Whee!")

say_whee()

# Output:
# Whee!
# Whee!
# Whee!

One problem with our decorators so far is that they obscure the original function's metadata, like its name (__name__) and docstring (__doc__).

If you checked greet.__name__ in our first decorator example, it would return wrapper, not greet. This can cause issues with debugging and introspection tools.

Python's standard library provides a solution: functools.wraps. This is a decorator for your wrapper function that copies the metadata from the original function to the wrapper.

import functools

def log_function_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        """This is the wrapper function."""
        print(f"Calling: {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_function_call
def greet(name):
    """Prints a friendly greeting."""
    print(f"Hello, {name}!")

print(greet.__name__)    # Output: greet
print(greet.__doc__)     # Output: Prints a friendly greeting.

Using functools.wraps is a best practice that ensures your decorated functions behave as expected, making your code more robust and maintainable.

Quiz Questions 1/5

What does it mean for functions in Python to be considered "first-class objects"?

Quiz Questions 2/5

A function that accepts another function as an argument or returns a function is known as a _________.