Module 5.2

Advanced Functions

Level up with *args, **kwargs, lambda, and decorators. These advanced features let you write flexible functions that accept any number of arguments, create inline anonymous functions, and wrap existing functions with new behavior.

50 min
Intermediate
Hands-on
What You'll Learn
  • *args for variable positional args
  • **kwargs for variable keyword args
  • Lambda expressions
  • Decorators and wrappers
  • Higher-order functions
Contents
01

*args and **kwargs

Sometimes you do not know how many arguments a function will receive. Python provides two special syntax features - *args and **kwargs - that allow functions to accept any number of arguments. These are essential for writing flexible, reusable code.

Definition

Variable-Length Arguments

*args (arbitrary positional arguments) collects any extra positional arguments passed to a function into a tuple. **kwargs (arbitrary keyword arguments) collects any extra keyword arguments into a dictionary. The names "args" and "kwargs" are conventions - you can use any valid identifier after the asterisks.

def func(*args, **kwargs): # args is tuple, kwargs is dict
Why Use *args and **kwargs? These features are invaluable when building wrapper functions, decorators, APIs, or any code that needs to forward arguments to other functions without knowing them in advance.
*args and **kwargs Flow
*args (Tuple)
func(1, 2, 3, 4, 5)
args = (1, 2, 3, 4, 5)

Collects all extra positional arguments into a tuple

**kwargs (Dict)
func(name="Alice", age=25)
kwargs = {"name": "Alice", "age": 25}

Collects all extra keyword arguments into a dictionary

Using *args

The *args parameter collects any number of positional arguments into a tuple. The name "args" is a convention; you can use any name after the asterisk. Inside the function, args behaves like a regular tuple - you can iterate over it, check its length, or access elements by index.

Without *args With *args
def add(a, b, c): return a+b+c def add(*args): return sum(args)
Fixed number of arguments only Any number of arguments
add(1, 2, 3) - works add(1, 2, 3, 4, 5, 6) - works
add(1, 2) - error! add(1) - works
def sum_all(*args):
    """Sum any number of arguments."""
    total = 0
    for num in args:
        total += num
    return total

print(sum_all(1, 2))           # Output: 3
print(sum_all(1, 2, 3, 4, 5))  # Output: 15
print(sum_all(10))             # Output: 10

# Combine with regular parameters
def greet(greeting, *names):
    for name in names:
        print(f"{greeting}, {name}!")

The *args parameter makes functions incredibly flexible by accepting any number of positional arguments. Regular parameters must come first in the function definition, then *args captures all remaining positional arguments as a tuple. Inside the function, you can iterate over args, check its length, or access elements by index. This pattern is essential for writing wrapper functions and APIs that need to forward arguments.

Using **kwargs

The **kwargs parameter collects any number of keyword arguments into a dictionary. This is useful for functions that need to accept arbitrary named options, configuration settings, or metadata. The keys become strings matching the argument names, and the values are the passed values.

Important: Keyword argument names must be valid Python identifiers. You cannot use reserved words like class or return as keyword arguments. Use class_ or cls instead.
def build_profile(**kwargs):
    """Build a user profile from keyword arguments."""
    for key, value in kwargs.items():
        print(f"{key}: {value}")

build_profile(name="Alice", age=25, city="NYC")
# Output:
# name: Alice
# age: 25
# city: NYC

# Combine all parameter types
def complex_func(required, *args, **kwargs):
    print(f"Required: {required}")
    print(f"Args: {args}")
    print(f"Kwargs: {kwargs}")

The **kwargs parameter collects all extra keyword arguments into a dictionary where keys are argument names and values are the passed values. Parameter order is strictly enforced: regular parameters first, then *args, then **kwargs. You can combine all three types in one function to create extremely flexible APIs. Access kwargs values using dictionary methods like .get(), .items(), or direct key access.

Unpacking with * and **

You can also use * and ** to unpack sequences and dictionaries when calling functions.

# Unpack a list as positional arguments
numbers = [1, 2, 3, 4, 5]
print(sum_all(*numbers))  # Same as sum_all(1, 2, 3, 4, 5)

# Unpack a dict as keyword arguments
user_data = {"name": "Bob", "age": 30, "job": "Developer"}
build_profile(**user_data)

# Combine both
def create_message(template, *values, **options):
    msg = template.format(*values)
    if options.get("uppercase"):
        msg = msg.upper()
    return msg

The * operator unpacks any iterable (list, tuple, set) into separate positional arguments when calling a function. The ** operator unpacks dictionaries into keyword arguments, matching dict keys to parameter names. This is the reverse of *args/**kwargs - instead of collecting arguments, you are spreading them out. Combining both operators lets you dynamically construct function calls from existing data structures.

Practice: *args and **kwargs

Task: Create a function multiply_all that takes any number of arguments using *args and returns their product. Test with 2, 3, and 5 numbers.

Show Solution
def multiply_all(*args):
    result = 1
    for num in args:
        result *= num
    return result

print(multiply_all(2, 3))         # Output: 6
print(multiply_all(2, 3, 4))      # Output: 24
print(multiply_all(1, 2, 3, 4, 5)) # Output: 120

Task: Create a function make_tag that takes a tag name and **kwargs for attributes. Return an HTML opening tag like <div class="container" id="main">.

Show Solution
def make_tag(tag, **kwargs):
    attrs = " ".join(f'{k}="{v}"' for k, v in kwargs.items())
    if attrs:
        return f"<{tag} {attrs}>"
    return f"<{tag}>"

print(make_tag("div", id="main", class_="container"))
# Output: 
print(make_tag("img", src="photo.jpg", alt="Photo")) # Output: Photo

Task: Create a function log_message that takes a required level (like "INFO"), *args for message parts joined by spaces, and **kwargs for metadata displayed as key=value pairs.

Show Solution
def log_message(level, *args, **kwargs):
    message = " ".join(str(a) for a in args)
    meta = " | ".join(f"{k}={v}" for k, v in kwargs.items())
    if meta:
        print(f"[{level}] {message} ({meta})")
    else:
        print(f"[{level}] {message}")

log_message("INFO", "User", "logged", "in", user="alice")
# Output: [INFO] User logged in (user=alice)
log_message("ERROR", "Connection", "failed", code=500, retry=True)
# Output: [ERROR] Connection failed (code=500 | retry=True)

Task: Create a function merge_dicts that takes any number of dictionaries using *args and merges them into one. Later dictionaries should override earlier ones for duplicate keys.

Show Solution
def merge_dicts(*dicts):
    result = {}
    for d in dicts:
        result.update(d)
    return result

# Test
d1 = {"a": 1, "b": 2}
d2 = {"b": 3, "c": 4}
d3 = {"c": 5, "d": 6}

print(merge_dicts(d1, d2))       # {'a': 1, 'b': 3, 'c': 4}
print(merge_dicts(d1, d2, d3))   # {'a': 1, 'b': 3, 'c': 5, 'd': 6}
02

Lambda Functions

Lambda functions are small, anonymous functions defined in a single line. They are useful for short operations, especially when passing functions as arguments to other functions like map(), filter(), and sorted().

Definition

Lambda Function

A lambda function (also called an anonymous function) is a small, inline function defined with the lambda keyword. Unlike regular functions defined with def, lambdas have no name and consist of a single expression whose result is automatically returned.

lambda parameters: expression
Origin: The term "lambda" comes from lambda calculus, a mathematical system developed in the 1930s. Python borrowed this concept from functional programming languages like Lisp and Haskell.
def vs lambda Comparison
Regular Function (def)
def square(x):
    return x ** 2
  • Has a name
  • Can have multiple statements
  • Can have docstrings
  • Better for complex logic
Lambda Function
square = lambda x: x ** 2
  • Anonymous (no inherent name)
  • Single expression only
  • No docstrings
  • Best for simple operations

Basic Lambda

Lambda functions can replace simple one-line functions. They are often assigned to variables or passed directly to other functions.

# Regular function
def square(x):
    return x ** 2

# Equivalent lambda
square = lambda x: x ** 2

print(square(5))  # Output: 25

# Lambda with multiple parameters
add = lambda a, b: a + b
print(add(3, 4))  # Output: 7

# Lambda in-line (no variable)
print((lambda x: x * 2)(10))  # Output: 20

Lambda functions are best suited for simple, one-line expressions that can be written concisely. They can take any number of parameters but must return a single expression result. While you can assign lambdas to variables, this practice is discouraged by PEP 8 style guide - use def instead for named functions. Reserve lambdas for inline use where defining a full function would be overkill.

Lambda with Built-in Functions

Lambdas shine when used with functions like map(), filter(), and sorted() that take a function as an argument.

# sorted() with lambda key
students = [("Alice", 85), ("Bob", 92), ("Carol", 78)]
by_score = sorted(students, key=lambda s: s[1], reverse=True)
print(by_score)  # [('Bob', 92), ('Alice', 85), ('Carol', 78)]

# filter() with lambda
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4, 6, 8, 10]

# map() with lambda
doubled = list(map(lambda x: x * 2, numbers))
print(doubled)  # [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

Lambda functions shine when used with higher-order functions like map(), filter(), and sorted() that require a function argument. They eliminate the need to define separate named functions for simple operations used only once. The key parameter in sorted() is especially common - it determines what value to sort by. Remember that map() and filter() return iterators, so wrap them in list() to see all results at once.

Use Case Lambda Example Description
Sorting by key sorted(items, key=lambda x: x.name) Sort objects by a specific attribute
Filtering filter(lambda x: x > 0, nums) Keep only positive numbers
Transforming map(lambda x: x.upper(), words) Convert all strings to uppercase
Conditional lambda x: "even" if x%2==0 else "odd" Return based on condition (ternary)
Lambda Limitations: Lambdas can only contain a single expression, not statements. You cannot use multi-line code, loops, or multiple assignments inside a lambda. Use def for anything more complex.

Practice: Lambda Functions

Task: Create a lambda function is_even that returns True if a number is even, False otherwise. Test it with 4 and 7.

Show Solution
is_even = lambda x: x % 2 == 0

print(is_even(4))  # Output: True
print(is_even(7))  # Output: False

Task: Given a list of dictionaries with "name" and "age" keys, use sorted() with a lambda to sort by age in ascending order.

Show Solution
people = [
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": 25},
    {"name": "Carol", "age": 35}
]

by_age = sorted(people, key=lambda p: p["age"])
print(by_age)
# [{'name': 'Bob', 'age': 25}, 
#  {'name': 'Alice', 'age': 30}, 
#  {'name': 'Carol', 'age': 35}]

Task: Given a list of numbers 1-10, use filter() to keep only odd numbers, then use map() to square them. Use lambdas for both operations.

Show Solution
numbers = list(range(1, 11))

# Filter odd numbers, then square them
odds = filter(lambda x: x % 2 != 0, numbers)
squared_odds = list(map(lambda x: x ** 2, odds))

print(squared_odds)  # [1, 9, 25, 49, 81]

Task: Create a lambda function grade that takes a score and returns "Pass" if the score is 60 or above, otherwise "Fail". Test with scores 75 and 45.

Show Solution
grade = lambda score: "Pass" if score >= 60 else "Fail"

print(grade(75))  # Output: Pass
print(grade(45))  # Output: Fail
print(grade(60))  # Output: Pass

# Using with map
scores = [85, 42, 67, 55, 90]
results = list(map(grade, scores))
print(results)  # ['Pass', 'Fail', 'Pass', 'Fail', 'Pass']
03

Decorators

Decorators are functions that wrap other functions to extend their behavior without modifying their code. They are one of Python's most powerful features, enabling clean separation of concerns and reusable functionality like logging, timing, authentication, and caching.

Definition

Decorator

A decorator is a function that takes another function as input and returns a new function that usually extends or modifies the behavior of the original. The @decorator syntax is syntactic sugar for func = decorator(func).

@decorator is equivalent to function = decorator(function)
Real-World Uses: Decorators are everywhere in Python - Flask uses @app.route for URL routing, Django uses @login_required for authentication, and Python itself provides @property, @staticmethod, and @classmethod.
Decorator Wrapper Pattern
@decorator
def my_function():
Before
Run code before the original function
Original
Call the wrapped function
After
Run code after the original function

Creating a Decorator

A decorator is a function that takes a function as input and returns a new function that usually calls the original.

def my_decorator(func):
    def wrapper():
        print("Before the function")
        func()
        print("After the function")
    return wrapper

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

say_hello()
# Output:
# Before the function
# Hello!
# After the function

The @decorator syntax above a function definition is syntactic sugar for say_hello = my_decorator(say_hello). The decorator receives the original function, creates a wrapper that adds behavior before and/or after calling it, then returns the wrapper. When you call the decorated function, you are actually calling the wrapper which controls when and how the original executes. This pattern enables aspect-oriented programming in Python.

Decorators with Arguments

To create decorators that work with functions taking arguments, use *args and **kwargs to pass through all parameters.

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

@timer
def slow_function(n):
    total = sum(range(n))
    return total

result = slow_function(1000000)
# Output: slow_function took 0.0312 seconds

Using *args and **kwargs in the wrapper function ensures your decorator works with any function signature - whether it takes no arguments, positional arguments, keyword arguments, or any combination. Always capture and return the result of the wrapped function call, otherwise decorated functions that return values will return None instead. The timer decorator pattern shown here is invaluable for performance profiling and optimization work.

Common Decorator Use Cases

Decorators are used for logging, authentication, caching, validation, and more. Here is a practical logging decorator.

def log_calls(func):
    def wrapper(*args, **kwargs):
        args_str = ", ".join(repr(a) for a in args)
        kwargs_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
        all_args = ", ".join(filter(None, [args_str, kwargs_str]))
        print(f"Calling {func.__name__}({all_args})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

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

add(3, 5)
# Calling add(3, 5)
# add returned 8

This logging decorator automatically tracks all function calls, their arguments, and return values without modifying the original function code. The repr() function formats arguments in a readable way, and the !r format specifier does the same in f-strings. This pattern is invaluable for debugging, creating audit trails, and understanding program flow. In production, you would typically write to a log file instead of printing.

Pattern Purpose Example Use
Timer Measure function execution time Performance profiling, optimization
Logger Log function calls and arguments Debugging, audit trails
Cache/Memoize Store and reuse computed results Expensive calculations, API calls
Auth Check Verify user permissions before access Web apps, API endpoints
Retry Automatically retry on failure Network requests, flaky operations
Validator Check inputs before execution Type checking, range validation
Pro Tip: Use functools.wraps(func) in your decorators to preserve the original function's name, docstring, and metadata. This helps with debugging and documentation.

Practice: Decorators

Task: Create a decorator add_greeting that prints "Hello!" before calling the wrapped function. Apply it to a function that prints "Nice to meet you."

Show Solution
def add_greeting(func):
    def wrapper():
        print("Hello!")
        func()
    return wrapper

@add_greeting
def introduce():
    print("Nice to meet you.")

introduce()
# Output:
# Hello!
# Nice to meet you.

Task: Create a decorator double_result that doubles the return value of the wrapped function. Apply it to a function that returns a number.

Show Solution
def double_result(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result * 2
    return wrapper

@double_result
def get_number(n):
    return n

print(get_number(5))   # Output: 10
print(get_number(21))  # Output: 42

Task: Create a decorator retry that catches exceptions and retries the function up to 3 times. Print which attempt is being made. If all attempts fail, re-raise the exception.

Show Solution
def retry(func):
    def wrapper(*args, **kwargs):
        for attempt in range(1, 4):
            try:
                print(f"Attempt {attempt}")
                return func(*args, **kwargs)
            except Exception as e:
                if attempt == 3:
                    raise
                print(f"Failed: {e}, retrying...")
    return wrapper

@retry
def risky_operation():
    import random
    if random.random() < 0.7:
        raise ValueError("Random failure!")
    return "Success!"

Task: Create a decorator count_calls that counts how many times a function has been called. Store the count as an attribute on the wrapper function and print it each time.

Show Solution
def count_calls(func):
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        print(f"{func.__name__} called {wrapper.calls} times")
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@count_calls
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Alice")  # say_hello called 1 times \n Hello, Alice!
say_hello("Bob")    # say_hello called 2 times \n Hello, Bob!
print(say_hello.calls)  # 2

Interactive: Decorator Explorer

Select a function and decorator to see how decorators transform behavior.

Code
# Select options above
Output

Click Run to see the output

04

Higher-Order Functions

Higher-order functions are functions that take other functions as arguments or return functions as results. This powerful concept enables functional programming patterns in Python, making code more modular, reusable, and expressive.

Definition

Higher-Order Function

A higher-order function is a function that does at least one of the following: (1) takes one or more functions as arguments, or (2) returns a function as its result. This is possible because in Python, functions are first-class objects - they can be assigned to variables, stored in data structures, and passed around like any other value.

map(func, iterable) and filter(func, iterable) are higher-order functions
First-Class Functions: Python treats functions as first-class citizens, meaning they have the same rights as other objects. You can assign them to variables, pass them as arguments, return them from functions, and store them in lists or dictionaries.
Types of Higher-Order Functions
Takes Function as Input
def apply(func, value):
    return func(value)

apply(str.upper, "hello")  # "HELLO"

Examples: map(), filter(), sorted(key=...)

Returns Function as Output
def make_adder(n):
    def adder(x):
        return x + n
    return adder

add5 = make_adder(5)  # Returns a function

Examples: decorators, function factories

Functions as Arguments

In Python, functions are first-class objects. You can pass them as arguments to other functions just like any other value.

def apply_operation(func, x, y):
    """Apply any two-argument function to x and y."""
    return func(x, y)

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

def multiply(a, b):
    return a * b

print(apply_operation(add, 5, 3))       # Output: 8
print(apply_operation(multiply, 5, 3))  # Output: 15

The apply_operation function demonstrates that functions are first-class objects in Python - they can be passed as arguments just like integers or strings. Notice we pass add and multiply without parentheses; parentheses would call the function and pass its return value instead. This pattern enables powerful abstractions where behavior can be swapped at runtime. It is the foundation for callbacks, event handlers, and plugin architectures.

Built-in Higher-Order Functions

Python provides several built-in higher-order functions that operate on iterables using a function you provide.

# map() applies a function to each element
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(squared)  # [1, 4, 9, 16, 25]

# filter() keeps elements where function returns True
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4]

# reduce() accumulates values (from functools)
from functools import reduce
total = reduce(lambda acc, x: acc + x, numbers, 0)
print(total)  # 15

These three functions form the core of functional programming in Python. map() applies a transformation to every element, creating a new sequence of the same length. filter() tests each element and keeps only those that pass. reduce() (from functools) combines all elements into a single accumulated value using a two-argument function. While list comprehensions often replace map/filter in modern Python, understanding these functions is essential for functional programming patterns.

Functions Returning Functions

A higher-order function can also return a new function. This pattern is useful for creating specialized or configured functions.

def make_multiplier(n):
    """Return a function that multiplies by n."""
    def multiplier(x):
        return x * n
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))  # Output: 10
print(triple(5))  # Output: 15
print(double(triple(4)))  # Output: 24

make_multiplier is a function factory - it manufactures and returns new functions configured with specific behavior. Each call creates an independent function with the value of n "baked in" through closure. The returned functions remember their configuration and can be stored, passed around, or composed together. This pattern is the basis for currying, partial application, and building domain-specific languages in Python.

Practice: Higher-Order Functions

Task: Create a function apply_to_all(func, items) that applies the given function to each item in the list and returns a new list with the results. Do not use map().

Show Solution
def apply_to_all(func, items):
    result = []
    for item in items:
        result.append(func(item))
    return result

# Test
numbers = [1, 2, 3, 4, 5]
print(apply_to_all(lambda x: x * 2, numbers))  # [2, 4, 6, 8, 10]
print(apply_to_all(str.upper, ["a", "b", "c"]))  # ['A', 'B', 'C']

Task: Create a function make_power(n) that returns a function which raises its argument to the power n. Create square and cube functions using it.

Show Solution
def make_power(n):
    def power(x):
        return x ** n
    return power

square = make_power(2)
cube = make_power(3)

print(square(4))  # Output: 16
print(cube(3))    # Output: 27
print(make_power(4)(2))  # Output: 16

Task: Create a function compose(f, g) that returns a new function which applies g first, then f. So compose(f, g)(x) equals f(g(x)).

Show Solution
def compose(f, g):
    def composed(x):
        return f(g(x))
    return composed

# Test
add_one = lambda x: x + 1
double = lambda x: x * 2

add_then_double = compose(double, add_one)  # double(add_one(x))
double_then_add = compose(add_one, double)  # add_one(double(x))

print(add_then_double(5))  # (5+1)*2 = 12
print(double_then_add(5))  # (5*2)+1 = 11

Task: Create a function pipeline(*funcs) that takes multiple functions and returns a new function that applies them left-to-right. For example, pipeline(f, g, h)(x) equals h(g(f(x))).

Show Solution
def pipeline(*funcs):
    def apply(x):
        result = x
        for func in funcs:
            result = func(result)
        return result
    return apply

# Test
add_one = lambda x: x + 1
double = lambda x: x * 2
square = lambda x: x ** 2

process = pipeline(add_one, double, square)
print(process(3))  # ((3+1)*2)**2 = 64
05

Closures

A closure is a function that remembers values from its enclosing scope even after that scope has finished executing. Closures are the mechanism behind decorators, function factories, and data hiding in Python.

Definition

Closure

A closure is a nested function that captures and retains access to variables from its enclosing (outer) function's scope, even after the outer function has finished executing. The inner function "closes over" those variables, keeping them alive in memory.

inner_func.__closure__ - contains the captured variables (cell objects)
Why Closures Matter: Closures enable data encapsulation without classes, allow functions to have persistent private state, and are the foundation for decorators and callback functions in event-driven programming.
How Closures Work
1. Outer Function Called
outer("Hello")

Creates local variable message

2. Inner Function Returned
return inner

Inner captures message in closure

3. Closure Accessed Later
greet()

message still accessible!

Understanding Closures

When an inner function references a variable from an outer function, Python keeps that variable alive in the closure, allowing the inner function to access it later. This happens automatically when you define a nested function.

def outer(message):
    # message is in outer's local scope
    
    def inner():
        # inner "closes over" message
        print(message)
    
    return inner

greet = outer("Hello, World!")
greet()  # Output: Hello, World!

# message is still accessible even though outer() has returned
farewell = outer("Goodbye!")
farewell()  # Output: Goodbye!

The inner function captures and "closes over" the message variable from its enclosing scope. Even after outer() finishes executing and its local scope would normally be destroyed, the message variable survives because inner holds a reference to it. Each call to outer() creates a completely independent closure with its own copy of message. You can verify this by checking inner.__closure__ which contains the captured variables.

Practical Closure Example

Closures are useful for creating functions with private state that persists across calls.

def make_counter(start=0):
    count = start
    
    def counter():
        nonlocal count
        count += 1
        return count
    
    return counter

counter1 = make_counter()
print(counter1())  # Output: 1
print(counter1())  # Output: 2

counter2 = make_counter(100)
print(counter2())  # Output: 101
print(counter1())  # Output: 3 (independent state)

The nonlocal keyword is essential here - without it, assigning to count would create a new local variable instead of modifying the enclosed one. Each call to make_counter() creates a fresh closure with its own independent count variable. This demonstrates how closures can maintain private state that persists across function calls. The state is truly private - there is no way to access count directly from outside the closure.

Closures vs Classes

Closures can sometimes replace simple classes when you need to maintain state with a single method.

# Using a closure
def make_accumulator(initial=0):
    total = initial
    def add(value):
        nonlocal total
        total += value
        return total
    return add

acc = make_accumulator(10)
print(acc(5))   # Output: 15
print(acc(3))   # Output: 18

# Equivalent class approach
class Accumulator:
    def __init__(self, initial=0):
        self.total = initial
    def add(self, value):
        self.total += value
        return self.total

Closures provide a lightweight alternative to classes when you need a single callable with private state. They have less syntactic overhead and work naturally with functional programming patterns. However, classes are better when you need multiple methods, inheritance, or more complex state management. The closure approach is common in JavaScript but less idiomatic in Python, where classes are often preferred for stateful objects.

Practice: Closures

Task: Create a function make_greeter(greeting) that returns a function which takes a name and returns "greeting, name!". Create hello and hi greeters.

Show Solution
def make_greeter(greeting):
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

hello = make_greeter("Hello")
hi = make_greeter("Hi")

print(hello("Alice"))  # Output: Hello, Alice!
print(hi("Bob"))       # Output: Hi, Bob!

Task: Create a closure make_averager() that returns a function. Each call to the returned function adds a number and returns the running average of all numbers added so far.

Show Solution
def make_averager():
    numbers = []
    
    def add(value):
        numbers.append(value)
        return sum(numbers) / len(numbers)
    
    return add

avg = make_averager()
print(avg(10))  # Output: 10.0
print(avg(20))  # Output: 15.0
print(avg(30))  # Output: 20.0

Task: Create a function memoize(func) that returns a memoized version of func. It should cache results and return cached values for repeated calls with the same arguments.

Show Solution
def memoize(func):
    cache = {}
    
    def memoized(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    
    return memoized

@memoize
def slow_add(a, b):
    print(f"Computing {a} + {b}...")
    return a + b

print(slow_add(2, 3))  # Computing 2 + 3... then 5
print(slow_add(2, 3))  # Just 5 (cached)
print(slow_add(4, 5))  # Computing 4 + 5... then 9

Task: Create a function make_rate_limiter(max_calls) that returns a function. The returned function should return True for the first max_calls invocations, then False for any additional calls.

Show Solution
def make_rate_limiter(max_calls):
    calls = 0
    
    def check():
        nonlocal calls
        if calls < max_calls:
            calls += 1
            return True
        return False
    
    return check

limiter = make_rate_limiter(3)
print(limiter())  # True (call 1)
print(limiter())  # True (call 2)
print(limiter())  # True (call 3)
print(limiter())  # False (exceeded limit)
print(limiter())  # False

Concept Summary

Quick reference comparing all advanced function concepts covered in this lesson.

Concept What It Does Syntax Best For
*args Collects extra positional args into tuple def f(*args) Variable number of inputs
**kwargs Collects extra keyword args into dict def f(**kwargs) Optional named parameters
Lambda Creates anonymous inline function lambda x: x+1 Simple one-expression functions
Decorator Wraps function with extra behavior @decorator Logging, auth, caching
Higher-Order Takes/returns functions map(func, list) Functional programming patterns
Closure Remembers enclosing scope variables Nested function + free variable Stateful functions, factories

Key Takeaways

*args Collects Positional

*args gathers extra positional arguments into a tuple, allowing functions to accept any number of values

**kwargs Collects Keywords

**kwargs gathers extra keyword arguments into a dictionary for flexible named parameters

Lambda for Inline

Lambda creates anonymous one-line functions, ideal for map(), filter(), sorted() key functions

Decorators Wrap Functions

Decorators add behavior before/after functions without modifying their code using @syntax

Unpack with * and **

Use * to unpack iterables and ** to unpack dicts when calling functions with existing data

Practical Patterns

Decorators enable logging, timing, caching, and authentication without code duplication

Knowledge Check

Quick Quiz

Test your understanding of advanced Python functions

1 What does *args collect in a function?
2 What is the correct order of parameters in a function definition?
3 What is a limitation of lambda functions?
4 What does the @decorator syntax do to a function?
5 What is a closure in Python?
6 What keyword allows a nested function to modify a variable from its enclosing scope?
Answer all questions to check your score