Статьи

ТВЕРДОЙ: Часть 2 — Открытый / Закрытый Принцип

Одиночная ответственность (SRP) , Открытый / Закрытый (OCP), Подстановка Лискова, Разделение интерфейса и Инверсия зависимости. Пять гибких принципов, которыми вы должны руководствоваться каждый раз, когда вам нужно написать код.

Программные объекты (классы, модули, функции и т. Д.) Должны быть открыты для расширения, но закрыты для модификации.

Принцип Open / Closed, короче говоря, OCP, зачислен французскому программисту Бертрану Майеру , который впервые опубликовал его в своей книге « Построение объектно-ориентированного программного обеспечения» в 1988 году.

Этот принцип приобрел популярность в начале 2000-х годов, когда он стал одним из принципов SOLID, определенных Робертом К. Мартином в его книге Agile Software Development, Principles, Patterns and Practices, а затем переиздан в версии C # книги Agile Principles, Patterns. и практики в C # .

Здесь мы в основном говорим о том, чтобы спроектировать наши модули, классы и функции таким образом, чтобы при необходимости новой функциональности мы не изменяли существующий код, а скорее писали новый код, который будет использоваться существующим кодом. Это звучит немного странно, особенно если мы работаем в таких языках, как Java, C, C ++ или C #, где это относится не только к самому исходному коду, но и к двоичному. Мы хотим создавать новые функции способами, которые не требуют повторного развертывания существующих двоичных файлов, исполняемых файлов или библиотек DLL.

По мере продвижения в этих уроках мы можем поместить каждый новый принцип в контекст уже обсужденных. Мы уже обсуждали Единую ответственность (SRP), в которой говорится, что модуль должен иметь только одну причину для изменения. Если мы подумаем об OCP и SRP, мы можем заметить, что они дополняют друг друга. Код, специально разработанный с учетом требований SRP, будет близок к принципам OCP или будет легко заставить его уважать эти принципы. Когда у нас есть код, который имеет единственную причину для изменения, введение новой функции создаст вторичную причину для этого изменения. Таким образом, SRP и OCP будут нарушены. Точно так же, если у нас есть код, который должен изменяться только тогда, когда изменяется его основная функция, и должен оставаться неизменным, когда к нему добавляется новая функция, таким образом, с учетом OCP, в основном также будет соблюдаться SRP.

Это не означает, что SRP всегда приводит к OCP или наоборот, но в большинстве случаев, если один из них соблюдается, добиться второго достаточно просто.

С чисто технической точки зрения принцип Open / Closed очень прост. Простые отношения между двумя классами, подобные приведенному ниже, нарушают OCP.

violate1

Класс User использует класс Logic напрямую. Если нам нужно реализовать второй класс Logic таким образом, который позволит нам использовать как текущий, так и новый класс, необходимо будет изменить существующий класс Logic . User напрямую связан с реализацией Logic , мы не можем предоставить новую Logic не затрагивая текущую. И когда мы говорим о статически типизированных языках, вполне возможно, что класс User также потребует изменений. Если мы говорим о скомпилированных языках, то, скорее всего, как исполняемый файл User и исполняемый файл или динамическая библиотека Logic потребуют перекомпиляции и повторного развертывания для наших клиентов — процесса, которого мы хотим избежать при любой возможности.

Основываясь только на приведенной выше схеме, можно сделать вывод, что любой класс, непосредственно использующий другой класс, фактически нарушает принцип Open / Closed. И это правильно, строго говоря. Мне показалось довольно интересным найти ограничения, в тот момент, когда вы подводите черту и решаете, что OCP сложнее уважать, чем модифицировать существующий код, или стоимость архитектуры не оправдывает затраты на изменение существующего кода.

Допустим, мы хотим написать класс, который может обеспечить прогресс в процентах для файла, который загружается через наше приложение. У нас будет два основных класса, Progress и File , и я думаю, что мы захотим использовать их, как в тесте ниже.

1
2
3
4
5
6
7
8
9
function testItCanGetTheProgressOfAFileAsAPercent() {
    $file = new File();
    $file->length = 200;
    $file->sent = 100;
 
    $progress = new Progress($file);
 
    $this->assertEquals(50, $progress->getAsPercent());
}

В этом тесте мы являемся пользователем Progress . Мы хотим получить значение в процентах, независимо от фактического размера файла. Мы используем File в качестве источника информации для нашего Progress . Файл имеет длину в байтах и ​​поле с именем sent представляющее объем данных, отправленных тому, кто выполняет загрузку. Нам не важно, как эти значения обновляются в приложении. Мы можем предположить, что для нас есть какая-то магическая логика, поэтому в тесте мы можем установить их явно.

1
2
3
4
class File {
    public $length;
    public $sent;
}

Класс File — это простой объект данных, содержащий два поля. Конечно, в реальной жизни, он также может содержать другую информацию и поведение, например, имя файла, путь, относительный путь, текущий каталог, тип, разрешения и так далее.

01
02
03
04
05
06
07
08
09
10
11
12
13
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 и преобразовывать их в процент. Просто, и это работает.

1
2
3
4
5
Testing started at 5:39 PM …
PHPUnit 3.7.28 by Sebastian Bergmann.
.
Time: 15 ms, Memory: 2.50Mb
OK (1 test, 1 assertion)

Этот код кажется правильным, однако он нарушает принцип Open / Closed. Но почему? И как?

Каждое приложение, которое должно развиваться вовремя, будет нуждаться в новых функциях. Одной из новых функций нашего приложения может быть потоковая передача музыки, а не просто загрузка файлов. Длина File представлена ​​в байтах, продолжительность музыки в секундах. Мы хотим предложить приятный индикатор прогресса нашим слушателям, но можем ли мы использовать тот, который у нас уже есть?

Нет мы не можем. Наш прогресс связан с File . Он понимает только файлы, хотя может применяться и к музыкальному контенту. Но чтобы сделать это, мы должны изменить его, мы должны сообщить Progress о Music и File . Если наш дизайн будет соответствовать OCP, нам не нужно трогать File или Progress . Мы могли бы просто повторно использовать существующий Progress и применить его к Music .

Динамически типизированные языки обладают преимуществами угадывания типов объектов во время выполнения. Это позволяет нам удалить подсказку из конструктора Progress , и код все равно будет работать.

01
02
03
04
05
06
07
08
09
10
11
12
13
class Progress {
 
    private $file;
 
    function __construct($file) {
        $this->file = $file;
    }
 
    function getAsPercent() {
        return $this->file->sent * 100 / $this->file->length;
    }
 
}

Теперь мы можем бросить что угодно в Progress . Я имею в виду буквально все, что угодно:

01
02
03
04
05
06
07
08
09
10
11
12
13
class Music {
 
    public $length;
    public $sent;
 
    public $artist;
    public $album;
    public $releaseDate;
 
    function getAlbumCoverFile() {
        return ‘Images/Covers/’ .
    }
}

И урок Music подобный приведенному выше, будет работать просто отлично. Мы можем легко проверить это с помощью теста, очень похожего на File .

1
2
3
4
5
6
7
8
9
function testItCanGetTheProgressOfAMusicStreamAsAPercent() {
    $music = new Music();
    $music->length = 200;
    $music->sent = 100;
 
    $progress = new Progress($music);
 
    $this->assertEquals(50, $progress->getAsPercent());
}

Таким образом, с классом Progress можно использовать любой измеримый контент. Может быть, мы должны выразить это в коде, также изменив имя переменной:

01
02
03
04
05
06
07
08
09
10
11
12
13
class Progress {
 
    private $measurableContent;
 
    function __construct($measurableContent) {
        $this->measurableContent = $measurableContent;
    }
 
    function getAsPercent() {
        return $this->measurableContent->sent * 100 / $this->measurableContent->length;
    }
 
}

Хорошо, но у нас огромная проблема с этим подходом. Когда у нас был File указанный как typehint, мы были уверены, что наш класс может обрабатывать. Это было явно, и если что-то пришло, хорошая ошибка сказала нам об этом.

1
2
3
Argument 1 passed to Progress::__construct()
must be an instance of File,
instance of Music given.

Но без подсказки мы должны полагаться на тот факт, что все, что приходит, будет иметь две открытые переменные с некоторыми точными именами, такими как « length » и « sent ». В противном случае у нас будет отказано по завещанию.

Отказался от завещания: класс, который переопределяет метод базового класса таким образом, что контракт базового класса не выполняется производным классом. ~ Источник Википедия.

Это один из запахов кода, представленный более подробно в премиальном курсе « Обнаружение запахов кода» . Короче говоря, мы не хотим в конечном итоге пытаться вызывать методы или обращаться к полям объектов, которые не соответствуют нашему контракту. Когда у нас была напечатанная подсказка, она была указана в договоре. Поля и методы класса File . Теперь, когда у нас ничего нет, мы можем отправить что угодно, даже строку, и это приведет к ужасной ошибке.

1
2
3
4
function testItFailsWithAParameterThatDoesNotRespectTheImplicitContract() {
    $progress = new Progress(‘some string’);
    $this->assertEquals(50, $progress->getAsPercent());
}

Подобный тест, в котором мы отправляем простую строку, выдаст отклоненный запрос:

1
Trying to get property of non-object.

Хотя в обоих случаях конечный результат один и тот же, что означает, что код нарушается, в первом случае получилось приятное сообщение. Этот, однако, очень неясен. Нет никакого способа узнать, что это за переменная — в нашем случае это строка, и какие свойства были найдены и не найдены. Сложно отлаживать и решать проблему. Программист должен открыть класс Progress прочитать его и понять. Контракт, в этом случае, когда мы явно не указываем подсказку, определяется поведением Progress . Это неявный контракт, известный только Progress . В нашем примере это определяется доступом к двум полям, sent и length , в getAsPercent() . В реальной жизни неявный контракт может быть очень сложным и его трудно обнаружить, просто посмотрев несколько секунд в классе.

Это решение рекомендуется только в том случае, если ни одно из приведенных ниже предложений не может быть легко реализовано или если они повлекут за собой серьезные архитектурные изменения, которые не оправдывают усилия.

Это наиболее распространенное и, вероятно, наиболее подходящее решение для соблюдения OCP. Это просто и эффективно.

стратегия

Шаблон Стратегии просто вводит использование интерфейса. Интерфейс — это особый тип сущности в объектно-ориентированном программировании (ООП), который определяет контракт между клиентом и классом сервера. Оба класса будут придерживаться контракта, чтобы обеспечить ожидаемое поведение. Может быть несколько не связанных между собой серверных классов, которые соблюдают один и тот же контракт и могут обслуживать один и тот же класс клиента.

1
2
3
4
interface Measurable {
    function getLength();
    function getSent();
}

В интерфейсе мы можем определить только поведение. Вот почему вместо непосредственного использования открытых переменных нам придется подумать об использовании геттеров и сеттеров. На этом этапе адаптация других классов не составит труда. Наша IDE может сделать большую часть работы.

1
2
3
4
5
6
7
8
9
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 может быть инициализирован значениями в конструкторе или получить информацию из третьего класса. Кто знает? Иметь сеттеры в этом классе было бы странно.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 очень похожа, вы можете проверить ее содержимое в прилагаемом исходном коде. Мы почти закончили.

01
02
03
04
05
06
07
08
09
10
11
12
13
class Progress {
 
    private $measurableContent;
 
    function __construct(Measurable $measurableContent) {
        $this->measurableContent = $measurableContent;
    }
 
    function getAsPercent() {
        return $this->measurableContent->getSent() * 100 / $this->measurableContent->getLength();
    }
 
}

Progress также нуждался в небольшом обновлении. Теперь мы можем указать тип, используя typehinting, в конструкторе. Ожидаемый тип Measurable . Теперь у нас есть явный контракт. Progress может быть уверен, что методы доступа будут всегда присутствовать, потому что они определены в интерфейсе Measurable . File и Music также могут быть уверены, что они могут обеспечить все, что необходимо для Progress , просто реализуя все методы интерфейса, что является обязательным требованием, когда класс реализует интерфейс.

Этот шаблон проектирования более подробно объясняется в курсе Agile Design Patterns .

Люди обычно называют интерфейсы с заглавной IFile I перед ними или со словом « Interface » в конце, например, IFile или FileInterface . Это нотация старого стиля, навязанная некоторыми устаревшими стандартами. Мы так далеко отошли от венгерских обозначений или необходимости указывать тип переменной или объекта в его имени, чтобы легче было его идентифицировать. IDE идентифицируют что-либо за долю секунды для нас. Это позволяет нам сосредоточиться на том, что мы действительно хотим абстрагировать.

Интерфейсы принадлежат их клиентам. Да. Когда вы хотите назвать интерфейс, вы должны подумать о клиенте и забыть о реализации. Когда мы назвали наш интерфейс Измеримым, мы сделали так, думая о прогрессе. Если бы у меня был прогресс, что мне нужно было бы обеспечить процентом? Ответ прост, что-то, что мы можем измерить. Таким образом, название Измерим.

Другая причина заключается в том, что реализация может быть из разных доменов. В нашем случае есть файлы и музыка. Но мы вполне можем повторно использовать наш Progress в гоночном симуляторе. В этом случае измеряемыми классами будут скорость, топливо и т. Д. Хорошо, не правда ли?

Шаблон проектирования Template Template очень похож на стратегию, но вместо интерфейса он использует абстрактный класс. Рекомендуется использовать шаблонный метод шаблона, когда у нас есть клиент, очень специфичный для нашего приложения, с ограниченной возможностью повторного использования и когда классы сервера имеют общее поведение.

template_method

Этот шаблон проектирования более подробно объясняется в курсе Agile Design Patterns .

Итак, как все это влияет на нашу архитектуру высокого уровня?

HighLevelDesign

Если изображение выше представляет текущую архитектуру нашего приложения, добавление нового модуля с пятью новыми классами (синие) должно оказать умеренное влияние на наш дизайн (красный класс).

HighLevelDesignWithNewClasses

В большинстве систем нельзя ожидать абсолютно никакого влияния на существующий код при введении новых классов. Однако соблюдение открытого / закрытого принципа значительно сократит классы и модули, которые требуют постоянных изменений.

Как и с любым другим принципом, постарайтесь не думать обо всем, что было раньше. Если вы сделаете это, вы получите интерфейс для каждого из ваших классов. Такой дизайн будет сложно поддерживать и понимать. Обычно самый безопасный путь — подумать о возможностях и определить, будут ли другие типы классов серверов. Много раз вы можете легко представить себе новую функцию или вы можете найти ее в очереди проекта, которая создаст другой класс сервера. В этих случаях добавьте интерфейс с самого начала. Если вы не можете определить, или если вы не уверены — в большинстве случаев — просто опустите его. Позвольте следующему программисту или, возможно, даже вам самим добавить интерфейс, когда вам понадобится вторая реализация.

Если вы следуете своей дисциплине и добавляете интерфейсы, как только требуется второй сервер, изменения будут незначительными и простыми. Помните, что если требуемый код изменяется один раз, существует высокая вероятность того, что потребуется повторное изменение. Когда эта возможность станет реальностью, OCP сэкономит вам много времени и усилий.

Спасибо за чтение.