SOLID «O»: Принцип открытости/закрытости
Принцип открытости-закрытости (англ. Open/closed principle) легко нарушить, но и написать код, который соответствует этому принципу, не так уж и сложно.
Сущность программного обеспечения (классы, модули и функции) должна быть открыта для усовершенствования, но закрыта для различных изменений.
Принцип открытости-закрытости (OCP) был сформулирован французским программистом Бертраном Майером и впервые вышел в мир в его книге «Object-Oriented Software Construction» в 1988 году. Популярность к этому принципу пришла в начале 2000-х годов, когда его включили в SOLID – список принципов объектно-ориентированного программирования, определенных Робертом С. Мартином в его книге о быстрой разработке программ на языке C#.
Здесь же речь идет о разработке модулей, классов и функций таким образом, чтобы в ситуации, когда понадобится новый функционал, не пришлось менять уже существующий код. Решение – в написании нового кода, который будет использовать существующий. Это может вызвать недоумение у разработчиков, которые пишут на Java, C, C++ или C#, так как затрагивается не только исходный код, но и двоичный. Имеется в виду создание новых возможностей таким образом, чтобы не пришлось заново распределять двоичные файлы, файлы с расширением «exe» и DLL-библиотеки.
SRP в контексте SOLID
Если двигаться дальше, получится, что каждый новый принцип можно рассматривать в контексте уже рассмотренных ранее. Так, принцип единственной обязанности (SRP) гласит, что на одном объекте может лежать только одна обязанность. Сравнивая OCP и SRP, можно отметить их комплементарность, взаимодополняемость. Код, разработанный с учетом SRP, визуально будет близок к такому же коду, но учитывающему OCP. Когда у нас есть код, каждый объект которого имеет одну обязанность, введение новой функции создаст вторую обязанность, второй повод для изменения. Это может нарушить оба принципа.
Точно также, если у нас есть код, который должен меняться только тогда, когда его основные функции меняются или, наоборот, должны оставаться неизменными при добавлении новой функции, то в этом коде будут соблюдены оба принципа. Но это не значит, что SRP-принцип всегда приводит к OCP, или, наоборот, но в преобладающем большинстве случаев, если соблюден один принцип, привести код к соблюдению второго не составит большого труда.
Очевидный пример нарушения принципа OCP
С исключительно технической точки зрения принцип открытости-закрытости очень прост: между двумя классами есть простые связи, но один из классов нарушает принцип OCP.
Класс User напрямую использует класс Logic. Если второй класс Logic нужно реализовать таким образом, чтобы можно было использовать и старые наработки, и новые, существующий класс Logic придется несколько изменить. User напрямую связан с классом Logic, а значит, способа изменить его так, чтобы не пришлось менять и User, попросту не существует. Когда речь идет о языках со статической типизацией, то класс User, скорее всего, тоже придется изменить.
Когда же речь идет о компилируемых языках, то исполняемые файлы User и Logic и динамические библиотеки потребуют перекомпиляции клиентов, а это – крайне нежелательный процесс.
Смотрим код
Основываясь на размещенной выше схеме, можно сделать вывод о том, что если любой один класс использует другой класс, то принцип открытости-закрытости будет нарушаться. Строго говоря, это – верно. Очень интересно найти тот самый предел, ту черту, за которой приходит понимание: соответствовать принципу OCP гораздо сложнее, чем изменить уже существующий код, или же затраты на изменение кода будут слишком большими.
К примеру, нужно написать класс, который показывает прогресс закачки файла через некое приложение в процентах. Использоваться будет два основных класса - Progress и File.
function testItCanGetTheProgressOfAFileAsAPercent() {
$file = new File();
$file->length = 200;
$file->sent = 100;
$progress = new Progress($file);
$this->assertEquals(50, $progress->getAsPercent());
}
В примере был использован Progress. В качестве результата нужно получить значение в процентах, независимо от фактического размера файла. Класс File был использован в качестве источника информации для класса Progress. У файла есть определенная длина в байтах и поле под названием sent, которое предоставляет объем данных, переданных загрузчику. В данный момент не важно, как именно эти значения будут обновляться в приложении. Можно предположить, что существует некая волшебная логика, которая это и делает, поэтому в примере их можно просто установить.
class File {
public $length;
public $sent;
}
Класс File – это простой объект данных, содержащий 2 поля. В реальной жизни, конечно, у него может быть другое содержимое и поведение, например, имя файла, путь и относительный путь, директория, тип, разрешение и так далее.
class Progress {
private $file;
function __construct(File $file) {
$this->file = $file;
}
function getAsPercent() {
return $this->file->sent * 100 / $this->file->length;
}
}
Проще говоря, Progress – это класс, который принимает File в конструкторе. Для ясности тип переменной был определен в параметрах конструктора. Существует единственный полезный метод в Progress, это - getAsPercent(), который принимает отправляемые значения и длину из File и переводит все в проценты. Просто, понятно и работает.
Testing started at 5:39 PM ...
PHPUnit 3.7.28 by Sebastian Bergmann.
.
Time: 15 ms, Memory: 2.50Mb
OK (1 test, 1 assertion)
Код выглядит правильно, но он все равно нарушает принцип открытости-закрытости. Но, как и почему?
Изменение требований
Вполне ожидаемо, что каждое приложение будет эволюционировать по мере того, как появится нужда в новых функциях. Одной из новых возможностей некоего нашего приложения может стать не только закачка музыки, но и ее прослушивание. Длина File представлена в байтах, а продолжительность музыки – в секундах. Слушателям нужно предложить хороший показатель прогресса, но нельзя ли использовать для этого уже существующий?
Оказывается, нет, нельзя, так как наш прогресс уже связан с File и понимает только файлы. Это не изменится даже тогда, когда мы сможем переделать его для распознавания музыки. Но для того, чтобы появилось распознавание музыкальных файлов, нужно, чтобы Progress имел представление о Music и о File. Если бы решение соответствовало принципу OCP, то File или Progress не пришлось бы менять, а существующий показатель прогресса можно было бы легко адаптировать к музыке.
Решение 1: используем динамическую природу PHP
У динамически типизированных языков есть преимущество: они могут распознавать тип объекта в процессе исполнения. Это позволяет не использовать отдельное определение типа в конструкторе Progress с вполне работоспособным кодом.
class Progress {
private $file;
function __construct($file) {
$this->file = $file;
}
function getAsPercent() {
return $this->file->sent * 100 / $this->file->length;
}
}
Теперь в Progress можно добавить все, что только угодно.
class Music {
public $length;
public $sent;
public $artist;
public $album;
public $releaseDate;
function getAlbumCoverFile() {
return 'Images/Covers/' . $this->artist . '/' . $this->album . '.png';
}
}
И класс Music будет работать отлично. Проверить это можно на простом примере:
function testItCanGetTheProgressOfAMusicStreamAsAPercent() {
$music = new Music();
$music->length = 200;
$music->sent = 100;
$progress = new Progress($music);
$this->assertEquals(50, $progress->getAsPercent());
}
В общем, любое измеримое содержание можно использовать вместе с классом Progress. Можно выразить это в переменной, изменив ее имя:
class Progress {
private $measurableContent;
function __construct($measurableContent) {
$this->measurableContent = $measurableContent;
}
function getAsPercent() {
return $this->measurableContent->sent * 100 / $this->measurableContent->length;
}
}
Все, кажется, отлично, но в этом подходе есть одна громадная проблема. Когда File был указан в роли определителя типа, уверенность в том, что класс будет отменно работать, только крепчала. Это было вполне очевидно, а о каких-либо неточностях могла бы сообщить простая ошибка.
Argument 1 passed to Progress::__construct()
must be an instance of File,
instance of Music given.
Конечный результат был бы одинаковым в обоих случаях, но в первом мы получили бы сообщение. Правда, очень неясное. Нет способа узнать, что переменной (в нашем случае это - строка) не хватает каких-либо свойств или они просто не были найдены. Отладка и дебагинг в этом случае становятся проблемой: программисту приходится открывать класс Progress и перечитывать его для того, чтобы найти и понять проблему. Это можно уточнить по поведению Progress, а если точнее, то благодаря доступу к полям sent и length в методе getAsPercent(). Но в реальной жизни все может быть гораздо сложнее.
Подобное решение нужно применять только в том случае, если ни одно из предложенных ниже решений нельзя реализовать с минимальными затратами (трудность реализации или слишком большие архитектурные изменения, которые не оправдают усилий).
Решение 2: Стратегия паттернов проектирования
Это – наиболее распространенное и наиболее доступное решение для соответствия OCP, простой и эффективный метод.
Шаблон Стратегия отлично показывает используемый интерфейс. Интерфейс – это особый тип организации в объектно-ориентированном программировании, который определяет отношения между клиентом и классом сервера. Оба класса будут вести себя так, чтобы достигнуть желаемого.
interface Measurable {
function getLength();
function getSent();
}
В интерфейсе мы можем определить только поведение, поэтому вместо прямого использования общедоступных переменных следует подумать о сеттерах и геттерах. А адаптировать другие классы будет просто, ведь об этом практически полностью позаботится IDE.
function testItCanGetTheProgressOfAFileAsAPercent() {
$file = new File();
$file->setLength(200);
$file->setSent(100);
$progress = new Progress($file);
$this->assertEquals(50, $progress->getAsPercent());
}
Как обычно, сначала пойдут примеры, где мы будем пользоваться сеттерами для установки значений. Кстати, сеттеры также можно определить в нашем измерительном интерфейсе (Measurable), но будьте внимательны с тем, что вы туда прописываете. Интерфейс для определения контракта между клиентским классом Progress и различными серверными классами File и Music.
Нужно ли устанавливать значения для Progress? Вероятно, нет. Вряд ли вам придется определять сеттеры в интерфейсе, но если вы все же решите это сделать, то заставите все серверные классы работать с сеттерами. Одним из них вполне могут быть необходимы сеттеры, но другие могут вести себя неожиданно непредсказуемо. Что нужно сделать для того, чтобы Progress показывал температуру печи? Класс OvenTemperature можно инициализировать со значениями в конструкторе или же получить информацию от третьего класса. Но иметь здесь сеттеры – это странно.
class File implements Measurable {
private $length;
private $sent;
public $filename;
public $owner;
function setLength($length) {
$this->length = $length;
}
function getLength() {
return $this->length;
}
function setSent($sent) {
$this->sent = $sent;
}
function getSent() {
return $this->sent;
}
function getRelativePath() {
return dirname($this->filename);
}
function getFullPath() {
return realpath($this->getRelativePath());
}
}
Класс File был слегка изменен для того, чтобы соответствовать требованиям выше, и теперь он реализует интерфейс Measurable; в нем есть сеттеры и геттеры для полей, которые нам нужны. А класс Music на него очень похож.
class Progress {
private $measurableContent;
function __construct(Measurable $measurableContent) {
$this->measurableContent = $measurableContent;
}
function getAsPercent() {
return $this->measurableContent->getSent() * 100 / $this->measurableContent->getLength();
}
}
Класс Progress тоже нужно немножко обновить, указав в конструкторе тип. Наш тип – это Measurable (измерительный). После этого у нас появляется явный контракт. В Progress всегда будут методы доступа – мы определили их в интерфейсе измерения. Классы File и Music всегда смогут сделать все, что нужно классу Progress, путем простого исполнения методов интерфейса: это необходимо, когда класс реализует интерфейс.
Заметка об имени интерфейса
Люди часто называют интерфейс с заглавной буквы или добавляют слово «Interface» в конце, например, IFile или FileInterface. Это обозначение старого образца и работало оно со старыми стандартами. Имя переменной или файла должно четко и ясно давать понять суть его содержимого. IDE определяет что-либо со скоростью в долю секунды и именно это позволяет нам сконцентрироваться на работе.
Интерфейсы принадлежат их клиентам и поэтому, когда мы ходим дать имя интерфейсу, нам нужно полностью забыть о реализации и думать только о клиенте. Когда мы назвали интерфейс измеримым (Measurable), то думали о Progress. Если бы мы были прогрессом, что нам нужно было бы для того, чтобы переводить что-либо в проценты? Ответ более, чем прост: что-то, что можно посчитать. Поэтому мы и присвоили название Measurable.
Не стоит забывать о том, что реализация может быть из разных областей. В нашем случае, это – музыка и файлы. Но готовый прогресс мы легко можем заново использовать в гоночном симуляторе, и тогда нашими измеряемыми классами станут скорость, количество топлива и так далее.
Решение 3: используем шаблонный метод
Шаблонный метод очень смахивает на стратегию, но с одним отличием: вместо интерфейса он использует абстрактные классы. Шаблонный метод рекомендуется использовать в том случае, если клиент для нашего приложения очень специфический, с небольшой возможностью повторного использования и в том случае, если у серверных классов общее поведение.
Просмотр высокого уровня
Итак, как же все это влияет на нашу архитектуру высокого уровня?
Если изображение выше представляет текущую архитектуру нашего приложения, то добавление нового модуля с пятью классами (синий цвет) должно вполне ожидаемо повлиять на всю расстановку (красный цвет).
В большинстве систем невозможно не повлиять на код при вводе новых классов. Но соответствие принципу открытости-закрытости может значительно сократить классы и модули, требующие постоянного изменения.
Изучая очередной новый принцип, не пытайтесь удержать в голове одновременно с ним и все остальное, иначе вы просто запутаетесь в интерфейсах для каждого класса. Такую конструкцию будет тяжело понимать и поддерживать. Наиболее оптимальным решением в этом случае станет учет возможностей и определение, будут ли здесь другие типы и серверные классы.
В общем-то, можно легко представить себе новую функцию или найти ее в логах другого серверного класса. В таких случаях, интерфейс нужно добавить с самого начала. Если же вы не уверены или не можете разобраться – просто пропустите эту часть. Пусть добавлением интерфейса занимается другой программист или даже вы, но в будущем.
Если вы будете все продумывать при добавлении интерфейсов, то модификаций будет мало, они будут быстрыми и легкими. Помните, если код пришлось изменить один раз, скорее всего, это нужно будет делать снова и здесь принцип открытости-закрытости может здорово помочь.
Источник: code.tutsplus.com