2021-01-27
Discussion here and here . Call to arms for idiomatic OO code here .
People bash OO a lot these days, I'm increasingly coming to the opinion they're right, at least in Python. My point here is not to argue that OO is bad per se, more that its introduction is simply unnecessary, AKA not useful.
All OO code can be refactored into equivalent non-OO code that's as easy or more easy to understand.
Let's take an example that should pan out in OO's favour, we've all seen/written code somewhat like the following:
class ApiClient:
def __init__(self, root_url: str, session_cls: sessionmaker):
self.root_url = root_url
self.session_cls = session_cls
def construct_url(self, entity: str) -> str:
return f"{self.root_url}/v1/{entity}"
def get_items(self, entity: str) -> List[Item]:
resp = requests.get(self.construct_url(entity))
resp.raise_for_status()
return [Item(**n) for n in resp.json()["items"]]
def save_items(self, entity: str) -> None:
with scoped_session(self.session_cls) as session:
session.add(self.get_items(entity))
class ClientA(ApiClient):
def construct_url(self, entity: str) -> str:
return f"{self.root_url}/{entity}"
class ClientB(ApiClient):
def construct_url(self, entity: str) -> str:
return f"{self.root_url}/a/special/place/{entity}"
client_a = ClientA("https://client-a", session_cls)
client_a.save_items("bars")
We chose OO it because we wanted to bind the root_url
to something and we didn't want to pass around the sessionmaker
. We also wanted to utilise inheritance to hook into a method halfway through the call stack.
But what if we do just pass data around, and write 'boring' functions, what happens then?
@dataclass
class Client:
root_url: str
url_layout: str
client_a = Client(
root_url="https://client-a",
url_layout="{root_url}/{entity}",
)
client_b = Client(
root_url="https://client-b",
url_layout="{root_url}/a/special/place/{entity}",
)
def construct_url(client: Client, entity: str) -> str:
return client.url_layout.format(root_url=client.root_url, entity=entity)
def get_items(client: Client, entity: str) -> List[Item]:
resp = requests.get(construct_url(client, entity))
resp.raise_for_status()
return [Item(**n) for n in resp.json()["items"]]
def save_items(client: Client, session_cls: session_cls, entity: str) -> None:
with scoped_session(session_cls) as session:
session.add(get_items(client, entity))
save_items(client_a, session_cls, "bars")
We had to pass round the Client
and the session_cls
around.
🤷
Who cares? We even wrote like 10% fewer characters. Also, the conjecture stands, the resulting code is at least as easy to understand and we didn't need any OO.
I've heard this style referred to as the bag-of-functions style. That is to say, all your code just consists of typed data and module-namespaced-bags-of-functions.
Use this pattern to reuse config/db session classes over the lifetime of an application.
Just try writing without them, I promise it's going to be OK. (To be fair, it's only the introduction of type hints to Python that has made the bag-of-functions style so pleasant).
If you've taken the pure-FP/hexagonal-architecture pill, you want to write pure classes that take impure 'adapter' instances for getting-the-current-datetime/API-calls/talking-to-the-db/other-impure-stuff
. The idea is nice in principle - should be good for testing right? - in practice, you can just use
freezegun
/ use
responses
/
test with the db
(the other-impure-stuff
tends to not actually exist) and save yourself a lot of hassle.
I'd like to make exceptions for the following:
@dataclass
s in the refactored code, these are fine - they're just record types. Python 5 will only have these, not 'normal' classes.
Exception
s. The usage of try: ... except SomeClass: ...
fundamentally ties you to a heirarchical worldview, this is fine, just don't make it too complicated.
Enum
s - same as above, they fit in well with the rest of Python.
pandas.DataFrame
/ sqlalchemy.Session
. In general though, don't kid yourself that you're building anything that exciting, it's just vanity getting the better of you.
My point here is not to argue that OO is bad per se.
OK, I lied, it's not just a case of OO being a largely futile addition to the language, it's that it often obscures the problem at hand and encourages bad behaviours:
self
forces you to write functions with a smaller state-space that are easier to test.