BishopPhillips
BishopPhillips
BPC Home BPC Python Topic Home BPC RiskManager BPC SurveyManager BPC RiskWiki Learn HTML 5 and CSS Enquiry

Python Techniques - Closures

Creating Function Closures.

Author: Jonathan Bishop
AI Revolution

Closures are yet another advanced yet perhaps obscure technique in Python. They allow us to stateful functions.  By stateful we mean functions that can hold their state between successive calls.  A closure is so named because it "closes over" variables from an outer scope, preserving their state even after the outer function has finished executing. The idea comes from functional programming, where a function can capture and remember the environment in which it was created.  There are two specific features of closures:

  • It "closes" over its surrounding scope
    • When a function is defined inside another function, it retains access to variables from the outer function, even if that function has completed execution.
  • It Encapsulates variables
    • The inner function is essentially "sealed" with the variables from the outer function, making them persist independently.

 

1. A simple Example of a Closure

The basic structure of a closure is simple.  It involves declaring a function (we will call the "inner function") inside another function (we will call the "outer function") with variables for which we want to hold state declared in the outer function, but accessed in the inner function and returning a pointer to the inner function as the return result of the outer function.  The outer function's role is essentially just to establish state that the inner function will utilise in subsequent calls:

 

Example:


def outer(x):
    def inner():
        return x  # x is "closed over"
    return inner

closure_function = outer(10)
print(closure_function())  # Output: 10

Outer's purpose is to declare the variable x (in this case as a parameter to outer(), to act as the envelope for inner() and to return a pointer to inner to the calling routine which is then assigned to the variable "closure_function".   Even though outer() has finished executing, inner() still remembers x = 10, which is why calling closure_function() returns 10.

 

2. Closure Use Cases

The simple use case examples cover ten actions:

  1. Creating a Counter
  2. Function Customisation
  3. Memoization
  4. Data Validator
  5. Timer 
  6. Task Scheduler
  7. API Request Rate Limiter
  8. Function Decorator
  9. Generating Unique IDs
  10. Custom Sorting

1. Create a Counter with a Closure:


def counter(start=0):
    count = start  # Persistent state inside the closure
    def increment():
        nonlocal count  # Allows modification of `count` inside closure
        count += 1
        return count
    return increment

# Create a counter instance
counter1 = counter(5)  # Starts at 5

print(counter1())  # Output: 6
print(counter1())  # Output: 7
print(counter1())  # Output: 8
 

The outer function (counter) initializes count. The inner function (increment) remembers the state of count, even after counter finishes execution. The nonlocal keyword allows updating the count variable inside the closure. Every call to counter1() increments the stored count value without resetting it.

2. Function Customization:

A closure can customize functions dynamically, allowing for parameterized behavior:


def multiplier(factor):
    def multiply(num):
        return num * factor  # Retains access to `factor`
    return multiply  # Returns closure function

# Create different multiplier functions
double = multiplier(2)
triple = multiplier(3)

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

The multiplier(factor) function creates and returns a specialized function (multiply(num)). Each returned function remembers its factor, uniquely from previous or later calls, even after multiplier has finished executing. We can create customized functions (double, triple) without modifying the core logic.

This approach is great for configurable functions, like tax calculators, discount functions, or scalable processing functions.

3. Memoization:

Closures can be incredibly useful for memoization, which is a technique for caching previously computed values to optimize performance.


def memoize_factorial():
    cache = {}  # Store computed results

    def factorial(n):
        if n in cache:  # Check if result is already cached
            return cache[n]

        if n <= 1:
            result = 1
        else:
            result = n * factorial(n - 1)

        cache[n] = result  # Store result in cache
        return result

    return factorial  # Return the closure function

# Create a memoized factorial function
fast_factorial = memoize_factorial()

print(fast_factorial(5))  # Output: 120
print(fast_factorial(6))  # Output: 720 (reuses 5! from cache)
 

The cache dictionary persists inside the closure, allowing stored values to be reused. Recursive calls avoid redundant computations by checking stored results first. Saves processing time—calling fast_factorial(6) reuses 5! instead of recalculating it.

This approach is commonly used in dynamic programming problems, like Fibonacci sequences, pathfinding algorithms, and expensive mathematical computations.

 

Here is an example of using memoization with a closure to optimize Fibonacci sequence calculations efficiently:


def memoize_fibonacci():
    cache = {}  # Dictionary to store computed Fibonacci values

    def fibonacci(n):
        if n in cache:  # Check if result is already cached
            return cache[n]

        if n <= 1:
            result = n  # Base cases: fib(0) = 0, fib(1) = 1
        else:
            result = fibonacci(n - 1) + fibonacci(n - 2)  # Recursive calculation

        cache[n] = result  # Store computed result in cache
        return result

    return fibonacci  # Return the closure function

# Create a memoized Fibonacci function
fast_fibonacci = memoize_fibonacci()

print(fast_fibonacci(10))  # Output: 55
print(fast_fibonacci(20))  # Output: 6765 (previous values are reused)
print(fast_fibonacci(30))  # Output: 832040 (highly optimized!)

 

The cache dictionary persists in the closure, allowing previously computed results to be reused instead of recalculating them. Recursive calls avoid redundant calculations, dramatically improving performance. Each result is stored, ensuring repeated calls retrieve values instantly without recomputation.

The naive Fibonacci recursive approach has an exponential time complexity (O(2^n)), but with memoization, it drops to linear time complexity (O(n)), making it vastly faster for larger numbers.

 

Memoization with functools.lru_cache

Python has a library that provides facilities to automatically manage memoization, in the form of functools.lru_cache:


from functools import lru_cache

@lru_cache(maxsize=None)  # Cache unlimited values
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(10))  # Output: 55
print(fibonacci(20))  # Output: 6765
print(fibonacci(30))  # Output: 832040

 

lru_cache works well in this scenario because it provides:

  • Automatic caching – No need to manually manage storage.
  • Fast lookups – Previously computed results are retrieved instantly.
  • Minimal code changes – Just adding @lru_cache() optimizes recursive functions effortlessly.
  • Memory-efficient – Limits cache size (maxsize) to avoid excessive memory usage.

This method is ideal when you need quick optimizations without modifying existing function logic.

4. Data Validator Using Closures

Closures can be used to create customized validators that enforce specific rules.

def validator(rule):
    def validate(value):
        return rule(value)  # Uses a predefined rule function
    return validate

is_positive = validator(lambda x: x > 0)
is_even = validator(lambda x: x % 2 == 0)

print(is_positive(5))  # Output: True
print(is_positive(-3))  # Output: False
print(is_even(10))  # Output: True
print(is_even(7))  # Output: False

Use Case: Useful for input validation systems, where different validation rules can be applied dynamically.

5. Timer Function Using Closures

Closures can be used to measure execution time without needing global variables.

import time

def stopwatch():
    start_time = time.time()  # Capture start time

    def elapsed():
        return time.time() - start_time  # Calculate elapsed time

    return elapsed

timer = stopwatch()
time.sleep(2)  # Simulate a delay
print(f"Elapsed Time: {timer():.2f} seconds")  # Output: ~2.00 seconds

Use Case: Tracking execution time for performance analysis or profiling code behavior.

6. Task Scheduler with Closures

Closures can help schedule delayed function execution.

def schedule_task(delay):
    def task():
        time.sleep(delay)  # Pause execution
        print(f"Task executed after {delay} seconds")
    return task

delayed_task = schedule_task(3)
delayed_task()  # Runs after 3 seconds

Use Case: Useful in event scheduling, background tasks, or automated workflows.

7. API Request Rate Limiter

Closures can help limit function calls to prevent excessive API requests.

import time

def rate_limiter(interval):
    last_call = [0]  # Mutable object to store state

    def limited_call():
        now = time.time()
        if now - last_call[0] >= interval:
            last_call[0] = now
            print("API Request Sent!")
        else:
            print("Rate limit exceeded, try again later.")

    return limited_call

api_call = rate_limiter(5)

api_call()  # Sends request
time.sleep(3)
api_call()  # Too soon, blocked
time.sleep(2)
api_call()  # Allowed again

Use Case: Used in web applications and server-side development to control request rates.

8. Function Decorators with Closures

Closures can simplify function decorators, which modify behavior dynamically.  A function decorator in Python is a special type of function that modifies the behavior of another function without changing its actual code. It’s a wrapper function that takes a function as input, adds extra functionality, and returns the enhanced version.

The decorator syntax is pretty straightforward, following the closure syntax with the addition of the @x_y_z statement that automates the original encapsulation of the target function.


def uppercase_decorator(func):
    def wrapper():
        result = func()
        return result.upper()  # Modify behavior
    return wrapper

@uppercase_decorator  # Applying the decorator
def greet():
    return "Hello, Jonathan!"

print(greet())  # Output: "HELLO, JONATHAN!"

 

In the example below I show a decorator that logs function calls. This type of approach is useful in debugging or auditing code in that it allows for additional capability to be wrapped around a function without altering the target function's logic or code.

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        return func(*args, **kwargs)
    return wrapper

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

greet("Jonathan")  # Output: Logs call details before execution
 

Python offers a special syntax to simplify the construction and "instantiation" of decorator declarations: "@user_decorator_function_name". Although the mechanism could be set up with just a standard closure, the special syntax form clarifies what is intended and simplifies the code.

In this next example we continue with the applications in code monitoring, auditing and improvement, with a decorator designed to measure execution time:


import time

def timeit(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()  # Capture start time
        result = func(*args, **kwargs)  # Call the original function
        end_time = time.time()  # Capture end time
        print(f"Execution time: {end_time - start_time:.6f} seconds")  # Print duration
        return result
    return wrapper

@timeit
def example_function(n):
    time.sleep(n)  # Simulate a delay
    return f"Function executed after {n} seconds"

print(example_function(2))  # Output: Execution time: ~2.000000 seconds

 

This decorator captures start time before function execution, then runs the actual function and captures the end time. It calculates duration and prints execution time. It can be applied to any function with @timeit without modifying the function itself.

The benefits of code decorators lie in:

  • Code Reusability - Apply the same logic to multiple functions without duplication.
  • Encapsulation - Keeps function logic clean while adding extra behavior.
  • Logging, Timing, Authentication, Memoization - Ideal for these tasks.

Use Case: Useful for logging, debugging, and modifying function behavior without changing the original function.

9. Generating Unique IDs with Closures

Closures can maintain an internal counter to generate unique identifiers.

def unique_id_generator():
    counter = 0  # Maintain state within closure

    def generate():
        nonlocal counter
        counter += 1
        return f"ID_{counter}"

    return generate

get_id = unique_id_generator()

print(get_id())  # Output: ID_1
print(get_id())  # Output: ID_2
print(get_id())  # Output: ID_3

Use Case: Used in database transactions, tracking unique records, and temporary session identifiers.

10. Custom Sorting with Closures

Closures can customize sorting behavior dynamically.

def sort_key(extractor):
    def key_func(item):
        return extractor(item)  # Apply extractor function
    return key_func

items = [{"name": "Alice", "age": 25}, {"name": "Bob", "age": 30}, {"name": "Charlie", "age": 20}]

sorted_by_age = sorted(items, key=sort_key(lambda x: x["age"]))
print(sorted_by_age)  # Sorted by age dynamically!

Use Case: Useful in data analysis, database queries, and customized sorting logic.

3. Caveats and Best Practices

3.1 Caveats of Using Closures

1. Potential Memory Issues (Long-lived Closures)

  • If a closure holds onto large objects or data unnecessarily, it can prevent garbage collection, leading to memory leaks.

  • Best Practice: Ensure that closures only capture variables they truly need.

2. Unexpected Variable Binding

  • Closures capture variables, not their values at the time of definition.

  • If a closure references a mutable object, changes made to that object can affect subsequent function calls.

Example of unexpected binding:

funcs = []
for i in range(3):
    funcs.append(lambda: i)  # Captures `i` by reference

print(funcs[0]())  # Output: 2
print(funcs[1]())  # Output: 2
print(funcs[2]())  # Output: 2

 The closure captures the variable i, not its value at the time of creation!

Best Practice: If you need to capture current values, use default arguments:

funcs = [lambda i=i: i for i in range(3)]  # `i` gets fixed as default
print(funcs[0]())  # Output: 0
print(funcs[1]())  # Output: 1
print(funcs[2]())  # Output: 2

3. Performance Concerns in High-Volume Use Cases

  • Closures, especially when used for memoization, create persistent state.

  • If thousands of closures are generated dynamically, it can lead to overhead in memory.

Best Practice: Consider weak references or functools.lru_cache if caching is required.

3.2 Best Practices for Using Closures

Use Closures When You Need Persistent State

  • Closures are great for stateful functions, such as counters, memoization, and function customization.

Be Mindful of Mutable Captured Variables

  • Avoid modifying mutable objects inside closures unless explicitly needed.

Use nonlocal for Variable Updates

  • When modifying variables inside a closure, explicitly use nonlocal to prevent unintended behaviors.

python

def counter():
    count = 0
    def increment():
        nonlocal count  # Ensures `count` is updated inside closure
        count += 1
        return count
    return increment

counter_func = counter()
print(counter_func())  # Output: 1
print(counter_func())  # Output: 2

Avoid Excessive Use in High-Performance Loops

  • If closures are created too frequently, consider refactoring with class-based solutions.

Consider Alternatives Where Closures Aren’t Ideal

  • If heavy state management is required, classes or memoization decorators might be better.

Summary Closures are excellent for maintaining state and encapsulating logic, but they must be used carefully to prevent memory issues, unexpected variable bindings, and performance overhead.