Принцип открытости означает что поведение системы
Простое объяснение принципов SOLID
Принципы SOLID — это стандарт программирования, который все разработчики должны хорошо понимать, чтобы избегать создания плохой архитектуры. Этот стандарт широко используется в ООП. Если применять его правильно, он делает код более расширяемым, логичным и читабельным. Когда разработчик создаёт приложение, руководствуясь плохой архитектурой, код получается негибким, даже небольшие изменения в нём могут привести к багам. Поэтому нужно следовать принципам SOLID.
На их освоение потребуется какое-то время, но если вы будете писать код в соответствии с этими принципами, то его качество повысится, а вы освоите создание хорошей архитектуры ПО.
Чтобы понять принципы SOLID, нужно чётко понимать, как использовать интерфейсы. Если у вас такого понимания нет, то сначала почитайте документацию.
Я буду объяснять SOLID самым простым способом, так что новичкам легче будет разобраться. Будем рассматривать принципы один за другим.
Принцип единственной ответственности (Single Responsibility Principle)
Существует лишь одна причина, приводящая к изменению класса.
Один класс должен решать только какую-то одну задачу. Он может иметь несколько методов, но они должны использоваться лишь для решения общей задачи. Все методы и свойства должны служить одной цели. Если класс имеет несколько назначений, его нужно разделить на отдельные классы.
Приведённый здесь класс нарушает принцип единственной ответственности. Почему он должен извлекать данные из базы? Это задача для уровня хранения данных, на котором данные сохраняются и извлекаются из хранилища (например, базы данных). Это ответственность не этого класса.
Также данный класс не должен отвечать за формат следующего метода, потому что нам могут понадобиться данные другого формата, например, XML, JSON, HTML и т.д.
Код после рефакторинга будет выглядеть так:
Принцип открытости/закрытости (Open-closed Principle)
Программные сущности должны быть открыты для расширения, но закрыты для модификации.
Программные сущности (классы, модули, функции и прочее) должны быть расширяемыми без изменения своего содержимого. Если строго соблюдать этот принцип, то можно регулировать поведение кода без изменения самого исходника.
Как же можно решить поставленную задачу? Смотрите:
Принцип подстановки Барбары Лисков (Liskov Substitution Principle)
Пусть φ(x) — доказуемое свойство объекта x типа T. Тогда φ(y) должно быть верным для объектов y типа S, где S — подтип T.
Функции, использующие указатели ссылок на базовые классы, должны уметь использовать объекты производных классов, даже не зная об этом.
Попросту говоря: подкласс/производный класс должен быть взаимозаменяем с базовым/родительским классом.
Значит, любая реализация абстракции (интерфейса) должна быть взаимозаменяемой в любом месте, в котором принимается эта абстракция. По сути, когда мы используем в коде интерфейсы, то используем контракт не только по входным данным, принимаемым интерфейсом, но и по выходным данным, возвращаемым разными классами, реализующими этот интерфейс. В обоих случаях данные должны быть одного типа.
В этом коде нарушен обсуждаемый принцип и показан способ исправления:
Принцип разделения интерфейса (Interface Segregation Principle)
Нельзя заставлять клиента реализовать интерфейс, которым он не пользуется.
Это означает, что нужно разбивать интерфейсы на более мелкие, лучше удовлетворяющие конкретным потребностям клиентов.
Как и в случае с принципом единственной ответственности, цель принципа разделения интерфейса заключается в минимизации побочных эффектов и повторов за счёт разделения ПО на независимые части.
Принцип инверсии зависимостей (Dependency Inversion Principle)
Высокоуровневые модули не должны зависеть от низкоуровневых. Оба вида модулей должны зависеть от абстракций.
Абстракции не должны зависеть от подробностей. Подробности должны зависеть от абстракций.
Проще говоря: зависьте от абстракций, а не от чего-то конкретного.
Применяя этот принцип, одни модули можно легко заменять другими, всего лишь меняя модуль зависимости, и тогда никакие перемены в низкоуровневом модуле не повлияют на высокоуровневый.
Есть распространённое заблуждение, что «инверсия зависимостей» является синонимом «внедрения зависимостей». Но это разные вещи.
Класс PasswordReminder должен зависеть от абстракций, а не от чего-то конкретного. Но как это сделать? Рассмотрим пример:
Принцип открытости/закрытости
Давайте рассмотрим второй принцип SOLID — открытости/закрытости. Перевод статьи на эту тему опубликовал сайт webdevblog.ru.
Принцип открытости/закрытости:
“Программные объекты (классы, модули, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации”
Что это значит? Как можно быть открытым и закрытым программным объектом одновременно!
Давайте начнем рассмотрение, используя реальный сценарий, чтобы проиллюстрировать проблему, а затем перейдем к решению и техническим деталям.
Допустим вы написали свой класс для просмотра фотографий PhotoViewer и совершенно уверены, что он должен открывать только фотографии в формате JPEG. Позже вы поняли, что ваши пользователи постоянно пытаются просматривать PNG-фотографии. А поскольку ваше приложение не поддерживает, возникает ошибка. Итак, вы решили отредактировать исходный код, чтобы он стал поддерживать PNG.
Теперь, каковы проблемы вашего кода после внесения изменений?
Что, если вы решили добавить еще один тип? Что на 100% произойдет сейчас или тогда?
В этот момент вы должны добавить еще один оператор else if. Добавление нового типа за другим делает функцию openPhoto слишком большой. А поскольку все типы существуют в одном месте, удаление некоторых строк кода из одного типа может привести к сбою других.
Таким образом, код открыт для расширения, что означает, что вы можете расширить его другими функциями. Но не закрыт для модификации, потому что каждый раз, когда вы добавляете тип, вы изменяете строки кода функции openPhoto. Многие изменения в одном и том же блоке кода по разным причинам указывают на плохой дизайн.
Хорошо, как нам разделить JPEG и PNG на разные функции в классе просмотра фотографий? Ну например так:
Теперь ваш код открыт для расширения. Вы можете легко добавлять новые типы, добавляя новые функции. Но та же проблема все еще существует. Решение не закрыто на 100% для модификации, так как изменения коснутся всех строк кода класса. Также обратите внимание, что добавление другого типа к классу PhotoViewer сделает его больше и возложит на него больше обязанностей. Это нарушает принцип единой ответственности.
Решение заключается в использовании функций объектно-ориентированного программирования с использованием универсального интерфейса для представления универсального типа фотографии, который может реализовать любой новый тип.
Как видите, добавление нового типа не изменит ни одной строчки исходного кода. Вам нужно только реализовать интерфейс фотографий с новым типом и отправить фотографию в программу просмотра фотографий.
Решение на 100% открыто для расширения и закрыто для модификации. Обратите внимание, что также применяется принцип единой ответственности.
Теперь вы знаете, что Open-Closed означает, что ваш код открыт для расширения новыми функциями и закрыт с точки зрения изменения исходного кода.
Принципы SOLID: принцип открытости-закрытости
Принцип открытости/закрытости — The Open Closed Principle или OCP — один из пяти основных принципов объектно-ориентированного программирования и проектирования, сформулированных Робертом Мартином.
Принцип декларирует, что программные сущности (классы, модули, функции и т. п.) должны быть открыты для расширения, но закрыты для изменения. Это означает, что эти сущности могут менять свое поведение без изменения их исходного кода.
В этом контексте открытость для расширения — это возможность добавить для класса, модуля или функции новое поведение, если необходимость в этом возникнет, а закрытость для изменений — это запрет на изменение исходного кода программных сущностей. На первый взгляд, это звучит сложно и противоречиво. Но если разобраться, то принцип вполне логичен.
Следование принципу OCP заключается в том, что программное обеспечение изменяется не через изменение существующего кода, а через добавление нового кода. То есть созданный изначально код остаётся «нетронутым» и стабильным, а новая функциональность внедряется либо через наследование реализации, либо через использование абстрактных интерфейсов и полиморфизм.
Принцип открытости/закрытости Мейера основывается на идее, что разработанная изначально реализация класса в дальнейшем не модифицируется (разве что исправляются ошибки), а любые изменения производятся через создание нового класса, который обычно наследуется от изначального. Согласно определению Мейера реализация интерфейса может быть унаследована и переиспользована, но интерфейс может и измениться в новой реализации.
Позже был сформулирован полиморфный принцип открытости/закрытости. Он основывается на строгой реализации интерфейсов и на наследовании от абстрактных базовых классов или на полиморфизме. Созданный изначально интерфейс должен быть закрыт для модификаций, а новые реализации как минимум соответсвуют этому изначальному интерфейсу, но могут поддерживать и другие, более расширенные.
Принцип открытости/закрытости
Я должен сказать, что тот, кто придумал определения этих принципов, конечно, не думал о менее опытных разработчиках. То же самое происходит и с принципом “открыто-закрыто”, и те, до кого он дойдет быстрее, будут на шаг впереди по странности. 😂😂
Как бы то ни было, давайте посмотрим на определение, найденное в сети для этого принципа: классы должны быть открыты для расширения, но закрыты для модификации. А?? Чтоо?? Вы что то поняли? Да, я тоже не особо, когда впервые столкнулся с этим, но со временем я пришел к пониманию: код, написанный один раз, не должен быть изменен.
Что вы меня грузите.
В философском смысле это звучит круто — если код не изменился, он останется предсказуемым, и новые ошибки не будут появляться. Но как вообще можно мечтать о коде, который не меняется, когда все, что мы делаем как разработчики, все время гонимся за дедлайнами новых правок?
Ну, во-первых, принцип не означает, что даже одна строка существующего кода не может быть изменена; это было бы прямо из волшебной страны. Мир меняется, бизнес меняется, а значит, меняется и код – от этого никуда не деться. Но этот принцип означает, что мы максимально ограничиваем возможность изменения существующего кода. И он также как бы говорит вам, как это сделать: классы должны быть открыты для расширения и закрыты для модификации.
“Расширение” здесь означает повторное использование, независимо от того, происходит ли повторное использование в форме дочерних классов, наследующих функциональность от родительского класса, или другие классы хранят экземпляры класса и вызывают его методы.
Итак, вернемся к вопросу на миллион долларов: как вы пишете код, который переживает изменения? И здесь, боюсь, ни у кого нет четкого ответа. В объектно-ориентированном программировании было открыто и усовершенствовано несколько методов для достижения этой цели, начиная с этих принципов SOLID, которые мы изучаем, и заканчивая общими шаблонами проектирования, корпоративными шаблонами, архитектурными шаблонами и так далее. Идеального ответа не существует, и поэтому разработчик должен подниматься все выше и выше, собирая как можно больше инструментов и стараясь сделать все возможное.
Имея это в виду, давайте рассмотрим одну такую технику. Предположим, нам нужно добавить функциональность для преобразования данного HTML-контента (возможно, счета-фактуры?) в PDF-файл, а также запустить немедленную загрузку в браузере. Давайте также предположим, что у нас есть платная подписка на гипотетический сервис под названием MilkyWay, который будет делать фактическую генерацию PDF. Мы могли бы в конечном итоге написать метод контроллера, например вот:
Я пропустил проверку запроса и т. д., чтобы сосредоточиться на главном вопросе. Вы можете заметить, что мы воспользовались советами из предыдущего урока Принцип единой ответственности: SOLID принципы на примере Laravel. Мы переложили ответственность на сервис MilkyWay. Контроллер ничего не знает о входящей информации и какой формат файла генерируется и то, как он генерируется. В ответственности контроллера лишь передать данные сервису и отдать обработанные данные пользователю.
Но есть небольшая проблема.
Наш метод контроллера слишком зависит от класса MilkyWay. Если следующая версия API MilkyWay изменит интерфейс, наш метод перестанет работать. И если мы хотим когда-нибудь использовать какой-то другой сервис, нам придется буквально выполнить глобальный поиск в нашем редакторе кода и изменить все фрагменты кода, которые упоминают MilkyWay. И почему это плохо? Потому что это значительно увеличивает вероятность ошибки и является бременем для бизнеса (время разработчика тратится на разборку бардака).
Все это напрасно, потому что мы создали метод, который не был закрыт для изменения.
Можем ли мы сделать лучше?
В этом случае мы можем воспользоваться практикой, которая идет примерно так – программируем интерфейсы, а не реализации.
Итак, какие инструменты у нас есть в PHP для создания новых типов? Вообще говоря, у нас есть наследование и интерфейсы. В нашем случае создание базового класса для всех классов PDF не будет хорошей идеей, потому что трудно представить себе различные типы PDF-движков/сервисов, разделяющих одно и то же поведение. Возможно, они могут совместно использовать этот setContent() метод, но даже там процесс получения контента может отличаться для каждого класса PDF-сервиса, поэтому ввод всего в иерархию наследования ухудшит ситуацию.
С учетом этого давайте создадим интерфейс, который определяет, какие методы мы хотим, чтобы все наши классы PDF содержали:
Итак, что же мы имеем здесь?
Через этот интерфейс мы говорим, что ожидаем, что все наши классы PDF будут иметь по крайней мере эти три метода. Теперь, если сервис, который мы хотим использовать (MilkyWay, в нашем случае), не следует этому интерфейсу, наша задача – написать класс (используя паттерн Адаптер), который это делает. Примерный набросок того, как мы могли бы написать класс – адаптер для нашего MilkyWay сервиса, выглядит следующим образом:
Итак, как все это связано с принципом “открыто-закрыто” и Laravel?
А пока просто думайте об этом как о чем-то, что может создавать для нас новые экземпляры классов. Давайте посмотрим, как это поможет.
В сервисном контейнере в нашем примере мы можем написать что-то вроде этого:
Вооружившись всеми этими знаниями, мы можем переписать наш метод контроллера загрузки PDF следующим образом:
Самая большая выгода из всех
Этот метод теперь закрыт для модификации и устойчив к изменениям. Позвольте мне объяснить. Предположим, завтра мы почувствуем, что сервис MilkyWay слишком дорогой (или, как это часто бывает, их клиентская поддержка стала дерьмовой); в результате мы опробовали другой сервис, называемый SilkyWay и хотим перейти к нему. Все, что нам теперь нужно сделать, это написать новый PDFGenerator класс-оболочку SilkyWay и изменить привязку в нашем коде контейнера службы:
Больше ничего менять не нужно, потому что наше приложение написано на интерфейсах (интерфейс PDFGenerator), а не на конкретных реализациях. Бизнес-требование изменилось, был добавлен новый код (класс оболочки), и только одна строка кода была изменена — все остальное осталось нетронутым, и вся команда может спокойно идти домой и спать спокойно.
Хотите спать спокойно? Следуйте принципу “открыто-закрыто”! 🤭😆
Принцип открытости-закрытости
Привет, Хабр! Перед вами перевод статьи Роберта Мартина Open-Closed Principle, которую он опубликовал в январе 1996 года. Статья, мягко говоря, не самая свежая. Но в рунете статьи дяди Боба про SOLID пересказывают только в урезанном виде, поэтому я подумал, что полный перевод лишним не будет.
Я решил начать с буквы O, так как принцип открытости-закрытости, по сути, является центральным. Среди прочего тут есть много важных тонкостей, на которые стоит обратить внимание:
Это первая статья в моей колонке Заметки Инженера для The C++ Report. Статьи, публикуемые в этой колонке, будут фокусироваться на использовании C++ и ООП и затрагивать сложности в разработке ПО. Я постараюсь сделать так, чтобы материалы были прагматичны и полезны для практикующих инженеров. Для документации объектно-ориентированного дизайна в этих статьях я буду использовать нотацию Буча.
С объектно-ориентированным программированием связано много эвристик. Например, «все переменные-члены (member variables) должны быть закрытыми (private)», или «следует избегать глобальных переменных», или «определение типов во время исполнения опасно». В чем причина таких эвристик? Почему они правдивы? Всегда ли они правдивы? В этой колонке исследуется принцип проектирования, лежащий в основе этих эвристик, — принцип открытости-закрытости.
Ивар Якобсон сказал: «Все системы изменяются в процессе жизненного цикла. Это нужно иметь в виду при проектировании системы, у которой ожидается больше одной версии». Как же мы можем спроектировать систему, чтобы она была устойчива перед лицом изменений и у которой ожидается больше чем одна версия? Бертран Мейер рассказал нам об этом еще в далеком 1988 году, когда сформулирован знаменитый ныне принцип открытости-закрытости:
Програмные сущности (классы, модули, функции и т.д.) должны быть открыты для расширения и закрыты для изменений.
Если одно изменение в программе влечет за собой каскад изменений в зависимых модулях, то в программе проявляются нежелательные признаки «плохого» дизайна.
Программа становится хрупкой, негибкой, непредсказуемой и непереиспользуемой. Принцип открытости-закрытости решает эти проблемы очень прямолинейным путем. Он говорит, что необходимо проектировать модули, которые никогда не меняются. Когда требования меняются, нужно расширять поведение таких модулей путем добавления нового кода, а не изменением старого, уже работающего кода.
Описание
Модули, отвечающие принципу открытости-закрытости, имеют два главных признака:
Кажется, что два этих признака друг с другом не вяжутся. Стандартный способ расширить поведение модуля — внести в него изменения. Модуль, который не может быть изменен, обычно мыслится как модуль с фиксированным поведением. Как же могут быть выполнены эти два противоположных условия?
Ключ к решению — абстракция
В С++, используя принципы объектно-ориентированного проектирования, возможно создать фиксированные абстракции, которые могут представлять неограниченный набор возможных поведений.
Абстракции — это абстрактные базовые классы, а неограниченный набор возможных поведений представлен всеми возможными классами-наследниками. Модуль может манипулировать абстракцией. Такой модуль закрыт для изменений, так как он зависит от фиксированной абстракции. Также поведение модуля может быть расширено созданием новых наследников абстракции.
Закрытый клиент
Открытый клиент
Абстракция Shape
Рассмотрим приложение, которое должно рисовать круги и квадраты в стандартном GUI. Круги и квадраты должны быть нарисованы в определенном порядке. В соответствующем порядке будет составлен список кругов и квадратов, программа должна пройтись в этом порядке по списку и нарисовать каждый круг или квадрат.
На C, используя техники процедурного программирования, не отвечающие принципу открытости-закрытости, мы могли бы решить эту задачу как показано на листинге 1. Здесь мы видим множество структур данных с одним и тем же первым элементом. Этот элемент — код типа, который идентифицирует структуру данных как круг или квадрат. Функция DrawAllShapes проходит по массиву указателей на эти структуры данных, узнавая код типа и затем вызывая соответствующую функцию ( DrawCircle или DrawSquare ).
Функция DrawAllShapes не отвечает принципу открытости-закрытости, потому что она не может быть «закрыта» от новых типов фигур. Если бы я захотел расширить эту функцию возможностью рисовать фигуры из списка, включающего треугольники, то мне потребовалось бы изменить функцию. Фактически я должен изменять функцию для каждого нового типа фигуры, который мне нужно рисовать.
В реальном мире класс Shape содержал бы много других методов. И все же добавить новую фигуру в приложение все еще очень просто, так как все, что нужно сделать, — это ввести нового наследника и реализовать эти функции. Не нужно рыскать по всему приложению в поисках мест, требующих изменений.
Поэтому программы, отвечающие принципу открытости-закрытости, изменяются путем добавления нового кода, а не изменением уже существующего, в них не происходит каскадных изменений, свойственных программам, которые этому принципу не соответствуют.
Стратегия ввода закрытости
Очевидно, что ни одна программа не может быть на 100% закрыта. Например, что произойдет с функцией DrawAllShapes из листинга 2, если мы решим, что сначала должны быть нарисованы круги, а затем квадраты? Функция DrawAllShapes не закрыта от такого рода изменений. В целом не важно, насколько «закрыт» модуль, всегда есть какой-то тип изменений, от которого он не закрыт.
Так как закрытость не может быть полной, она должна вводиться стратегически. То есть проектировщик должен выбрать типы изменений, от которых программа будет закрыта. Это требует определенного опыта. Опытный разработчик знает пользователей и индустрию достаточно хорошо, чтобы просчитывать вероятность различных изменений. Затем он убеждается, что принцип открытости-закрытости соблюдается для наиболее вероятных изменений.
Использование абстракции для доcтижения дополнительной закрытости
Как мы можем закрыть функцию DrawAllShapes от изменений в порядке рисования? Помним, что закрытие базируется на абстракции. Поэтому, чтобы закрыть DrawAllShapes от упорядочивания, нам нужна некая «абстракция упорядочивания». Частный случай упорядочивания, представленный выше, — это рисование фигур одного типа перед фигурами другого типа.
В C++ эта функция может быть представлена как перегрузка оператора « Shape с методами сортировки.
Использование Data Driven подхода для достижения закрытости
Закрытости наследников класса Shape можно достигнуть, используя табличный подход, который не провоцирует изменения в каждом наследуемом классе. Пример такого подхода приведен в листинге 6.
Применив этот подход, мы успешно закрыли функцию DrawAllShapes от изменений, связанных с упорядочиванием, и каждого наследника класса Shape — от введения нового наследника или от изменения в политике упорядочивания объектов класса Shape в зависимости от их типа (например, такого, что объекты класса Squares должны рисоваться первыми).
Единственный элемент, который не закрыт от изменения порядка рисования фигур, — это таблица. Таблица может быть помещена в отдельный модуль, отделенный от всех остальных модулей, и поэтому ее изменения не затронут другие модули.
Дальнейшее закрытие
Эвристики и конвенции
Как уже было сказано в начале статьи, принцип открытости-закрытости является ключевой мотивацией, которая стоит за многими эвристиками и конвенциями, появившимися за многие годы развития парадигмы ООП. Далее приведены наиболее важные из них.
Делайте все переменные-члены приватными
В ООП мы ожидаем, что методы класса не закрыты от изменений переменных — членов этого класса. Однако мы ожидаем, что любой другой класс, включая подклассы, закрыты от изменений этих переменных. Это называется инкапсуляцией.
Мы знаем, что тип или смысл этой переменной никогда не изменится. Так почему бы не сделать ее public и не дать клиенту прямой доступ к ней? Если переменная действительно никогда не изменится, если все клиенты соблюдают правила и лишь читают из этой переменной, тогда нет ничего страшного в том, что переменная публична. Однако подумайте, что случится, если один из клиентов воспользуется возможностью писать в эту переменную и изменит ее значение.
Никаких глобальных переменных… вообще!
Аргумент против глобальных переменных тот же, что и аргумент против публичных переменных-членов. Ни один модуль, который зависит от глобальной переменной, не может быть закрыт от модуля, который может писать в нее. Любой модуль, который использует эту переменную способом, не предполагаемым другими модулями, сломает эти модули. Это слишком рискованно — иметь множество модулей, зависящих от капризов какого-то одного зловредного модуля.
С другой стороны, в тех случаях, когда глобальные переменные имеют небольшое количество зависимых от них модулей или не могут быть использованы неправильным образом, они не причиняют вреда. Проектировщик должен оценить, сколько закрытости приноситься в жертву и определить, стоит ли того удобство, предоставляемое глобальной переменной.
Тут опять же вступают в игру проблемы стиля. Альтернативы использованию глобальных переменных обычно недороги. В таких случаях использование техники, которая привносит хоть и небольшой, но риск для закрытости вместо техники, которая такой риск полностью исключает, — это признак плохого стиля. Однако иногда использование глобальных переменных по-настоящему удобно. Типичный пример — глобальные переменные cout и cin. В таких случаях, если принцип открытости-закрытости не нарушается, можно пожертвовать стилем ради удобства.
RTTI — это опасно
Разница между ними в том, что в первом случае, приведенном в листинге 9, код нужно менять каждый раз, когда появляется новый наследник класса Shape (не говоря уже о том, что это абсолютно нелепое решение). Однако в листинге 10 в этом случае никаких изменений не требуется. Поэтому код из листинга 10 не нарушает принцип открытости-закрытости.
Правилом большого пальца в данном случае можно считать то, что RTTI можно использовать, если принцип открытости-закрытости не нарушается.
Заключение
Я мог бы еще долго говорить о принципе открытости-закрытости. Во многих отношениях этот принцип является наиболее важным для объектно-ориентированного программирования. Соблюдение именно этого принципа обеспечивает ключевые преимущества объектно-ориентированной технологии, а именно переиспользуемость и поддерживаемость.
При этом важно понимать, что использование объектно-ориентированного языка программирования еще не гарантирует соблюдение принципа открытости-закрытости. Эта ответственность ложится на плечи проектировщика, который должен увидеть, какие части программы, скорее всего, будут со временем изменены, и ввести для них правильные абстракции.