3.3. OOP Inheritance vs. Composition

3.3.1. Rationale

  • Composition over Inheritance

Please excuse me, for code style in this chapter. Instead writing:

>>> class Car:
...     def engine_start(self):
...         pass
...
...     def engine_stop(self):
...         pass

I will write:

>>> class Car:
...     def engine_start(self): pass
...     def engine_stop(self): pass

This way the code is more dense and idea is much clearer to present. There won't be any method implementations in examples.

3.3.2. Problem

  • Code duplication

>>> class Car:
...     def engine_start(self): pass
...     def engine_stop(self): pass
>>>
>>>
>>> class Truck:
...     def engine_start(self): pass
...     def engine_stop(self): pass

3.3.3. Inheritance

>>> class Vehicle:
...     def engine_start(self): pass
...     def engine_stop(self): pass
>>>
>>>
>>> class Car(Vehicle):
...     pass
>>>
>>> class Truck(Vehicle):
...     pass

3.3.4. Inheritance Problem

  • Motorcycle is a vehicle, but doesn't have windows.

>>> class Vehicle:
...     def engine_start(self): pass
...     def engine_stop(self): pass
...     def window_open(self): pass
...     def window_close(self): pass
>>>
>>>
>>> class Car(Vehicle):
...     pass
>>>
>>> class Truck(Vehicle):
...     pass
>>>
>>> class Motorcycle(Vehicle):
...     def window_open(self): raise NotImplementedError
...     def window_close(self): raise NotImplementedError

3.3.5. Multilevel Inheritance

>>> class Vehicle:
...     def engine_start(self): pass
...     def engine_stop(self): pass
>>>
>>> class VehicleWithWindows(Vehicle):
...     def window_open(self): pass
...     def window_close(self): pass
>>>
>>>
>>> class Car(VehicleWithWindows):
...     pass
>>>
>>> class Truck(VehicleWithWindows):
...     pass
>>>
>>> class Motorcycle(Vehicle):
...     pass

3.3.6. Composition

>>> class Vehicle:
...     pass
>>>
>>> class Engine:
...     def engine_start(self): pass
...     def engine_stop(self): pass
>>>
>>> class Windows:
...     def window_open(self): pass
...     def window_close(self): pass
>>>
>>>
>>> class Car(Vehicle):
...     engine: Engine
...     window: Windows
>>>
>>> class Truck(Vehicle):
...     engine: Engine
...     window: Windows
>>>
>>> class Motorcycle(Vehicle):
...     engine: Engine

3.3.7. Aggregation

>>> class Vehicle:
...     pass
>>>
>>> class Part:
...     pass
>>>
>>> class Engine(Part):
...     def engine_start(self): pass
...     def engine_stop(self): pass
>>>
>>> class Windows(Part):
...     def window_open(self): pass
...     def window_close(self): pass
>>>
>>>
>>> class Car(Vehicle):
...     parts: list[Part] = [Engine, Windows]
>>>
>>> class Truck(Vehicle):
...     parts: list[Part] = [Engine, Windows]
>>>
>>> class Motorcycle(Vehicle):
...     parts: list[Part] = [Engine]

3.3.8. Mixin Classes

  • More information in Method Resolution Order

>>> class Vehicle:
...     pass
>>>
>>> class HasEngine:
...     def engine_start(self): pass
...     def engine_stop(self): pass
>>>
>>> class HasWindows:
...     def window_open(self): pass
...     def window_close(self): pass
>>>
>>>
>>> class Car(Vehicle, HasEngine, HasWindows):
...     pass
>>>
>>> class Truck(Vehicle, HasEngine, HasWindows):
...     pass
>>>
>>> class Motorcycle(Vehicle, HasEngine):
...     pass

3.3.9. Case Study

Problem:

>>> class Astronaut:
...     def __init__(self, firstname, lastname):
...         self.firstname = firstname
...         self.lastname = lastname
...
...     def to_pickle(self):
...         import pickle
...         data = vars(self)
...         return pickle.dumps(data)
...
...     def to_json(self):
...         import json
...         data = vars(self)
...         return json.dumps(data)

This class contains methods, which could be also used by other classes, this will lower the amount of code to maintain. So we refactor and Extract superclass.

>>> class Serialize:
...     def to_pickle(self):
...         import pickle
...         data = vars(self)
...         return pickle.dumps(data)
...
...     def to_json(self):
...         import json
...         data = vars(self)
...         return json.dumps(data)
>>>
>>>
>>> class Astronaut(Serialize):
...     def __init__(self, firstname, lastname):
...         self.firstname = firstname
...         self.lastname = lastname

It's better. Now we can reuse Serialize class. However... Is that true, that each class can be serialized to JSON and Pickle at the same time?

We can improve code by splitting those capabilities into separate classes. In this case, the Multi level inheritance is a bad pattern here:

>>> class ToJSON:
...     def to_json(self):
...         import json
...         data = vars(self)
...         return json.dumps(data)
>>>
>>> class ToPickle(ToJSON):
...     def to_pickle(self):
...         import pickle
...         data = vars(self)
...         return pickle.dumps(data)
>>>
>>>
>>> class Astronaut(ToPickle):
...     def __init__(self, firstname, lastname):
...         self.firstname = firstname
...         self.lastname = lastname
>>>
>>>
>>> astro = Astronaut('Mark', 'Watney')
>>>
>>> print(astro.to_json())
{"firstname": "Mark", "lastname": "Watney"}
>>>
>>> print(astro.to_pickle())  
b'\x80\x04\x95I\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\tAstronaut' \
b'\x94\x93\x94)\x81\x94}\x94(\x8c\tfirstname\x94\x8c\x04Mark' \
b'\x94\x8c\x08lastname\x94\x8c\x06Watney\x94ub.'

It will work as intended for the end-user, but the code structure is disturbed. Not all classes which are serialized to Pickle, are also serialized to JSON. In out case it's a must. This kind of Multi-level inheritance could be found in languages which does not support Multiple inheritance. Java is such language. In that case, developers are not using inheritance, and they even go to the extreme, by considering inheritance a bad practice. They use composition:

>>> class ToJSON:
...     def to_json(self):
...         import json
...         data = {attrname: attrvalue
...                 for attrname, attrvalue in vars(self).items()
...                 if not attrname.startswith('_')}
...         return json.dumps(data)
>>>
>>> class ToPickle:
...     def to_pickle(self):
...         import pickle
...         data = {attrname: attrvalue
...                 for attrname, attrvalue in vars(self).items()
...                 if not attrname.startswith('_')}
...         return pickle.dumps(data)
>>>
>>>
>>> class Astronaut:
...     firstname: str
...     lastname: str
...     __json_serializer: ToJSON
...     __pickle_serializer: ToPickle
...
...     def __init__(self, firstname, lastname, json_serializer=ToJSON, pickle_serializer=ToPickle):
...         self.firstname = firstname
...         self.lastname = lastname
...         self.__json_serializer = json_serializer
...         self.__pickle_serializer = pickle_serializer
...
...     def to_json(self):
...         return self.__json_serializer.to_json(self)
...
...     def to_pickle(self):
...         return self.__pickle_serializer.to_pickle(self)
>>>
>>>
>>> astro = Astronaut('Mark', 'Watney')
>>>
>>> print(astro.to_json())
{"firstname": "Mark", "lastname": "Watney"}
>>>
>>> print(astro.to_pickle())  
b'\x80\x04\x95\xa3\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\tAstronaut\x94\x93\x94)\x81\x94}\x94(\x8c\tfirstname\x94\x8c\x04Mark\x94\x8c\x08lastname\x94\x8c\x06Watney\x94\x8c\x1b_Astronaut__json_serializer\x94h\x00\x8c\x06ToJSON\x94\x93\x94\x8c\x1d_Astronaut__pickle_serializer\x94h\x00\x8c\x08ToPickle\x94\x93\x94ub.'
>>>
>>>
>>> # It give me ability to write something better
>>> class MyBetterSerializer(ToJSON):
...     def to_json(self):
...         return ...
>>>
>>> astro = Astronaut('Mark', 'Watney', json_serializer=MyBetterSerializer)

This work as intended, and nothing changed for the end-user. This maybe a good pattern for Java, but for Python ecosystem is over-engineered (to complex for that particular usecase).

That was a must, because Java don't have Multiple inheritance and Simple inheritance or Multilevel inheritance was a bad idea. In Python there is Multiple inheritance capability which enables to create a small and specialized classes and mix them together in order to create objects. Those are called Mixin classes and they use multiple inheritance mechanism:

>>> class ToJSON:
...     def to_json(self):
...         import json
...         data = vars(self)
...         return json.dumps(data)
>>>
>>> class ToPickle:
...     def to_pickle(self):
...         import pickle
...         data = vars(self)
...         return pickle.dumps(data)
>>>
>>>
>>> class Astronaut(ToJSON, ToPickle):
...     def __init__(self, firstname, lastname):
...         self.firstname = firstname
...         self.lastname = lastname
>>>
>>>
>>> astro = Astronaut('Mark', 'Watney')
>>>
>>> print(astro.to_json())
{"firstname": "Mark", "lastname": "Watney"}
>>>
>>> print(astro.to_pickle())  
b'\x80\x04\x95I\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\tAstronaut' \
b'\x94\x93\x94)\x81\x94}\x94(\x8c\tfirstname\x94\x8c\x04Mark' \
b'\x94\x8c\x08lastname\x94\x8c\x06Watney\x94ub.'

3.3.10. Assignments

Code 3.3. Solution
"""
* Assignment: OOP Composition Multilevel
* Complexity: easy
* Lines of code: 8 lines
* Time: 3 min

English:
    1. Create class `MarsMission` from classes `Habitat`, `Rocket`, `Astronaut`
    2. Use multilevel inheritance
    3. Assignment demonstrates syntax, so do not add any attributes and methods
    4. Run doctests - all must succeed

Polish:
    1. Stwórz klasę `MarsMission` z klas `Habitat`, `Rocket`, `Astronaut`
    2. Użyj wielopoziomowego dziedziczenia
    3. Zadanie demonstruje składnię, nie dodawaj żadnych atrybutów i metod
    4. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass

    >>> assert isclass(Habitat)
    >>> assert isclass(Astronaut)
    >>> assert isclass(Rocket)
    >>> assert isclass(MarsMission)
    >>> assert issubclass(MarsMission, Habitat)
    >>> assert issubclass(MarsMission, Astronaut)
    >>> assert issubclass(MarsMission, Rocket)
"""


Code 3.4. Solution
"""
* Assignment: OOP Composition Composition
* Complexity: easy
* Lines of code: 2 lines
* Time: 5 min

English:
    1. Create class `MarsMission` from classes `Habitat`, `Rocket`, `Astronaut`
    2. Use composition
    3. Assignment demonstrates syntax, so do not add any attributes and methods (only type annotations)
    4. Run doctests - all must succeed

Polish:
    1. Stwórz klasę `MarsMission` z klas `Habitat`, `Rocket`, `Astronaut`
    2. Użyj kompozycji
    3. Zadanie demonstruje składnię, nie dodawaj żadnych atrybutów i metod (tylko anotacje typów)
    4. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass

    >>> assert isclass(Habitat)
    >>> assert isclass(Astronaut)
    >>> assert isclass(Rocket)
    >>> assert isclass(MarsMission)
    >>> assert MarsMission.__annotations__['habitat'] is Habitat
    >>> assert MarsMission.__annotations__['astronaut'] is Astronaut
    >>> assert MarsMission.__annotations__['rocket'] is Rocket
"""


Code 3.5. Solution
"""
* Assignment: OOP Composition Mixin
* Complexity: easy
* Lines of code: 8 lines
* Time: 3 min

English:
    1. Create class `MarsMission` from classes `Habitat`, `Rocket`, `Astronaut`
    2. Use mixins classes
    3. You can modify given classes
    4. Assignment demonstrates syntax, so do not add any attributes and methods
    5. Run doctests - all must succeed

Polish:
    1. Stwórz klasę `MarsMission` z klas `Habitat`, `Rocket`, `Astronaut`
    2. Użyj klas domieszkowych (mixin)
    3. Możesz modyfikować dane klasy
    4. Zadanie demonstruje składnię, nie dodawaj żadnych atrybutów i metod
    5. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass

    >>> assert isclass(Habitat)
    >>> assert isclass(Astronaut)
    >>> assert isclass(Rocket)
    >>> assert isclass(MarsMission)
    >>> assert issubclass(MarsMission, Habitat)
    >>> assert issubclass(MarsMission, Astronaut)
    >>> assert issubclass(MarsMission, Rocket)
"""


Code 3.6. Solution
"""
* Assignment: OOP Composition Decompose
* Complexity: easy
* Lines of code: 30 lines
* Time: 8 min

English:
    1. Refactor class `Hero` to use composition
    2. Name mixin classes: `HasHealth` and `HasPosition`
    3. Note, that order of inheritance is important
        a. Try to inherit from `HasPosition`, `HasHealth`
        b. Then `HasHealth`, `HasPosition`
        c. What changes?
    4. Run doctests - all must succeed

Polish:
    1. Zrefaktoruj klasę `Hero` aby użyć kompozycji
    2. Nazwij klasy domieszkowe: `HasHealth` i `HasPosition`
    3. Zwróć uwagę, że kolejność dziedziczenia ma znaczenie
        a. Spróbuj dziedziczyć po `HasPosition`, `HasHealth`
        b. A później `HasHealth`, `HasPosition`
        c. Co się zmieniło?
    4. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from random import seed; seed(0)
    >>> from inspect import isclass

    >>> assert isclass(Hero)
    >>> assert isclass(HasHealth)
    >>> assert isclass(HasPosition)
    >>> assert issubclass(Hero, HasHealth)
    >>> assert issubclass(Hero, HasPosition)
    >>> assert hasattr(HasHealth, 'HEALTH_MIN')
    >>> assert hasattr(HasHealth, 'HEALTH_MAX')
    >>> assert hasattr(HasHealth, '_health')
    >>> assert hasattr(HasHealth, 'is_alive')
    >>> assert hasattr(HasHealth, 'is_dead')
    >>> assert hasattr(HasPosition, '_position_x')
    >>> assert hasattr(HasPosition, 'position_set')
    >>> assert hasattr(HasPosition, 'position_change')
    >>> assert hasattr(HasPosition, 'position_get')
    >>> assert hasattr(Hero, 'HEALTH_MIN')
    >>> assert hasattr(Hero, 'HEALTH_MAX')
    >>> assert hasattr(Hero, '_health')
    >>> assert hasattr(Hero, '_position_x')
    >>> assert hasattr(Hero, 'is_alive')
    >>> assert hasattr(Hero, 'is_dead')
    >>> assert hasattr(Hero, 'position_set')
    >>> assert hasattr(Hero, 'position_change')
    >>> assert hasattr(Hero, 'position_get')
    >>> watney = Hero()
    >>> watney.is_alive()
    True
    >>> watney.position_set(x=1, y=2)
    >>> watney.position_change(left=1, up=2)
    >>> watney.position_get()
    (0, 0)
    >>> watney.position_change(right=1, down=2)
    >>> watney.position_get()
    (1, 2)
"""

from dataclasses import dataclass
from random import randint


@dataclass
class Hero:
    HEALTH_MIN: int = 10
    HEALTH_MAX: int = 20
    _health: int = 0
    _position_x: int = 0
    _position_y: int = 0

    def position_set(self, x: int, y: int) -> None:
        self._position_x = x
        self._position_y = y

    def position_change(self, right=0, left=0, down=0, up=0):
        x = self._position_x + right - left
        y = self._position_y + down - up
        self.position_set(x, y)

    def position_get(self) -> tuple:
        return self._position_x, self._position_y

    def __post_init__(self) -> None:
        self._health = randint(self.HEALTH_MIN, self.HEALTH_MAX)

    def is_alive(self) -> bool:
        return self._health > 0

    def is_dead(self) -> bool:
        return self._health <= 0