Принципы проектирования SOLID с примерами на Python
- среда, 16 февраля 2022 г. в 00:37:01
Эффективный алгоритм – основа работы эффективного программного обеспечения. Когда алгоритм уже есть, следующая задача – сделать так, чтобы ПО было разработано с использованием лучших методов проектирования и лучшей архитектуры. Специалисты, исследователи и эксперты определили лучшие практики эффективного проектирования программных приложений. Одной из наиболее популярных среди них являются принципы проектирования, известные под аббревиатурой SOLID.
Самыми известными и важными считаются принципы проектирования, предложенные Робертом К. Мартином (также известным как Дядя Боб). Дядя Боб представил много разных принципов проектирования, однако самых популярных всего 5, сокращенно их называют SOLID-принципами. В основном они сфокусированы вокруг объектно-ориентированной парадигмы проектирования ПО. Если учитывать эти рекомендации при разработке объектно-ориентированного ПО, код станет не таким сложным, снизится риск поломок, улучшится взаимодействие между различными объектами и код станет более гибким, читаемым и управляемым.
SOLID-принципы Дяди Боба расшифровываются следующим образом:
S – Принцип единственной ответственности (Single Responsibility Principle),
O – Принцип открытости/закрытости (Open‐Closed Principle),
L – Принцип подстановки Барбары Лисков (Liskov Substitution Principle),
I – Принцип разделения интерфейсов (Interface Segregation Principle),
D – Принцип инверсии зависимостей (Dependency Inversion Principle).
В этой статье я поделюсь своим пониманием принципов проектирования SOLID Роберта К. Мартина и снабжу его примерами на Python.
Примечание: Примеры кода, которые я привожу, будут весьма минималистичны по своей природе, поскольку они написаны с единственной целью – объяснить соответствующий принцип. Они могут быть неполными или не соответствовать какому-либо другому принципу или передовой практике. Я прошу читателей принять это во внимание при чтении кода каждого принципа.
Принцип единой ответственности гласит, что у каждого класса должна быть только одна «ответственность» и он не должен брать на себя другие обязанности. Роберт К. Мартин объяснял его так: «У класса должна быть лишь одна причина для изменения».
Давайте в качестве примера возьмем приложение телефонного справочника. Мы будем делать телефонный справочник, в котором будет класс TelephoneDirectory
. Он будет «нести ответственность» за ведение записей справочника, то есть телефонных номеров и названий организаций, которым принадлежат номера. Ожидается, что класс будет выполнять следующие операции: добавлять новую запись (Name
и Telephone
Number
), удалять существующую запись, изменять номер телефона, присвоенный сущности Name
, и предоставлять поиск, который будет возвращать номер, присвоенный сущности Name
.
Класс TelephoneDirectory
может выглядеть следующим образом:
Сейчас наш класс TelephoneDirectory
выглядит хорошо, в нем точно реализованы ожидаемые функции:
А теперь скажем, что в проекте есть еще два требования – Сохранить содержимое справочника в базе данных и перенести содержимое справочника в файл.
Теперь добавим еще два метода в класс TelephoneDirectory
, как показано ниже:
Так вот, именно сейчас мы нарушили принцип единственной ответственности. Добавив функции сохранения в базу данных и сохранения в файл, мы дали классу дополнительные обязанности, которые не входят в его основную зону ответственности. Теперь в классе есть дополнительные функции, которые могут привести к его изменению. В будущем, если появятся какие-то требования, связанные с сохранением данных, это может привести к изменениям в классе TelephoneDirectory
. Получается, что класс TelephoneDirectory
подвержен изменениям по причинам, которые не являются его основной ответственностью.
Принцип единственной ответственности требует от нас не добавлять дополнительные обязанности к классу, чтобы нам не приходилось менять класс, когда нам нужно изменить функционал сохранения справочника в базу данных или в файл. Мы можем передать экземпляр класса TelephoneDirectory
экземплярам этих классов и записать любые дополнительные функции в них.
Так мы гарантируем, что у класса TelephoneDirectory
есть лишь одна причина для изменения – это изменения в его основной «ответственности».
Примеры кода выше вы можете найти на GitHub
Принцип открытости/закрытости впервые был сформулирован Бернардом Мейером в 1988 году. Роберт К. Мартин говорил о нем так «Наиболее важный принцип открытости/закрытости гласит «Сущности программы (классы, модули, функции и т.п.) должны быть открыты для расширения, но закрыты для изменений».
Следование этому принципу гарантирует, что класс определен достаточно, чтобы делать то, что он должен делать. Добавление любых дополнительных функций может быть реализовано путем создания новых сущностей, которые расширяют возможности существующего класса и добавляют дополнительные функции самим себе. Таким образом можно предотвратить частые и тривиальные изменения в хорошо зарекомендовавшем себя классе низкого уровня.
Допустим, у нас есть приложение для магазина одежды. Среди функций системы есть функция применения специальных скидок в зависимости от типа одежды.
Пример ниже показывает один из способов реализации этого требования.
В примере у нас есть класс DiscountCalculator
, который умеет хранить тип одежды. В нем есть функция, которая рассчитывает скидку в зависимости от типа одежды и возвращает новую стоимость за вычетом суммы скидки.
Эта конструкция нарушает принцип открытости/закрытости, поскольку этот класс потребует изменения, если будет добавляться какой-то тип одежды или если сумма скидки на какую-либо одежду изменится.
Как видно из примера выше, теперь у нас есть очень простой базовый класс DiscountCalculator
с одним абстрактным методом get_discounted_price
. Мы создали новые классы для одежды, которые расширяют базовый класс DiscountCalculator
. Следовательно, теперь каждый подкласс будет реализовывать функционал скидок самостоятельно. Сделав так, мы устранили предыдущие ограничения, которые требовали внесения изменений в базовый класс. Теперь, не изменяя базовый класс, мы можем добавлять больше одежды, а также изменять размер скидки на отдельный вид одежды по мере необходимости.
Примеры кода выше вы можете найти на GitHub
Принцип подстановки Лисков был одним из самых сложных принципов для меня, и чтобы понять его правильно, мне пришлось посмотреть различные примеры в Интернете. Я считаю, что после осознания, этот принцип станет одним из самых простых, среди принципов, которых следует придерживаться при разработке объектно-ориентированных приложений.
Принцип подстановки Лисков гласит: «Объекты в программе должны быть заменяемы экземплярами их подтипов без ущерба корректности работы программы».
Принцип подстановки Лисков был предложен Барбарой Лисков. Он предполагает отношение подтипов, называемое сильным поведенческим подтипом. Этот принцип говорит нам о том, что если класс Sub
является подтипом класса Sup
, тогда в программе объекты типа Sup
должны легко заменяться объектами типа Sub
без необходимости изменения кода. Дядя Боб включил этот принцип в число 5 лучших принципов проектирования SOLID.
Допустим, у нас есть базовый класс Car
, который отвечает за тип автомобиля. Класс Car
наследуется подклассом PetrolCar
. Аналогично, базовый класс Car
может быть унаследован другими классами, которые могут расширять его возможности.
Как мы видим здесь, стандартной спецификации для добавления свойств Car
не существует, и разработчикам остается реализовать ее удобным для них способом. Один разработчик может реализовать ее как словарь, а другой как кортеж. Таким образом, она может быть реализована несколькими способами.
Пока проблем нет. Но давайте предположим, что есть задача найти все автомобили красного цвета. Давайте попробуем написать функцию, которая брала бы все автомобили и пыталась найти все красные путем реализации объекта суперкласса Car
.
Как видно из кода, мы пытаемся просмотреть список объектов Car
. Именно здесь мы нарушаем принцип подстановки Лисков, поскольку мы не можем заменить объекты супертипа Car
объектами подтипа PetrolCar
внутри функции поиска красных автомобилей.
Лучшим варианты было бы реализовать методы setter
и getter
в суперклассе Car
. С их помощью мы можем устанавливать и получать свойства автомобиля, не оставляя эту реализацию последующим разработчикам. Таким образом, мы просто получаем свойства с помощью метода setter
, и его реализация остается инкапсулированной в суперклассе.
Так мы сможем соблюсти принцип подстановки Лисков, как показано ниже:
Примеры кода выше вы можете найти на GitHub
Принцип разделения интерфейсов гласит, что «Ни один клиент не должен зависеть от методов, которые он не использует».
Принцип разделения интерфейсов был предложен Робертом К. Мартином, когда он консультировал компанию Xerox.
Принцип разделения интерфейсов предполагает создание небольших интерфейсов, известных как «ролевые интерфейсы», вместо большого интерфейса, состоящего из нескольких методов. Разделяя методы по ролям на более мелкие интерфейсы, клиенты будут зависеть только от методов, которые имеют к ним отношение.
Допустим, мы разрабатываем приложение для различных коммуникационных устройств. Мы говорим, что устройство связи – это устройство, которое будет иметь одну или несколько из следующих функций: совершать звонки, отправлять SMS или искать в Интернете. Итак, мы создаем интерфейс с именем CommunicationDevice
и добавляем соответствующие абстрактные методы для каждой из этих функций, чтобы любой создаваемый класс смог реализовать эти методы.
Затем мы создаем класс SmartPhone
с помощью интерфейса CommunicationDevice
и реализуем функционал абстрактных методов. До сих пор все было в порядке.
Теперь предположим, что нам нужно создать стационарный телефон. Он тоже является устройством связи, поэтому мы создаем новый класс LandlinePhone
через тот же интерфейс CommunicationDevice
. Именно здесь мы сталкиваемся с проблемой из-за объемного интерфейса CommunicationDevice
. В классе LandlinePhone
мы реализовываем метод make_calls()
, но поскольку мы также наследуем абстрактные методы send_sms()
и browse_internet()
, мы должны предоставить реализацию и этих двух абстрактных методов в классе LandlinePhone
, даже если они в принципе неприменимы к этому виду телефонов. Мы можем либо создать исключение, либо оставить pass
вместо реализации, но нам все равно нужно ее предоставить.
Все можно исправить, следуя принципу разделения интерфейсов, как в примере ниже. Вместо создания большого интерфейса мы создаем более маленькие ролевые интерфейсы для каждого метода. Соответствующие классы будут использовать только связанные интерфейсы.
Примеры кода выше вы можете найти на GitHub
Принцип инверсии зависимостей гласит:
Модуль высокого уровня не должен зависеть от модулей низкого уровня. И то, и другое должно зависеть от абстракций.
Абстракции не должны зависеть от деталей реализации. Детали реализации должны зависеть от абстракций.
Если ваш код уже реализует принципы открытости/закрытости и подстановки Лисков, он уже будет неявно согласован с принципом инверсии зависимостей.
Следуя принципу открытости/закрытости, вы создаете интерфейсы, которые можно использовать для предоставления различных высокоуровневых реализаций. Следуя принципу подстановки Лисков, вы гарантируете, что сможете заменить экземпляры класса низкого уровня объектами класса высокого уровня без какого-либо негативного воздействия на приложение. Таким образом, следуя этим двум принципам, вы гарантируете, что ваши классы высокого уровня и классы низкого уровня зависят от интерфейсов. Следовательно, вы неявно следуете принципу инверсии зависимостей.
Как показано в коде ниже, у нас есть класс Student
, который мы используем для создания экземпляров Student
и класса TeamMemberships
, который содержатся сведения о принадлежности учеников к разным командам.
Теперь мы определим высокоуровневый класс Analysis
, где нам нужно отсеять всех учеников, принадлежащих красной команде.
Как видно из реализации, мы напрямую используем team_student_memberships.team_memberships
в высокоуровневом классе Analysis
, и мы используем реализацию этого списка непосредственно в классе высокого уровня. На данный момент все нормально, но представьте ситуацию, в которой нам нужно изменить эту реализацию со списка на что-то другое. В этом случае наш класс высокого уровня Analysis
сломается, поскольку он зависит от деталей реализации TeamMemberships
низкого уровня.
Теперь взгляните на пример ниже, в котором мы меняем эту реализацию и приводим ее в соответствие с принципом инверсии зависимостей.
Чтобы следовать принципу инверсии зависимостей, нам необходимо убедиться, что класс высокого уровня Analysis
не зависит от конкретной реализации класса низкого уровня TeamMembership
. Вместо этого он должен зависеть от некоторой абстракции.
Итак, мы создаем интерфейс TeamMembershipLookup
, который содержит абстрактный метод find_all_students_of_team
, передающийся любому классу, наследующему этот интерфейс. Мы наследуем наш класс TeamMembership
от этого интерфейса, следовательно, теперь класс TeamMembership
должен предоставлять реализацию функции find_all_students_of_team
. Затем эта функция передает результаты любому другому вызывающему ее объекту. Мы перенесли обработку, которая делалась в классе высокого уровня Analysis
в TeamMemberships
через интерфейс TeamMembershipLookup
.
Сделав все это, мы убрали зависимость класса Analysis
от класса TeamMemberships
и перенесли ее в интерфейс TeamMembershipLookup
. Теперь класс высокого уровня не зависит от деталей реализации класса низкого уровня. Любые изменения в деталях реализации класса низкого уровня не влияют на класс высокого уровня.
Примеры кода выше вы можете найти на GitHub
Итог:
Принцип | Смысл |
Принцип единственной ответственности | У класса должна быть всего одна причина для изменения. |
Принцип открытости/закрытости | Сущности программы (классы, модули, функции и т.п.) должны быть открыты для расширения, но закрыты для изменений. |
Принцип подстановки Барбары Лисков | Объекты в программе должны быть заменяемы экземплярами их подтипов без ущерба корректности работы программы. |
Принцип разделения интерфейсов | Ни один клиент не должен зависеть от методов, которые он не использует. |
Принцип инверсии зависимостей | Модуль высокого уровня не должен зависеть от модулей низкого уровня. И то, и другое должно зависеть от абстракций. Абстракции не должны зависеть от деталей реализации. Детали реализации должны зависеть от абстракций. |
Примечание: Весь код на Python из этой статьи можно загрузить с моего GitHub вместе с PDF-файлом этой статьи.
Источники:
Дмитрий Нестерюк для курса по паттернам проектирования на Udemy.
Джордан Хадженс из DevCamp с его туториалами на YouTube.
Wikipedia с ее великолепным информативным контентом.
Перевод материала подготовлен для будущих студентов специализации Python Developer. Всех желающих приглашаем на открытый урок, на котором мы познакомимся с Декораторами, узнаем, что они из себя представляют и как работают, а также научимся создавать их самостоятельно.