Skip to content

Chapter 03. Objected Oriented Programming in Python

We have already seen the perks and pitfalls of Python as a dynamic language and its duck typing. Now we will explore both object oriented foundations and solid concepts applied through Python programming.

Foundations

  • Encapsulation.
    • Define public, protected and private data.
    • Public data can be accessed ‘from the outside’.
    • Protected data only internally (when using inheritance).
    • Private data only in the declared class.
  • Inheritance and abstractions:
    • Definition of generic functionality and properties in the base class.
    • Concrete methods in the inherited classes.
    • Abstract classes need a concrete inherited class (specialisation).
    • A class can inherit from an abstract class or another concrete class.
  • Polymorphism.
    • The same interface applies for different data types or classes
    • Can be applied to classes (through inheritance) and methods.
  • Aggregation and composition.
    • Aggregation: the associated objects do not need each other ‘to exist’.
    • Composition: the associated objects ‘need’ each other to ‘coexist’. The main object owns

Encapsulation

In python encapsulation does not really exist, think of it more like a 'rule of conduct'. We identify protected (internal) methods and parameters with a simple underscore _ and private with double __ so: - def _get_my_variable(...) -> Any is meant to be used only while developing in the tool. Preferrebly within an instanced object. - def __get_my_variable(...) -> Any is only meant to be used within its class / module. And not to be exposed.

class BasicEncapsulation:
    def __init__(self):
        self.public_property = 42
        self._protected_property = 4.2
        self.__private_property = 0.42

    def public_method(self):
        pass

    def _protected_method(self):
        pass

    def __private_method(self):
        pass

Inheritance

We have already shown simple inheritance, but of course Python allows us to do multiple inheritance whenever needed.

class DikeProfile:

    characteristic_points: List[Point]

    def __init__(self) -> None:
        self.characteristic_points = []

class DikeReinforcement:
    reinforcement_input: DikeReinforcementInput

    def __init__(self) -> None:
        self.reinforcement_input = None

class DikeReinforcementProfile(DikeProfile, DikeReinforcement):
    def __str__(self) -> str:
        return "Reinforced Profile"


_drp = DikeReinforcementProfile()
assert isinstance(_drp, DikeReinforcement)
assert isinstance(_drp, DikeProfile)
assert isinstance(_drp, DikeReinforcement)

Abstractions

To create abstract methods or classes we use the library ABC:

from abc import ABC, abstractmethod

class DikeProfileBase(ABC):
    characteristic_points: List[Point]

    def __init__(self) -> None:
        self.characteristic_points = []

    @abstractmethod
    def __str__(self) -> str:
        raise NotImplementedError("Implement in concrete class")

    def set_points_from_tuples(self, tuple_list: List[Tuple[float, float]]):
        if not tuple_list:
            raise ValueError("tuple_list argument required.")
        self.characteristic_points = list(map(Point, tuple_list))

class DikeProfile(DikeProfileBase):
    ...
    def __str__(self) -> str:
        return "Initial Dike Profile"

    @classmethod
    def from_tuple_list(cls, tuple_list: List[Tuple[float, float]]) -> DikeProfile:
        _dike = cls()
        _dike.set_points_from_tuples(tuple_list)
        return _dike

assert issubclass(DikeProfile, DikeProfileBase)

Polymorphism

Although it's possible to apply polymorphism in Python. In my experience is seldom used. Concrete methods and well-applied SRP provide better code.

class DikeProfileCalculator:
    def calculate_characteristic_points(self):
        # Base dike profile operations.
        pass
class PipingDikeProfileCalculator(DikeProfileCalculator):
    def calculate_characteristic_points(self):
        # Reinforcement piping dike operations.
        return super().calculate_characteristic_point

SOLID

Single responsibility principle.

By creating 'builders' and leaving the classes only as datastructures we reduce the amount of responsibility a class needs to do.

Open for extension, closed for modification.

A class should be extendable without modifying the class itself. Whenever you start having an if-else to differenciate behaviors, try to create a new concrete class.

  • Given:

    class DikeMaterial:
        def __init__(self, cost: float, material_type: str):
            self.cost = cost
            self.material_type = material_type
    
        def get_material_cost(self) -> float:
            if self.material_type == "sand":
                return self.cost
            else:
                return self.cost * 1.5
    
    def get_total_cost(self, material_list: List[DikeMaterial]) -> float:
        return max(_material.get_material_cost() for _material in material_list)
    
    _material_list = [DikeMaterial(2.4, "sand"), DikeMaterial(4.2, "clay")]
    get_total_cost(_material_list)
    

  • We can do instead:

    class DikeMaterial:
        def __init__(self, price: float):
            self.price = price
    
        def get_material_cost(self) -> float:
            return self.price
    
    class ClayMaterial(DikeMaterial):
        def get_material_cost(self):
            return self.price * 1.5
    
    _material_list = [Bridge(2.4), ClayMaterial(4.2)]
    _costs = sum(_m.get_material_cost() for _m in material_list)
    

In addition, initaiting classes through classmethods, or external constructors / factories might allow you to easily create one or the other. Delegating even more responsibilities and being more aligned with the Open-closed principle. Example:

from typing import Protocol
from __future__ import annotations

class MaterialProtocol(Protocol):
    price: float
    name: str

    def get_cost(self) -> float:
        pass

class SandMaterial(MaterialProtocol):
    price: float

    def get_cost(self) -> float:
        return self.price * 1.14

    @classmethod
    def initiate_with_price(cls, price: float) -> Bridge:
        # Through class methods.
        _material = cls()
        _material.price = price
        return _material

def build_material(material_type: Type[MaterialProtocol], price: float) -> MaterialProtocol:
    # Through a 'builder'
    _material = bridge_type()
    _material.price = price
    return _material

Liskov substitution.

A subclass must be substitutable by its super class. In my opinion, this principle is not really applyable in Python. Examples below:

class SuperSand(SandMaterial):
    def get_cost(self) -> float:
        # Super taxed!
        return self.price * 2


_material = SuperSand.initiate_with_price(2)
# 1. First we prove the bridge is the same type as the base.
assert isinstance(_material, SandMaterial), "Failed principle!"

# 2. Then we prove the cost will be the same regardless of how it is typed (parent or subclass)
def get_as_sand_material(s_material: SandMaterial) -> float:
    return s_material.get_cost()

# To make it work, we should invoke 'super':
_m_cost = _material.get_cost()
assert get_as_sand_material(super(SuperSand, _material)) != _m_cost, "Failed principle!"
assert get_as_sand_material(_material) != _m_cost, "Failed principle!"

Interface segregation principle.

Intefaces in Python are relatively "new", you can implement interfaces through typing.Protocol.

from typing import List, Protocol, Tuple
from shapely.geometry import Point
from typing_extensions import runtime_checkable

@runtime_checkable
class DikeProfileProtocol(Protocol):
    characteristic_points: List[Point]
    height: float
    width: float

class DikeProfile(DikeProfileProtocol):
    ...

_dike = DikeProfile()
assert isinstance(_dike, DikeProfileProtocol)
# A protocol can't be instantiated, try this:
DikeProfileProtocol()

Notice that the properties can be defined as inlines in the protocol. As long as they are declared later on (either with decorators or as inlines) the contract will be fulfilled.

Pay attention as well to @runtime_checkable, it will allow us to verify whether an instance implements said protocol.

Dependency inversion principle.

Depend on abstractions, not concretions.

from matplotlib import pyplot
from shapely.geometry import LineString
from dikesfordummies.dike.dike_profile import DikeProfile

def _plot_line(ax, ob, color):
    parts = hasattr(ob, "geoms") and ob or [ob]
    for part in parts:
        x, y = part.xy
        ax.plot(x, y, color=color, linewidth=3, solid_capstyle="round", zorder=1)


def plot_profile(dike_profile: ReinforcementDikeProfile) -> pyplot:
    fig = pyplot.figure(1, dpi=90)
    _subplot = fig.add_subplot(221)
    _plot_line(
        _subplot, LineString(dike_profile.characteristic_points), color="#03a9fc"
    )
    return fig

In theory, the above code should only work for a ReinforcementDikeProfile, well, it's python so it will swallow also a regular profile. But we should aim to make the methods / classes depending on higher level of abstractions, so we could replace it with either the base class, or a protocol.

def plot_profile(dike_profile: DikeProfile)
...
def plot_profile(dike_profile: DikeProfileProtocol)