# Topic covered
* SOLID Principles
* Issue with BAD Code
* Motivation behind the usage of SOLID Principles
* SOLID
  * Single Responsibility Principle (SRP) 
  * Open closed Principle (OSP)
  * Liskov substitution Principle (LSP)
  * Interface Segregation Principle (ISP)
  * Dependency Inversion Principle (DIP)

4.1 SOLID Principles

SOLID principles is a popular set of design principles that are used in object-oriented software development.

The SOLID principle was introduced by Robert C. Martin, also known as Uncle Bob.

Design principles intended to make software designs more understandable, flexible and maintainable so that many developers can collaboratively work on.

4.2 Issue with BAD Code

  1. It's Confusing - -> readability problem
  2. Rigidity problem - -> code that has dependency cannot make isolated change
  3. Fragility - -> change at some point, breaks code at some other place

OO helps to prevent rigidity, fragility, non re-usability

4.3 Motivation behind the usage of SOLID Principles

Important factors during the development cycle.

Maintainability: Maintainable systems are very important to the organisations.

Testability: Test driven development (TDD) is required when we design and develop large scale systems

Flexibility and Extensibility: Flexibility and extensibility is a very much desirable factor of enterprise applications. Hence, we should design the application to make it flexible so that it can be adapted to work in different ways and extensible so that we can add new features easily.

Parallel Development: It is one of the key features in the application development as it is not practical to have the entire development team working simultaneously on the same feature or component.

Loose Coupling (Independent): We can address many of the requirements listed above by ensuring that our design results in an application that loosely couples many parts that makes up the application.

4.4 SOLID Acronym

S : Single Responsibility Principle (SRP) 
O : Open closed Principle (OSP) 
L : Liskov substitution Principle (LSP) 
I : Interface Segregation Principle (ISP) 
D : Dependency Inversion Principle (DIP)

4.5 Single Responsibility Principle

A class should have one and only one reason to change, meaning that a class should have only one job.

Everything in the class should be related to that single purpose. There can be many members in the class as long as they related to the single responsibility.

And that responsibility should be entirely encapsulated by the class. With SRP, classes become smaller, cleaner and less fragile.

class Journal:
  def __init__(self):
    self.entries = []
    self.count = 0

  def add_entry(self, text):
    self.entries.append(f"{self.count}: {text}")
    self.count += 1

  def remove_entry(self, pos):
    del self.entries[pos]

  def __str__(self):
    return "\n".join(self.entries)

  # break SRP
  def save(self, filename):
    file = open(filename, "w")
    file.write(str(self))
    file.close()

    def load(self, filename):
      pass

    def load_from_web(self, uri):
      pass


class PersistenceManager:
  def save_to_file(journal, filename):
    file = open(filename, "w")
    file.write(str(journal))
    file.close()


j = Journal()
j.add_entry("I cried today.")
j.add_entry("I ate a bug.")
print(f"Journal entries:\n{j}\n")

p = PersistenceManager()
file = r'c:\temp\journal.txt'
p.save_to_file(j, file)

# verify!
with open(file) as fh:
  print(fh.read())

4.6 Open-Closed Principle

Software entities(classes, modules, functions, etc) should be open for extension, but closed for modification. Because changing the current behaviour of a Class will affect all the systems using that Class.

Implementation guidelines

  1. The simplest way to apply OCP is to implement the new functionality on new derived (sub) classes that inherit the original class implementation.
  2. Another way is to allow client to access the original class with an abstract interface,
  3. So, at any given point of time when there is a requirement change instead of touching the existing functionality it’s always suggested to create new classes and leave the original implementation untouched.

What if I do not follow Open closed principle

  • May end up testing the entire functionality along with the requirement.
  • Not following the Open Closed Principle breaks the SRP since the class or function might end up doing multiple tasks.
  • Maintenance of the class becomes difficult since the code of the class increases by thousands of unorganized lines.
from enum import Enum

class Color(Enum):
  RED = 1
  GREEN = 2
  BLUE = 3

class Size(Enum):
  SMALL = 1
  MEDIUM = 2
  LARGE = 3

class Product:
  def __init__(self, name, color, size):
    self.name = name
    self.color = color
    self.size = size

class ProductFilter:
  def filter_by_color(self, products, color):
    for p in products:
      if p.color == color: yield p

  def filter_by_size(self, products, size):
    for p in products:
      if p.size == size: yield p

  def filter_by_size_and_color(self, products, size, color):
    for p in products:
      if p.color == color and p.size == size: yield p

  # state space explosion
  # 3 criteria
  # c s w cs sw cw csw = 7 methods

  # OCP = open for extension, closed for modification


class Specification:
  def is_satisfied(self, item):
    pass

  # and operator makes life easier
  def __and__(self, other):
    return AndSpecification(self, other)


class Filter:
  def filter(self, items, spec):
    pass


class ColorSpecification(Specification):
  def __init__(self, color):
    self.color = color

  def is_satisfied(self, item):
    return item.color == self.color


class SizeSpecification(Specification):
  def __init__(self, size):
    self.size = size

  def is_satisfied(self, item):
    return item.size == self.size


# class AndSpecification(Specification):
#     def __init__(self, spec1, spec2):
#         self.spec2 = spec2
#         self.spec1 = spec1
#
#     def is_satisfied(self, item):
#         return self.spec1.is_satisfied(item) and \
#                self.spec2.is_satisfied(item)

class AndSpecification(Specification):
  def __init__(self, *args):
    self.args = args

  def is_satisfied(self, item):
    return all(map(
      lambda spec: spec.is_satisfied(item), self.args))


class BetterFilter(Filter):
  def filter(self, items, spec):
    for item in items:
      if spec.is_satisfied(item): yield item


apple = Product('Apple', Color.GREEN, Size.SMALL)
tree = Product('Tree', Color.GREEN, Size.LARGE)
house = Product('House', Color.BLUE, Size.LARGE)

products = [apple, tree, house]

pf = ProductFilter()
print('Green products (old):')
for p in pf.filter_by_color(products, Color.GREEN):
  print(f' - {p.name} is green')

# ^ BEFORE

# v AFTER
bf = BetterFilter()

print('Green products (new):')
green = ColorSpecification(Color.GREEN)
for p in bf.filter(products, green):
  print(f' - {p.name} is green')

print('Large products:')
large = SizeSpecification(Size.LARGE)
for p in bf.filter(products, large):
  print(f' - {p.name} is large')

print('Large blue items:')
# large_blue = AndSpecification(large, ColorSpecification(Color.BLUE))
large_blue = large & ColorSpecification(Color.BLUE)
for p in bf.filter(products, large_blue):
  print(f' - {p.name} is large and blue')

4.7 Liskov Substitution Principle

Introduced by Barbara Liskov state that “objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program”

We can also state that derived types must be completely substitutable for their base types.

The child Class should be able to do everything the parent Class can do. This principle is just an extension of the Open Close Principle.

Implementation guidelines

In the process of development we should ensure that

  • No new exceptions can be thrown by the subtype unless they are part of the existing exception hierarchy.
  • We should also ensure that Clients should not know which specific subtype they are calling, nor should they need to know that.
  • The client should behave the same regardless of the subtype instance that it is given.
  • New derived classes just extend without replacing the functionality of old classes
class Rectangle:
    def __init__(self, width, height):
        self._height = height
        self._width = width

    @property
    def area(self):
        return self._width * self._height

    def __str__(self):
        return f'Width: {self.width}, height: {self.height}'

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        self._height = value


class Square(Rectangle):
    def __init__(self, size):
        Rectangle.__init__(self, size, size)

    @Rectangle.width.setter
    def width(self, value):
        _width = _height = value

    @Rectangle.height.setter
    def height(self, value):
        _width = _height = value


def use_it(rc):
    w = rc.width
    rc.height = 10  # unpleasant side effect
    expected = int(w * 10)
    print(f'Expected an area of {expected}, got {rc.area}')


rc = Rectangle(2, 3)
use_it(rc)

sq = Square(5)
use_it(sq)

4.8 Interface Segregation Principle

Many client-specific interfaces are better than one general-purpose interface. It means instead of creating one big interface we can break down it to smaller interfaces.

A client should never be forced to implement an interface that it doesn’t use, or clients should not be forced to depend on methods they do not use.

The ISP was first used and formulated by Robert C. Martin while consulting for Xerox.

from abc import abstractmethod

class Machine:
    def print(self, document):
        raise NotImplementedError()

    def fax(self, document):
        raise NotImplementedError()

    def scan(self, document):
        raise NotImplementedError()

# ok if you need a multifunction device
class MultiFunctionPrinter(Machine):
    def print(self, document):
        pass

    def fax(self, document):
        pass

    def scan(self, document):
        pass

class OldFashionedPrinter(Machine):
    def print(self, document):
        # ok - print stuff
        pass

    def fax(self, document):
        pass  # do-nothing

    def scan(self, document):
        """Not supported!"""
        raise NotImplementedError('Printer cannot scan!')

class Printer:
    @abstractmethod
    def print(self, document): pass

class Scanner:
    @abstractmethod
    def scan(self, document): pass

# same for Fax, etc.

class MyPrinter(Printer):
    def print(self, document):
        print(document)

class Photocopier(Printer, Scanner):
    def print(self, document):
        print(document)

    def scan(self, document):
        pass  # something meaningful

class MultiFunctionDevice(Printer, Scanner):  # , Fax, etc
    @abstractmethod
    def print(self, document):
        pass

    @abstractmethod
    def scan(self, document):
        pass

class MultiFunctionMachine(MultiFunctionDevice):
    def __init__(self, printer, scanner):
        self.printer = printer
        self.scanner = scanner

    def print(self, document):
        self.printer.print(document)

    def scan(self, document):
        self.scanner.scan(document)


printer = OldFashionedPrinter()
printer.fax(123)  # nothing happens
printer.scan(123)  # oops!

4.9 Dependency Inversion Principle

Entities must depend on abstractions, not on concretions.

It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.

Abstractions should not depend on the details whereas the details should depend on abstractions.

from abc import abstractmethod
from enum import Enum

class Relationship(Enum):
    PARENT = 0
    CHILD = 1
    SIBLING = 2

class Person:
    def __init__(self, name):
        self.name = name

class RelationshipBrowser:
    @abstractmethod
    def find_all_children_of(self, name): pass

class Relationships(RelationshipBrowser):  # low-level
    relations = []

    def add_parent_and_child(self, parent, child):
        self.relations.append((parent, Relationship.PARENT, child))
        self.relations.append((child, Relationship.PARENT, parent))
            
    def find_all_children_of(self, name):
        for r in self.relations:
            if r[0].name == name and r[1] == Relationship.PARENT:
                yield r[2].name

class Research:
    # dependency on a low-level module directly
    # bad because strongly dependent on e.g. storage type

    # def __init__(self, relationships):
    #     # high-level: find all of john's children
    #     relations = relationships.relations
    #     for r in relations:
    #         if r[0].name == 'John' and r[1] == Relationship.PARENT:
    #             print(f'John has a child called {r[2].name}.')

    def __init__(self, browser):
        for p in browser.find_all_children_of("John"):
            print(f'John has a child called {p}')


parent = Person('John')
child1 = Person('Chris')
child2 = Person('Matt')

# low-level module
relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)

Research(relationships)

4.10 If we don’t follow SOLID Principles we

  1. End up with tight or strong coupling of the code with many other modules/applications
  2. Tight coupling causes time to implement any new requirement, features or any bug fixes and sometimes it creates unknown issues
  3. End up with a code which is not testable
  4. End up with duplication of code
  5. End up creating new bugs by fixing another bug
  6. End up with many unknown issues in the application development cycle

4.11 Following SOLID Principles helps us to

  1. Achieve reduction in complexity of code
  2. Increase readability, extensibility and maintenance
  3. Reduce error and implement re-usability
  4. Achieve Better testability
  5. Reduce tight coupling

.

Practice Data Munging problem: http://codekata.com/kata/kata04-data-munging

Create proper classes. Try to apply OOPs concepts and do it using minimum amount of code.

Reference