Группа принципов проектирования, связанных с разработкой программного обеспечения, предложенные Робертом Мартином. Характеризуют принципы, которые рекомендуется соблюдать при написании программного кода. Эти правила помогают писать код, который легко масштабировать и поддерживать.

Основная цель статьи - познакомить Вас с общими принципами 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)

Так получилось рассмотреть принципы проектирования на простых примерах. При следовании принципам ваш код будет качественнее. Его будет легко масштабировать и поддерживать.

Основное правило в больших проектах.

Если код работает, то старайтесь его не изменять. Используйте расширение функциональности, а не модификацию. Изменение кода приводит к его к нестабильной работе и отказам, которые бывает сложно отследить.

Код примеров GitHub

Комментарии (13)


  1. oleg_shamshura
    11.11.2022 12:31
    +2

    Народная загадка: RightAngleShape -- но не прямоугольник!

    Мы же за понятность кода, не так ли?


  1. 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 самым трудным для понимания и уточнял, что в первую очередь под "единой ответственностью" имеется в виду ответственность группы лиц, т. е. класс не должен закрывать потребности, например, бухгалтерии и транспортного отдела.


  1. Explorus
    11.11.2022 15:02

    Было бы неплохо добавить, что зачастую первый принцип противоречит второму, и как решается эта проблема.


    1. sshikov
      11.11.2022 18:28

      Для меня вот SRP нигде не противоречит OCP. Если у вас это происходит «зачастую» — сможете показать хотя бы три примера?


    1. pupyrchik
      14.11.2022 10:46

      На первый принцип еще посмотреть надо. Его растолковывают не всегда так, как подразумевал Мартин.


  1. hardtop
    12.11.2022 00:31

    Примеры выглядят: когда долго писал на java, но потом надо было на питоне наваять.


  1. pinoquinho
    12.11.2022 13:01

    OpenClose принцип можно интерпретировать как открыто для использования, но реализация должна быть скрыта. Если говорить про ООП, то класс предоставляет к использованию открытые методы (и все они предназначены для использования извне) и скрывает детали реализации - всё остальное скрыто. Открыть то, что нужно и скрыть всё остальное, чтоб не дать запутаться в дебрях реализации.

    На более высоком уровне - это разделение компонентов на API и реализацию. Всё, что доступно для использования вынесено в API. При этом компонент с API имеет минимум зависимостей (норма - если вообще не имеет, или имеет зависимости только от других, более высокоуровневых API). И при использовании код зависит только от API и при этом скрываются все зависимости от реализации.


  1. masai
    12.11.2022 13:34

    Датаклассы не совсем по назначению используются. Их используют, когда объект предназначен для хранения значений, к которым осуществляется доступ через точку, а в примерах полноценные классы с методами. Полей всего пара штук, так что какой-то особой необходимости в использовании именно датаклассов тут нет.

    Ещё можно понять, почему Rectangle — датакласс (хотя это тоже спорный вопрос), но Printer у меня совсем не ассоциируется с данными.

    Однако даже в Rectangle и Square датаклассы используются очень необычным образом. Поля объявлены как private, а потом отдельно объявлены одноимённые свойства. Но зачем датакласс тогда нужен, если ни одно из преимуществ датаклассов не используется?


    1. masai
      13.11.2022 01:53

      Было интересно услышать доводы того, кто поставил минус.


  1. Sergeant101
    12.11.2022 16:03

    А вот можно вопрос про сохранение?

    Почему добавление, удаление и вывод в классе реестра норм, а вот сохранение - уже избыточно?

    Операция ведь только для реестра предназначена, и актор у нее один - реестр.


    1. igor6130
      12.11.2022 17:16

      Потому что это не функционал реестра. SaveManager сохраняет данные в сторонний файл. Позже этот класс можно будет использовать для сохранения иных сущностей.


  1. lexxpavlov
    13.11.2022 23:33

    В разделе про ISP в коде верное решение не указано, а только показана проблема (что пришлось сделать заглушку с исключением)? Или я не совсем понял, как код связан с текстом.


  1. behapas733
    14.11.2022 10:46

    Отличная статья, спасибо!

    Если вдруг напишите подобного рода статью про многопоточность в Python (pyside/pyqt), с радостью прочитаю!