Группа принципов проектирования, связанных с разработкой программного обеспечения, предложенные Робертом Мартином. Характеризуют принципы, которые рекомендуется соблюдать при написании программного кода. Эти правила помогают писать код, который легко масштабировать и поддерживать.
Основная цель статьи - познакомить Вас с общими принципами SOLID и показать примеры на языке Python.
В принципы проектирования входят:
SRP - принцип единой ответственности
OCP - принцип открытости и закрытости
LSP - принцип подстановки Лисков
ISP - принцип разделения интерфейса
DIP - принцип инверсии зависимостей
SRP - принцип единой ответственности
Класс имеет свою ответственность и он не должен брать на себя другие ответственности.
Рассмотрим пример
Создадим класс реестр для возможности сохранения действий на компьютере.
Описание класса реестр:
добавление записей в реестр
удаление записей в реестре
вывод записей всего реестра
Допустим Нам необходимо сохранять данные реестра. Для этого возможно расширить наш класс и добавить в него метод сохранения. При этом класс Register получить дополнительную ответственность, которая будет являться избыточной и при расширении вариантов сохранения или экспорта реестра приведет к сложности поддержки и масштабирования в будущем.
Выход: Выделить новую ответственность в отдельный класс SaveManager
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class Register:
entries: ClassVar = []
count: ClassVar = 0
def __str__(self) -> str:
'''Вывод всех записей'''
return '\n'.join(self.entries)
def add_entry(self, entry: str) -> None:
'''Добавление записей'''
self.count += 1
self.entries.append(f'{self.count} - {entry}')
def remove_entry(self, pos: int) -> None:
'''Удаление записей'''
del self.entries[pos]
class SaveManager:
'''Класс сохранения регистра в файл'''
@staticmethod
def save_to_file(register, filename) -> None:
file = open(filename, 'w')
file.write((str(register)))
file.close()
if __name__ == '__main__':
reg = Register()
reg.add_entry('Добавление файла hello')
reg.add_entry('Изменение файла hello')
file = r'C:\temp\Register.txt'
SaveManager.save_to_file(reg, file)
with open(file) as f:
print(f.read())
OCP - принцип открытости и закрытости
Принцип гласит, что добавление функциональности должно осуществляться через расширение, а не через модификацию. Вносить изменения бывает трудно или дорого — когда небольшое изменение в одной части системы вызывает лавину изменений в других частях.
Рассмотрим пример
Опишем классы для создания объекта продукт Product. Заведем также классы перечисления цвета и размера. Опишем класс фильтрации продуктов по цвету, а в дальнейшем и по размеру. Задумаемся о будущем, нам может понадобиться функциональность дополнительных фильтров и их комбинаций. При этом класс фильтрации будет дополняться и это приведет к его геометрическому росту и сложности его поддержки и тестированию.
Решение
Необходимо создать класс спецификации фильтра Specification и непосредственно сам фильтр Filter. При расширении видов фильтрации, вносим дополнительный класс спецификаций, при изменении способа фильтрации - вносим классы фильтрации.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
class Color(Enum):
RED = 1
YELLOW = 2
GREEN = 3
class Size(Enum):
SMALL = 1
MEDIUM = 2
LARGE = 3
@dataclass
class Product:
name: str
color: Color
size: Size
@dataclass
class ProductFilter:
def filter_by_color(self, products, color):
for product in products:
if product.color == color:
yield product
def filter_by_size(self, products, size):
for product in products:
if product.size == size:
yield product
class Specification(ABC):
@abstractmethod
def is_satisfied(self, item):
pass
class Filter(ABC):
@abstractmethod
def filter(self, items, spec):
pass
@dataclass
class ColorSpecification(Specification):
color: Color
def is_satisfied(self, item) -> bool:
return item.color == self.color
@dataclass
class SizeSpecification(Specification):
size: Size
def is_satisfied(self, item) -> bool:
return item.size == self.size
class BetterFilter(Filter):
def filter(self, items, spec):
for item in items:
if spec.is_satisfied(item):
yield item
@dataclass
class СombinatorSpecification(Specification):
def __init__(self, *args):
self.args = args
def is_satisfied(self, item) -> bool:
return all(map(
lambda spec: spec.is_satisfied(item), self.args
))
if __name__ == '__main__':
apple = Product('Apple', Color.GREEN, Size.SMALL)
tree = Product('Tree', Color.GREEN, Size.LARGE)
tomat = Product('Tomat', Color.RED, Size.SMALL)
products = [apple, tree, tomat]
bf = BetterFilter()
green = ColorSpecification(Color.GREEN)
for p in bf.filter(products, green):
print(f'- {p.name} is green')
print('Проверка нескольких параметров фильтра - комбинатор')
small_green = СombinatorSpecification(SizeSpecification(Size.SMALL),
ColorSpecification(Color.GREEN))
for p in bf.filter(products, small_green):
print(f'- {p.name} is small and green')
LSP - принцип подстановки Лисков
Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.
Рассмотрим пример
Опишем пример наследования в геометрических фигурах: прямоугольник и квадрат. Квадрат является частным случаем прямоугольника, поэтому Вам захочется выстроить наследования класса квадрат от прямоугольника. В квадрате высота равна ширине, поэтому передадим в конструктор родителя один параметр два раза.
from dataclasses import dataclass
@dataclass
class Rectangle:
width: int
height: int
............
@dataclass
class Square(Rectangle):
def __init__(self, size: int):
Rectangle.__init__(self, size, size)
......
Проблема возникнет в ситуации изменения стороны квадрата. sq.width = 10. Теперь автоматически он не будет менять высоту height. У нас будут методы, которые зависят от параметров класса и их работа будет нарушена.
Нарушение Поведение наследников должно быть ожидаемым для функций, которые используют базовый класс.
Решение проблемы
Согласно LSP нам необходимо использовать общий интерфейс для обоих классов и не наследовать Square от Rectangle. Этот общий интерфейс должен быть таким, чтобы в классах, реализующих его, предусловия не были более сильными, а постусловия не были более слабыми.
Первый способ — переделать иерархию так, чтобы Square не наследовался от Rectangle. Мы можем ввести новый класс, чтобы и квадрат, и прямоугольник наследовались от него.
Создадим абстрактный класс RightAngleShape, чтобы описать фигуры с прямым углом.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class RightAngleShape(ABC):
@abstractmethod
def area(self):
pass
@dataclass
class Rectangle(RightAngleShape):
_width: int
_height: int
@property
def width(self):
return self._width
@width.setter
def widht(self, value):
if value <= 0:
raise Exception
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
if value <= 0:
raise Exception
self._height = value
@property
def area(self):
return self._width * self._height
def __str__(self):
return f'Width: {self.width}, height: {self.height}'
@dataclass
class Square(RightAngleShape):
_size: int
@property
def size(self):
return self._width
@size.setter
def size(self, value):
if value <= 0:
raise Exception
self._size = value
@property
def area(self):
return self._size * self._size
if __name__ == '__main__':
print('Прямоугольник')
rs = Rectangle(5, 10)
print(rs.area)
rs.height = 20
print(rs.area)
print('Квадрат')
sq = Square(5)
print(sq.area)
sq.size = 10
print(sq.area)
ISP - принцип разделения интерфейса
Проблема ООП может быть, когда при наследовании класс-потомок получает вместе с нужной функциональностью кучу неиспользуемой и ненужной.
Принцип гласит: Сущности не должны зависеть от интерфейсов, которые они не используют.
Рассмотрим пример
Опишем класс объекта принтера. Принтер имеет функциональность сканирования и печати. Абстрактный класс будет регламентировать эти два метода print и scan. Все классы наследники будут должны реализовать их.
Но есть принтеры, которые не имеют функцию печати, но базовый класс регламентирует ее объявить. Приходиться делать заглушку, а теперь представьте есть класс с большим описанием функциональности. Заглушек становиться много.
Решение проблемы
Описать классы по функциональности и наследоваться от нескольких классов. Класс будет наследоваться одновременно от классов Print и Scan.
Это позволит отвязать родительские методы друг от друга и использовать в классе Printer только нужную функциональность.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
@dataclass
class MultiPrinter(Printer):
def print(self, document):
'''Реализация печати'''
pass
def scan(self, document):
'''Реализация сканирования'''
pass
@dataclass
class OldPrinter(Printer):
def print(self, document):
'''Реализация печати'''
pass
DIP - принцип инверсии зависимостей
Принцип инверсии зависимостей предполагает, что:
Высокоуровневые модули не должны зависеть от низкоуровневых; оба типа должны зависеть от абстракций.
Абстракции не должны зависеть от деталей, детали должны зависеть от абстракций.
Рассмотрим пример
Программа имеет реализацию создания вложенности каталогов. Для этого используется класс RelationshipFolder, который хранит отношения папок: родитель, ребенок, сосед. Метод Research позволяет вывести вложенные папки в каталог. Метод Research опирается на знание реализации конструкции хранения зависимостей в виде списка. Если мы спланируем изменить способ хранения отношений, то функция сломается.
Это противоречит правилу инверсии зависимостей и создает сильную связь метода от входящего объекта. Принцип гласит, что функция не должна зависит от внутренней реализации объекта.
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar
class Relationship(Enum):
PARENT = 0
CHILD = 1
NEIGHBOR = 2
@dataclass
class Folder:
name: str
@dataclass
class RelationshipFolder:
relations: ClassVar = []
def add_parrent_and_child(self, parent, child):
self.relations.append((parent, Relationship.PARENT, child))
self.relations.append((child, Relationship.CHILD, parent))
def Research(name_folder: str, relations: RelationshipFolder):
for i in relations.relations:
if i[0].name == name_folder and i[1] == Relationship.PARENT:
print(f'Подпапки главного каталога {i[2].name}')
Решение проблемы
Решением данной проблемы является предоставления вспомогательных методов внутри низкоуровневого модуля (объекта). Создаем абстрактный класс RelationshipBrowser, который будет регламентировать наличие метода поиска отношений папки с именем all_child_of. Наследуем наш класс RelationshipFolder от RelationshipBrowser и опишем реализацию метода поиска отношений. Перепишем метод вывода отношений в консоль.
Так понизилась зависимость метода вывода отношений и метода поиска подкаталога. То есть понизили зацепление модулей.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar
class Relationship(Enum):
PARENT = 0
CHILD = 1
NEIGHBOR = 2
@dataclass
class Folder:
name: str
@dataclass
class RelationshipBrowser(ABC):
@abstractmethod
def all_child_of(self, name_folder: str):
pass
@dataclass
class RelationshipFolder(RelationshipBrowser):
relations: ClassVar = []
def add_parrent_and_child(self, parent, child):
self.relations.append((parent, Relationship.PARENT, child))
self.relations.append((child, Relationship.CHILD, parent))
def all_child_of(self, name_folder: str):
for i in self.relations:
if i[0].name == name_folder and i[1] == Relationship.PARENT:
yield i[2].name
def Research(name_folder: str, relations: RelationshipFolder):
for i in relations.all_child_of(name_folder):
print(f'Подпапки главного каталога {i}')
if __name__ == '__main__':
root = Folder('C//:')
program = Folder('Program')
window = Folder('Window')
relation = RelationshipFolder()
relation.add_parrent_and_child(root, program)
relation.add_parrent_and_child(root, window)
Research('C//:', relation)
Так получилось рассмотреть принципы проектирования на простых примерах. При следовании принципам ваш код будет качественнее. Его будет легко масштабировать и поддерживать.
Основное правило в больших проектах.
Если код работает, то старайтесь его не изменять. Используйте расширение функциональности, а не модификацию. Изменение кода приводит к его к нестабильной работе и отказам, которые бывает сложно отследить.