Functions to Classes¶
Understanding the evolution from functions → closures → classes reveals Python's design philosophy.
Mental Model
Functions bundle logic, closures bundle logic with captured state, and classes bundle logic with state and a public interface. Each step adds structure: a closure is a function that remembers its environment; a class is a closure that names its variables (attributes) and exposes multiple entry points (methods). When a closure starts feeling cramped, it is time for a class.
The Evolution¶
Functions¶
1. First-Class Objects¶
Functions can be assigned, passed, and returned.
```python def square(x): return x * x
f = square print(f(4)) # 16 ```
2. Stateless¶
Functions don't retain state between calls.
```python def add_ten(x): return x + 10
result = add_ten(5) # 15
No memory of previous calls¶
```
3. Limitations¶
Cannot encapsulate data with behavior.
Closures¶
1. Capturing Variables¶
```python def make_multiplier(factor): def multiply(x): return x * factor # factor is captured return multiply
times3 = make_multiplier(3) print(times3(10)) # 30 ```
2. Free Variables¶
factor is a free variable in multiply:
- Used inside the function
- Not defined locally
- Captured from enclosing scope
3. State Retention¶
```python times3 = make_multiplier(3) del make_multiplier # Can delete outer function
print(times3(10)) # 30 - still works! ```
Free Variables¶
1. Definition¶
A variable that is:
- Referenced in a function
- Not bound (defined) in that function
- Comes from an enclosing scope
2. Example¶
```python x = 10 # global
def my_function(y): return x + y # x is FREE, y is BOUND ```
3. Where They Live¶
Free variables are stored in cell objects attached to the function via __closure__ — not in __dict__ or __globals__. This is how closures retain state after the enclosing function returns.
```python def outer(x): def inner(y): return x + y # x is FREE in inner return inner
f = outer(10) print(f.code.co_freevars) # ('x',) print(f.closure[0].cell_contents) # 10 ```
Function Object Structure
__code__ stores names; runtime structures hold values.
```text function object (static) ├── code │ ├── co_varnames → local variable names │ ├── co_freevars → enclosing variable names │ └── co_names → global variable names ├── globals → global namespace (name → value) └── closure → enclosing values (cell objects)
execution frame (dynamic, per call) └── locals → local variable values ```
This maps directly to LEGB lookup:
- L ocals → names in
co_varnames, values in frame - E nclosing → names in
co_freevars, values in__closure__ - G lobals → names in
co_names, values in__globals__ - B uiltins → fallback from
__globals__["__builtins__"]
From Closure to Class¶
1. Closure Version¶
```python def make_multiplier(factor): def multiply(x): return x * factor return multiply
times3 = make_multiplier(3) print(times3(10)) # 30 ```
2. Class Version¶
```python class Multiplier: def init(self, factor): self.factor = factor # attribute instead of free variable
def multiply(self, x):
return x * self.factor
def __call__(self, x):
return self.multiply(x)
times3 = Multiplier(3) print(times3(10)) # 30 ```
3. Key Differences¶
- Closure: captures free variables
- Class: stores attributes explicitly
Key Insight
A class is a named, reusable closure with multiple behaviors. Closures store state in hidden cell objects (__closure__), while classes store state explicitly in instance attributes (__dict__). This is the fundamental difference: implicit vs explicit state.
Class Advantages¶
1. Multiple Methods¶
```python class Counter: def init(self): self.count = 0
def increment(self):
self.count += 1
def decrement(self):
self.count -= 1
def reset(self):
self.count = 0
```
2. Named State¶
python
class Rectangle:
def __init__(self, width, height):
self.width = width # explicit names
self.height = height
3. Special Methods¶
```python class Point: def init(self, x, y): self.x = x self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
```
Why Classes Scale Better¶
Closures work well for simple cases but hit limits as complexity grows:
Closures vs Classes at Scale
| Dimension | Closure | Class |
|---|---|---|
| State visibility | Implicit (hidden in __closure__) |
Explicit (self.attr, vars()) |
| Number of behaviors | Usually one function | Multiple methods |
| Extensibility | Cannot inherit or compose | Inheritance, composition, protocols |
| Introspection | __closure__[0].cell_contents |
vars(obj), dir(obj), isinstance() |
| Testing | Hard to mock or inspect | Easy to subclass, mock, inject |
When you find yourself returning multiple functions from a closure, passing mutable containers as shared state, or wishing you could add a method — it is time to switch to a class.
Closure Limitations¶
1. Single Function¶
Closures typically return one function.
2. Implicit State¶
State is captured implicitly through free variables.
3. Hidden Introspection¶
Closure state can be inspected via __closure__, but it is obscure compared to class attributes.
python
times3 = make_multiplier(3)
print(times3.__closure__[0].cell_contents) # 3 — accessible but buried
print(vars(Multiplier(3))) # {'factor': 3} — explicit
Advanced: Class After Deletion
Instances survive even if the class itself is deleted — but class-level features become inaccessible.
```python class Multiplier: class_var = "I exist"
def __init__(self, factor):
self.factor = factor
times3 = Multiplier(3) del Multiplier # Delete class
print(times3.factor) # ✅ Works: 3 print(times3.class_var) # ❌ AttributeError
new_obj = Multiplier(5) # ❌ NameError¶
```
This is an implementation detail of Python's reference model, not a pattern you should rely on in practice.
When to Use Each¶
1. Use Functions¶
Simple, stateless operations.
2. Use Closures¶
Encapsulate simple state with single behavior.
3. Use Classes¶
- Multiple methods needed
- Complex state
- Need inheritance
- Need special methods
The Unified Model¶
Functions, Closures, and Classes Are All Objects
In Python, functions, closures, and classes are not different paradigms — they are different layers of the same object model:
- Function → object with code + reference to globals
- Closure → function + captured state (
__closure__) - Class → object that creates objects (via
__new__+__init__)
All behavior ultimately comes from attribute access, method calls, and object protocols. OOP is not separate from Python — it is built on the same data model that powers everything else.
Key Takeaways¶
- Functions → Closures → Classes progression.
- Closures capture free variables.
- Classes provide explicit attributes.
- Classes offer more features (methods, inheritance).
- Choose based on complexity needs.
- All three are objects in Python's data model — different layers of the same system.
Exercises¶
Exercise 1.
Start with a function make_counter() that returns a closure tracking a count. Then refactor it into a Counter class with increment(), decrement(), and value() methods. Show both implementations and discuss when the class version is preferable.
Solution to Exercise 1
# Closure version
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
def decrement():
nonlocal count
count -= 1
return count
def value():
return count
return increment, decrement, value
inc, dec, val = make_counter()
print(inc()) # 1
print(inc()) # 2
print(dec()) # 1
# Class version
class Counter:
def __init__(self):
self._count = 0
def increment(self):
self._count += 1
return self._count
def decrement(self):
self._count -= 1
return self._count
def value(self):
return self._count
c = Counter()
print(c.increment()) # 1
print(c.increment()) # 2
print(c.decrement()) # 1
# Class is better: easier to extend, inspect, and test
Exercise 2.
Write a function create_greeter(greeting) that returns a closure: a function accepting name and returning f"{greeting}, {name}!". Then convert this into a Greeter class with __init__ (accepts greeting) and __call__ (accepts name). Show both produce the same results.
Solution to Exercise 2
# Closure version
def create_greeter(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
hello = create_greeter("Hello")
print(hello("Alice")) # Hello, Alice!
# Class version
class Greeter:
def __init__(self, greeting):
self.greeting = greeting
def __call__(self, name):
return f"{self.greeting}, {name}!"
hi = Greeter("Hi")
print(hi("Bob")) # Hi, Bob!
# Both produce same results
assert hello("Alice") == create_greeter("Hello")("Alice")
Exercise 3.
Create three implementations of a simple accumulator (stores and sums numbers): (1) a function using a global variable, (2) a closure with nonlocal, and (3) a class with add(n) and total() methods. Compare the three approaches and explain why the class version is most maintainable.
Solution to Exercise 3
# 1. Global variable (worst)
_total = 0
def add_global(n):
global _total
_total += n
def get_total():
return _total
# 2. Closure (better)
def make_accumulator():
total = 0
def add(n):
nonlocal total
total += n
return total
return add
acc = make_accumulator()
print(acc(10)) # 10
print(acc(20)) # 30
# 3. Class (best)
class Accumulator:
def __init__(self):
self._total = 0
def add(self, n):
self._total += n
return self._total
def total(self):
return self._total
a = Accumulator()
print(a.add(10)) # 10
print(a.add(20)) # 30
print(a.total()) # 30
# Class: inspectable, testable, extensible, multiple instances
Exercise 4.
Using the function object structure shown in the "Free Variables" section, inspect a closure's internals. Create a closure make_adder(n) that returns a function adding n to its argument. Then print __code__.co_freevars, __closure__[0].cell_contents, and __code__.co_varnames on the returned function. Explain what each reveals.
Solution to Exercise 4
def make_adder(n):
def add(x):
return x + n
return add
add5 = make_adder(5)
print(add5.__code__.co_freevars) # ('n',)
print(add5.__closure__[0].cell_contents) # 5
print(add5.__code__.co_varnames) # ('x',)
# co_freevars: names of variables captured from the enclosing scope.
# Here 'n' is free — used inside add() but defined in make_adder().
#
# __closure__[0].cell_contents: the actual value captured for 'n'.
# This is how the closure retains state after make_adder() returns.
#
# co_varnames: names of local variables (parameters + locals).
# Here 'x' is the parameter of add().
#
# Together these map to LEGB:
# L = co_varnames (locals)
# E = co_freevars + __closure__ (enclosing)
# G = __globals__ (module-level)
Exercise 5.
A colleague writes the following closure-based "counter" that returns three functions. Explain why this design becomes awkward as requirements grow, and refactor it into a class. Add a reset() method and a history attribute that tracks all values — features that would be painful to add to the closure version.
python
def make_counter(start=0):
count = [start] # mutable container to work around nonlocal
def increment():
count[0] += 1
return count[0]
def decrement():
count[0] -= 1
return count[0]
def value():
return count[0]
return increment, decrement, value
Solution to Exercise 5
The closure returns three separate functions sharing a mutable list. This works but is fragile:
- Adding
reset()requires returning a fourth function. - Adding
historyrequires sharing another mutable container across all functions. - The caller must unpack and track three (or more) unrelated variables:
inc, dec, val = make_counter(). - There is no
isinstance()check, norepr, and no way to inspect state cleanly.
Refactored as a class:
class Counter:
def __init__(self, start=0):
self._count = start
self.history = [start]
def increment(self):
self._count += 1
self.history.append(self._count)
return self._count
def decrement(self):
self._count -= 1
self.history.append(self._count)
return self._count
def value(self):
return self._count
def reset(self):
self._count = 0
self.history.append(0)
def __repr__(self):
return f"Counter({self._count})"
c = Counter()
c.increment()
c.increment()
c.decrement()
print(c) # Counter(1)
print(c.history) # [0, 1, 2, 1]
c.reset()
print(c.history) # [0, 1, 2, 1, 0]
The class version is a single object with a clear API, inspectable state, and easy extensibility. This is the moment when closures stop scaling and classes become the right tool.