Группа принципов проектирования, связанных с разработкой программного обеспечения, предложенные Робертом Мартином. Характеризуют принципы, которые рекомендуется соблюдать при написании программного кода. Эти правила помогают писать код, который легко масштабировать и поддерживать.
Основная цель статьи - познакомить Вас с общими принципами 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
@abstractmethod
def scan(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
def scan(self, document):
'''Отсутствия функции сканирования'''
raise NotImplementedError('Принтер не умеет сканировать,'
'но функцию необходимо описать')
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)
Так получилось рассмотреть принципы проектирования на простых примерах. При следовании принципам ваш код будет качественнее. Его будет легко масштабировать и поддерживать.
Основное правило в больших проектах.
Если код работает, то старайтесь его не изменять. Используйте расширение функциональности, а не модификацию. Изменение кода приводит к его к нестабильной работе и отказам, которые бывает сложно отследить.
Комментарии (13)
sepulkary
11.11.2022 13:21+5Для лучшего понимания я пытался сформулировать эти принципы своими словами, может быть, кому-то это поможет:
SRP
Single-responsibility principle, принцип единственной ответственности. Предполагает проектирование классов, имеющих только одну причину для изменения, позволяет вести проектирование в направлении, противоположном созданию «Божественных объектов». Класс должен отвечать за удовлетворение запросов только одной группы лиц.
OCP
Open–closed principle, принцип открытости/закрытости. Классы должны быть закрыты от изменения (чтобы код, опирающийся на эти классы, не нуждался в обновлении), но открыты для расширения (классу можно добавить новое поведение). Вкратце — хочешь изменить поведение класса — не трогай старый код (не считая рефакторинга, т. е. изменение программы без изменения внешнего поведения), добавь новый. Если расширение требований ведет к значительным изменениям в существующем коде, значит, были допущены архитектурные ошибки.
LSP
Liskov substitution principle, принцип подстановки Барбары Лисков: поведение наследующих классов должно быть ожидаемым для кода, использующего переменную базового класса. Или, другими словами, подкласс не должен требовать от вызывающего кода больше, чем базовый класс, и не должен предоставлять вызывающему коду меньше, чем базовый класс.
ISP
Interface segregation principle, принцип разделения интерфейса. Клиент интерфейса не должен зависеть от неиспользуемых методов. В соответствии с принципом ISP рекомендуется создавать минималистичные интерфейсы, содержащие минимальное количество специфичных методов. Если пользователь интерфейса не пользуется каким-либо методом интерфейса, то лучше создать новый интерфейс, без этого метода.
DIP
Dependency inversion principle, принцип инверсии зависимостей. Модули верхнего уровня не должны обращаться к модулям нижнего уровня напрямую, между ними должна быть «прокладка» из абстракций (т. е. интерфейсов). Причем абстракции не должны зависеть от реализаций, реализации должны зависеть от абстракций.
Больше — на GitHub'е.
@Technoit, сам Роберт Мартин считал SRP самым трудным для понимания и уточнял, что в первую очередь под "единой ответственностью" имеется в виду ответственность группы лиц, т. е. класс не должен закрывать потребности, например, бухгалтерии и транспортного отдела.
Explorus
11.11.2022 15:02Было бы неплохо добавить, что зачастую первый принцип противоречит второму, и как решается эта проблема.
sshikov
11.11.2022 18:28Для меня вот SRP нигде не противоречит OCP. Если у вас это происходит «зачастую» — сможете показать хотя бы три примера?
pupyrchik
14.11.2022 10:46На первый принцип еще посмотреть надо. Его растолковывают не всегда так, как подразумевал Мартин.
hardtop
12.11.2022 00:31Примеры выглядят: когда долго писал на java, но потом надо было на питоне наваять.
pinoquinho
12.11.2022 13:01OpenClose принцип можно интерпретировать как открыто для использования, но реализация должна быть скрыта. Если говорить про ООП, то класс предоставляет к использованию открытые методы (и все они предназначены для использования извне) и скрывает детали реализации - всё остальное скрыто. Открыть то, что нужно и скрыть всё остальное, чтоб не дать запутаться в дебрях реализации.
На более высоком уровне - это разделение компонентов на API и реализацию. Всё, что доступно для использования вынесено в API. При этом компонент с API имеет минимум зависимостей (норма - если вообще не имеет, или имеет зависимости только от других, более высокоуровневых API). И при использовании код зависит только от API и при этом скрываются все зависимости от реализации.
masai
12.11.2022 13:34Датаклассы не совсем по назначению используются. Их используют, когда объект предназначен для хранения значений, к которым осуществляется доступ через точку, а в примерах полноценные классы с методами. Полей всего пара штук, так что какой-то особой необходимости в использовании именно датаклассов тут нет.
Ещё можно понять, почему Rectangle — датакласс (хотя это тоже спорный вопрос), но Printer у меня совсем не ассоциируется с данными.
Однако даже в Rectangle и Square датаклассы используются очень необычным образом. Поля объявлены как private, а потом отдельно объявлены одноимённые свойства. Но зачем датакласс тогда нужен, если ни одно из преимуществ датаклассов не используется?
Sergeant101
12.11.2022 16:03А вот можно вопрос про сохранение?
Почему добавление, удаление и вывод в классе реестра норм, а вот сохранение - уже избыточно?
Операция ведь только для реестра предназначена, и актор у нее один - реестр.
igor6130
12.11.2022 17:16Потому что это не функционал реестра. SaveManager сохраняет данные в сторонний файл. Позже этот класс можно будет использовать для сохранения иных сущностей.
lexxpavlov
13.11.2022 23:33В разделе про ISP в коде верное решение не указано, а только показана проблема (что пришлось сделать заглушку с исключением)? Или я не совсем понял, как код связан с текстом.
behapas733
14.11.2022 10:46Отличная статья, спасибо!
Если вдруг напишите подобного рода статью про многопоточность в Python (pyside/pyqt), с радостью прочитаю!
oleg_shamshura
Народная загадка: RightAngleShape -- но не прямоугольник!
Мы же за понятность кода, не так ли?