Modularity vs Inheritance
27 Jun 2021Further to my previous post, I wanted to talk a bit more about how the DownloadFn
is used, and why it’s defined as a stand-alone function.
The basic idea is we have something generating file ‘events’, and these events are passed to a ‘thing’ which does something with those events. This thing, I called Worker
because, for the life of me, I can’t think of a better name.
The worker interface looks like this
class Worker(Protocol):
def enact(event: Event): ...
This is pretty vague. Essentially, an event is passed to the Worker
and depending on the type of event it will do a different thing.
Worker
is a class, because it collects together methods for handling different events in a particular way. For example, we might have a POSIX worker, which enacts the events for a POSIX-based target filesystem. Or we might have a ‘logging’ worker, which simply writes the incoming events to a log file.
Now, I could have defined the interface as something more specific, like
class Workers:
def enact_create(self, create_event): ...
def enact_delete(self, delete_event): ...
def enact(self, event):
if event.type == EventType.CREATE:
self.enact_create(event):
# etc
But I didn’t want to over-specify the interface.
As a particular example, we can have MOVE
events and RENAME
events, but in the context of a POSIX worker, those correspond to the same operation - mv
Where the download function comes into play is in the create
method
Suppose we went with this approach
class Worker:
def download(self, source, target):
with source.open() as f:
target.write_bytest(f.read())
Now, as in the previous post, we want to extend the download functionality to do rollback on error. In a ‘traditional’ object-oriented approach, we might try something with inheritance, like
class RollbackWorker(Worker):
def download(source, target):
try:
super().download(source, target)
except Exception:
if target.exists():
target.unlink()
raise
Not so bad. But then if we want to add download verification…
Things quickly balloon. And the worst part is, we’re creating all these subclasses, but we’re only change one, auxiliary method - that is, the method we’re changing isn’t even part of the Worker
interface.
So instead, what I did was this
class Worker:
def __init__(self, downloadfn):
self.downloadfn = downloadfn
def create(self, event):
#...
self.downloadfn(source, target)
Now, we can drop in any of the bespoke download functions from the previous post, no subclasses required.
We can even drop in a mock one for testing
download = Mock()
worker = Worker(download)
worker.enact(create_event)
download.assert_called_with(...)
Not that there’s anything wrong with sub-classing, per se. But in this specific case, it made more sense to separate out the bit that can change (the download function) from the central class (Worker).
Chris.