(Fowler) Refactoring introduces one of my favorites patterns, called the Command (formerly “method object”). This is an object built around a single method. He gives two refactoring patterns to use it:
Replace function with command (method object) Replace command (method object) with function
Python makes this particularly attractive because it gives you the __call__ method to make it seamless:
class ThingDoer:
def __init__(self, ...):
...
def __call__(self, ...):
...
do_thing = ThingDoer(...)
do_thing(...)
This construct makes a lot of sense when ThingDoer needs to manage persistent state across multiple calls to do_thing.
I often use it in a more underhanded way, however. Often, I need to go through a fairly lengthy process of setting things up for a single call. The traditional approach would be to use a Factory to construct some fairly lightweight function or command. But you’re only using it in one place.
In this case, I like to put the factory as a class method of my Command object, keeping the constructor free of side-effects for simpler unit testing. This keeps everything together, while still maintaining some separation:
class ThingDoer:
def __init__(self, x, y):
self.x = x
self.y = y
@classmethod
def build(cls, ...) -> "ThingDoer":
x = ...
y = ...
return cls(x, y)
def __call__(self) # Notice no arguments!
...
What makes this underhanded is that we don’t end up passing in the setup arguments in the __call__ method but rather in the build method. This is not the end of the world, but it does come with some significant drawbacks:
- It makes the call’s behavior more opaque. What exactly is this call doing? Viewed in isolation, it seems to work purely by side effects.
- If you aren’t deeply familiar with Python, you may well have no idea what’s happening here. If the code reaches a larger team of engineers, the result may be accelerated rot.
This is all the more true if you also use @dataclass to make it even more succinct:
@dataclass
class ThingDoer:
x: int
y: str
@classmethod
def build(cls, ...) -> "ThingDoer":
x = ...
y = ...
return cls(x, y)
def __call__(self) # Notice no arguments!
...
I think this is beautiful, but people have certainly viewed it with shock.