4.9. S.O.L.I.D.

4.9.1. Recap

OOP Principles:

  • Encapsulation

  • Abstraction

  • Inheritance

  • Polymorphism

Problems:

  • Rigidity - mixing higher level with low level implementation

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

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

  • No Reusability - cannot reuse code, and need to implement from scratch

Patterns:

  • K.I.S.S. - Keep It Simple Stupid

  • Y.A.G.N.I. - You Ain't Gonna Need It

  • D.R.Y. - Do not Repeat Yourself

4.9.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 4.1. S.O.L.I.D. Principles

4.9.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 4.2. 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

4.9.4. Open/Closed Principle

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification

—Bertrand Mayer

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

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

  • This idea has many different interpretations

  • Sometimes it refers to use of abstract base classes to create fixed interfaces with multiple implementations

  • The view we take is that objects have internal invariants and that subclasses shouldn't be able to break those invariants

  • In other words, the classes capabilities can be extended but the underlying class shouldn't get broken

  • Source: 1

Adding new parser (PDF,Txt) class should not break the Document class.

>>> class PDF:
...     pass
>>>
>>> class Txt:
...     pass
>>>
>>>
>>> class Document:
...     def __new__(cls, *args, **kwargs):
...         filename, extension = args[0].split('.')
...         if extension == 'pdf':
...             return PDF()
...         elif extension == 'txt':
...             return Txt()
>>>
>>>
>>> file1 = Document('myfile.pdf')
>>> file2 = Document('myfile.txt')
>>>
>>> print(file1)  
<PDF object at 0x...>
>>>
>>> print(file2)  
<Txt object at 0x...>
>>> class Setosa:
...     pass
>>>
>>> class Versicolor:
...     pass
>>>
>>> class Virginica:
...     pass
>>>
>>>
>>> def factory(species):
...     try:
...         classname = species.capitalize()
...         return globals()[classname]
...     except KeyError:
...         raise NotImplementedError
>>>
>>>
>>> iris = factory('setosa')
>>> print(iris)
<class 'Setosa'>
>>> 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

4.9.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

If S is a subtype of T, then objects of type T may be replaced with objects of the S

—Barbara Liskov

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

  • It's all about polymorphism

  • Example:

    • Lots of code in Python works with dictionaries

    • An OrderedDict is a dict subclass that keeps most of the API intact (fully Liskov substitutable)

    • It can be used just about everywhere in Python instead of dicts

  • Any part of the API which is not fully substitutable is a Liskov violation

  • This is common and normal

  • In particular, subclasses can have different constructor signatures (for example the array API [from array import array] is very similar to the list API but the constructor is different)

  • Goal is to isolate or minimize the impact

  • Problem:

    • Taxonomy hierarchies do not neatly transform into useful class hierarchies (Circle and Ellipse problem)

    • Substitutability can be a hard problem

    • More importantly, it challenges our conceptual view of a subclass as simple a form of specialization

    • Clarity comes from thinking about the design in terms of code reuse (the class that has the most reusable code should be the parent)

  • Source: 1

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

Figure 4.4. 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'
>>> from collections import OrderedDict
>>>
>>>
>>> assert hasattr(dict, 'clear')
>>> assert hasattr(dict, 'copy')
>>> assert hasattr(dict, 'fromkeys')
>>> assert hasattr(dict, 'get')
>>> assert hasattr(dict, 'items')
>>> assert hasattr(dict, 'keys')
>>> assert hasattr(dict, 'pop')
>>> assert hasattr(dict, 'popitem')
>>> assert hasattr(dict, 'setdefault')
>>> assert hasattr(dict, 'update')
>>> assert hasattr(dict, 'values')
>>>
>>> assert hasattr(OrderedDict, 'clear')
>>> assert hasattr(OrderedDict, 'copy')
>>> assert hasattr(OrderedDict, 'fromkeys')
>>> assert hasattr(OrderedDict, 'get')
>>> assert hasattr(OrderedDict, 'items')
>>> assert hasattr(OrderedDict, 'keys')
>>> assert hasattr(OrderedDict, 'pop')
>>> assert hasattr(OrderedDict, 'popitem')
>>> assert hasattr(OrderedDict, 'setdefault')
>>> assert hasattr(OrderedDict, 'update')
>>> assert hasattr(OrderedDict, 'values')
>>> from abc import ABCMeta, abstractmethod
>>> from dataclasses import dataclass
>>>
>>>
>>> @dataclass
... class Person(metaclass=ABCMeta):
...     name: str
...
...     @abstractmethod
...     def say_hello(self):
...         pass
>>>
>>>
>>> class Astronaut(Person):
...     def say_hello(self):
...         return f'Hello {self.name}'
>>>
>>> class Cosmonaut(Person):
...     def say_hello(self):
...         return f'Привет {self.name}'
>>>
>>>
>>> def hello(crew: list[Person]) -> None:
...     for member in crew:
...         print(member.say_hello())
>>>
>>>
>>> crew = [Astronaut('Mark Watney'),
...         Cosmonaut('Иван Иванович'),
...         Astronaut('Melissa Lewis'),
...         Cosmonaut('Jan Twardowski')]
>>>
>>> hello(crew)
Hello Mark Watney
Привет Иван Иванович
Hello Melissa Lewis
Привет Jan Twardowski

4.9.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 4.5. S.O.L.I.D. Principles - Interface Segregation Principle

Todo

Make image about code examples below

Bad:

>>> class Serializable:
...     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(Serializable):
...     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

4.9.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 4.6. S.O.L.I.D. - Dependency Inversion Principle

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

Figure 4.7. 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')
Hello

Good:

>>> class Astronaut:
...     def say_hello(self):
...         print('Hello')
>>>
>>> class Cosmonaut:
...     def say_hello(self):
...         print('Привет!')
>>>
>>> class Taikonaut:
...     def say_hello(self):
...         print('你好')
>>>
>>>
>>> watney = Astronaut()
>>> watney.say_hello()
Hello
>>> class Cache:
...     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(Cache):
...     def is_valid(self, key: str) -> bool:
...         ...
...
...     def get(self, key: str) -> str:
...         ...
...
...     def set(self, key: str, value: str) -> None:
...         ...
>>>
>>>
>>> db: Cache = CacheDatabase()
>>> db.set('name', 'Jan Twardowski')
>>> db.is_valid('name')
>>> db.get('name')

4.9.8. References

1(1,2)

Raymond Hettinger. The Art of Subclassing. 2012. https://www.youtube.com/watch?v=miGolgp9xq8

4.9.9. Assignments

Todo

Create assignments