Do I Need Object Orientation?
Most people reach for object-oriented programming in Python way too early. They have a little bit of state to manage or a few related functions, and suddenly they are writing a class with __init__, self, and method calls for everything. But if you look closely, they are not modeling rich behavior or building reusable abstractions. They are just trying to group some things together.
In this post, I want to break down what people usually mean when they say they "need a class" and show how you can achieve the same goals using closures, generators, and other Python features. Object orientation has its place, but it should not always be the first tool you reach for.
To explore this, I am going to walk through a few common arguments people give for using classes and show how each one can be addressed without object orientation.
I need a class because…
I need to manage state
I need encapsulation
I need inheritance
Let’s go through each of these and see what is really going on.
"I Need to Manage State"
This phrase comes up a lot, and it’s often what prompts people to reach for object orientation. But managing state doesn't require a class, it just requires some mechanism for holding onto a value across time.
Let’s take a look at three different ways to manage state in Python. The first is the most familiar object-oriented approach:
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
account = BankAccount()
print(f'{account.balance = }')
account.balance += 50
print(f'{account.balance = }')
account.balance = 0
account.balance = 50
Here, we're using an attribute (self.balance) to store state. It’s neatly wrapped in a class, which gives us the option to later add behavior (hey that's a reason to reach for a class first) if we want it. But for simple state tracking, this is arguably more than we need.
Now compare that with just using a plain variable:
balance = 0
print(f'{balance = }')
balance += 50
print(f'{balance = }')
balance = 0
balance = 50
This is the simplest possible form of state management. It works perfectly fine as long as you’re not juggling multiple balances, or needing to pass the state around.
For something a bit more structured, but without writing a class, you might use a dictionary:
account = {'balance': 0}
print(f'{account['balance'] = }')
account['balance'] += 50
print(f'{account['balance'] = }')
account['balance'] = 0
account['balance'] = 50
This gives you some namespacing and makes it easier to expand to more fields ("account_number", "owner", etc.) later. It’s a flexible approach, especially for scripting or prototyping. Of course, I would personally advocate for using a namedtuple or dataclass for this particular endeavor, but we're talking about class alternatives, and neither of those fit the bill.
The takeaway here is: you don’t need a class just because you want to update a value over time. Python gives you several tools and it’s worth using the one that fits your complexity level. Keep it simple until it needs to be structured.
"I Need Encapsulation"
This one often shows up as “I need to manage state.” But what people are really saying is: “I have some related data that are repeatedly used together AND I want to write a few methods that operate solely with these values in mind AND I (may) want to control how they're accessed and updated.” This is encapsulation.
For the latter portion of our definition, "...control how they're accessed and updated" isn't a pattern or enforcement we see in Python's take on Object Orientation, so I won't go into much depth discussing it (in fact, I write my own getter/setter outside of a class in closure the example below!).
Let’s start with the classic object-oriented version:
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError('Insufficient Funds!')
self.balance -= amount
return self.balance
def deposit(self, amount):
self.balance += amount
return self.balance
account = BankAccount(balance=30)
print(
f'{account.balance = }',
f'{account.deposit(40) = }',
f'{account.withdraw(10) = }',
f'{account.balance = }',
sep='\n',
)
account.balance = 30
account.deposit(40) = 70
account.withdraw(10) = 60
account.balance = 60
This is what most people reach for first. A class holds onto an internal state (self.balance), and access is routed through methods.
But this kind of encapsulation isn’t exclusive to object-oriented programming. You can achieve the same pattern using closures:
def bank_account(balance=0):
def withdraw(amount):
nonlocal balance
if amount > balance:
raise ValueError('Insufficient Funds!')
balance -= amount
return balance
def deposit(amount):
nonlocal balance
balance += amount
return balance
def check_balance(): # that's a getter!
return balance
return withdraw, deposit, check_balance
withdraw, deposit, check_balance = bank_account(balance=30)
print(
f'{check_balance() = }',
f'{deposit(40) = }',
f'{withdraw(10) = }',
f'{check_balance() = }',
sep='\n',
)
check_balance() = 30
deposit(40) = 70
withdraw(10) = 60
check_balance() = 60
Here, the state lives in a closed-over variable, and you expose specific functions to interact with it. You still get encapsulation—just without a class.
In fact, closures aren't the only alternative we have at our disposal. Generator Coroutines also let us maintain internal state across calls:
def bank_account(balance=30):
while True:
amount = yield balance
if amount is not None:
balance += amount
account = bank_account(balance=30)
print(
f'{account.send(None) = }', # check balance
f'{account.send(40) = }', # deposit
f'{account.send(-10) = }', # withdraw
f'{account.send(None) = }', # check balance
sep='\n',
)
account.send(None) = 30
account.send(40) = 70
account.send(-10) = 60
account.send(None) = 60
OK, this is a bit of a stretch. I’m not using separate deposit and withdraw methods here, just interpreting the sign of the number to determine the action. It’s functional, but not very expressive.
So let’s clean that up a bit and add explicit action semantics using pattern matching:
def bank_account(balance=30):
while True:
match (yield balance):
case ('withdraw', amount):
if amount > balance:
raise ValueError('Insufficient Funds!')
balance -= amount
case ('deposit', amount):
balance += amount
account = bank_account(balance=30)
print(
f'{account.send(None) = }', # check balance
f'{account.send(('deposit', 40)) = }', # deposit
f'{account.send(('withdraw', 10)) = }', # withdraw
f'{account.send(None) = }', # check balance
sep='\n',
)
account.send(None) = 30
account.send(('deposit', 40)) = 70
account.send(('withdraw', 10)) = 60
account.send(None) = 60
This version behaves more like a stateful object with a method dispatcher. Instead of calling methods, you send in commands, and the generator coroutine internally manages how state is updated.
So, do you need a class to encapsulate behavior and state? Not really. You need a mechanism to hold state and a way to interact with it cleanly. Classes, closures, and generator coroutines all offer different takes on that pattern. Of course, we would most commonly reach for a class here (especially if we want more than one method). That said, if it is a single class with a single method... do you really need all that complexity?
"I Need Inheritance"
Inheritance is a key concept in object orientation. But that doesn’t mean inheritance itself is exclusive to object orientation. What we’re really talking about here is code reuse. And there’s more than one way to achieve that in Python.
The class-based example below introduces a CheckingAccount that extends the behavior of a BankAccount. This is classic inheritance: the child class reuses the parent’s logic and adds its own rules around overdrafts.
class CheckingAccount(BankAccount):
def __init__(self, balance=0, overdraft_limit=0):
super().__init__(balance=balance) # supplement the parent __init__
self.overdraft_limit = overdraft_limit
def withdraw(self, amount): # override (not supplement) the parent
if amount > self.balance + self.overdraft_limit:
raise ValueError("Overdraft limit exceeded")
self.balance -= amount
return self.balance
account = CheckingAccount(balance=30, overdraft_limit=200)
print(
f'{account.balance = }',
f'{account.deposit(40) = }',
f'{account.withdraw(80) = }',
f'{account.balance = }',
sep='\n',
)
account.balance = 30
account.deposit(40) = 70
account.withdraw(80) = -10
account.balance = -10
But you don’t have to use inheritance just to override or tweak behavior. You can accomplish the same thing by composing functions and capturing shared state. In the closure version below, we build a checking account by composing the behavior of a basic bank_account. Then we override the withdraw function to apply our own overdraft logic, reusing the rest as-is.
def bank_account(balance=0):
def withdraw(amount):
nonlocal balance
if amount > balance:
raise ValueError('Insufficient Funds!')
balance -= amount
return balance
def deposit(amount):
nonlocal balance
balance += amount
return balance
def check_balance(): # getter
return balance
def update_balance(new_balance): # setter
nonlocal balance
balance = new_balance
return balance
return withdraw, deposit, check_balance, update_balance
def checking_account(balance=0, overdraft_limit=0):
# overriding the "parent" withdraw function
_, _deposit, _check_balance, _update_balance = bank_account(balance)
def withdraw(amount): #
balance = _check_balance() # self.balance
if amount > (balance + overdraft_limit):
raise ValueError('Insufficient Funds!')
return _update_balance(balance - amount) # self.balance = …
return withdraw, _deposit, _check_balance, _update_balance
withdraw, deposit, check_balance, update_balance = checking_account(balance=30, overdraft_limit=100)
print(
f'{check_balance() = }',
f'{deposit(40) = }',
f'{withdraw(80) = }',
f'{check_balance() = }',
sep='\n',
)
check_balance() = 30
deposit(40) = 70
withdraw(80) = -10
check_balance() = -10
You’ll notice this gives you the same result. The checking_account builds on top of bank_account, but without subclassing. It works without inheritance, lexical scoping, and closures to carry state forward and reuse behavior.
Of course, in terms of readability the class approach is much easier on the eyes as we can easily see the base behavior that we're overriding (this is a bit more opaque in the closure approach). However, that inheritance is not something that is solely unique to object orientation and on its own does not motivate me enough to start writing my own classes.
I Need Better Ergonomics
On a superficial level, this reason feels less meaningful than some of the previous assertions. However, I would argue that ergonomics is incredibly important when both reading and writing code and you can see this in some of the more extreme examples I wrote above.
Just because one can use these alternative patterns for state management, encapsulation, and inheritance does not also mean that they are the most sensible option. We want to write code that makes sense to our future selves and others with whom we work alongside. Oftentimes, reaching for a familiar class with some methods is the way to do that.
This idea though cuts both ways. Take a look at the following code:
class BankAccount: # Class formulation
def __init__(self, balance=0):
self.balance = balance
def update(self, amount):
self.balance += amount
return self.balance
## vs
def bank_account(balance=0): # Coroutine formulation
while True:
amount = yield balance
if amount is not None:
balance += amount
I am willing to guess that the class BankAccount formulation feels more familiar and thus more intuitive to the everyday Pythonista.
However, we should also consider that...
The first time you encounter a class or function is rarely in the source code
Classes are incredibly flexible, they can have a dynamic number of methods/attributes and can implement any number of data model methods.
Generator/coroutines are relatively simple: you instantiate them then you send values into them.
display(
type(BankAccount()) , # Custom class, what can I do with this?
type(bank_account()), # generator. I can usually call next(…) and .send
)
__main__.BankAccount
generator
When I encounter a new class in some third-party code I am using, I first use dir(…) it to see what non-data-model methods are present. From there, I attempt to figure out how I can make use of this particular class and where it fits into the code. If the methods or attributes are unintuitive, then I try to find whatever documentation is available. If none of those introspection tricks help me make sense of the object, I fall back to reading the source.
However, if I encounter a generator coroutine, I know that the API space (the range of all possible methods and attributes) is incredibly small. Specifically for a generator I can perform only 1 meaningful action: I can pass values in and retrieve values back out via .send all I need to figure out is, "What should I be passing in here?" which is a much simpler task than the above scenario. With the coroutine, the interaction surface is so small that the entire, “How do I use this?” question becomes narrower and easier to answer.
(for those overly nitpicky individuals, yes I can call next(…), or .send or .throw or call .close. But let's be honest, next(…) and .send(None) are the exact same and there are small number of occurring instances where one would have a coroutine throw an exception or manually close it.)
So When Do I NEED Object Orientation
So, when do you know without a doubt that you need object orientation in Python? When you want to integrate your code with Python’s rich vocabulary. Do you want your data to be usable with the len function? Or maybe it needs to clean up external resources reliably, so it supports the context manager protocol? Or do you want to customize how it's displayed when printed? Or expose fast look-up behavior with getitem instead of a regular method?
This is when object orientation in Python isn’t just useful. It’s expected. If you want your code to work naturally with things like with, print, len, and in, then a class is the right tool.
But that’s very different from reaching for classes just to group related functions or manage state. We often do this to make code easier to write or reason about. But when you do, I want you to pause and ask: am I writing a class because it's the simplest thing to write? Or because I think I'm supposed to?
My advice? Keep it simple until complexity is needed. Write functions first. Refactor into a class later. Use a dataclass when you need to hold onto heterogeneous data and add methods only when the data really calls for it.
Wrap-Up
That's all from me this week. What do you think about object orientation? Do you ever find yourself leaning on it when there's a better option? Let me know on the DUTC Discord server! I'd love to hear your thoughts on Object Orientation in Python.