12 июля 2023

🐍🧱 ООП в Python: принципы SOLID для начинающих

iOS-developer, ИТ-переводчица, пишу статьи и гайды.
В этой статье мы разберем значение и цели каждого принципа SOLID, а также применим принципы SOLID для рефакторинга.
🐍🧱 ООП в Python: принципы SOLID для начинающих
Данная статья является переводом. Автор: Leodanis Pozo Ramos. Ссылка на оригинал.

SOLID — это набор из пяти принципов объектно-ориентированного проектирования, которые могут помочь вам написать более удобный, гибкий и масштабируемый код на основе хорошо спроектированных, четко структурированных классов. Эти принципы являются фундаментальной частью лучших практик объектно-ориентированного проектирования.

В этом уроке вы:

  1. Поймете значение и цель каждого принципа SOLID.
  2. Определите код Python, который нарушает некоторые принципы SOLID.
  3. Примените принципы SOLID для рефакторинга кода Python и улучшения его дизайна.

На протяжении всего обучения вы будете кодировать практические примеры, чтобы узнать, как принципы SOLID могут привести к созданию хорошо организованного, гибкого, удобного в сопровождении и масштабируемого кода.

Чтобы получить максимальную отдачу от этого руководства, вы должны хорошо разбираться в концепциях объектно-ориентированного программирования Python, таких как классы, интерфейсы и наследование.

Бесплатный бонус: кликните здесь, чтобы загрузить пример кода, чтобы вы могли создавать чистые, удобные в сопровождении классы с помощью принципов SOLID в Python.

Объектно-ориентированное проектирование в Python: принципы SOLID

Когда дело доходит до написания классов и проектирования их взаимодействия в Python, вы можете следовать ряду принципов, которые помогут вам создавать лучший объектно-ориентированный код. Один из самых популярных и общепринятых наборов стандартов объектно-ориентированного проектирования (ООП) известен как принципы SOLID.

Если вы перешли с C++ или Java, возможно, вы уже знакомы с этими принципами. Возможно, вам интересно, применимы ли принципы SOLID к коду Python. Ответ на этот вопрос — твердое да. Если вы пишете объектно-ориентированный код, вам следует подумать о применении этих принципов к вашему ООП.

Но что такое эти SOLID принципы? SOLID — это аббревиатура, объединяющая пять основных принципов, применимых к объектно-ориентированному проектированию. Эти принципы заключаются в следующем:

  1. Принцип единственной ответственности (SRP)
  2. Принцип открытости/закрытости (OCP)
  3. Принцип подстановки Лисков (LSP)
  4. Принцип разделения интерфейса (ISP)
  5. Принцип инверсии зависимостей (DIP)

Вы подробно изучите каждый из этих принципов и напишите реальные примеры на Python. В процессе вы получите четкое представление о том, как писать более простой, организованный, масштабируемый и пригодный для повторного использования объектно-ориентированный код, применяя принципы SOLID. Для начала вы начнете с первого принципа в списке.

1. Принцип единственной ответственности (SRP)

Принцип единственной ответственности (SRP) был введен Робертом С. Мартином, более известным под своим прозвищем Дядя Боб, который является уважаемой фигурой в мире разработки программного обеспечения и одним из первых, кто подписал Манифест Agile. Фактически он ввел термин SOLID.

Принцип единой ответственности гласит: у класса должна быть только одна причина для изменения.

Это означает, что у класса должна быть только одна ответственность, выраженная через его методы. Если класс занимается более чем одной задачей, вам следует разделить эти задачи на отдельные классы.

Примечание
Вы найдете там различные формулировки принципов SOLID. В этом руководстве вы будете ссылаться на них в соответствии с формулировкой, которую дядя Боб использует в своей книге Agile Software Development: Principles, Patterns, and Practices. Итак, все прямые цитаты взяты из этой книги.

Если вы хотите прочитать альтернативные формулировки в кратком обзоре этих и связанных с ними принципов, ознакомьтесь с «Принципами OOD» дяди Боба.

Этот принцип тесно связан с концепцией разделения задач, которая предполагает, что вы должны разделить свои программы на разделы. Каждый раздел должен касаться отдельной проблемы.

Чтобы проиллюстрировать принцип единой ответственности и то, как он может помочь вам улучшить объектно-ориентированный дизайн, предположим, что у вас есть следующий класс FileManager:

        # file_manager_srp.py

from pathlib import Path
from zipfile import ZipFile

class FileManager:
    def __init__(self, filename):
        self.path = Path(filename)

    def read(self, encoding="utf-8"):
        return self.path.read_text(encoding)

    def write(self, data, encoding="utf-8"):
        self.path.write_text(data, encoding)

    def compress(self):
        with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
            archive.write(self.path)

    def decompress(self):
        with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
            archive.extractall()
    

В этом примере у вашего класса FileManager две разные обязанности. Он использует методы .read()и .write() для управления файлом. Он также работает с ZIP-архивами, предоставляя методы .compress() и .decompress().

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

        # file_manager_srp.py

from pathlib import Path
from zipfile import ZipFile

class FileManager:
    def __init__(self, filename):
        self.path = Path(filename)

    def read(self, encoding="utf-8"):
        return self.path.read_text(encoding)

    def write(self, data, encoding="utf-8"):
        self.path.write_text(data, encoding)

class ZipFileManager:
    def __init__(self, filename):
        self.path = Path(filename)

    def compress(self):
        with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
            archive.write(self.path)

    def decompress(self):
        with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
            archive.extractall()
    

Теперь у вас есть два меньших класса, у каждого из которых есть только одна обязанность. FileManager заботится об управлении файлом, а ZipFileManager обрабатывает сжатие и распаковку файла с использованием формата ZIP. Эти два класса меньше, поэтому ими легче управлять. Их также легче анализировать, тестировать и отлаживать.

Понятие ответственности в этом контексте может быть довольно субъективным. Наличие единой ответственности не обязательно означает наличие единого метода. Ответственность напрямую связана не с количеством методов, а с основной задачей, за которую отвечает ваш класс, в зависимости от вашего представления о том, что класс представляет в вашем коде. Однако эта субъективность не должна мешать вам использовать SRP.

🐍 Библиотека питониста
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста»
🐍🎓 Библиотека собеса по Python
Подтянуть свои знания по Python вы можете на нашем телеграм-канале «Библиотека собеса по Python»
🐍🧩 Библиотека задач по Python
Интересные задачи по Python для практики можно найти на нашем телеграм-канале «Библиотека задач по Python»

2. Принцип открытости-закрытости (OCP)

Принцип открытости-закрытости (OCP) для объектно-ориентированного проектирования был первоначально введен Бертраном Мейером в 1988 году и означает, что:

Программные сущности (классы, модули, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации.

Чтобы понять, что такое принцип открытости-закрытости, рассмотрим следующий класс Shape:

        # shapes_ocp.py

from math import pi

class Shape:
    def __init__(self, shape_type, **kwargs):
        self.shape_type = shape_type
        if self.shape_type == "rectangle":
            self.width = kwargs["width"]
            self.height = kwargs["height"]
        elif self.shape_type == "circle":
            self.radius = kwargs["radius"]

    def calculate_area(self):
        if self.shape_type == "rectangle":
            return self.width * self.height
        elif self.shape_type == "circle":
            return pi * self.radius**2
    

Инициализатор Shape принимает аргумент shape_type, который может быть либо rectangle, либо circle. Он также принимает определенный набор аргументов ключевого слова, используя синтаксис **kwargs. Если вы установите тип формы на rectangle, то вы также должны передать аргументы ключевого слова width и height, чтобы вы могли построить правильный прямоугольник.

Напротив, если для типа формы задано значение circle, необходимо также передать аргумент radius для построения круга.

Примечание
Этот пример может показаться несколько экстремальным. Его цель состоит в том, чтобы четко раскрыть основную идею принципа открытости-закрытости.

Shape также имеет метод .calculate_area(), который вычисляет площадь текущей формы в соответствии с ее .shape_type:

        >>> from shapes_ocp import Shape

>>> rectangle = Shape("rectangle", width=10, height=5)
>>> rectangle.calculate_area()
50
>>> circle = Shape("circle", radius=5)
>>> circle.calculate_area()
78.53981633974483
    

Класс работает. Вы можете создавать круги и прямоугольники, вычислять их площадь и так далее. Тем не менее, класс выглядит довольно плохо. Что-то с ним не так на первый взгляд.

Представьте, что вам нужно добавить новую фигуру, например, квадрат. Как бы вы это сделали? Что ж, вариант здесь состоит в том, чтобы добавить еще один пункт elif к .__init__() и к .calculate_area(), чтобы вы могли удовлетворить требования к квадратной форме.

Необходимость внесения этих изменений для создания новых фигур означает, что ваш класс открыт для модификации. Это нарушает принцип открытости-закрытости. Как вы можете исправить свой класс, чтобы сделать его открытым для расширения, но закрытым для модификации? Вот возможное решение:

        # shapes_ocp.py

from abc import ABC, abstractmethod
from math import pi

class Shape(ABC):
    def __init__(self, shape_type):
        self.shape_type = shape_type

    @abstractmethod
    def calculate_area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        super().__init__("circle")
        self.radius = radius

    def calculate_area(self):
        return pi * self.radius**2

class Rectangle(Shape):
    def __init__(self, width, height):
        super().__init__("rectangle")
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        super().__init__("square")
        self.side = side

    def calculate_area(self):
        return self.side**2
    

В этом коде вы полностью реорганизовали класс Shape, превратив его в абстрактный базовый класс (ABC). Этот класс предоставляет необходимый интерфейс (API) для любой формы, которую вы хотите определить. Этот интерфейс состоит из атрибута .shape_type и метода .calculate_area(), которые вы должны переопределить во всех подклассах.

Примечание
В приведенном выше примере и некоторых примерах в следующих разделах используются абстрактные базовые классы Python для реализации так называемого наследования интерфейса. В этом типе наследования подклассы наследуют интерфейсы, а не функциональность. Напротив, когда классы наследуют функциональность, вам предоставляется наследование реализации.

Это обновление закрывает класс для модификаций. Теперь вы можете добавлять новые формы в дизайн вашего класса без необходимости изменять файлы Shape. В любом случае вам придется реализовать требуемый интерфейс, что также сделает ваши классы полиморфными.

3. Принцип подстановки Лисков (LSP)

Принцип подстановки Лисков (LSP) был представлен Барбарой Лисков на конференции OOPSLA в 1987 году. С тех пор этот принцип является фундаментальной частью объектно-ориентированного программирования. Принцип гласит, что: подтипы должны быть взаимозаменяемыми для своих базовых типов.

Например, если у вас есть фрагмент кода, который работает с классом Shape, вы должны иметь возможность заменить этот класс любым из его подклассов, например Circle или Rectangle, без нарушения кода.

Примечание
Вы можете прочитать материалы конференции из основного доклада, где Барбара Лисков впервые поделилась этим принципом, или вы можете посмотреть короткий фрагмент интервью с ней для большего контекста.

На практике этот принцип заключается в том, чтобы заставить ваши подклассы вести себя как их базовые классы, не нарушая чьих-либо ожиданий, когда они вызывают одни и те же методы. Чтобы продолжить с примерами, связанными с фигурами, предположим, что у вас есть класс, Rectangle, подобный следующему:

        # shapes_lsp.py

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height
    

В Rectangle есть метод .calculate_area(), который работает с атрибутами экземпляра .width и .height.

Поскольку квадрат — это частный случай прямоугольника с равными сторонами, вы думаете о создании класса Square из Rectangle для повторного использования кода. Затем вы переопределяете метод setter для атрибутов .width и .height так, чтобы при изменении одной стороны изменялась и другая сторона:

        # shapes_lsp.py

# ...

class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    def __setattr__(self, key, value):
        super().__setattr__(key, value)
        if key in ("width", "height"):
            self.__dict__["width"] = value
            self.__dict__["height"] = value
    

В этом фрагменте кода вы определили Square как подкласс Rectangle. Как и следовало ожидать, конструктор класса принимает в качестве аргумента только сторону квадрата. Внутри .__init__() метод инициализирует родительские атрибуты .width и .height с аргументом side.

Вы также определили специальный метод, .__setattr__(), для подключения к механизму установки атрибутов Python и перехвата присвоения нового значения любому атрибуту .width или .height. В частности когда вы устанавливаете один из этих атрибутов, для другого атрибута также устанавливается то же значение:

        >>> from shapes_lsp import Square

>>> square = Square(5)
>>> vars(square)
{'width': 5, 'height': 5}

>>> square.width = 7
>>> vars(square)
{'width': 7, 'height': 7}

>>> square.height = 9
>>> vars(square)
{'width': 9, 'height': 9}
    

Теперь вы уверены, что объект Square всегда остается допустимым квадратом, облегчая вашу жизнь за небольшую плату в виде небольшого количества потраченной впустую памяти. К сожалению, это нарушает принцип подстановки Лисков, потому что вы не можете заменить экземпляры Rectangle их аналогами Square.

Когда кто-то ожидает в своем коде прямоугольный объект, он может предположить, что он будет вести себя как один, предоставляя два независимых атрибута .width и .height. Тем временем ваш класс Square разрушает это предположение, изменяя поведение, обещанное интерфейсом объекта. Это может иметь неожиданные и нежелательные последствия, которые, вероятно, будет трудно отладить.

Хотя квадрат — это особый тип прямоугольника в математике, классы, представляющие эти фигуры, не должны находиться в отношениях родитель-потомок, если вы хотите, чтобы они соответствовали принципу подстановки Лисков. Один из способов решить эту проблему — создать базовый класс для Rectangle и Square для расширения:

        # shapes_lsp.py

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def calculate_area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def calculate_area(self):
        return self.side ** 2
    

Shape становится типом, который вы можете заменить с помощью полиморфизма на Rectangle или Square, которые теперь являются одноуровневыми, а не родительским и дочерним. Обратите внимание, что оба конкретных типа фигур имеют разные наборы атрибутов, разные методы инициализации и потенциально могут реализовывать еще больше разных поведений. Единственное, что у них общего, это способность вычислять их площадь.

С этой реализацией вы можете использовать тип Shape взаимозаменяемо с его Square и Rectangle подтипами, когда вас интересует только их общее поведение:

        >>> from shapes_lsp import Rectangle, Square

>>> def get_total_area(shapes):
...     return sum(shape.calculate_area() for shape in shapes)

>>> get_total_area([Rectangle(10, 5), Square(5)])
75
    

Здесь вы передаете пару, состоящую из прямоугольника и квадрата, в функцию, которая вычисляет их общую площадь. Поскольку функция заботится только о методе .calculate_area(), не имеет значения, что формы разные. В этом суть принципа подстановки Лисков.

<a href="https://proglib.io/p/solid-principy-chto-takoe-i-zachem-nuzhny-razbiraem-po-bukvam-2022-02-02" target="_blank">🧱 SOLID-принципы: что такое и зачем нужны. Разбираем по буквам</a>
🧱 SOLID-принципы: что такое и зачем нужны. Разбираем по буквам

4. Принцип разделения интерфейсов (ISP)

Принцип разделения интерфейсов (ISP) исходит из того же принципа, что и принцип единственной ответственности. Да, это еще одно перо в шляпе дяди Боба. Основная идея принципа заключается в том, что:

Клиентов не следует заставлять зависеть от методов, которые они не используют. Интерфейсы принадлежат клиентам, а не иерархиям.

В этом случае клиенты — это классы и подклассы, а интерфейсы состоят из методов и атрибутов. Другими словами, если класс не использует определенные методы или атрибуты, то эти методы и атрибуты должны быть разделены на более конкретные классы.

Рассмотрим следующий пример иерархии классов для моделирования печатных машин:

        # printers_isp.py

from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document):
        pass

    @abstractmethod
    def fax(self, document):
        pass

    @abstractmethod
    def scan(self, document):
        pass

class OldPrinter(Printer):
    def print(self, document):
        print(f"Printing {document} in black and white...")

    def fax(self, document):
        raise NotImplementedError("Fax functionality not supported")

    def scan(self, document):
        raise NotImplementedError("Scan functionality not supported")

class ModernPrinter(Printer):
    def print(self, document):
        print(f"Printing {document} in color...")

    def fax(self, document):
        print(f"Faxing {document}...")

    def scan(self, document):
        print(f"Scanning {document}...")

    

В этом примере базовый класс Printer предоставляет интерфейс, который должны реализовать его подклассы. OldPrinter наследуется от Printer и должен реализовывать тот же интерфейс. Однако OldPrinter не использует методы .fax() и .scan(), поскольку этот тип принтера не поддерживает эти функции.

Эта реализация нарушает ISP, поскольку она вынуждает OldPrinter предоставлять интерфейс, который класс не реализует или не требует. Чтобы решить эту проблему, вы должны разделить интерфейсы на более мелкие и более конкретные классы. Затем вы можете создавать конкретные классы, наследуя несколько классов интерфейса по мере необходимости:

        # printers_isp.py

from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document):
        pass

class Fax(ABC):
    @abstractmethod
    def fax(self, document):
        pass

class Scanner(ABC):
    @abstractmethod
    def scan(self, document):
        pass

class OldPrinter(Printer):
    def print(self, document):
        print(f"Printing {document} in black and white...")

class NewPrinter(Printer, Fax, Scanner):
    def print(self, document):
        print(f"Printing {document} in color...")

    def fax(self, document):
        print(f"Faxing {document}...")

    def scan(self, document):
        print(f"Scanning {document}...")
    

Теперь Printer, Fax и Scanner являются базовыми классами, которые предоставляют определенные интерфейсы с одной ответственностью каждый. Чтобы создать OldPrinter, вы наследуете только интерфейс Printer. Таким образом, в классе не будет неиспользуемых методов. Чтобы создать класс ModernPrinter, вам нужно наследоваться от всех интерфейсов. Короче говоря, вы разделили интерфейс Printer.

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

5. Принцип инверсии зависимостей (DIP)

Принцип инверсии зависимостей (DIP) является последним принципом в наборе SOLID. Этот принцип гласит, что: абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Это звучит довольно сложно. Вот пример, который поможет прояснить это. Предположим, вы создаете приложение и у вас есть класс FrontEnd для удобного отображения данных пользователям. В настоящее время приложение получает данные из базы данных, поэтому в итоге вы получите следующий код:

        # app_dip.py

class FrontEnd:
    def __init__(self, back_end):
        self.back_end = back_end

    def display_data(self):
        data = self.back_end.get_data_from_database()
        print("Display data:", data)

class BackEnd:
    def get_data_from_database(self):
        return "Data from the database"
    

В этом примере класс FrontEnd зависит от BackEnd класса и его конкретной реализации. Можно сказать, что оба класса тесно связаны. Эта связь может привести к проблемам с масштабируемостью. Например, предположим, что ваше приложение быстро растет, и вы хотите, чтобы оно могло считывать данные из REST API. Как бы Вы это сделали?

Вы можете подумать о добавлении нового метода BackEnd для получения данных из REST API. Однако для этого также потребуется модифицировать FrontEnd, который должен быть закрыт для модификации по принципу открытости-закрытости.

Чтобы решить эту проблему, вы можете применить принцип инверсии зависимостей и сделать ваши классы зависимыми от абстракций, а не от конкретных реализаций, таких как BackEnd. В этом конкретном примере вы можете ввести класс DataSource, который предоставляет интерфейс для использования в ваших конкретных классах:

        # app_dip.py

from abc import ABC, abstractmethod

class FrontEnd:
    def __init__(self, data_source):
        self.data_source = data_source

    def display_data(self):
        data = self.data_source.get_data()
        print("Display data:", data)

class DataSource(ABC):
    @abstractmethod
    def get_data(self):
        pass

class Database(DataSource):
    def get_data(self):
        return "Data from the database"

class API(DataSource):
    def get_data(self):
        return "Data from the API"
    

В этом перепроектировании ваших классов вы добавили класс DataSource как абстракцию, которая предоставляет требуемый интерфейс или метод .get_data(). Обратите внимание, как FrontEnd теперь зависит от интерфейса, предоставляемого DataSource, который является абстракцией.

Затем вы определяете Database класс, который является конкретной реализацией для тех случаев, когда вы хотите получить данные из своей базы данных. Этот класс зависит от абстракции DataSource через наследование. Наконец, вы определяете API класс для поддержки получения данных из REST API. Этот класс также зависит от абстракции DataSource.

Вот как вы можете использовать FrontEnd класс в своем коде:

        >>> from app_dip import API, Database, FrontEnd

>>> db_front_end = FrontEnd(Database())
>>> db_front_end.display_data()
Display data: Data from the database

>>> api_front_end = FrontEnd(API())
>>> api_front_end.display_data()
Display data: Data from the API

    

Здесь вы сначала инициализируете FrontEnd с помощью объекта Database, а затем снова с помощью объекта API. Каждый раз, когда вы вызываете .display_data(), результат будет зависеть от конкретного источника данных, который вы используете. Обратите внимание, что вы также можете динамически изменить источник данных, переназначив .data_source атрибут в своем FrontEnd экземпляре.

Заключение

Вы многое узнали о пяти принципах SOLID, в том числе о том, как определить код, который их нарушает, и как провести рефакторинг кода в соответствии с лучшими практиками проектирования. Вы видели хорошие и плохие примеры, связанные с каждым принципом, и узнали, что применение принципов SOLID может помочь вам улучшить ваш объектно-ориентированный дизайн в Python.

В этом уроке вы узнали, как:

  1. Понимать значение и цель каждого принципа SOLID.
  2. Выявлять классы, которые нарушают некоторые принципы SOLID в Python.
  3. Использовать принципы SOLID, чтобы помочь вам реорганизовать код Python и улучшить ООП.

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

***

Материалы по теме

Источники

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ