Pull to refresh

Принципы проектирования SOLID

Reading time8 min
Views23K

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

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

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

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

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

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

Tags:
Hubs:
Total votes 10: ↑7 and ↓3+5
Comments19

Articles