SOLID principles

SOLID is an acronym for the five object-oriented design (OOD) principles by Robert C. Martin (also known as Uncle Bob). They provide a guide to writing maintainable, scalable, and robust software. They are high-level principles.

Single responsibility principle

A class should have a single responsibility.

Bad example

class Hero:
    lp = 50
    x = 2
    y = 3
    vx = 0
    vy = 0
    ax = 0
    ay = 0


Drawbacks:

  • The class is huge so difficult to understand
  • Difficult to test: any modification of the class would need to test the whole class again.
  • Difficult to extend/modify.

Good

  • Seperate the responsabilities into different classes
class Life:
    lp = 50

    def is_dead(self):
        return self.lp <= 0

class PhysicalPoint:
    x = 2
    y = 3
    vx = 0
    vy = 0
    ax = 0
    ay = 0

class Hero:
    def __init__(self):
        self.life = Life
        self.point = PhysicalPoint()

Open/closed principle

Prefer extensions via adding new classes than modification of existing classes.

Bad example

It is bad to modify existing classes, add/remove fields, modify methods etc.

Drawbacks:

  • Need for retest code that was already tested
  • Need to check that the modified class is still usable from other classes

For example, suppose you want to add a new type of ennemy would need to modify existing code.

def get_damage(enemy):
    if enemy.type == DRAGON:
        return enemy.force + 3
    elif enemy.type == WIZARD:
        return enemy.magic ** 2
    elif enemy.type == HUMAN:
        return 2
    else:
        raise NotImplementedError

Good example

  • do new classes that inherits from abstract classes, assign a behavior to a class by modifying a field/method which is abstract

class Enemy:
    def get_damage(self):
        raise NotImplementedError
    
class Dragon(Enemy):
    def get_damage(self):
        return self.force + 3
    
class Wizard(Enemy):
    def get_damage(self):
        return self.magic ** 2

class Human(Enemy):
    def get_damage(self):
        return 2
    
class SuperHuman(Enemy):
    def get_damage(self):
        return 10000

Liskov substitution principle

Barbara Liskov - 2008 Turing Award

Wrong example

Suppose there exists a class Square. We might be tempted to create a class Rectangle as a subclass of Square because we reuse the field width, and just add a field height.

from graphviz import Digraph
dot = Digraph(graph_attr=dict(rankdir="BT"), edge_attr={'arrowhead':'empty'})
dot.edges([("Rectangle", "Square")])
dot

svg

from dataclasses import dataclass

@dataclass
class Square():
    width: float

    def area(self):
        return self.width * self.width
    
@dataclass
class Rectangle(Square):
    height: float

    def area(self):
        return self.width * self.height
    
L = [Square(2), Rectangle(2, 3)]

for shape in L:
    print(shape.area())
4
6

This causes problems. Indeed, the developer reads "Any rectangle is a square" which is false. Some invariants written in the class Square may no longer be true for a Rectangle.

Solution

Please do:

from graphviz import Digraph
dot = Digraph(graph_attr=dict(rankdir="BT"), edge_attr={'arrowhead':'empty'})
dot.edges([("Square", "Rectangle")])
dot

svg

from dataclasses import dataclass

@dataclass
class Rectangle():
    width: float
    height: float


    def area(self):
        """_summary_

        Returns:
            _type_: _description_
        """
        return self.width * self.height
    
class Square(Rectangle):
    def __init__(self, width):
        super().__init__(width, width)

    
L = [Square(2), Rectangle(2, 3)]

for shape in L:
    print(shape.area())
4
6

Interface segregation principle

Clients should not be forced to depend on methods that they do not use.

  • Build small devoted abstract classes specific to some clients

Instead of implementing Dijkstra's algorithm that takes a GridGraph, please implement it that takes an abstract Graph. Dijkstra's algorithm does not care about method like G.grid_width or G.grid_height.

Dependency inversion principle

Please always depend on abstraction, not on concrete classes.

Bad example

For instance, the class Hero here depends on the class Sound. The class Sound is specific and you create an object of type Sound.

class Hero:
    def __init__(self):
        self.soundAttack = Sound("attack.ogg")
    def attack(self):
        self.soundAttack.play()

Drawbacks

  • If the implementation if Sound changes (for instance, it does not take .ogg files anymore but .wav)
  • handling deaf persons would require to traverse all the classes using Sound and make the modification

Solution

Decide an abstract interface for the game first and stick to it (except if it is bad!).

class IGame:
    sound: ISound
    graphics: IGraphics

class Hero:
    def __init__(self, game: IGame):
        self.game = game

    def attack(self):
        self.game.sound.play("attack")

Advantages:

  • Robust to the change of a library/framework. If the implemntation of the very concrete Sound class changes, we just change the implementation of ISound. The modification are confined.
  • Robust for adding new feature. Suppose you want to be inclusive and target deaf persons. Just add a new subclass to ISound that also displays a subtitle.