Timothy had mastered function scope and understood how local variables vanished when functions returned. But Margaret had a more mysterious phenomenon to reveal. She led him deeper into The Experimental Workshop to a chamber where functions could carry memories with them—variables that persisted long after their parent functions had finished executing. "Today," she said, "you'll learn how functions can remember."
The Impossible Memory
Margaret demonstrated something that seemed to violate everything Timothy had learned:
def create_book_counter(starting_count: int):
count = starting_count
def increment() -> int:
nonlocal count
count += 1
return count
return increment # Return the function itself!
fiction_counter = create_book_counter(100)
print(fiction_counter()) # 101
print(fiction_counter()) # 102
print(fiction_counter()) # 103
Timothy stared in disbelief. The outer function create_book_counter
had finished executing—its laboratory should have been dismantled, its variables destroyed. Yet somehow fiction_counter
still remembered the value of count
and kept incrementing it across multiple calls.
"This," Margaret announced, "is a closure. The inner function captured its environment and carried it forward in time."
The Captured Environment
Margaret explained what Python did behind the scenes. When create_book_counter
returned the inner function, Python packaged up everything that function needed—including the count
variable—and bundled it with the function object. The inner function "closed over" its enclosing scope, preserving access even after the outer function ended.
def create_librarian(library_name: str):
books_processed = 0
def process_book(title: str) -> str:
nonlocal books_processed
books_processed += 1
return f"{library_name} librarian processed '{title}' (#{books_processed})"
return process_book
alexandria_librarian = create_librarian("Alexandria")
paris_librarian = create_librarian("Paris")
print(alexandria_librarian("1984"))
# "Alexandria librarian processed '1984' (#1)"
print(alexandria_librarian("Dune"))
# "Alexandria librarian processed 'Dune' (#2)"
print(paris_librarian("Foundation"))
# "Paris librarian processed 'Foundation' (#1)"
Each closure maintained its own independent captured environment. The Alexandria librarian and Paris librarian remembered different library names and kept separate book counts. They were born from the same function template but carried distinct memories.
The Function Factory Pattern
Timothy realized closures enabled a powerful pattern—function factories that configured behavior:
def create_late_fee_calculator(daily_rate: float):
def calculate_fee(days_overdue: int) -> float:
return days_overdue * daily_rate
return calculate_fee
standard_fee = create_late_fee_calculator(0.50)
premium_fee = create_late_fee_calculator(1.00)
print(standard_fee(5)) # $2.50
print(premium_fee(5)) # $5.00
The factory function create_late_fee_calculator
produced customized calculators, each remembering its own rate. Timothy could create as many specialized calculators as needed, each configured differently but sharing the same underlying logic.
The Private State
Margaret showed Timothy how closures could encapsulate state, hiding implementation details:
def create_checkout_system(max_checkouts: int):
current_checkouts = []
def checkout_book(patron_name: str, book_title: str) -> str:
if len(current_checkouts) >= max_checkouts:
return f"Checkout limit reached ({max_checkouts})"
current_checkouts.append((patron_name, book_title))
return f"Checked out '{book_title}' to {patron_name}"
def return_book(patron_name: str, book_title: str) -> str:
current_checkouts.remove((patron_name, book_title))
return f"Returned '{book_title}' from {patron_name}"
def get_status() -> str:
return f"{len(current_checkouts)} of {max_checkouts} books checked out"
return checkout_book, return_book, get_status
checkout, return_book, status = create_checkout_system(max_checkouts=3)
print(checkout("Alice", "1984")) # Success
print(checkout("Bob", "Dune")) # Success
print(status()) # "2 of 3 books checked out"
print(return_book("Alice", "1984")) # Success
print(status()) # "1 of 3 books checked out"
The current_checkouts
list was completely private—no external code could access or corrupt it. The three returned functions shared access to this hidden state, creating a self-contained system with controlled interfaces. This was encapsulation without classes.
The Loop Variable Trap
Margaret warned Timothy about a common closure pitfall with loops:
# WRONG - Common mistake
def create_shelf_labels():
labels = []
for section_number in range(3):
labels.append(lambda: f"Section {section_number}")
return labels
label_functions = create_shelf_labels()
for fn in label_functions:
print(fn()) # All print "Section 2" - wrong!
All the lambda functions shared the same section_number
variable. The crucial insight: closures capture the variable itself, not its value at the time the function is created. By the time the loop finished and the functions were called, section_number
held its final value of 2.
Margaret showed the fix using default arguments to capture values immediately:
# CORRECT - Capture value with default argument
def create_shelf_labels():
labels = []
for section_number in range(3):
labels.append(lambda n=section_number: f"Section {n}")
return labels
label_functions = create_shelf_labels()
for fn in label_functions:
print(fn()) # Correctly prints "Section 0", "Section 1", "Section 2"
The default argument n=section_number
evaluated immediately during each loop iteration, capturing that specific value rather than a reference to the changing variable.
The Decorator Foundation
Margaret revealed that closures formed the foundation for Python's decorator pattern:
def log_book_operation(operation_func):
def wrapper(book_title: str) -> str:
print(f"[LOG] Operation starting: {operation_func.__name__}")
result = operation_func(book_title)
print(f"[LOG] Operation completed: {result}")
return result
return wrapper
@log_book_operation
def catalog_book(title: str) -> str:
return f"Cataloged: {title}"
catalog_book("1984")
# [LOG] Operation starting: catalog_book
# [LOG] Operation completed: Cataloged: 1984
The wrapper function closed over operation_func
, remembering which function to call. Timothy realized decorators were just closures with specific syntax—a way to wrap functions with additional behavior while preserving their original functionality.
The Memory Consideration
Margaret mentioned that closures carried a memory trade-off worth understanding. Each closure maintained references to all variables in its captured scope:
def create_processors():
large_data = list(range(1000000)) # Big list
def process_small_value(x: int) -> int:
return x * 2 # Doesn't use large_data
return process_small_value
# But large_data stays in memory because it's in scope!
Even though the returned function never touched large_data
, that list remained in memory as part of the closure's captured environment. For most use cases with reasonably-sized variables, this overhead was negligible. But Timothy learned to be mindful of what variables lingered in scope when creating closures, especially when dealing with large data structures.
Timothy's Closure Wisdom
Through exploring closure phenomena, Timothy learned essential principles:
Closures remember their origins: Inner functions capture variables from enclosing scopes and carry them forward, even after outer functions return.
Independent state per closure: Multiple closures from the same factory maintain completely separate captured variables.
Data hiding without classes: Captured variables become private, accessible only through the returned functions—encapsulation achieved through scope.
Variable reference, not value: Closures capture the variable itself in loops; use default arguments to capture specific values at creation time.
The decorator engine: Python's decorator pattern is built on closures wrapping and enhancing functions.
Memory persists with access: Captured variables remain in memory as long as the closure exists, which matters for large data structures.
Modification requires nonlocal: Inner functions need the nonlocal
keyword to modify captured mutable values from enclosing scopes.
Timothy's exploration of closures revealed that functions were more than isolated operations—they could become carriers of persistent state, customizable factories, and sophisticated encapsulation mechanisms. The Experimental Workshop's most advanced techniques involved functions that remembered their origins and carried those memories forward, creating behavior that persisted across time and calls. Closures transformed functions from simple operations into stateful, configurable objects with hidden complexity.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (0)