Python - Nested Functions
Nested functions are functions defined inside other functions. The inner function has access to variables in the enclosing function's scope, even after the outer function has finished executing. This...
Key Insights
- Nested functions in Python create closures that capture variables from their enclosing scope, enabling powerful patterns like decorators, factory functions, and data encapsulation without classes
- Inner functions have access to the enclosing function’s local variables through the LEGB rule (Local, Enclosing, Global, Built-in), with
nonlocalkeyword allowing modification of enclosing scope variables - Nested functions execute faster than equivalent class-based solutions for simple encapsulation needs and provide a lightweight alternative to full object-oriented patterns
Understanding Nested Functions and Scope
Nested functions are functions defined inside other functions. The inner function has access to variables in the enclosing function’s scope, even after the outer function has finished executing. This mechanism creates closures—a fundamental concept in functional programming.
def outer_function(message):
def inner_function():
print(message)
return inner_function
# Create a closure
greeting = outer_function("Hello, World!")
greeting() # Output: Hello, World!
The inner function captures message from its enclosing scope. When outer_function returns inner_function, the inner function retains access to message even though outer_function has completed execution.
The LEGB Rule in Action
Python resolves variable names using the LEGB rule: Local, Enclosing, Global, Built-in. Understanding this hierarchy is critical when working with nested functions.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(f"Local: {x}")
inner()
print(f"Enclosing: {x}")
outer()
print(f"Global: {x}")
# Output:
# Local: local
# Enclosing: enclosing
# Global: global
Each function creates its own local scope. The inner function’s x doesn’t affect the enclosing function’s x, which doesn’t affect the global x.
Modifying Enclosing Scope with nonlocal
By default, assigning to a variable in an inner function creates a new local variable. Use nonlocal to modify variables in the enclosing scope.
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
def decrement():
nonlocal count
count -= 1
return count
def get_count():
return count
return increment, decrement, get_count
inc, dec, get = counter()
print(inc()) # 1
print(inc()) # 2
print(dec()) # 1
print(get()) # 1
Without nonlocal, attempting count += 1 would raise an UnboundLocalError because Python would treat count as a local variable that hasn’t been assigned yet.
Factory Functions Pattern
Nested functions excel at creating specialized functions with pre-configured behavior.
def create_multiplier(factor):
def multiply(number):
return number * factor
return multiply
double = create_multiplier(2)
triple = create_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
# More complex factory
def create_validator(min_value, max_value):
def validate(value):
if not isinstance(value, (int, float)):
return False, "Value must be numeric"
if value < min_value:
return False, f"Value must be >= {min_value}"
if value > max_value:
return False, f"Value must be <= {max_value}"
return True, "Valid"
return validate
age_validator = create_validator(0, 120)
print(age_validator(25)) # (True, 'Valid')
print(age_validator(-5)) # (False, 'Value must be >= 0')
print(age_validator(150)) # (False, 'Value must be <= 120')
Decorators Using Nested Functions
Decorators are perhaps the most common use case for nested functions in production code. They wrap functions to add behavior without modifying the original function.
import time
from functools import wraps
def timing_decorator(func):
@wraps(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
@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
The @wraps(func) decorator preserves the original function’s metadata. Without it, slow_function.__name__ would be wrapper instead of slow_function.
Parameterized Decorators
Decorators that accept arguments require an additional level of nesting.
def retry(max_attempts=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
attempts = 0
while attempts < max_attempts:
try:
return func(*args, **kwargs)
except Exception as e:
attempts += 1
if attempts >= max_attempts:
raise
print(f"Attempt {attempts} failed: {e}. Retrying...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def unreliable_api_call(success_rate=0.3):
import random
if random.random() > success_rate:
raise ConnectionError("API call failed")
return "Success"
# This will retry up to 3 times before raising the exception
try:
result = unreliable_api_call(success_rate=0.1)
print(result)
except ConnectionError as e:
print(f"Failed after retries: {e}")
Data Encapsulation Without Classes
Nested functions provide lightweight encapsulation when full classes are overkill.
def create_bank_account(initial_balance):
balance = initial_balance
transaction_history = []
def deposit(amount):
nonlocal balance
if amount <= 0:
raise ValueError("Deposit amount must be positive")
balance += amount
transaction_history.append(("deposit", amount))
return balance
def withdraw(amount):
nonlocal balance
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > balance:
raise ValueError("Insufficient funds")
balance -= amount
transaction_history.append(("withdrawal", amount))
return balance
def get_balance():
return balance
def get_history():
return transaction_history.copy()
return {
'deposit': deposit,
'withdraw': withdraw,
'balance': get_balance,
'history': get_history
}
account = create_bank_account(1000)
account['deposit'](500)
account['withdraw'](200)
print(account['balance']()) # 1300
print(account['history']()) # [('deposit', 500), ('withdrawal', 200)]
The balance and transaction_history variables are completely private—there’s no way to access them except through the provided functions.
Memoization with Nested Functions
Nested functions enable elegant memoization implementations for caching expensive function results.
def memoize(func):
cache = {}
@wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
def clear_cache():
cache.clear()
wrapper.clear_cache = clear_cache
wrapper.cache_info = lambda: {'size': len(cache), 'keys': list(cache.keys())}
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 memoization
print(fibonacci.cache_info()) # {'size': 101, 'keys': [...]}
fibonacci.clear_cache()
Performance Considerations
Nested functions have minimal overhead compared to equivalent class-based solutions for simple encapsulation needs.
# Nested function approach
def create_adder_func(x):
def add(y):
return x + y
return add
# Class-based approach
class Adder:
def __init__(self, x):
self.x = x
def add(self, y):
return self.x + y
# Both work identically
func_adder = create_adder_func(10)
class_adder = Adder(10)
print(func_adder(5)) # 15
print(class_adder.add(5)) # 15
For simple cases like this, the nested function approach is more concise and performs slightly better due to reduced overhead. Use classes when you need inheritance, multiple methods with shared state, or more complex object behavior.