Functions vs. Object Oriented Programming (OOP)
Objects vs. Functions
I volunteer with a few groups of new software developers and a question that keeps coming up is: "why should I use objects?". Typically this is couched in something like: "I just have a lot of functions organised into files, and I'm not sure what reorganising all of my code to be OOP would really do for me".
So I thought I'd write out a detailed explanation with examples & such for those who might find it useful. Here goes:
It's ok not to OOP
I love me some objects. In fact, I'll often use objects for no other reason than to have my code neatly tucked into classes, even when a function will do, but I'm crazy like that. The truth is, a lot of smaller projects & scripts don't need OOP to do what you want, but once you really get a handle on what they can do for you, you might find that you're Classifying All The Things.
Regardless, please don't read this as some sort of "Classes are the One True Path" rant, 'cause it's not.
Foodz
I like food, so the examples I'm going to use are food-based. For our exercise we're going to be writing code for an imaginary kitchen run by robots, an idea that in 2018 really isn't all that crazy.
We've got 50 people to feed at 20 tables. Our code needs to keep track of who ordered what from what table, as well as prepare the food in the kitchen and deliver it to our hungry patrons. That's a lot of code, so we won't be writing it all out here. Instead, I'll break down a rough idea of how this might be done using procedural code (functions in files) vs. object-oriented code.
The Procedural Way (functions)
When you're working with functions, you're effectively pushing data around and modifying it when necessary. For our kitchen example, you might start with a complex array of data for our patrons:
patrons = [
{
"name": "Amber",
"table": 1,
"order": [
{"name": "House Salad", "price": 350, "dressing": "Ranch"},
{"name": "Death by Chocolate", "price": 250, "flavour": "Chocolate"}
]
},
{"name": "Brianne", "table": 1, "order": ["steak", "cake"]},
{"name": "Charlie", "table": 2, "order": ["chicken burger", "pudding"]},
{"name": "Dianna", "table": 2, "order": ["salad", "pudding"]},
...
]
That takes care of who is sitting where and what they ordered. Next we also need instructions for making the food, which uses a branching system of rules:
def make_food(food: str) -> str:
if food == "salad":
return make_salad()
if food == "burger":
return make_burger()
if food == "steak":
return make_steak()
Each of those functions in turn might call other functions to do the "making",
for example, the steak might include something like marinade()
or salad.py
could have another function inside it called make_dressing()
which is called
from inside make_salad()
. The key thing to note here is that each of these
functions are either very specific, or contain branching code to decide which
thing to call next. It can get very messy, very fast as you add more and more
types of food.
Finally, we need to serve the stuff. The kitchen will announce to the waitbots that the food is ready (that's an exercise in queues & pub/sub for another day) and the waitbots will bring the food to everyone.
To do this, the food plates will have a type
, like salad
or burger
, but
we also need to include who the food is for. To do this procedurally, this
usually involves bundling bits of information together and passing that around,
so your business logic might do something like:
def process_order(patron: dict) -> None:
for food in patron["order"]:
deliver_to_table(patron["table"], make_food(food))
for patron in patrons:
process_order(order)
As make_food()
returns prepared food, then we can just pass the result of
the food making to deliver_to_table
along with the table number and we're
good, right?
The thing is, this is really complicated, and we've got a lot of raw data
floating around that's created a rather rigid system. What if a patron changes
their mind and wants to order a side of fries? What about all the different
types of burgers out there, are we going to create a separate method for
make_chicken_burger()
, make_veggie_burger()
, and make_cow_burger()
, or
just one larger method with a bunch of if pattie == "chicken":
code in it?
At first glance, this looks like something that will work, but in the long run, managing and extending this code is going to be painful.
The Object-oriented Way
Objects have two primary strengths:
- Encapsulation
- Extendability
I'm going to explain both before we get to how to run this kitchen the OOP way.
Encapsulation
It's just a fancy way of saying that objects know how to do stuff. Rather than taking raw data and acting upon it, you just create an object and tell it to do a high-level thing -- it will figure out the rest.
For example, assume that cooking a steak requires using a grill and (hopefully) includes a long series of instructions on how to use that grill. Assume also that it involves a marinade or rub and maybe a selection of sauces to apply.
In a procedural system, you'd have a function called make_steak()
which would
likely have internal calls to maridade_steak()
and to grill_steak()
. These
functions would likely be different from the instructions for cooking cow
burgers or chicken burgers, so each food type would require its own special
function with its own rules. Sure, you can probably have some functions
cross-call each other, but the more you do that, the messier your code becomes.
In an OOP system, the interface is as simple a calling steak.prepare()
--
the steak
object will figure out the rest for you.
Extendability
The "figure out the rest" part is only different from the procedural method because you can do stuff like subclassing in OOP. A rib-eye steak is a lot like a sirloin steak, but there may be slight differences in the preparation. Subclassing means that you can take the standard rules for steak preparation and extend them for your specific case. Suddenly your code is a lot simpler.
Have a look at the examples to see what I mean.
Our OOP Kitchen
We need to keep track of our patrons: where they're sitting, what they've
ordered, so we could create a Patron
class, but as the patrons aren't
really doing anything in our exercise, this would kinda be overkill. I mean
you could create a class called Patron
, but it wouldn't do much more than
hold data, which a dictionary
or list
will do just fine already:
class Patron:
def __init__(self, name: str, table: int, orders=None) -> None:
self.name = name
self.table = table
self.orders = orders or []
Well it's neat & clean, so let's keep it for now. I suppose we could later
extend Patron
to include a .pay()
method that would tally the costs for
their meal table and pay from their bank account, but for now, let's just use
this as an example of a really simply class.
Now, as each person comes through the door and they're seated, we create new Patron objects and attach them to our list of patrons:
patrons = [
Patron(name="Amber", table=1, order=["salad", "cake"]),
Patron(name="Brianne", table=1, order=["steak", "cake"]),
Patron(name="Charlie", table=2, order=["burger", "pudding"]),
Patron(name="Dianna", table=2, order=["salad", "pudding"]),
...
]
So far, not very useful. It's basically the same as our procedural system. However now let's make our code smarter and do away with these strings for the food, replacing them with objects.
We'll start with a Food
class:
class Food:
def __init__(self, name, price) -> None:
self.name = name
self.price = price
self.calories = 0
self.is_prepared = False
self.is_served = False
def prepare(self) -> None:
self.is_prepared = True
def serve(self) -> None:
self.is_served = True
def get_price(self) -> int:
return self.price
We now have a thing (food) that knows how to prepare itself. We can create
an instance of food, and it will know what it means to prepare itself. Of
course right now, all it does is set .is_prepared = True
, but should that
ever need to change to say, notify a central server that a particular food was
just prepared, you only need to modify the Food
class and your business logic
won't know the difference.
So that's encapsulation, but let's do the extension part. Let's define a series of different foods:
class Salad(Food):
def __init__(self, name, price, dressing) -> None:
super().__init__(name, price)
self.dressing = dressing
self.calories = 50
def prepare(self) -> None:
self._add_dressing()
super().prepare()
def _add_dressing(self):
self.calories += 200
class Burger(Food):
def __init__(self, name, price) -> None:
super().__init__(name, price)
self.temperature = 22
self.calories = 300
self.pattie = None # Defined in the subclasses
def prepare(self) -> None:
self._grill()
self._add_bun()
super().prepare()
def _grill(self):
self.temperature = 100
self.calories += 20
def _add_bun(self):
pass # Obviously important, but I'm not sure how to code this.
class ChickenBurger(Burger):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.pattie = "chicken"
class CowBurger(Burger):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.pattie = "cow"
self.calories += 30
This is the magic of extending your classes: you get to be lazy.
We defined Food
once and since everything else is a kind of food, we extend
that class to further define the food we're talking about:
- Salads are low on calories, but need dressing
- Cow burgers are calorific
- Chicken burgers have more calories than salads, but fewer than cow burgers
- Both cow & chicken burgers are prepared the same way, so we have the
intermediary
Burger
class that knows how to._grill()
and._add_bun()
. - Salads need to have an
_add_dressing()
step in their preparation.
Note that everywhere, we're calling super()
to make sure that we get the
benefits of the parent class. Not only that, but doing this ensures that if we
change the parent class (say we start notifying someone that food has been
prepared), then all the child classes automatically get that happening.
Now, let's go back to our patron definition and spice that up a bit:
patrons = [
Patron(
name="Amber",
table=1,
order=[
Salad(name="House", price=350, dressing="Ranch"),
Cake(name="Death by Chocolate", price=250, flavour="Chocolate")
]
),
Patron(
name="Brianne",
table=1,
order=[
Steak(name="Rib Eye", price=1399)
Cake(name="Lemontastic", price=200, flavour="lemon")
]
),
...
]
Now your business logic looks like this:
for patron in patrons:
for food in patron.orders:
food.prepare()
food.serve(patron.table)
At this stage, your code is ready to be extended like crazy just by editing the objects themselves:
- If you've introduced a new buffalo burger that has special preparation
instructions like combining with coriander, you can just create a subclass
of
Burger
with special instructions to do just that. - If you run a promotion on burgers, you can override
.get_price()
in yourBurger
class and put your discounting logic in there to affect all burgers. - If you want to trigger a notification of some kind when the food is served,
you just update the code in
Food.serve()
As a bonus, your code is a lot cleaner because your function calls aren't
riddled with all of these suffixes like prepare_steak()
, grill_steak()
,
etc. Instead, you have one concise interface: .prepare()
and .serve()
.
Let the object figure out what that means to it.
Finally, your objects know more about themselves, say for example, we now want
to tally a per-patron bill for the end of the night. OOP makes this easy, we
just update our Patron
class with a .get_bill()
method:
class Patron:
def __init__(self, name: str, table: int, orders=None) -> None:
self.name = name
self.table = table
self.orders = orders or []
def get_bill(self) -> None:
for order in self.orders:
print(f"{order.name}: {order.price}")
After that, you need only call .get_bill()
on each patron to get what they
owe. No looping over complex data sets, or calling special functions for
calculations. You could even modify Patron
to allow for coupon discounts --
your interface is the same: .get_bill()
while the Patron knows what that
means.
Comments
Alan Verdugo
6 Jun 2020, 7:15 p.m. |
Wow. Great post! I've been struggling with OOP and to be honest I feel very insecure about it (I think I know things, but I am not sure if I got them right), but this post was perfectly clear.
Post a Comment
Preview