visit
I've seen many Python interfaces in my career (as in API, not UI). You can quickly spot an ugly one by its size. Below are some recipes on how to make a neat one!
I’m going to assume that you read all that and decided to go with interfaces.
I think Protocols
are the best way to create interfaces in Python. This is what we are going to use here.
Here, we are going to discuss what makes a good interface.
class Animal(Protocol):
def current_weight(self) -> float: ...
def price(self) -> float: ...
def sleep(self, hours): ...
def eat(self, food): ...
def draw(self, context): ...
def look_for_food(self): ...
def hunger_level(self) -> float: ...
def is_asleep(self) -> bool: ...
def is_awake(self) -> bool: ...
def current_position() -> Tuple[float, float, float]: ...
def current_orientation() -> RotationMatrix: ...
def current_transform3d() -> HomogeneousMatrix: ...
What are the recipes for reducing an interface's size?
Sometimes there could be some obvious duplication methods (e.g., get_weights
vs. current_weight
). But more often you'll find methods that somewhat overlap in semantics. After a bit of thought, it's possible to remove some of them.
def current_position() -> Tuple[float, float, float]:
pass
def current_orientation() -> RotationMatrix:
pass
def current_transform3d() -> HomogeneousMatrix:
pass
The user extract position and orientation from the HomogeneousMatrix
returned by current_transform3d
. The other two getters could be replaced by .
def is_asleep(self) -> bool:
pass
def is_awake(self) -> bool:
pass
Is it true that animal.is_asleep() == not animal.is_awake()
? If yes, then you better remove one of them.
After removing duplicates, you should try to split the interface into independent parts.
Most likely, you don’t use all Animal
features in every part of your software:
1. there could be a function that handles the drawing of animals that uses only current_transform3d
and draw
:
def draw_entity(animal: Animal):
t = animal.current_transform3d()
c = some_drawing_context(t)
animal.draw(c)
2. there could be an animal life cycle algorithm:
def life_management(animal: Animal):
eating_logic(
animal.hunger_level(), animal.look_for_food(),
animal.eat())
sleeping_logic(animal.is_awake(), animal.sleep())
3. and a Zoo management system
def purchase_decision(animal: Animal, budget: float) -> bool:
w = animal.current_weight()
p = animal.price()
decide_to_buy(p, w, budget)
class VisualEntity(Protocol):
def current_transform3d() -> HomogeneousMatrix: ...
def draw(self, context): ...
class BehavingAgent:
def sleep(self, hours): ...
def eat(self, food): ...
def look_for_food(self): ...
def hunger_level(self) -> float: ...
def is_awake(self) -> bool: ...
class ZooAsset:
def current_weight(self) -> float: ...
def price(self) -> float: ...
Now you can see the advantages of many small interfaces over a single big one:
class BehavingAnimal(Protocol):
def lifecycle(self):
""" What happens during 24 hours period """
def eat(self, food):
""" What happens when the animal consumes food """
def sleep(self, hours):
""" What happens when the animal sleeps """
It is unlikely that an animal will spend a day without eating and sleeping. So there is a high chance that eat
and sleep
will be called from the lifecycle
implementation. Such an interface couples together two levels of abstraction. These are two interfaces in one, similar to the previous section.
A "lifecycle" part is used in some global application contexts, but eat
and sleep
are probably used only locally inside it.
class ElementaryAnimal(Protocol):
def eat(self, food): ...
def sleep(self, hours): ...
class LifecycleManagement(Protocol):
def lifecycle(self, animal: ElementaryAnimal): ...
Notice that now lifecycle
takes ElementaryAnimal
as an argument.
This clearly states that a lifecycle depends on something that can eat and sleep.
class Creature(Protocol):
def act(self, *args, **kwargs) -> object:
""" Do anything you want """
def perform_action(creature: Creature):
custom_args = ...
creature.act(*custom_args)
def perform_action(actor: Callable[..., Any]):
custom_args = ...
actor(*custom_args)
Related bonus topic: have you noticed that __init__
is typically not a part of any interface?
Why not? It seems like it is just another method you can have.
“A virtual call is a mechanism to get work done given partial information. To create an object, you need complete information. Consequently, a "call to a constructor" cannot be virtual.”
To summarize, a good interface should:
__init__
Originally published at .