Дизайнерски модели в Python: Част I

Принципи на проектиране на SOLID за обектно-ориентирано програмиране.

Структурният дизайн на ООП софтуера може да приеме всякаква форма. Принципите на проектиране на SOLID са набор от (най-добри практики) евристични структурни класове на ООП за подобряване на вашия код.

Целта на SOLID дизайна е проста:

„За създаване на разбираем, четим и тестван код, върху който много разработчици могат да работят съвместно.“

Принципите, съставени за образуване на акронима SOLID, са:

  • S— Принцип на единична отговорност
  • O— принцип отворено-затворено
  • L— Принцип на заместване на Лисков
  • I — Принцип на разделяне на интерфейса
  • D — Принцип на инверсия на зависимостта

Принцип на единната отговорност

Един клас трябва да прави само едно нещо (разделяне на проблемите) и следователно има само една причина да се промени.

Внедряване:

Да предположим, че имплементираме клас Journal, който се използва за съхраняване на записи в дневник. Класът позволява добавяне или премахване на записи в дневника.

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

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

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

    def __str__(self) -> str:
        return '\n'.join(self.entries)

Добавянето на функционалност за зареждане или запазване на даден журнал може да е желателно. Въпреки че прилагането на save_to_file() или load_from_file() метод в Journalе лесно, то може да причини нежелани странични ефекти, които нарушават SRP (принцип на единична отговорност).

В реално приложение управлението на постоянството на дневник (запазване и зареждане) може да се приложи към други типове обекти. Централизираният дизайн избягва дублирането на кодове. Също така е нежелателно да се създават „Божествени обекти“: раздути класове, които изпълняват всички задачи.

По-елегантно е да създадете друг клас, който да абстрахира методите за устойчивост.

class PersistenceManager:

    @staticmethod
    def save_to_file(journal, filename):
        with open(filename, 'w') as f:
            f.write(str(journal))

Принцип отворено-затворено

Класовете трябва да са отворени за разширяване и затворени за модификация.

Модификациясе отнася до промяна на кода на съществуващ клас, Разширение се отнася до добавяне на нова функционалност.

Модифицирането на съществуващ (тестван) код може да причини грешки. Избягвайте да докосвате тестван, надежден, (най-вече) производствен код. Интерфейси (или абстрактни класове) могат да се използват за заобикаляне на този проблем.

Интерфейсите ни позволяват да променим изричната логика след прилагане на решение, без да променяме първоначалното решение.

Внедряване:

Да предположим, че имаме няколко продукта, всеки с 3 атрибута (име, цвят и размер) и искаме да внедрим продуктов филтър за сортиране на продуктите. Наивно решение би било да се напише клас ProductFilter, който притежава метод за всеки тип филтър.

from enum import Enum

class colour(Enum):
    red     = 1
    green   = 2
    blue    = 3

class Size(Enum):
    small   = 1
    medium  = 2
    large   = 3

class Product:
    def __init__(self, name, colour, size) -> None:
        self.name   = name
        self.colour = colour
        self.size   = size

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

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

def sp():
    print('.....'*10)

# Instantiate ----------------------++
if __name__ == '__main__':
    apple   = Product('Apple', colour.green, Size.small)
    tree    = Product('Tree', colour.green, Size.large)
    house   = Product('house', colour.blue, Size.large)
    prods   = [apple, tree, house]
    pf      = ProductFilter()
    sp()
# Instantiate ----------------------++

Въпреки че това решение работи, то нарушава принципа отворено-затворено, ако искаме да добавим нов филтър на по-късен етап. Може да се препоръча вместо това да се внедрят базови класове.

class Specification:
    # INTERFACE (base class)
    def is_satisfied(self, item):
        pass

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

Филтърът може да се изключва по желание.

class ColourSpecification(Specification):
    def __init__(self, colour) -> None:
        super().__init__()
        self.colour = colour
    
    def is_satisfied(self, item):
        return item.colour == self.colour

class SizeSpecification(Specification):
    def __init__(self, size) -> None:
        super().__init__()
        self.size = size

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


class AndSpecification(Specification):
    def __init__(self, *args) -> None:
        super().__init__()
        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
def sp():
    print('.....'*10)


# Instantiate ----------------------++
if __name__ == '__main__':
    apple   = Product('Apple', colour.green, Size.small)
    tree    = Product('Tree', colour.green, Size.large)
    house   = Product('house', colour.blue, Size.large)
    prods   = [apple, tree, house]
    sp()
    print('Green products (new):')
    bf = BetterFilter()
    cs = ColourSpecification(colour.green)
    for p in bf.filter(items=prods, spec=cs):
        print('Product: {:<5} is green'.format(p.name))    
    sp()
    print('Large products (new):')
    bf = BetterFilter()
    ss = SizeSpecification(Size.large)
    for p in bf.filter(items=prods, spec=ss):
        print('Product: {:<5} is large'.format(p.name))    
    sp()
    print('Large blue items:')
    cs = ColourSpecification(colour.blue)
    ss = SizeSpecification(Size.large)
    ns = AndSpecification(cs,ss)
    for p in bf.filter(items=prods, spec=ns):
        print('Product {:<5} is large'.format(p.name))
# Instantiate ----------------------++

Настрана: Това има допълнителната полза от предотвратяването на експлозия в пространството на състоянието в този конкретен пример: увеличаване на кода непропорционално на броя на филтрите.

Принцип на заместване на Лисков

Принципът на заместване на Лисков гласи, че подкласовете трябва да могат да бъдат замествани за техните базови класове.

По-конкретно, ако class B е подклас на class A, очакваното поведение не трябва да се променя, когато се използват методи от B вместо методи от A. Дъщерният клас трябва само да разширява поведението на базовия клас.

За да подобрим предсказуемостта и да избегнем неочаквани скрити грешки, трябва да можем да предадем всеки обект от клас B на всеки метод, който очаква обект от клас A и методът не трябва да връща неочакван резултат.

Пример:Да предположим, че имаме клас rectangle с метод за връщане на областта. Използваме частни properties и setters, за да осигурим контрол върху атрибутите на правоъгълника.

class Rectangle:
    def __init__(self, width, height) -> None:
        self._width  = width
        self._height = height
    
    @property
    def area(self):
        return self._width * self._height
    
    def __str__(self) -> str:
        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

Ние декларираме външна функция за изчисляване на площта и очакваната площ, дадени определени параметри.

def fetch_area(rec, h=10):
    w = rec.width
    rec.height = h
    expc = int(w*h)
    print(f'\nExpected area: {expc:>{3}}, Received area: {rec.area}')

След това извличаме подклас за обработка на квадрати:

class Square(Rectangle):
    def __init__(self, size) -> None:
        super().__init__(size, size)
    
    @Rectangle.width.setter
    def width(self, value):
        self._width = self._height = value
    
    @Rectangle.height.setter
    def height(self, value):
        self._width = self._height = value

Накрая създаваме и двата класа и използваме метода fetch_area():

rc = Rectangle(12,43)
fetch_area(rc)

sq = Square(5)
fetch_area(sq)

Функцията FetchArea() сега работи само върху базовия клас rectangle, а не върху подкласа square— нарушавайки принципа на заместване на Лисков.

Принцип на разделяне на интерфейса

Много интерфейси, специфични за клиента, са по-добри от един интерфейс с общо предназначение. Клиентите не трябва да бъдат принуждавани да прилагат функция, от която не се нуждаят.

Това гарантира, че моделите на класовете са гъвкави, разширяеми и клиентите не трябва да прилагат никаква неподходяща логика.

Помислете за интерфейс за модерен принтер. Той разполага с редица функции. Интерфейсът може да се използва за реализиране на модерен многофункционален принтер.

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

class MultiFunctionPrinter(Machine):
    def print(self, docment):
        pass
    def fax(self, document):
        pass
    def scan(self, document):
        return super().scan(document)

Ако обаче искаме да внедрим по-стар (по-прост) принтер, използващ същия интерфейс, някои от функционалностите стават излишни.

class OldFashionPrinter(Machine):
    def print(self, docment):
        pass
    def fax(self, document):
        raise NotImplementedError('Fax not available.')
    def scan(self, document):
        return super().scan(document)

Създаването на екземпляр на OldFashionPrinter може да е объркващо и да доведе до неочаквано поведение. Класът съдържа метод fax — въпреки че не прави нищо. То е многословно и подвеждащо. Човек може да приложи warnings или exceptions, за да предупреди клиента, но повдигането на тези елементи може да причини проблеми надолу по веригата в по-големи приложения (срив на приложението по неясен начин).

Вместо това е за предпочитане да отделите интерфейсите:

from abc import abstractmethod

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

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

По този начин класовете надолу по веригата наследяват точно това, от което се нуждаят.

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

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

Ако все още се изисква многостранен интерфейс, той може да бъде създаден чрез наследяване от базовите класове.

class MultiFunctionDevice(Printer, Scanner):
    
    @abstractmethod
    def print(self, document):
        return super().print(document)
    
    @abstractmethod
    def scan(self, document):
        return super().scan(document)

Принцип на инверсия на зависимостта

Класовете трябва да зависят от интерфейси или абстрактни класове, а не от конкретни класове или функции.

Да предположим, че дефинираме клас person и клас, който съхранява relationships между хората. След това дефинираме клас Research за търсене на всички родители с име „Джон“.

from abc  import abstractmethod
from enum import Enum

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

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

# Low-level module (storage)
class Relationships(RelationshipBrowser):
    def __init__(self) -> None:
        self.relations = []

    def add_parent_and_child(self, parent, child):
        self.relations.append((parent, Relationship.PARENT, child))
        self.relations.append((child,  Relationship.CHILD,  parent))

# High-level module
class Research:
  def __init__(self, relationships) -> None:
    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}')

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

relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)
Research(relationships)

Това води до очакваното поведение, но нарушава Принципа на инверсия на зависимост.

Нарушение: Research class (модул от високо ниво) зависи от структурата на данните на relationships class (модул от ниско ниво (съхранение)). Кодът вече е уязвим към внедряването на структурата от данни relations.

По-добре е да разчитате на abstract classes и/или interfaces, за да подобрите устойчивостта на кода.

Това може да се постигне чрез въвеждане на интерфейсен модул RelationshipBrowser, който гарантира, че определена функционалност се изпълнява от неговите подкласове. По този начин research class може да зависи от структурата на интерфейса.

from abc  import abstractmethod
from enum import Enum

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

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

# interface
class RelationshipBrowser:
    @abstractmethod
    def find_all_child_of(self, name):
        pass

# Low-level module (storage)
class Relationships(RelationshipBrowser):
    def __init__(self) -> None:
        self.relations = []
    
    def add_parent_and_child(self, parent, child):
        self.relations.append((parent, Relationship.PARENT, child))
        self.relations.append((child,  Relationship.CHILD,  parent))

    def find_all_child_of(self, name):
        for r in self.relations:
            if r[0].name == name and r[1] == Relationship.PARENT:
                yield r[2].name

# High-level module
class Research:
    # No longer has a dependence on the internal mechanics of how a relationship is stored!
    def __init__(self, browser) -> None:
        for p in browser.find_all_child_of('John'):
            print(f'John has a child called {p}')

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

relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)
Research(relationships)

Резюме

Принцип на единната отговорност

  • Един клас трябва да има само една причина за промяна.
  • Разделение на грижите (SOC): независимите задачи трябва да се обработват от различни класове.

Принцип отворено-затворено

  • Класовете трябва да бъдат отворени за разширение, но затворени за модификация.

Принцип на заместване на Лисков

  • Базовите типове трябва да могат да се заменят с подтипове.
  • Това гарантира, че поддържаме очакваното поведение.

Принцип на сегментиране на интерфейса

  • Отделяне на интерфейси. Не добавяйте твърде много сложност към един интерфейс.
  • YAGNI - Няма да ви трябва!

Принцип на инверсия на зависимостта

  • Класовете/модулите трябва да зависят от абстрактни класове/интерфейси, а не от екземпляри.
  • Използвайте абстракции.