Принципы SOLID в Python
iOS-developer, ИТ-переводчица, пишу статьи и гайды. В этой статье мы разберем значение и цели каждого принципа SOLID, а также применим принципы SOLID для рефакторинга. Данная статья является переводом. Автор: Leodanis Pozo Ramos. Ссылка на оригинал. SOLID — это набор из пяти принципов объектно-ориентированного проектирования, которые могут помочь вам написать более удобный, гибкий и масштабируемый код на основе хорошо спроектированных, четко структурированных классов. Эти принципы являются фундаментальной частью лучших практик объектно-ориентированного проектирования. В этом уроке вы: На протяжении всего обучения вы будете кодировать практические примеры, чтобы узнать, как принципы SOLID могут привести к созданию хорошо организованного, гибкого, удобного в сопровождении и масштабируемого кода. Чтобы получить максимальную отдачу от этого руководства, вы должны хорошо разбираться в концепциях объектно-ориентированного программирования Python, таких как классы, интерфейсы и наследование. Бесплатный бонус: кликните здесь, чтобы загрузить пример кода, чтобы вы могли создавать чистые, удобные в сопровождении классы с помощью принципов SOLID в Python. Когда дело доходит до написания классов и проектирования их взаимодействия в Python, вы можете следовать ряду принципов, которые помогут вам создавать лучший объектно-ориентированный код. Один из самых популярных и общепринятых наборов стандартов объектно-ориентированного проектирования (ООП) известен как принципы SOLID. Если вы перешли с C++ или Java, возможно, вы уже знакомы с этими принципами. Возможно, вам интересно, применимы ли принципы SOLID к коду Python. Ответ на этот вопрос — твердое да. Если вы пишете объектно-ориентированный код, вам следует подумать о применении этих принципов к вашему ООП. Но что такое эти SOLID принципы? SOLID — это аббревиатура, объединяющая пять основных принципов, применимых к объектно-ориентированному проектированию. Эти принципы заключаются в следующем: Вы подробно изучите каждый из этих принципов и напишите реальные примеры на Python. В процессе вы получите четкое представление о том, как писать более простой, организованный, масштабируемый и пригодный для повторного использования объектно-ориентированный код, применяя принципы SOLID. Для начала вы начнете с первого принципа в списке. Принцип единственной ответственности (SRP) был введен Робертом С. Мартином, более известным под своим прозвищем Дядя Боб, который является уважаемой фигурой в мире разработки программного обеспечения и одним из первых, кто подписал Манифест Agile. Фактически он ввел термин SOLID. Принцип единой ответственности гласит: у класса должна быть только одна причина для изменения. Это означает, что у класса должна быть только одна ответственность, выраженная через его методы. Если класс занимается более чем одной задачей, вам следует разделить эти задачи на отдельные классы. Примечание Вы найдете там различные формулировки принципов SOLID. В этом руководстве вы будете ссылаться на них в соответствии с формулировкой, которую дядя Боб использует в своей книге Agile Software Development: Principles, Patterns, and Practices. Итак, все прямые цитаты взяты из этой книги. Если вы хотите прочитать альтернативные формулировки в кратком обзоре этих и связанных с ними принципов, ознакомьтесь с «Принципами OOD» дяди Боба. Этот принцип тесно связан с концепцией разделения задач, которая предполагает, что вы должны разделить свои программы на разделы. Каждый раздел должен касаться отдельной проблемы. Чтобы проиллюстрировать принцип единой ответственности и то, как он может помочь вам улучшить объектно-ориентированный дизайн, предположим, что у вас есть следующий класс В этом примере у вашего класса Этот класс нарушает принцип единственной ответственности, потому что у него есть две причины для изменения его внутренней реализации. Чтобы решить эту проблему и сделать ваш проект более надежным, вы можете разделить класс на два меньших, более целенаправленных класса, каждый из которых имеет свою специфику: Теперь у вас есть два меньших класса, у каждого из которых есть только одна обязанность. FileManager заботится об управлении файлом, а ZipFileManager обрабатывает сжатие и распаковку файла с использованием формата ZIP. Эти два класса меньше, поэтому ими легче управлять. Их также легче анализировать, тестировать и отлаживать. Понятие ответственности в этом контексте может быть довольно субъективным. Наличие единой ответственности не обязательно означает наличие единого метода. Ответственность напрямую связана не с количеством методов, а с основной задачей, за которую отвечает ваш класс, в зависимости от вашего представления о том, что класс представляет в вашем коде. Однако эта субъективность не должна мешать вам использовать SRP. Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста» Интересно, перейти к каналу Принцип открытости-закрытости (OCP) для объектно-ориентированного проектирования был первоначально введен Бертраном Мейером в 1988 году и означает, что: Программные сущности (классы, модули, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации. Чтобы понять, что такое принцип открытости-закрытости, рассмотрим следующий класс Shape: Инициализатор Shape принимает аргумент Напротив, если для типа формы задано значение Примечание Этот пример может показаться несколько экстремальным. Его цель состоит в том, чтобы четко раскрыть основную идею принципа открытости-закрытости. Класс работает. Вы можете создавать круги и прямоугольники, вычислять их площадь и так далее. Тем не менее, класс выглядит довольно плохо. Что-то с ним не так на первый взгляд. Представьте, что вам нужно добавить новую фигуру, например, квадрат. Как бы вы это сделали? Что ж, вариант здесь состоит в том, чтобы добавить еще один пункт elif к .__init__() и к Необходимость внесения этих изменений для создания новых фигур означает, что ваш класс открыт для модификации. Это нарушает принцип открытости-закрытости. Как вы можете исправить свой класс, чтобы сделать его открытым для расширения, но закрытым для модификации? Вот возможное решение: В этом коде вы полностью реорганизовали класс Примечание В приведенном выше примере и некоторых примерах в следующих разделах используются абстрактные базовые классы Python для реализации так называемого наследования интерфейса. В этом типе наследования подклассы наследуют интерфейсы, а не функциональность. Напротив, когда классы наследуют функциональность, вам предоставляется наследование реализации. Это обновление закрывает класс для модификаций. Теперь вы можете добавлять новые формы в дизайн вашего класса без необходимости изменять файлы Принцип подстановки Лисков (LSP) был представлен Барбарой Лисков на конференции OOPSLA в 1987 году. С тех пор этот принцип является фундаментальной частью объектно-ориентированного программирования. Принцип гласит, что: подтипы должны быть взаимозаменяемыми для своих базовых типов. Например, если у вас есть фрагмент кода, который работает с классом Примечание Вы можете прочитать материалы конференции из основного доклада, где Барбара Лисков впервые поделилась этим принципом, или вы можете посмотреть короткий фрагмент интервью с ней для большего контекста. На практике этот принцип заключается в том, чтобы заставить ваши подклассы вести себя как их базовые классы, не нарушая чьих-либо ожиданий, когда они вызывают одни и те же методы. Чтобы продолжить с примерами, связанными с фигурами, предположим, что у вас есть класс, В Поскольку квадрат — это частный случай прямоугольника с равными сторонами, вы думаете о создании класса В этом фрагменте кода вы определили Вы также определили специальный метод, .__setattr__(), для подключения к механизму установки атрибутов Python и перехвата присвоения нового значения любому атрибуту Теперь вы уверены, что объект Когда кто-то ожидает в своем коде прямоугольный объект, он может предположить, что он будет вести себя как один, предоставляя два независимых атрибута Хотя квадрат — это особый тип прямоугольника в математике, классы, представляющие эти фигуры, не должны находиться в отношениях родитель-потомок, если вы хотите, чтобы они соответствовали принципу подстановки Лисков. Один из способов решить эту проблему — создать базовый класс для С этой реализацией вы можете использовать тип Здесь вы передаете пару, состоящую из прямоугольника и квадрата, в функцию, которая вычисляет их общую площадь. Поскольку функция заботится только о методе 🧱 SOLID-принципы: что такое и зачем нужны. Разбираем по буквам Принцип разделения интерфейсов (ISP) исходит из того же принципа, что и принцип единственной ответственности. Да, это еще одно перо в шляпе дяди Боба. Основная идея принципа заключается в том, что: Клиентов не следует заставлять зависеть от методов, которые они не используют. Интерфейсы принадлежат клиентам, а не иерархиям. В этом случае клиенты — это классы и подклассы, а интерфейсы состоят из методов и атрибутов. Другими словами, если класс не использует определенные методы или атрибуты, то эти методы и атрибуты должны быть разделены на более конкретные классы. Рассмотрим следующий пример иерархии классов для моделирования печатных машин: В этом примере базовый класс Эта реализация нарушает ISP, поскольку она вынуждает Теперь Этот дизайн класса позволяет создавать разные машины с разными наборами функций, делая ваш дизайн более гибким и расширяемым. Принцип инверсии зависимостей (DIP) является последним принципом в наборе SOLID. Этот принцип гласит, что: абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций. Это звучит довольно сложно. Вот пример, который поможет прояснить это. Предположим, вы создаете приложение и у вас есть класс В этом примере класс Вы можете подумать о добавлении нового метода Чтобы решить эту проблему, вы можете применить принцип инверсии зависимостей и сделать ваши классы зависимыми от абстракций, а не от конкретных реализаций, таких как В этом перепроектировании ваших классов вы добавили класс Затем вы определяете Вот как вы можете использовать Здесь вы сначала инициализируете Вы многое узнали о пяти принципах SOLID, в том числе о том, как определить код, который их нарушает, и как провести рефакторинг кода в соответствии с лучшими практиками проектирования. Вы видели хорошие и плохие примеры, связанные с каждым принципом, и узнали, что применение принципов SOLID может помочь вам улучшить ваш объектно-ориентированный дизайн в Python. В этом уроке вы узнали, как: Благодаря этим знаниям, у вас есть прочная основа хорошо зарекомендовавших себя лучших практик, которые вы должны применять при разработке своих классов и их взаимосвязей в Python. Применяя эти принципы, вы можете создавать код, более удобный в сопровождении, расширяемый, масштабируемый и тестируемый. *** Galina Iaroshenko
Объектно-ориентированное проектирование в Python: принципы SOLID
1. Принцип единственной ответственности (SRP)
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()
2. Принцип открытости-закрытости (OCP)
# 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_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
.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()
, которые вы должны переопределить во всех подклассах.Shape
. В любом случае вам придется реализовать требуемый интерфейс, что также сделает ваши классы полиморфными.3. Принцип подстановки Лисков (LSP)
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
..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()
, не имеет значения, что формы разные. В этом суть принципа подстановки Лисков.4. Принцип разделения интерфейсов (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()
, поскольку этот тип принтера не поддерживает эти функции.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)
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
экземпляре.Заключение
Материалы по теме
- 0 views
- 0 Comment