Принципы SOLID, о которых должен знать каждый разработчик
Объектно-ориентированное программирование принесло в разработку ПО новые подходы к проектированию приложений. В частности, ООП позволило программистам комбинировать сущности, объединённые некоей общей целью или функционалом, в отдельных классах, рассчитанных на решение самостоятельных задач и независимых от других частей приложения. Однако само по себе применение ООП не означает, что разработчик застрахован от возможности создания непонятного, запутанного кода, который тяжело поддерживать. Роберт Мартин, для того, чтобы помочь всем желающим разрабатывать качественные ООП-приложения, разработал пять принципов объектно-ориентированного программирования и проектирования, говоря о которых, с подачи Майкла Фэзерса, используют акроним SOLID.
Что такое SOLID?
Вот как расшифровывается акроним SOLID:
- S: Single Responsibility Principle (Принцип единственной ответственности).
- O: Open-Closed Principle (Принцип открытости-закрытости).
- L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков).
- I: Interface Segregation Principle (Принцип разделения интерфейса).
- D: Dependency Inversion Principle (Принцип инверсии зависимостей).
Сейчас мы рассмотрим эти принципы на схематичных примерах. Обратите внимание на то, что главная цель примеров заключается в том, чтобы помочь читателю понять принципы SOLID, узнать, как их применять и как следовать им, проектируя приложения. Автор материала не стремился к тому, чтобы выйти на работающий код, который можно было бы использовать в реальных проектах.
Принцип единственной ответственности
«Одно поручение. Всего одно.» — Локи говорит Скурджу в фильме «Тор: Рагнарёк».
Каждый класс должен решать лишь одну задачу.
Класс должен быть ответственен лишь за что-то одно. Если класс отвечает за решение нескольких задач, его подсистемы, реализующие решение этих задач, оказываются связанными друг с другом. Изменения в одной такой подсистеме ведут к изменениям в другой.
Обратите внимание на то, что этот принцип применим не только к классам, но и к компонентам программного обеспечения в более широком смысле.
Например, рассмотрим этот код:
Класс Animal , представленный здесь, описывает какое-то животное. Этот класс нарушает принцип единственной ответственности. Как именно нарушается этот принцип?
В соответствии с принципом единственной ответственности класс должен решать лишь какую-то одну задачу. Он же решает две, занимаясь работой с хранилищем данных в методе saveAnimal и манипулируя свойствами объекта в конструкторе и в методе getAnimalName .
Как такая структура класса может привести к проблемам?
Если изменится порядок работы с хранилищем данных, используемым приложением, то придётся вносить изменения во все классы, работающие с хранилищем. Такая архитектура не отличается гибкостью, изменения одних подсистем затрагивают другие, что напоминает эффект домино.
Для того чтобы привести вышеприведённый код в соответствие с принципом единственной ответственности, создадим ещё один класс, единственной задачей которого является работа с хранилищем, в частности — сохранение в нём объектов класса Animal :
Вот что по этому поводу говорит Стив Фентон: «Проектируя классы, мы должны стремиться к тому, чтобы объединять родственные компоненты, то есть такие, изменения в которых происходят по одним и тем же причинам. Нам следует стараться разделять компоненты, изменения в которых вызывают различные причины».
Правильное применение принципа единственной ответственности приводит к высокой степени связности элементов внутри модуля, то есть к тому, что задачи, решаемые внутри него, хорошо соответствуют его главной цели.
Принцип открытости-закрытости
Программные сущности (классы, модули, функции) должны быть открыты для расширения, но не для модификации.
Продолжим работу над классом Animal .
Мы хотим перебрать список животных, каждое из которых представлено объектом класса Animal , и узнать о том, какие звуки они издают. Представим, что мы решаем эту задачу с помощью функции AnimalSounds :
Самая главная проблема такой архитектуры заключается в том, что функция определяет то, какой звук издаёт то или иное животное, анализируя конкретные объекты. Функция AnimalSound не соответствует принципу открытости-закрытости, так как, например, при появлении новых видов животных, нам, для того, чтобы с её помощью можно было бы узнавать звуки, издаваемые ими, придётся её изменить.
Добавим в массив новый элемент:
После этого нам придётся поменять код функции AnimalSound :
Как видите, при добавлении в массив нового животного придётся дополнять код функции. Пример это очень простой, но если подобная архитектура используется в реальном проекте, функцию придётся постоянно расширять, добавляя в неё новые выражения if .
Как привести функцию AnimalSound в соответствие с принципом открытости-закрытости? Например — так:
Можно заметить, что у класса Animal теперь есть виртуальный метод makeSound . При таком подходе нужно, чтобы классы, предназначенные для описания конкретных животных, расширяли бы класс Animal и реализовывали бы этот метод.
В результате у каждого класса, описывающего животного, будет собственный метод makeSound , а при переборе массива с животными в функции AnimalSound достаточно будет вызвать этот метод для каждого элемента массива.
Если теперь добавить в массив объект, описывающий новое животное, функцию AnimalSound менять не придётся. Мы привели её в соответствие с принципом открытости-закрытости.
Рассмотрим ещё один пример.
Представим, что у нас есть магазин. Мы даём клиентам скидку в 20%, используя такой класс:
Теперь решено разделить клиентов на две группы. Любимым ( fav ) клиентам даётся скидка в 20%, а VIP-клиентам ( vip ) — удвоенная скидка, то есть — 40%. Для того, чтобы реализовать эту логику, было решено модифицировать класс следующим образом:
Такой подход нарушает принцип открытости-закрытости. Как видно, здесь, если нам надо дать некоей группе клиентов особую скидку, приходится добавлять в класс новый код.
Для того чтобы переработать этот код в соответствии с принципом открытости-закрытости, добавим в проект новый класс, расширяющий класс Discount . В этом новом классе мы и реализуем новый механизм:
Если решено дать скидку в 80% «супер-VIP» клиентам, выглядеть это должно так:
Как видите, тут используется расширение возможностей классов, а не их модификация.
Принцип подстановки Барбары Лисков
Необходимо, чтобы подклассы могли бы служить заменой для своих суперклассов.
Цель этого принципа заключаются в том, чтобы классы-наследники могли бы использоваться вместо родительских классов, от которых они образованы, не нарушая работу программы. Если оказывается, что в коде проверяется тип класса, значит принцип подстановки нарушается.
Рассмотрим применение этого принципа, вернувшись к примеру с классом Animal . Напишем функцию, предназначенную для возврата информации о количествах конечностей животного.
Функция нарушает принцип подстановки (и принцип открытости-закрытости). Этот код должен знать о типах всех обрабатываемых им объектов и, в зависимости от типа, обращаться к соответствующей функции для подсчёта конечностей конкретного животного. Как результат, при создании нового типа животного функцию придётся переписывать:
Для того чтобы эта функция не нарушала принцип подстановки, преобразуем её с использованием требований, сформулированных Стивом Фентоном. Они заключаются в том, что методы, принимающие или возвращающие значения с типом некоего суперкласса ( Animal в нашем случае) должны также принимать и возвращать значения, типами которых являются его подклассы ( Pigeon ).
Вооружившись этими соображениями мы можем переделать функцию AnimalLegCount :
Теперь эта функция не интересуется типами передаваемых ей объектов. Она просто вызывает их методы LegCount . Всё, что она знает о типах — это то, что обрабатываемые ей объекты должны принадлежать классу Animal или его подклассам.
Теперь в классе Animal должен появиться метод LegCount :
А его подклассам нужно реализовать этот метод:
В результате, например, при обращении к методу LegCount для экземпляра класса Lion производится вызов метода, реализованного в этом классе, и возвращается именно то, что можно ожидать от вызова подобного метода.
Теперь функции AnimalLegCount не нужно знать о том, объект какого именно подкласса класса Animal она обрабатывает для того, чтобы узнать сведения о количестве конечностей у животного, представленного этим объектом. Функция просто вызывает метод LegCount класса Animal , так как подклассы этого класса должны реализовывать этот метод для того, чтобы их можно было бы использовать вместо него, не нарушая правильность работы программы.
Принцип разделения интерфейса
Создавайте узкоспециализированные интерфейсы, предназначенные для конкретного клиента. Клиенты не должны зависеть от интерфейсов, которые они не используют.
Этот принцип направлен на устранение недостатков, связанных с реализацией больших интерфейсов.
Рассмотрим интерфейс Shape :
Он описывает методы для рисования кругов ( drawCircle ), квадратов ( drawSquare ) и прямоугольников ( drawRectangle ). В результате классы, реализующие этот интерфейс и представляющие отдельные геометрические фигуры, такие, как круг (Circle), квадрат (Square) и прямоугольник (Rectangle), должны содержать реализацию всех этих методов. Выглядит это так:
Странный у нас получился код. Например, класс Rectangle , представляющий прямоугольник, реализует методы ( drawCircle и drawSquare ), которые ему совершенно не нужны. То же самое можно заметить и при анализе кода двух других классов.
Предположим, мы решим добавить в интерфейс Shape ещё один метод, drawTriangle , предназначенный для рисования треугольников:
Это приведёт к тому, что классам, представляющим конкретные геометрические фигуры, придётся реализовывать ещё и метод drawTriangle . В противном случае возникнет ошибка.
Как видно, при таком подходе невозможно создать класс, который реализует метод для вывода круга, но не реализует методы для вывода квадрата, прямоугольника и треугольника. Такие методы можно реализовать так, чтобы при их выводе выбрасывалась бы ошибка, указывающая на то, что подобную операцию выполнить невозможно.
Принцип разделения интерфейса предостерегает нас от создания интерфейсов, подобных Shape из нашего примера. Клиенты (у нас это классы Circle , Square и Rectangle ) не должны реализовывать методы, которые им не нужно использовать. Кроме того, этот принцип указывает на то, что интерфейс должен решать лишь какую-то одну задачу (в этом он похож на принцип единственной ответственности), поэтому всё, что выходит за рамки этой задачи, должно быть вынесено в другой интерфейс или интерфейсы.
В нашем же случае интерфейс Shape решает задачи, для решения которых необходимо создать отдельные интерфейсы. Следуя этой идее, переработаем код, создав отдельные интерфейсы для решения различных узкоспециализированных задач:
Теперь интерфейс ICircle используется лишь для рисования кругов, равно как и другие специализированные интерфейсы — для рисования других фигур. Интерфейс Shape может применяться в качестве универсального интерфейса.
Принцип инверсии зависимостей
Объектом зависимости должна быть абстракция, а не что-то конкретное.
- Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
В процессе разработки программного обеспечения существует момент, когда функционал приложения перестаёт помещаться в рамках одного модуля. Когда это происходит, нам приходится решать проблему зависимостей модулей. В результате, например, может оказаться так, что высокоуровневые компоненты зависят от низкоуровневых компонентов.
Здесь класс Http представляет собой высокоуровневый компонент, а XMLHttpService — низкоуровневый. Такая архитектура нарушает пункт A принципа инверсии зависимостей: «Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций».
Класс Http вынужденно зависит от класса XMLHttpService . Если мы решим изменить механизм, используемый классом Http для взаимодействия с сетью — скажем, это будет Node.js-сервис или, например, сервис-заглушка, применяемый для целей тестирования, нам придётся отредактировать все экземпляры класса Http , изменив соответствующий код. Это нарушает принцип открытости-закрытости.
Класс Http не должен знать о том, что именно используется для организации сетевого соединения. Поэтому мы создадим интерфейс Connection :
Интерфейс Connection содержит описание метода request и мы передаём классу Http аргумент типа Connection :
Теперь, вне зависимости от того, что именно используется для организации взаимодействия с сетью, класс Http может пользоваться тем, что ему передали, не заботясь о том, что скрывается за интерфейсом Connection .
Перепишем класс XMLHttpService таким образом, чтобы он реализовывал этот интерфейс:
В результате мы можем создать множество классов, реализующих интерфейс Connection и подходящих для использования в классе Http для организации обмена данными по сети:
Как можно заметить, здесь высокоуровневые и низкоуровневые модули зависят от абстракций. Класс Http (высокоуровневый модуль) зависит от интерфейса Connection (абстракция). Классы XMLHttpService , NodeHttpService и MockHttpService (низкоуровневые модули) также зависят от интерфейса Connection .
Кроме того, стоит отметить, что следуя принципу инверсии зависимостей, мы соблюдаем и принцип подстановки Барбары Лисков. А именно, оказывается, что типы XMLHttpService , NodeHttpService и MockHttpService могут служить заменой базовому типу Connection .
Итоги
Здесь мы рассмотрели пять принципов SOLID, которых следует придерживаться каждому ООП-разработчику. Поначалу это может оказаться непросто, но если к этому стремиться, подкрепляя желания практикой, данные принципы становятся естественной частью рабочего процесса, что оказывает огромное положительное воздействие на качество приложений и значительно облегчает их поддержку.
Еще больше полезной информации для программистов вы найдете на нашем сайте.
Принципы SOLID в картинках
Если вы знакомы с объектно-ориентированным программированием, то наверняка слышали и о принципах SOLID. Эти пять правил разработки ПО задают траекторию, по которой нужно следовать, когда пишешь программы, чтобы их проще было масштабировать и поддерживать. Они получили известность благодаря программисту Роберту Мартину.
В Сети множество отличных статей, где рассказывается о принципах SOLID, но иллюстрированных среди них мне практически не попадалось. Из-за этого таким людям со склонностью к визуальному восприятию информации – таким, как я – бывает сложно схватывать суть и не отвлекаться.
Основная цель этой статьи – лучше усвоить принципы SOLID через отрисовку иллюстраций, а также определить назначение каждого принципа. Дело в том, что некоторые из принципов кажутся похожими, но функции выполняют разные. Может получиться так, что одному принципу следуешь, а другой при этом нарушаешь, хотя с виду особой разницы между ними нет.
Чтобы проще читалось, я упоминаю здесь только классы, однако всё сказанное в статье применимо также к функциям, методам и модулям, так что имейте это в виду.
Принципы SOLID
S – Single Responsibility (Принцип единственной ответственности)
Каждый класс должен отвечать только за одну операцию.
Если класс отвечает за несколько операций сразу, вероятность возникновения багов возрастает – внося изменения, касающиеся одной из операций вы, сами того не подозревая, можете затронуть и другие.
Принцип служит для разделения типов поведения, благодаря которому ошибки, вызванные модификациями в одном поведении, не распространялись на прочие, не связанные с ним типы.
O — Open-Closed (Принцип открытости-закрытости)
Классы должны быть открыты для расширения, но закрыты для модификации.
Когда вы меняете текущее поведение класса, эти изменения сказываются на всех системах, работающих с данным классом. Если хотите, чтобы класс выполнял больше операций, то идеальный вариант – не заменять старые на новые, а добавлять новые к уже существующим.
Принцип служит для того, чтобы делать поведение класса более разнообразным, не вмешиваясь в текущие операции, которые он выполняет. Благодаря этому вы избегаете ошибок в тех фрагментах кода, где задействован этот класс.
L — Liskov Substitution (Принцип подстановки Барбары Лисков)
Если П является подтипом Т, то любые объекты типа Т, присутствующие в программе, могут заменяться объектами типа П без негативных последствий для функциональности программы.
В случаях, когда класс-потомок не способен выполнять те же действия, что и класс-родитель, возникает риск появления ошибок.
Если у вас имеется класс и вы создаете на его базе другой класс, исходный класс становится родителем, а новый – его потомком. Класс-потомок должен производить такие же операции, как и класс-родитель. Это называется наследственностью.
Необходимо, чтобы класс-потомок был способен обрабатывать те же запросы, что и родитель, и выдавать тот же результат. Или же результат может отличаться, но при этом относиться к тому же типу. На картинке это показано так: класс-родитель подаёт кофе (в любых видах), значит, для класса-потомка приемлемо подавать капучино (разновидность кофе), но неприемлемо подавать воду.
Если класс-потомок не удовлетворяет этим требованиям, значит, он слишком сильно отличается от родителя и нарушает принцип.
Принцип служит для того, чтобы обеспечить постоянство: класс-родитель и класс-потомок могут использоваться одинаковым образом без нарушения работы программы.
I — Interface Segregation (Принцип разделения интерфейсов)
Не следует ставить клиент в зависимость от методов, которые он не использует.
Когда классу приходится производить действия, не несущие никакой реальной пользы, это выливается в пустую трату ресурса, а в случае, если класс выполнять эти действия не способен, ведёт к возникновению багов.
Класс должен производить только те операции, которые необходимы для осуществления его функций. Все другие действия следует либо удалить совсем, либо переместить, если есть вероятность, что они понадобятся другому классу в будущем.
Принцип служит для того, чтобы раздробить единый набор действий на ряд наборов поменьше – таким образом, каждый класс делает то, что от него действительно требуется, и ничего больше.
D — Dependency Inversion (Принцип инверсии зависимостей)
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те, и другие должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Для начала объясню термины, которые здесь применяются, простыми словами.
Модули (или классы) верхнего уровня = классы, которые выполняют операцию при помощи инструмента
Модули (или классы) нижнего уровня = инструменты, которые нужны для выполнения операций
Абстракции – представляют интерфейс, соединяющий два класса
Детали = специфические характеристики работы инструмента
Согласно данному принципу, класс не должен соединяться с инструментом, который применяет для выполнения операции. Вместо этого он должен быть соединён с интерфейсом, который поможет установить связь между инструментом и классом.
Кроме того, принцип гласит, что ни интерфейс, ни класс, не обязаны вникать в специфику работы инструмента. Напротив, это инструмент должен подходить под требования интерфейса.
Этот принцип служит для того, чтобы устранить зависимость классов верхнего уровня от классов нижнего уровня за счёт введения интерфейсов.
Обобщая сказанное
Мы разобрали все пять принципов и сформулировали для каждого назначение. Всё это призвано помочь вам писать код, который можно модифицировать, расширять и тестировать с минимумом проблем. Спасибо, что прочитали; надеюсь, вы получили не меньше удовольствия, чем я в процессе работы над статьёй.
- объектно-ориентированное по
- принципы разработки
- solid
- Блог компании Productivity Inside
- Совершенный код
SOLID: 5 принципов объектно-ориентированного программирования
SOLID — это аббревиатура, обозначающая первые пять принципов объектно-ориентированного программирования, сформулированные Робертом С. Мартином (также известным как дядя Боб).
Примечание. Хотя эти принципы применимы к разным языкам программирования, в этой статье мы приведем примеры для языка PHP.
Эти принципы устанавливают практики, помогающие создавать программное обеспечение, которое можно обслуживать и расширять по мере развития проекта. Применение этих практик также поможет избавиться от плохого кода, оптимизировать код и создавать гибкое или адаптивное программное обеспечение.
SOLID включает следующие принципы:
- S — принцип единственной ответственности
- O — принцип открытости/закрытости
- L — принцип подстановки Лисков
- I — принцип разделения интерфейса
- D — принцип инверсии зависимостей
В этой статье мы расскажем о каждом из принципов SOLID, которые помогут вам стать лучшим программистом и избавиться от плохого кода.
Принцип единственной ответственности
Принцип единственной ответственности (SRP) гласит:
У класса должна быть одна и только одна причина для изменения, то есть у класса должна быть только одна работа.
Рассмотрим в качестве примера приложение, которое берет набор фигур, состоящий из кругов и квадратов, и рассчитывает сумму площадей всех фигур в наборе.
Для начала мы создадим классы фигур и используем конструкторы для настройки требуемых параметров.
В случае квадратов необходимо знать длину стороны:
class Square public $length; public function construct($length) $this->length = $length; > >
В случае кругов необходимо знать радиус :
class Circle public $radius; public function construct($radius) $this->radius = $radius; > >
Далее следует создать класс AreaCalculator и написать логику для суммирования площадей всех заданных фигур. Площадь квадрата равна значению длины в квадрате. Площадь круга равняется значению радиуса в квадрате, умноженному на число пи.
class AreaCalculator protected $shapes; public function __construct($shapes = []) $this->shapes = $shapes; > public function sum() foreach ($this->shapes as $shape) if (is_a($shape, 'Square')) $area[] = pow($shape->length, 2); > elseif (is_a($shape, 'Circle')) $area[] = pi() * pow($shape->radius, 2); > > return array_sum($area); > public function output() return implode('', [ '', 'Sum of the areas of provided shapes: ', $this->sum(), '', ]); > >
Чтобы использовать класс AreaCalculator , нужно создать экземпляр класса, передать в него массив фигур и вывести результат внизу страницы.
Вот пример с набором из трех фигур:
- круг радиусом 2
- квадрат с длиной стороны 5
- второй квадрат с длиной стороны 6
$shapes = [ new Circle(2), new Square(5), new Square(6), ]; $areas = new AreaCalculator($shapes); echo $areas->output();
Проблема с методом вывода заключается в том, что класс AreaCalculator использует логику для вывода данных.
Давайте рассмотрим сценарий, в котором вывод необходимо конвертировать в другой формат, например, JSON.
Вся логика будет обрабатываться классом AreaCalculator . Это нарушит принцип единственной ответственности. Класс AreaCalculator должен отвечать только за вычисление суммы площадей заданных фигур. Он не должен учитывать, что пользователь хочет получить результат в формате JSON или HTML.
Для решения этой проблемы вы можете создать отдельный класс SumCalculatorOutputter и использовать этот новый класс для обработки логики, необходимой для вывода данных пользователю:
class SumCalculatorOutputter protected $calculator; public function __constructor(AreaCalculator $calculator) $this->calculator = $calculator; > public function JSON() $data = [ 'sum' => $this->calculator->sum(), ]; return json_encode($data); > public function HTML() return implode('', [ '', 'Sum of the areas of provided shapes: ', $this->calculator->sum(), '', ]); > >
Класс SumCalculatorOutputter должен работать следующим образом:
$shapes = [ new Circle(2), new Square(5), new Square(6), ]; $areas = new AreaCalculator($shapes); $output = new SumCalculatorOutputter($areas); echo $output->JSON(); echo $output->HTML();
Логика, необходимая для вывода данных пользователю, обрабатывается классом SumCalculatorOutputter .
Это соответствует принципу единственной ответственности.
Принцип открытости/закрытости
Принцип открытости/закрытости гласит:
Объекты или сущности должны быть открыты для расширения, но закрыты для изменения.
Это означает, что у нас должна быть возможность расширять класс без изменения самого класса.
Давайте вернемся к классу AreaCalculator и посмотрим на метод sum :
class AreaCalculator protected $shapes; public function __construct($shapes = []) $this->shapes = $shapes; > public function sum() foreach ($this->shapes as $shape) if (is_a($shape, 'Square')) $area[] = pow($shape->length, 2); > elseif (is_a($shape, 'Circle')) $area[] = pi() * pow($shape->radius, 2); > > return array_sum($area); > >
Рассмотрим сценарий, когда пользователю нужно получать сумму площадей дополнительных фигур, таких как треугольники, пятигранники, шестигранники и т. д. В этом случае нам бы пришлось постоянно редактировать этот файл и добавлять в него дополнительные блоки if / else . Это нарушит принцип открытости/закрытости.
Однако мы можем улучшить метод sum , убрав логику расчета площади каждой фигуры из метода класса AreaCalculator и прикрепив ее к классу каждой фигуры.
Вот метод area , определенный в классе Square :
class Square public $length; public function __construct($length) $this->length = $length; > public function area() return pow($this->length, 2); > >
Вот метод area , определенный в классе Circle :
class Circle public $radius; public function construct($radius) $this->radius = $radius; > public function area() return pi() * pow($shape->radius, 2); > >
В этом случае метод sum класса AreaCalculator можно переписать так:
class AreaCalculator // . public function sum() foreach ($this->shapes as $shape) $area[] = $shape->area(); > return array_sum($area); > >
Теперь вы можете создавать новые классы фигур и передавать их для расчета суммы без нарушения кода.
Однако при этом возникает другая проблема. Как определить, что передаваемый в класс AreaCalculator объект действительно является фигурой, или что для этой фигуры задан метод area ?
Кодирование в интерфейс является неотъемлемой частью принципов SOLID.
Создайте ShapeInterface , поддерживающий метод area :
interface ShapeInterface public function area(); >
Измените классы фигур, чтобы реализовать интерфейс ShapeInterface .
Вот обновление класса Square :
class Square implements ShapeInterface // . >
А вот обновление класса Circle :
class Circle implements ShapeInterface // . >
В методе sum класса AreaCalculator вы можете проверить, являются ли фигуры экземплярами ShapeInterface ; а если это не так, программа выдаст исключение:
class AreaCalculator // . public function sum() foreach ($this->shapes as $shape) if (is_a($shape, 'ShapeInterface')) $area[] = $shape->area(); continue; > throw new AreaCalculatorInvalidShapeException(); > return array_sum($area); > >
Это соответствует принципу открытости/закрытости.
Принцип подстановки Лисков
Принцип подстановки Лисков гласит:
Пусть q(x) будет доказанным свойством объектов x типа T. Тогда q(y) будет доказанным свойством объектов y типа S, где S является подтипом T.
Это означает, что каждый подкласс или производный класс должен быть заменяемым на базовый класс или родительский класс.
Возьмем класс AreaCalculator из нашего примера и рассмотрим новый класс VolumeCalculator , расширяющий класс AreaCalculator :
class VolumeCalculator extends AreaCalculator public function construct($shapes = []) parent::construct($shapes); > public function sum() // logic to calculate the volumes and then return an array of output return [$summedData]; > >
Помните, что класс SumCalculatorOutputter выглядит примерно так:
class SumCalculatorOutputter protected $calculator; public function __constructor(AreaCalculator $calculator) $this->calculator = $calculator; > public function JSON() $data = array( 'sum' => $this->calculator->sum(); ); return json_encode($data); > public function HTML() return implode('', array( '', 'Sum of the areas of provided shapes: ', $this->calculator->sum(), '' )); > >
Если мы попробуем выполнить такой пример:
$areas = new AreaCalculator($shapes); $volumes = new VolumeCalculator($solidShapes); $output = new SumCalculatorOutputter($areas); $output2 = new SumCalculatorOutputter($volumes);
Когда мы вызовем метод HTML для объекта $output2 , мы получим сообщение об ошибке E_NOTICE , информирующее нас о преобразовании массива в строку.
Чтобы исправить это, вместо вывода массива из метода sum класса VolumeCalculator мы будем возвращать $summedData :
class VolumeCalculator extends AreaCalculator public function construct($shapes = []) parent::construct($shapes); > public function sum() // logic to calculate the volumes and then return a value of output return $summedData; > >
Значение $summedData может быть дробным числом, двойным числом или целым числом.
Это соответствует принципу подстановки Лисков.
Принцип разделения интерфейса
Принцип разделения интерфейса гласит:
Клиент никогда не должен быть вынужден реализовывать интерфейс, который он не использует, или клиенты не должны вынужденно зависеть от методов, которые они не используют.
Возьмем предыдущий пример с ShapeInterface . Допустим, нам нужно добавить поддержку новых трехмерных фигур Cuboid и Spheroid , и для этих фигур также требуется рассчитывать объем .
Давайте посмотрим, что произойдет, если мы изменим ShapeInterface , чтобы добавить новый контракт:
interface ShapeInterface public function area(); public function volume(); >
Теперь все создаваемые фигуры должны иметь метод volume , но мы знаем, что квадраты — двухмерные фигуры, и у них нет объема. В результате этот интерфейс принуждает класс Square реализовывать метод, который он не может использовать.
Это нарушает принцип разделения интерфейса. Вместо этого мы можем создать новый интерфейс ThreeDimensionalShapeInterface , в котором имеется контракт volume , и трехмерные фигуры смогут реализовывать этот интерфейс:
interface ShapeInterface public function area(); > interface ThreeDimensionalShapeInterface public function volume(); > class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface public function area() // calculate the surface area of the cuboid > public function volume() // calculate the volume of the cuboid > >
Этот подход намного лучше, но здесь нужно следить за правильностью выбора интерфейса. Вместо использования интерфейса ShapeInterface или ThreeDimensionalShapeInterface мы можем создать еще один интерфейс, например ManageShapeInterface , и реализовать его и для двухмерных, и для трехмерных фигур.
Так мы получим единый API для управления фигурами:
interface ManageShapeInterface public function calculate(); > class Square implements ShapeInterface, ManageShapeInterface public function area() // calculate the area of the square > public function calculate() return $this->area(); > > class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface, ManageShapeInterface public function area() // calculate the surface area of the cuboid > public function volume() // calculate the volume of the cuboid > public function calculate() return $this->area(); > >
Теперь в классе AreaCalculator мы можем заменить вызов метода area вызовом метода calculate и проверить, является ли объект экземпляром класса ManageShapeInterface , а не ShapeInterface .
Это соответствует принципу разделения интерфейса.
Принцип инверсии зависимостей
Принцип инверсии зависимостей гласит:
Сущности должны зависеть от абстракций, а не от чего-то конкретного. Это означает, что модуль высокого уровня не должен зависеть от модуля низкого уровня, но они оба должны зависеть от абстракций.
Этот принцип открывает возможности разъединения.
Вот пример модуля PasswordReminder , подключаемого к базе данных MySQL:
class MySQLConnection public function connect() // handle the database connection return 'Database connection'; > > class PasswordReminder private $dbConnection; public function __construct(MySQLConnection $dbConnection) $this->dbConnection = $dbConnection; > >
Во-первых, MySQLConnection — это модуль низкого уровня, а PasswordReminder — модуль высокого уровня, однако определение D в принципах SOLID гласит: зависимость от абстракций, а не от чего-то конкретного. В приведенном выше фрагменте этот принцип нарушен, потому что класс PasswordReminder вынужденно зависит от класса MySQLConnection .
Если впоследствии вам потребуется изменить систему базы данных, вам также будет нужно изменить класс PasswordReminder , а это нарушит принцип открытости/закрытости.
Класс PasswordReminder не должен зависеть от того, какую базу данных использует ваше приложение. Чтобы решить эти проблемы, вы можете запрограммировать интерфейс, поскольку модули высокого уровня и низкого уровня должны зависеть от абстракции:
interface DBConnectionInterface public function connect(); >
Интерфейс содержит метод connect, и класс MySQLConnection реализует этот интерфейс. Вместо того, чтобы прямо указывать тип класса MySQLConnection в конструкторе PasswordReminder , мы указываем тип класса DBConnectionInterface , и в этом случае, какую бы базу данных ни использовало ваше приложение, класс PasswordReminder сможет подключиться к этой базе данных без каких-либо проблем, и принцип открытости/закрытости не будет нарушен.
class MySQLConnection implements DBConnectionInterface public function connect() // handle the database connection return 'Database connection'; > > class PasswordReminder private $dbConnection; public function __construct(DBConnectionInterface $dbConnection) $this->dbConnection = $dbConnection; > >
В этом коде модули высокого уровня и модули низкого уровня зависят от абстракции.
Заключение
В этой статье мы рассказали о пяти принципах SOLID, применяемых в объектно-ориентированном программировании. Проекты, соответствующие принципам SOLID, можно передавать коллегам, расширять, модифицировать, тестировать и перерабатывать с меньшим количеством сложностей.
Чтобы продолжить обучение, прочитайте о других практиках Agile и разработки адаптивного программного обеспечения.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
Принципы SOLID на примерах
Всем привет! Данная статья — эта попытка объяснить принципы SOLID на примерах пcевдокода на Java. Статья будет полезна начинающим разработчикам понять данные принципы проектирования.
Вначале рассмотрим общее понятие, что такое SOLID и как расшифровывается каждая буква данной аббревиатуры.
SOLID — это принципы разработки программного обеспечения, следуя которым Вы получите хороший код, который в дальнейшем будет хорошо масштабироваться и поддерживаться в рабочем состоянии.
S — Single Responsibility Principle — принцип единственной ответственности. Каждый класс должен иметь только одну зону ответственности.
O — Open closed Principle — принцип открытости-закрытости. Классы должны быть открыты для расширения, но закрыты для изменения.
L — Liskov substitution Principle — принцип подстановки Барбары Лисков. Должна быть возможность вместо базового (родительского) типа (класса) подставить любой его подтип (класс-наследник), при этом работа программы не должна измениться.
I — Interface Segregation Principle — принцип разделения интерфейсов. Данный принцип обозначает, что не нужно заставлять клиента (класс) реализовывать интерфейс, который не имеет к нему отношения.
D — Dependency Inversion Principle — принцип инверсии зависимостей. Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те, и другие должны зависеть от абстракции. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Рассмотрим первый принцип — принцип единственной ответственности на примере.
Допустим у нас есть класс RentCarService и в нем есть несколько методов: найти машину по номеру, забронировать машину, распечатать заказ, получить информацию о машине, отправить сообщение.
public class RentCarService < public Car findCar(String carNo) < //find car by number return car; >public Order orderCar(String carNo, Client client) < //client order car return order; >public void printOrder(Order order) < //print order >public void getCarInterestInfo(String carType) < if (carType.equals("sedan")) < //do some job >if (carType.equals("pickup")) < //do some job >if (carType.equals("van")) < //do some job >> public void sendMessage(String typeMessage, String message) < if (typeMessage.equals("email")) < //write email //use JavaMailSenderAPI >> >
У данного класса есть несколько зон ответственности, что является нарушением первого принципа. Возьмем метод получения информации об машине. Теперь у нас есть только три типа машин sedan, pickup и van, но если Заказчик захочет добавить еще несколько типов, тогда придется изменять и дописывать данный метод.
Или возьмем метод отправки сообщения. Если кроме отправки сообщения по электронной почте необходимо будет добавить отправку смс, то также необходимо будет изменять данный метод.
Одним словом, данный класс нарушает принцип единой ответственности, так как отвечает за разные действия.
Необходимо разделить данный класс RentCarService на несколько, и тем самым, следуя принципу единой ответственности, предоставить каждому классу отвечать только за одну зону или действие, так в дальнейшем его будет проще дополнять и модифицировать.
Необходимо создать класс PrinterService и вынести там функционал по печати.
public class PrinterService < public void printOrder(Order order) < //print order >>
Аналогично работа связанная с поиском информации о машине перенести в класс CarInfoService.
public class CarInfoService < public void getCarInterestInfo(String carType) < if (carType.equals("sedan")) < //do some job >if (carType.equals("pickup")) < //do some job >if (carType.equals("van")) < //do some job >> >
Метод по отправке сообщений перенести в класс NotificationService.
public class NotificationService < public void sendMessage(String typeMessage, String message) < if (typeMessage.equals("email")) < //write email //use JavaMailSenderAPI >> >
А метод поиска машины в CarService.
public class CarService < public Car findCar(String carNo) < //find car by number return car; >>
И в классе RentCarService останется только один метод.
public class RentCarService < public Order orderCar(String carNo, Client client) < //client order car return order; >>
Теперь каждый класс несет ответственность только за одну зону и есть только одна причина для его изменения.
Принцип открытости-закрытости рассмотрим на примере только что созданного класса по отправке сообщений.
public class NotificationService < public void sendMessage(String typeMessage, String message) < if (typeMessage.equals("email")) < //write email //use JavaMailSenderAPI >> >
Допустим нам необходимо кроме отправки сообщения по электронной почте отправлять еще смс сообщения. И мы можем дописать метод sendMessage таким образом:
public class NotificationService < public void sendMessage(String typeMessage, String message) < if (typeMessage.equals("email")) < //write email //use JavaMailSenderAPI >if (typeMessage.equals("sms")) < //write sms //send sms >> >
Но в данном случае мы нарушим второй принцип, потому что класс должен быть закрыт для модификации, но открыт для расширения, а мы модифицируем (изменяем) метод.
Для того чтобы придерживаться принципа открытости-закрытости нам необходимо спроектировать наш код таким образом, чтобы каждый мог повторно использовать нашу функцию, просто расширив ее. Поэтому создадим интерфейс NotificationService и в нем поместим метод sendMessage.
public interface NotificationService
Далее создадим класс EmailNotification, который имплементит интерфейс NotificationService и реализует метод отправки сообщений по электронной почте.
public class EmailNotification implements NotificationService < @Override public void sendMessage(String message) < //write email //use JavaMailSenderAPI >>
Создадим аналогично класс MobileNotification, который будет отвечать за отправку смс сообщений.
public class MobileNotification implements NotificationService < @Override public void sendMessage(String message) < //write sms //send sms >>
Проектируя таким образом код мы не будем нарушать принцип открытости-закрытости, так как мы расширяем нашу функциональность, а не изменяем (модифицируем) наш класс.
Давайте сейчас рассмотрим третий принцип: принцип подстановки Барбары Лисков.
Данный принцип непосредственно связан с наследованием классов. Допустим у нас есть базовый класс Счет (Account), в котором есть три метода: просмотр остатка на счете, пополнение счета и оплата.
public class Account < public BigDecimal balance(String numberAccount)< //logic return bigDecimal; >; public void refill(String numberAccount, BigDecimal sum) < //logic >public void payment(String numberAccount, BigDecimal sum) < //logic >>
Нам необходимо написать еще два класса: зарплатный счет и депозитный счет, при этом зарплатный счет должен поддерживать все операции, представленные в базовом классе, а депозитный счет — не должен поддерживать проведение оплаты.
public class SalaryAccount extends Account< @Override public BigDecimal balance(String numberAccount)< //logic return bigDecimal; >; @Override public void refill(String numberAccount, BigDecimal sum) < //logic >@Override public void payment(String numberAccount, BigDecimal sum) < //logic >>
public class DepositAccount extends Account< @Override public BigDecimal balance(String numberAccount)< //logic return bigDecimal; >; @Override public void refill(String numberAccount, BigDecimal sum) < //logic >@Override public void payment(String numberAccount, BigDecimal sum) < throw new UnsupportedOperationException("Operation not supported"); >>
Если сейчас в коде программы везде, где мы использовали класс Account заменить на его класс-наследник (подтип) SalaryAccount, то программа продолжит нормально работать, так как в классе SalaryAccount доступны все операции, которые есть и в классе Account.
Если же мы такое попробуем сделать с классом DepositAccount, то есть заменим базовый класс Account на его класс-наследник DepositAccount, то программа начнет неправильно работать, так как при вызове метода payment() будет выбрасываться исключение new UnsupportedOperationException. Таким образом произошло нарушение принципа подстановки Барбары Лисков.
Для того чтобы следовать принципу подстановки Барбары Лисков необходимо в базовый (родительский) класс выносить только общую логику, характерную для классов наследников, которые будут ее реализовывать и, соответственно, можно будет базовый класс без проблем заменить на его класс-наследник.
В нашем случае класс Account будет выглядеть следующим образом.
public class Account < public BigDecimal balance(String numberAccount)< //logic return bigDecimal; >; public void refill(String numberAccount, BigDecimal sum) < //logic >>
Мы сможем от него наследовать класс DepositAccount.
public class DepositAccount extends Account< @Override public BigDecimal balance(String numberAccount)< //logic return bigDecimal; >; @Override public void refill(String numberAccount, BigDecimal sum) < //logic >>
Создадим дополнительный класс PaymentAccount, который унаследуем от Account и его расширим методом проведения оплаты.
public class PaymentAccount extends Account < public void payment(String numberAccount, BigDecimal sum)< //logic >>
И наш класс SalaryAccount уже унаследуем от класса PaymentAccount.
public class SalaryAccount extends PaymentAccount< @Override public BigDecimal balance(String numberAccount)< //logic return bigDecimal; >; @Override public void refill(String numberAccount, BigDecimal sum) < //logic >@Override public void payment(String numberAccount, BigDecimal sum) < //logic >>
Сейчас замена класса PaymentAccount на его класс-наследник SalaryAccount не «поломает» нашу программу, так как класс SalaryAccount имеет доступ ко всем методам, что и PaymentAccount. Также все будет хорошо при замене класса Account на его класс-наследник PaymentAccount.
Принцип подстановки Барбары Лисков заключается в правильном использовании отношения наследования. Мы должны создавать наследников какого-либо базового класса тогда и только тогда, когда они собираются правильно реализовать его логику, не вызывая проблем при замене родителей на наследников.
Рассмотрим теперь принцип разделения интерфейсов.
Допустим у нас имеется интерфейс Payments и в нем есть три метода: оплата WebMoney, оплата банковской карточкой и оплата по номеру телефона.
public interface Payments
Далее нам надо реализовать два класса-сервиса, которые будут у себя реализовывать различные виды проведения оплат (класс InternetPaymentService и TerminalPaymentService). При этом TerminalPaymentService не будет поддерживать проведение оплат по номеру телефона. Но если мы оба класса имплементим от интерфейса Payments, то мы будем «заставлять» TerminalPaymentService реализовывать метод, который ему не нужен.
public class InternetPaymentService implements Payments < @Override public void payWebMoney() < //logic >@Override public void payCreditCard() < //logic >@Override public void payPhoneNumber() < //logic >>
public class TerminalPaymentService implements Payments < @Override public void payWebMoney() < //logic >@Override public void payCreditCard() < //logic >@Override public void payPhoneNumber() < //. >>
Таким образом произойдет нарушение принципа разделения интерфейсов.
Для того чтобы этого не происходило необходимо разделить наш исходный интерфейс Payments на несколько и, создавая классы, имплементить в них только те интерфейсы с методами, которые им нужны.
public interface WebMoneyPayment
public interface CreditCardPayment
public interface PhoneNumberPayment
public class InternetPaymentService implements WebMoneyPayment, CreditCardPayment, PhoneNumberPayment < @Override public void payWebMoney() < //logic >@Override public void payCreditCard() < //logic >@Override public void payPhoneNumber() < //logic >>
public class TerminalPaymentService implements WebMoneyPayment, CreditCardPayment < @Override public void payWebMoney() < //logic >@Override public void payCreditCard() < //logic >>
Давайте сейчас рассмотрим последний принцип: принцип инверсии зависимостей.
Допустим мы пишем приложение для магазина и решаем вопросы с проведением оплат. Вначале это просто небольшой магазин, где оплата происходит только за наличные. Создаем класс Cash и класс Shop.
public class Cash < public void doTransaction(BigDecimal amount)< //logic >>
public class Shop < private Cash cash; public Shop(Cash cash) < this.cash = cash; >public void doPayment(Object order, BigDecimal amount) < cash.doTransaction(amount); >>
Вроде все хорошо, но мы уже нарушили принцип инверсии зависимостей, так как мы тесно связали оплату наличными к нашему магазину. И если в дальнейшем нам необходимо будет добавить оплату еще банковской картой и телефоном («100% понадобится»), то нам придется переписывать и изменять много кода. Мы в нашем коде модуль верхнего уровня тесно связали с модулем нижнего уровня, а нужно чтобы оба уровня зависели от абстракции.
Поэтому создадим интерфейс Payments.
public interface Payments
Теперь все наши классы по оплате будут имплементить данный интерфейс.
public class Cash implements Payments < @Override public void doTransaction(BigDecimal amount) < //logic >>
public class BankCard implements Payments < @Override public void doTransaction(BigDecimal amount) < //logic >>
public class PayByPhone implements Payments < @Override public void doTransaction(BigDecimal amount) < //logic >>
Теперь надо перепроектировать реализацию нашего магазина.
public class Shop < private Payments payments; public Shop(Payments payments) < this.payments = payments; >public void doPayment(Object order, BigDecimal amount) < payments.doTransaction(amount); >>
Сейчас наш магазин слабо связан с системой оплаты, то есть он зависит от абстракции и уже не важно каким способом оплаты будут пользоваться (наличными, картой или телефоном) все будет работать.
Мы рассмотрели на примерах псевдокода принципы SOLID, надеюсь кому-то будет это полезно.
Спасибо Всем, кто дочитал до конца. Всем пока.
- принципы проектирования
- принципы разработки
- solid