Why is an omnivore a kind of vegetarian?
An important principle in object oriented design is that you can replace an object of one class with another object of its subclass, and the code should still work. For example, suppose we are creating a calorie counter for various foods. We have a parent class
class Food:
def __init__(self, name, calories=0):
self.name = name
self.calories = calories
and child classes
class Vegetable(Food):
is_leafy = True
and
class Meat(Food):
is_bloody = True
Consider a function that returns the calories of an item of food:
def get_calories(food):
return food.calories
Because a child class inherits the methods and attributes from its parent class - possibly overriding some and adding others - any code that expects Food will also work with Vegetable and Meat.
>>> get_calories(Food('gruel', 1))
1
>>> get_calories(Vegetable('potato', 100))
100
>>> get_calories(Meat('steak', 500))
500
This ability to replace with a subclass is known as the “Liskov Substitution Principle”.
Now let’s turn to the consumers of food. Here is a possible class hierarchy:
class Omnivore:
def eat(self, food):
print(food.name, "YUM!")
class Vegetarian(Omnivore):
def eat(self, food):
if not isinstance(food, Vegetable):
raise Exception(food.name + " EWW")
super().eat(food)
class Carnivore(Omnivore):
def eat(self, food):
if not isinstance(food, Meat):
raise Exception(food.name + " EWW")
super().eat(food)
Vegetarian and Carnivore override the eat method of Omnivore to raise an exception when fed an argument that violates their respective dietary restrictions. An omnivore can consume both Vegetable and Meat:
>>> guest = Omnivore()
>>> guest.eat(Vegetable('potato'))
potato YUM!
>>> guest.eat(Meat('steak'))
steak YUM!
But a vegetarian cannot consume Meat:
>>> guest = Vegetarian()
>>> guest.eat(Vegetable('potato'))
potato YUM!
>>> guest.eat(Meat('steak'))
Traceback (most recent call last):
...
Exception: steak EWW
The code breaks when an Omnivore is replaced by a Vegetarian. Therefore, the Liskov Substitution Principle implies, perhaps counterintuitively, that Vegetarian is not a subclass of Omnivore.
This dilemma is resolved by observing that a Vegetarian can be replaced by an Omnivore, and hence the class hierarchy should be inverted - Omnivore being a subclass of Vegetarian. Of course, we can repeat the argument for Carnivore, so Omnivore inherits from both Vegetarian and Carnivore:
class Vegetarian:
def eat(self, food):
if not isinstance(food, Vegetable):
return super().eat(food)
print(food.name, "YUM!")
class Carnivore:
def eat(self, food):
if not isinstance(food, Meat):
return super().eat(food)
print(food.name, "YUM!")
class Omnivore(Vegetarian, Carnivore):
pass
The key difference here is that, instead of raising an error, Vegetarian and Carnivore pass the call to eat up the inheritance chain, hoping that another class is able to accept the kind of food. The implementation of Omnivore is now trivial and intuitive - nothing more than the union of the consumers of Vegetable and Meat.
>>> guest = Vegetarian()
>>> guest.eat(Vegetable('potato'))
potato YUM!
>>> guest = Omnivore()
>>> guest.eat(Vegetable('potato'))
potato YUM!
>>> guest = Carnivore()
>>> guest.eat(Meat('steak'))
steak YUM!
>>> guest = Omnivore()
>>> guest.eat(Meat('steak'))
steak YUM!
We see that a Vegetarian or Carnivore can be replaced by an Omnivore, so our class hierarchy obeys Liskov Substitution. This example also illustrates a general rule: a method of the child class should accept an argument that is less restrictive (or not more restrictive) than the corresponding method of the parent class. An Omnivore can eat either Vegetable or Meat, whereas a Vegetarian or Carnivore can only eat one of the food classes. In other words, even though a child class is more specific than its parent, the child’s methods take arguments that are more general. This property of arguments is known as “contravariance”.