6.13. S.O.L.I.D.

6.13.1. Recap

  • Rigidity - mixing higher level with low level implementation

  • Fragility - if you change something, some other thing will break

  • Reusability

  • Coupling - interdependencies a.k.a "spaghetti code"

  • K.I.S.S.

  • D.R.Y.

  • OOP:

    • Encapsulation

    • Polymorphism

    • Inheritance

6.13.2. Rationale

  • SRP: The Single Responsibility Principle

  • OCP: The Open / Closed Principle

  • LSP: The Liskov Substitution Principle

  • ISP: The Interface Segregation Principle

  • DIP: The Dependency Inversion Principle

../../_images/oop-solid.png

Figure 6.10. S.O.L.I.D. Principles

6.13.3. Single Responsibility Principle

A class should have one, and only one, reason to change.

—Robert C. Martin

Every module or class should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class. All its services should be narrowly aligned with that responsibility.

../../_images/oop-solid-srp.png

Figure 6.11. S.O.L.I.D. - Single Responsibility Principle

Bad:

from dataclasses import dataclass


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

    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

    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[int, int]:
        return self._position_x, self._position_y

Good:

from dataclasses import dataclass


@dataclass
class HasHealth:
    HEALTH_MIN: int = 10
    HEALTH_MAX: int = 20
    _health: int = 0

    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


@dataclass
class HasPosition:
    _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[int, int]:
        return self._position_x, self._position_y


class Hero(HasHealth, HasPosition):
    pass

6.13.4. Open/Closed Principle

Modules [classes] should be open for extension, but closed for modification.

—Bertrand Mayer

../../_images/oop-solid-ocp.png

Figure 6.12. S.O.L.I.D. - Open/Closed Principle

from random import randint


class Critter:
    HEALTH_MIN: int = 0
    HEALTH_MAX: int = 10

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


class Skeleton(Critter):
    HEALTH_MIN: int = 10
    HEALTH_MAX: int = 20


class Troll(Hero):
    HEALTH_MIN: int = 100
    HEALTH_MAX: int = 200


class Dragon(Critter):
    HEALTH_MIN: int = 1000
    HEALTH_MAX: int = 2000
from random import randint


class Critter:
    HEALTH_MIN: int
    HEALTH_MAX: int

    def __init__(self):
        self._health = self._get_initial_health()

    def _get_initial_health(self):
        return randint(self.HEALTH_MIN, self.HEALTH_MAX)


class Regular(Critter):
    pass


class Elite(Critter):
    def _get_initial_health(self):
        hp = super()._get_initial_health()
        return hp * 2


class Boss(Critter):
    def _get_initial_health(self):
        hp = super()._get_initial_health()
        return hp * 10

6.13.5. Liskov Substitution Principle

Derived classes must be usable through the base class interface, without the need for the user to know the difference.

—Barbara Liskov

  • Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program

../../_images/oop-solid-lsp.png

Figure 6.13. S.O.L.I.D. - Liskov Substitution Principle

class mystr(str):
    pass


a = str('Mark Watney')
a.upper()
# MARK WATNEY

b = mystr('Mark Watney')
b.upper()
# MARK WATNEY

6.13.6. Interface Segregation Principle

  • many specific interfaces are better than one general-purpose interface

The interface-segregation principle (ISP) states that no client should be forced to depend on methods it does not use. ISP splits interfaces that are very large into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them. Such shrunken interfaces are also called role interfaces. ISP is intended to keep a system decoupled and thus easier to refactor, change, and redeploy. ISP is one of the five SOLID principles of object-oriented design, similar to the High Cohesion Principle of GRASP.

../../_images/oop-solid-isp.png

Figure 6.14. S.O.L.I.D. Principles - Interface Segregation Principle

Bad:

class Mixin:
    def json_loads(self):
        raise NotImplementedError

    def json_dumps(self):
        raise NotImplementedError

    def pickle_loads(self):
        raise NotImplementedError

    def pickle_dumps(self):
        raise NotImplementedError

    def csv_loads(self):
        raise NotImplementedError

    def csv_dumps(self):
        raise NotImplementedError


class User(Mixin):
    def __init__(self, firstname, lastname):
        self.firstname = firstname
        self.lastname = lastname

Good:

class JSONMixin:
    def json_loads(self):
        raise NotImplementedError

    def json_dumps(self):
        raise NotImplementedError


class PickleMixin:
    def pickle_loads(self):
        raise NotImplementedError

    def pickle_dumps(self):
        raise NotImplementedError


class CSVMixin:
    def csv_loads(self):
        raise NotImplementedError

    def csv_dumps(self):
        raise NotImplementedError


class User(JSONMixin, PickleMixin, CSVMixin):
    def __init__(self, firstname, lastname):
        self.firstname = firstname
        self.lastname = lastname

6.13.7. Dependency Inversion Principle

Clients should not be forced to depend on methods that they do not use. Program to an interface, not an implementation.

—Robert C. Martin

../../_images/oop-solid-dip.png

Figure 6.15. S.O.L.I.D. - Dependency Inversion Principle

../../_images/oop-solid-deps.png

Figure 6.16. Class Dependencies should depend upon abstractions, not concretions

When following this principle, the conventional dependency relationships established from high-level, policy-setting modules to low-level, dependency modules are reversed, thus rendering high-level modules independent of the low-level module implementation details. The principle states:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.

  2. Abstractions should not depend on details. Details should depend on abstractions.

By dictating that both high-level and low-level objects must depend on the same abstraction this design principle inverts the way some people may think about object-oriented programming.

Bad:

watney = 'Astronaut'

if watney == 'Astronaut':
    print('Hello')
elif watney == 'Cosmonaut':
    print('Привет!')
elif watney == 'Taikonaut':
    print('你好')
else:
    print('Default Value')

Good:

class Astronaut:
    def say_hello():
        print('Hello')


class Cosmonaut:
    def say_hello():
        print('Привет!')


class Taikonaut:
    def say_hello():
        print('你好')


watney = Astronaut()
watney.say_hello()
class CacheInterface:
    def get(self, key: str) -> str:
        raise NotImplementedError

    def set(self, key: str, value: str) -> None:
        raise NotImplementedError

    def is_valid(self, key: str) -> bool:
        raise NotImplementedError


class CacheDatabase(CacheInterface):
    def is_valid(self, key: str) -> bool:
        ...

    def get(self, key: str) -> str:
        ...

    def set(self, key: str, value: str) -> None:
        ...


db: CacheInterface = CacheDatabase()
db.set('name', 'Jan Twardowski')
db.is_valid('name')
db.get('name')