The Zen of Python is a famously foundational set of principles that establishes the concept of pythonic coding. It's a pretty good guide for noobs to help them figure out all the wild unknowns of the programming world and strive towards a clear and reasonable direction on their programming journey - especially since python is one of the most popular languages for learning to code in 2023. However, I often find the phrase "There should be one-- and preferably only one --obvious way to do it." rather misleading.
Admittedly, I am not-so-humbly a far stretch from a beginner programmer now and neither did I start learning programming with python, but I still think that you should hear me out.
First of all, let's hyperfixate on this "obvious way". In programming, there are a couple of dominant paradigms you see in play right now - Object Oriented Programming, and Functional Programming being perhaps the two with the most exposure.
OOP relies on Classes and treats "objects"/"actors"/"things" as first-class citizens. It's often seen in languages such as Java, C#, and Smalltalk - a pure object-oriented language. Functional programming treats logic as a first-class citizen, doing away with silly things like "state" and "parents", and is often seen in languages such as Elixir and Haskell. But the reality is that the vast majority of general-purpose programming languages out there are multi-paradigm and support both OOP and FP. This includes many of your favourites like Java, C#, PHP, Fortran, R, JavaScript and Kotlin.
Introducing: gorilla fruit
Let's start at the beginning with a pretty simple code block that lists out some items. Let's say we want to know the ingredients in a gorilla's fruit salad:
def gorilla(fruit_salad):
print("The gorilla's fruit salad contains the following unique items:")
for fruit in fruit_salad:
print("\t", fruit)
if __name__ == '__main__':
gorilla({'apple', 'banana', 'orange'})
$ python gorilla.py
The gorilla's fruit salad contains the following unique items:
apple
orange
banana
It's pretty straightforward. We are just printing out the items like so.
What is that if name == 'main' stuff?
But because I am not a savage I'm going to add the currently completely useless python type hints like so:
def gorilla(fruit_salad: set) -> None:
print("The gorilla's fruit salad contains the following unique items:")
for fruit in fruit_salad:
print("\t", fruit)
Object-oriented route
You may be asking yourself what the relationship is between gorillas and fruit salads. We can solve this through the explicit structure of Object Oriented programming.
class Gorilla:
def __init__(self, fruit_salad: set) -> None:
self.fruit_salad = fruit_salad
def print_fruits(self) -> None:
print("The gorilla's fruit salad contains the following unique items:")
for fruit in self.fruit_salad:
print("\t", fruit)
if __name__ == "__main__":
g = Gorilla({"apple", "banana", "orange"})
g.print_fruits()
Now it's much more clear. A gorilla has a property called "fruit salad". It can be considered a thing that the gorilla has - i.e. an attribute.
Remind me what the init() function is?
Gorilla
and there is one object called g
of this class.But why stop here when we know that we can treat each type of fruit as an entity too? For additional posterity, let's say a new business requirement was added to track the nutritional value of each piece of fruit - either "low", "medium", or "high":
from enum import Enum
from typing import Set
class NutritionalValue(Enum):
LOW = 1
MEDIUM = 2
HIGH = 3
class Fruit:
def __init__(self, name: str, nutritional_value: NutritionalValue) -> None:
self.name = name
self.nutritional_value = nutritional_value
class Gorilla:
def __init__(self, fruit_salad: Set[Fruit]) -> None:
self.fruit_salad = fruit_salad
def print_fruits(self) -> None:
print("The gorilla's fruit salad contains the following unique items:")
for fruit in self.fruit_salad:
print("\t", fruit.name)
if __name__ == "__main__":
apple = Fruit("apple", NutritionalValue.MEDIUM)
banana = Fruit("banana", NutritionalValue.HIGH)
pear = Fruit("pear", NutritionalValue.HIGH)
g = Gorilla({apple, banana, pear})
g.print_fruits()
Look at that - a perfect class-based architecture. Why not use an enumeration to define our nutritional values? Nobody can stop us. We can do this by inheriting from the Enum
class provided by python.
And with that, we've taken a really simple, three-line function and turned it into a bloated 35-line file with two library imports. You may be asking yourself why anyone in their right mind would do such a thing, and you would be totally valid to do so.
You want to design code to be open to change and resilient enough to withstand it. You also want your code to be easily understood.
Trying to write one-liners is the programming equivalent of the fitness world's "ego-lifting". It looks cool and it might feel good but if you keep it up sooner or later you are going to fall into a world of pain. If this is you, adjust your internal compass to assess what good code is accordingly.
Let's see what we can do with functional programming instead.
Functional programming route
def gorilla(fruit_salad: set) -> None:
print("The gorilla's fruit salad contains the following unique items:")
[print("\t", fruit) for fruit in fruit_salad]
if __name__ == "__main__":
gorilla({"apple", "pear", "orange"})
What is more functional than a good old iterator? Because we aren't even returning anything from this function, we don't even need to assign anything to its result. We are simply going to go over every fruit, applying our slightly modified print()
logic.
However, there is the argument to be made here that by doing this we have lost a bit of readability. That's why it's important to be as explicit as possible when using FP, also relying on patterns such as encapsulation to keep things tidier.
Instead of going directly into an iterator, what if we created our own custom generator function? Generator functions in python are any function that ends with one or more yield
statements instead of the classic return
statement. They work differently than regular functions as they can be invoked repeatedly to produce a series of yielded outputs.
from typing import Set, Iterator
def format_fruit(fruit_salad: Set[str]) -> Iterator[str]:
for fruit in fruit_salad:
yield f"\t{fruit}"
def gorilla(fruit_salad: set) -> None:
print("The gorilla's fruit salad contains the following unique items:")
for formatted_fruit in format_fruit(fruit_salad):
print(formatted_fruit)
I've also updated the type hints to be a bit stronger. Even though format_fruit
is a generator function, in this case, it makes more sense to type hint the return value as Iterator
, because I generally agree with this guy.
You may have noticed something - in this example, we are using a for
block which we refactored out previously with an iterator. And previously we still had our entire "logic" i.e. print("\t", fruit)
in the iterator which here we have refactored into a generator function. Can we combine these two refactors together?
from typing import Set, Iterator
def format_fruit(fruit_salad: Set[str]) -> Iterator[str]:
for fruit in fruit_salad:
yield f"\t{fruit}"
def gorilla(fruit_salad: Set[str]) -> None:
print("The gorilla's fruit salad contains the following unique items:")
[print(formatted_fruit) for formatted_fruit in format_fruit(fruit_salad)]
Or if you are even more insane, why not move all the logic into the generator function:
from typing import Set, Iterator
def format_fruit(fruit_salad: Set[str]) -> Iterator[str]:
for fruit in fruit_salad:
yield print(f"\t{fruit}")
def gorilla(fruit_salad: Set[str]) -> None:
print("The gorilla's fruit salad contains the following unique items:")
[() for formatted_fruit in format_fruit(fruit_salad)]
Now that is what I call delightfully functional python code. Guido would hate it!
So that's been two ways of applying refactors to turn perfectly reasonable python code into something much more complicated. You witnessed how Object-Oriented code tends to become much longer and verbose with an object-first approach, whilst Functional code becomes condensed and, as a result, harder to keep readable, yet maintains a stateless mathematical purity in its workings.
Which did you prefer? Remember, "There should be one-- and preferably only one --obvious way to do it."