SOLID — это набор из пяти принципов объектно-ориентированного проектирования, которые могут помочь вам написать более удобный, гибкий и масштабируемый код на основе хорошо спроектированных, четко структурированных классов. Эти принципы являются фундаментальной частью лучших практик объектно-ориентированного проектирования.
В этом уроке вы:
- Поймете значение и цель каждого принципа SOLID.
- Определите код Python, который нарушает некоторые принципы SOLID.
- Примените принципы SOLID для рефакторинга кода Python и улучшения его дизайна.
На протяжении всего обучения вы будете кодировать практические примеры, чтобы узнать, как принципы SOLID могут привести к созданию хорошо организованного, гибкого, удобного в сопровождении и масштабируемого кода.
Чтобы получить максимальную отдачу от этого руководства, вы должны хорошо разбираться в концепциях объектно-ориентированного программирования Python, таких как классы, интерфейсы и наследование.
Бесплатный бонус: кликните здесь, чтобы загрузить пример кода, чтобы вы могли создавать чистые, удобные в сопровождении классы с помощью принципов SOLID в Python.
Объектно-ориентированное проектирование в Python: принципы SOLID
Когда дело доходит до написания классов и проектирования их взаимодействия в Python, вы можете следовать ряду принципов, которые помогут вам создавать лучший объектно-ориентированный код. Один из самых популярных и общепринятых наборов стандартов объектно-ориентированного проектирования (ООП) известен как принципы SOLID.
Если вы перешли с C++ или Java, возможно, вы уже знакомы с этими принципами. Возможно, вам интересно, применимы ли принципы SOLID к коду Python. Ответ на этот вопрос — твердое да. Если вы пишете объектно-ориентированный код, вам следует подумать о применении этих принципов к вашему ООП.
Но что такое эти SOLID принципы? SOLID — это аббревиатура, объединяющая пять основных принципов, применимых к объектно-ориентированному проектированию. Эти принципы заключаются в следующем:
- Принцип единственной ответственности (SRP)
- Принцип открытости/закрытости (OCP)
- Принцип подстановки Лисков (LSP)
- Принцип разделения интерфейса (ISP)
- Принцип инверсии зависимостей (DIP)
Вы подробно изучите каждый из этих принципов и напишите реальные примеры на Python. В процессе вы получите четкое представление о том, как писать более простой, организованный, масштабируемый и пригодный для повторного использования объектно-ориентированный код, применяя принципы SOLID. Для начала вы начнете с первого принципа в списке.
1. Принцип единственной ответственности (SRP)
Принцип единственной ответственности (SRP) был введен Робертом С. Мартином, более известным под своим прозвищем Дядя Боб, который является уважаемой фигурой в мире разработки программного обеспечения и одним из первых, кто подписал Манифест Agile. Фактически он ввел термин SOLID.
Принцип единой ответственности гласит: у класса должна быть только одна причина для изменения.
Это означает, что у класса должна быть только одна ответственность, выраженная через его методы. Если класс занимается более чем одной задачей, вам следует разделить эти задачи на отдельные классы.
Если вы хотите прочитать альтернативные формулировки в кратком обзоре этих и связанных с ними принципов, ознакомьтесь с «Принципами OOD» дяди Боба.
Этот принцип тесно связан с концепцией разделения задач, которая предполагает, что вы должны разделить свои программы на разделы. Каждый раздел должен касаться отдельной проблемы.
Чтобы проиллюстрировать принцип единой ответственности и то, как он может помочь вам улучшить объектно-ориентированный дизайн, предположим, что у вас есть следующий класс FileManager
:
В этом примере у вашего класса FileManager
две разные обязанности. Он использует методы .read()
и .write()
для управления файлом. Он также работает с ZIP-архивами, предоставляя методы .compress()
и .decompress()
.
Этот класс нарушает принцип единственной ответственности, потому что у него есть две причины для изменения его внутренней реализации. Чтобы решить эту проблему и сделать ваш проект более надежным, вы можете разделить класс на два меньших, более целенаправленных класса, каждый из которых имеет свою специфику:
Теперь у вас есть два меньших класса, у каждого из которых есть только одна обязанность. FileManager заботится об управлении файлом, а ZipFileManager обрабатывает сжатие и распаковку файла с использованием формата ZIP. Эти два класса меньше, поэтому ими легче управлять. Их также легче анализировать, тестировать и отлаживать.
Понятие ответственности в этом контексте может быть довольно субъективным. Наличие единой ответственности не обязательно означает наличие единого метода. Ответственность напрямую связана не с количеством методов, а с основной задачей, за которую отвечает ваш класс, в зависимости от вашего представления о том, что класс представляет в вашем коде. Однако эта субъективность не должна мешать вам использовать SRP.
2. Принцип открытости-закрытости (OCP)
Принцип открытости-закрытости (OCP) для объектно-ориентированного проектирования был первоначально введен Бертраном Мейером в 1988 году и означает, что:
Программные сущности (классы, модули, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации.
Чтобы понять, что такое принцип открытости-закрытости, рассмотрим следующий класс Shape:
Инициализатор Shape принимает аргумент shape_type
, который может быть либо rectangle
, либо circle
. Он также принимает определенный набор аргументов ключевого слова, используя синтаксис **kwargs. Если вы установите тип формы на rectangle
, то вы также должны передать аргументы ключевого слова width
и height
, чтобы вы могли построить правильный прямоугольник.
Напротив, если для типа формы задано значение circle
, необходимо также передать аргумент radius
для построения круга.
Shape
также имеет метод .calculate_area()
, который вычисляет площадь текущей формы в соответствии с ее .shape_type
:
Класс работает. Вы можете создавать круги и прямоугольники, вычислять их площадь и так далее. Тем не менее, класс выглядит довольно плохо. Что-то с ним не так на первый взгляд.
Представьте, что вам нужно добавить новую фигуру, например, квадрат. Как бы вы это сделали? Что ж, вариант здесь состоит в том, чтобы добавить еще один пункт elif к .__init__() и к .calculate_area()
, чтобы вы могли удовлетворить требования к квадратной форме.
Необходимость внесения этих изменений для создания новых фигур означает, что ваш класс открыт для модификации. Это нарушает принцип открытости-закрытости. Как вы можете исправить свой класс, чтобы сделать его открытым для расширения, но закрытым для модификации? Вот возможное решение:
В этом коде вы полностью реорганизовали класс Shape
, превратив его в абстрактный базовый класс (ABC). Этот класс предоставляет необходимый интерфейс (API) для любой формы, которую вы хотите определить. Этот интерфейс состоит из атрибута .shape_type
и метода .calculate_area()
, которые вы должны переопределить во всех подклассах.
Это обновление закрывает класс для модификаций. Теперь вы можете добавлять новые формы в дизайн вашего класса без необходимости изменять файлы Shape
. В любом случае вам придется реализовать требуемый интерфейс, что также сделает ваши классы полиморфными.
3. Принцип подстановки Лисков (LSP)
Принцип подстановки Лисков (LSP) был представлен Барбарой Лисков на конференции OOPSLA в 1987 году. С тех пор этот принцип является фундаментальной частью объектно-ориентированного программирования. Принцип гласит, что: подтипы должны быть взаимозаменяемыми для своих базовых типов.
Например, если у вас есть фрагмент кода, который работает с классом Shape
, вы должны иметь возможность заменить этот класс любым из его подклассов, например Circle
или Rectangle
, без нарушения кода.
На практике этот принцип заключается в том, чтобы заставить ваши подклассы вести себя как их базовые классы, не нарушая чьих-либо ожиданий, когда они вызывают одни и те же методы. Чтобы продолжить с примерами, связанными с фигурами, предположим, что у вас есть класс, Rectangle
, подобный следующему:
В Rectangle
есть метод .calculate_area()
, который работает с атрибутами экземпляра .width
и .height
.
Поскольку квадрат — это частный случай прямоугольника с равными сторонами, вы думаете о создании класса Square
из Rectangle
для повторного использования кода. Затем вы переопределяете метод setter
для атрибутов .width
и .height
так, чтобы при изменении одной стороны изменялась и другая сторона:
В этом фрагменте кода вы определили Square
как подкласс Rectangle
. Как и следовало ожидать, конструктор класса принимает в качестве аргумента только сторону квадрата. Внутри .__init__()
метод инициализирует родительские атрибуты .width
и .height
с аргументом side
.
Вы также определили специальный метод, .__setattr__(), для подключения к механизму установки атрибутов Python и перехвата присвоения нового значения любому атрибуту .width
или .height
. В частности когда вы устанавливаете один из этих атрибутов, для другого атрибута также устанавливается то же значение:
Теперь вы уверены, что объект Square
всегда остается допустимым квадратом, облегчая вашу жизнь за небольшую плату в виде небольшого количества потраченной впустую памяти. К сожалению, это нарушает принцип подстановки Лисков, потому что вы не можете заменить экземпляры Rectangle
их аналогами Square
.
Когда кто-то ожидает в своем коде прямоугольный объект, он может предположить, что он будет вести себя как один, предоставляя два независимых атрибута .width
и .height
. Тем временем ваш класс Square
разрушает это предположение, изменяя поведение, обещанное интерфейсом объекта. Это может иметь неожиданные и нежелательные последствия, которые, вероятно, будет трудно отладить.
Хотя квадрат — это особый тип прямоугольника в математике, классы, представляющие эти фигуры, не должны находиться в отношениях родитель-потомок, если вы хотите, чтобы они соответствовали принципу подстановки Лисков. Один из способов решить эту проблему — создать базовый класс для Rectangle
и Square
для расширения:
Shape
становится типом, который вы можете заменить с помощью полиморфизма на Rectangle
или Square
, которые теперь являются одноуровневыми, а не родительским и дочерним. Обратите внимание, что оба конкретных типа фигур имеют разные наборы атрибутов, разные методы инициализации и потенциально могут реализовывать еще больше разных поведений. Единственное, что у них общего, это способность вычислять их площадь.
С этой реализацией вы можете использовать тип Shape
взаимозаменяемо с его Square
и Rectangle
подтипами, когда вас интересует только их общее поведение:
Здесь вы передаете пару, состоящую из прямоугольника и квадрата, в функцию, которая вычисляет их общую площадь. Поскольку функция заботится только о методе .calculate_area()
, не имеет значения, что формы разные. В этом суть принципа подстановки Лисков.
4. Принцип разделения интерфейсов (ISP)
Принцип разделения интерфейсов (ISP) исходит из того же принципа, что и принцип единственной ответственности. Да, это еще одно перо в шляпе дяди Боба. Основная идея принципа заключается в том, что:
Клиентов не следует заставлять зависеть от методов, которые они не используют. Интерфейсы принадлежат клиентам, а не иерархиям.
В этом случае клиенты — это классы и подклассы, а интерфейсы состоят из методов и атрибутов. Другими словами, если класс не использует определенные методы или атрибуты, то эти методы и атрибуты должны быть разделены на более конкретные классы.
Рассмотрим следующий пример иерархии классов для моделирования печатных машин:
В этом примере базовый класс Printer
предоставляет интерфейс, который должны реализовать его подклассы. OldPrinter
наследуется от Printer
и должен реализовывать тот же интерфейс. Однако OldPrinter
не использует методы .fax()
и .scan()
, поскольку этот тип принтера не поддерживает эти функции.
Эта реализация нарушает ISP, поскольку она вынуждает OldPrinter
предоставлять интерфейс, который класс не реализует или не требует. Чтобы решить эту проблему, вы должны разделить интерфейсы на более мелкие и более конкретные классы. Затем вы можете создавать конкретные классы, наследуя несколько классов интерфейса по мере необходимости:
Теперь Printer
, Fax
и Scanner
являются базовыми классами, которые предоставляют определенные интерфейсы с одной ответственностью каждый. Чтобы создать OldPrinter
, вы наследуете только интерфейс Printer
. Таким образом, в классе не будет неиспользуемых методов. Чтобы создать класс ModernPrinter
, вам нужно наследоваться от всех интерфейсов. Короче говоря, вы разделили интерфейс Printer
.
Этот дизайн класса позволяет создавать разные машины с разными наборами функций, делая ваш дизайн более гибким и расширяемым.
5. Принцип инверсии зависимостей (DIP)
Принцип инверсии зависимостей (DIP) является последним принципом в наборе SOLID. Этот принцип гласит, что: абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Это звучит довольно сложно. Вот пример, который поможет прояснить это. Предположим, вы создаете приложение и у вас есть класс FrontEnd
для удобного отображения данных пользователям. В настоящее время приложение получает данные из базы данных, поэтому в итоге вы получите следующий код:
В этом примере класс FrontEnd
зависит от BackEnd
класса и его конкретной реализации. Можно сказать, что оба класса тесно связаны. Эта связь может привести к проблемам с масштабируемостью. Например, предположим, что ваше приложение быстро растет, и вы хотите, чтобы оно могло считывать данные из REST API. Как бы Вы это сделали?
Вы можете подумать о добавлении нового метода BackEnd
для получения данных из REST API. Однако для этого также потребуется модифицировать FrontEnd
, который должен быть закрыт для модификации по принципу открытости-закрытости.
Чтобы решить эту проблему, вы можете применить принцип инверсии зависимостей и сделать ваши классы зависимыми от абстракций, а не от конкретных реализаций, таких как BackEnd
. В этом конкретном примере вы можете ввести класс DataSource
, который предоставляет интерфейс для использования в ваших конкретных классах:
В этом перепроектировании ваших классов вы добавили класс DataSource
как абстракцию, которая предоставляет требуемый интерфейс или метод .get_data()
. Обратите внимание, как FrontEnd
теперь зависит от интерфейса, предоставляемого DataSource
, который является абстракцией.
Затем вы определяете Database
класс, который является конкретной реализацией для тех случаев, когда вы хотите получить данные из своей базы данных. Этот класс зависит от абстракции DataSource
через наследование. Наконец, вы определяете API класс для поддержки получения данных из REST API. Этот класс также зависит от абстракции DataSource
.
Вот как вы можете использовать FrontEnd
класс в своем коде:
Здесь вы сначала инициализируете FrontEnd
с помощью объекта Database
, а затем снова с помощью объекта API. Каждый раз, когда вы вызываете .display_data()
, результат будет зависеть от конкретного источника данных, который вы используете. Обратите внимание, что вы также можете динамически изменить источник данных, переназначив .data_source
атрибут в своем FrontEnd
экземпляре.
Заключение
Вы многое узнали о пяти принципах SOLID, в том числе о том, как определить код, который их нарушает, и как провести рефакторинг кода в соответствии с лучшими практиками проектирования. Вы видели хорошие и плохие примеры, связанные с каждым принципом, и узнали, что применение принципов SOLID может помочь вам улучшить ваш объектно-ориентированный дизайн в Python.
В этом уроке вы узнали, как:
- Понимать значение и цель каждого принципа SOLID.
- Выявлять классы, которые нарушают некоторые принципы SOLID в Python.
- Использовать принципы SOLID, чтобы помочь вам реорганизовать код Python и улучшить ООП.
Благодаря этим знаниям, у вас есть прочная основа хорошо зарекомендовавших себя лучших практик, которые вы должны применять при разработке своих классов и их взаимосвязей в Python. Применяя эти принципы, вы можете создавать код, более удобный в сопровождении, расширяемый, масштабируемый и тестируемый.
Комментарии