Python - Closures with Examples
• Closures allow inner functions to remember and access variables from their enclosing scope even after the outer function has finished executing, enabling powerful patterns like data encapsulation...
Key Insights
• Closures allow inner functions to remember and access variables from their enclosing scope even after the outer function has finished executing, enabling powerful patterns like data encapsulation and factory functions.
• Python creates closures automatically when an inner function references variables from an outer function’s scope, storing these references in the function’s __closure__ attribute.
• Closures are particularly useful for creating decorators, callback functions with state, and implementing functional programming patterns without relying on classes.
Understanding Closures
A closure occurs when a nested function references variables from its enclosing scope. Python preserves these variables in the inner function’s closure, allowing access even after the outer function returns.
def outer_function(message):
# This variable is in the enclosing scope
prefix = "Message: "
def inner_function():
# inner_function "closes over" message and prefix
return prefix + message
return inner_function
# Create a closure
my_closure = outer_function("Hello, World!")
print(my_closure()) # Output: Message: Hello, World!
# The outer function has finished, but the closure retains access
print(my_closure()) # Still works: Message: Hello, World!
You can inspect a closure’s captured variables:
print(my_closure.__closure__) # Shows cell objects
print(my_closure.__code__.co_freevars) # ('message', 'prefix')
# Access the actual values
for cell in my_closure.__closure__:
print(cell.cell_contents)
# Output:
# Hello, World!
# Message:
Factory Functions
Closures excel at creating specialized functions with pre-configured behavior:
def multiplier_factory(factor):
def multiply(number):
return number * factor
return multiply
# Create specialized multiplier functions
double = multiplier_factory(2)
triple = multiplier_factory(3)
times_ten = multiplier_factory(10)
print(double(5)) # 10
print(triple(5)) # 15
print(times_ten(5)) # 50
A more practical example with configuration:
def create_logger(level, prefix):
def log(message):
formatted = f"[{level.upper()}] {prefix}: {message}"
print(formatted)
return log
error_logger = create_logger("error", "Database")
info_logger = create_logger("info", "API")
error_logger("Connection failed") # [ERROR] Database: Connection failed
info_logger("Request received") # [INFO] API: Request received
Maintaining State
Closures provide a lightweight alternative to classes for maintaining state:
def counter(start=0):
count = [start] # Use list to allow modification
def increment():
count[0] += 1
return count[0]
def decrement():
count[0] -= 1
return count[0]
def get_value():
return count[0]
return increment, decrement, get_value
inc, dec, get = counter(10)
print(inc()) # 11
print(inc()) # 12
print(dec()) # 11
print(get()) # 11
Using nonlocal keyword for cleaner state modification:
def counter_nonlocal(start=0):
count = start
def increment():
nonlocal count
count += 1
return count
def reset():
nonlocal count
count = start
return count
return increment, reset
inc, reset = counter_nonlocal(0)
print(inc()) # 1
print(inc()) # 2
print(reset()) # 0
Decorators Using Closures
Decorators are perhaps the most common use of closures in Python:
def timing_decorator(func):
import time
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
@timing_decorator
def slow_function(n):
total = 0
for i in range(n):
total += i
return total
result = slow_function(1000000)
# Output: slow_function took 0.0523 seconds
Decorator with configurable parameters:
def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
results = []
for _ in range(times):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(3)
def greet(name):
return f"Hello, {name}!"
print(greet("Alice"))
# Output: ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']
Practical Applications
Memoization
def memoize(func):
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(100)) # Executes quickly due to caching
Callback Functions with Context
def create_button_handler(button_id, action_type):
def handle_click(event):
print(f"Button {button_id} clicked: {action_type}")
print(f"Event data: {event}")
return handle_click
# Simulate button handlers
save_handler = create_button_handler("btn_save", "save")
delete_handler = create_button_handler("btn_delete", "delete")
save_handler({"x": 100, "y": 200})
# Output: Button btn_save clicked: save
# Event data: {'x': 100, 'y': 200}
delete_handler({"x": 150, "y": 250})
# Output: Button btn_delete clicked: delete
# Event data: {'x': 150, 'y': 250}
Partial Function Application
def partial(func, *partial_args):
def wrapper(*args):
return func(*partial_args, *args)
return wrapper
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(5)) # 125
Common Pitfalls
Late Binding in Loops
# Incorrect - all closures reference the same variable
functions = []
for i in range(5):
def func():
return i
functions.append(func)
print([f() for f in functions]) # [4, 4, 4, 4, 4]
# Correct - create new binding for each iteration
functions = []
for i in range(5):
def func(x=i): # Default argument captures current value
return x
functions.append(func)
print([f() for f in functions]) # [0, 1, 2, 3, 4]
# Alternative using lambda
functions = [lambda x=i: x for i in range(5)]
print([f() for f in functions]) # [0, 1, 2, 3, 4]
Modifying Enclosed Variables
def broken_counter():
count = 0
def increment():
count += 1 # UnboundLocalError!
return count
return increment
# Fix with nonlocal
def working_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
inc = working_counter()
print(inc()) # 1
print(inc()) # 2
Performance Considerations
Closures have minimal overhead but do consume memory for captured variables:
import sys
def create_closure(data):
def inner():
return data
return inner
closure = create_closure([1, 2, 3])
print(sys.getsizeof(closure)) # Size of function object
# Memory is retained as long as closure exists
large_data = list(range(1000000))
closure_with_large_data = create_closure(large_data)
# large_data is kept in memory even if original reference is deleted
del large_data
# Still accessible through closure
print(len(closure_with_large_data())) # 1000000
Closures provide elegant solutions for encapsulation, state management, and functional programming patterns. They’re fundamental to Python’s decorator syntax and enable writing cleaner, more maintainable code without the overhead of full class definitions. Understanding closures is essential for advanced Python development, particularly when working with callbacks, decorators, and functional programming paradigms.