Дизайнерски модели в 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 - Няма да ви трябва!
Принцип на инверсия на зависимостта
- Класовете/модулите трябва да зависят от абстрактни класове/интерфейси, а не от екземпляри.
- Използвайте абстракции.