вторник, 26 августа 2014 г.

Open/Closed Principle

Цикл статей о SOLID принципах

--------------------------------------------------

Принцип открыт/закрыт (Open-Closed Principle, OCP): Программные сущности (классы, модули, функции и т.п.) должны быть открытыми для расширения, но закрытыми для модификации.
Роберт Мартин. Принципы, паттерны и практики гибкой разработки, 2010

Из всех «цельных» (SOLID) принципов, принцип Открыт/Закрыт является самым неоднозначным. Его неоднозначность кроется в противоречивости его определения (как что-то может быть одновременно «открытым» и «закрытым»?), а подкрепляется неоднозначными и разнообразными формулировками этого принципа в разных источниках. Не удивительно, что даже такие монстры, типа Эрика Липперта или Джона Скита относятся к этому принципу неоднозначно и признаются в его непонимании.

Давайте разберемся с определениями!

Принцип Открыт-Закрыт был изначально сформулирован Бертраном Мейером в первом издании его книги «Объектно-ориентированное конструирование программных систем» еще в 1988 году, но популярность этот принцип завоевал благодаря трудам Роберта Мартина.

Определение от Боба Мартина: программные сущности (классы, модули, функции и т. п.) должны быть открыты для расширения, но закрыты для модификации.

Таким образом у модулей есть две основные характеристики:

  • Они открыты для расширения. Это означает, что поведение модуля можно расширить. Когда требования к приложению изменяются, мы добавляем в модуль новое поведение, отвечающее изменившимся требованиям. Иными словами, мы можем изменить состав функций модуля.
  • Они закрыты для модификации. Расширение поведения модуля не сопряжено с изменениями в исходном или двоичном коде модуля. Двоичное исполняемое представление модуля, будь то компонуемая библиотека, DLL или EXE-файл, остается неизменным (выделено мною).

Означает ли это определение, что теперь мы не имеем права менять детали реализации модулей и исправлять в них ошибки? Означает ли это, что теперь любой код должен отвечать критериям pluggable-архитектуры и другие решения являются design-smells?

Нет, не означает!

Со временем даже сам «дядюшка» Боб стал смотреть на этот мир проще, и в своей статье An Open and Closed Case объясняет, что он был юн и горяч, ведь в момент первого описания этого принципа в 1996-м году ему было лишь 43 (!) года, поэтому со временем он стал более мудрым и менее категоричным (хотя приведенное выше определение взято из книги, опубликованной в 2006-м!).

Последнее объяснение принципа Открыт/Закрыт от «дядюшки» Боба звучит так:

Мейер хочет, чтобы было легко изменять поведение модуля без изменения его исходного кода!

Теперь давайте перейдем к определению от Бертрана Мейера и попробуем понять, что же имел ввиду исходный автор этого принципа.

Определение от Бертрана Мейера: модули должны иметь возможность быть как открытыми, так и закрытыми.

При этом понятия открытости и закрытости определяются так:

  • Модуль называют открытым, если он еще доступен для расширения. Например, имеется возможность расширить множество операций в нем или добавить поля к его структурам данных.
  • Модуль называют закрытым, если он доступен для использования другими модулями. Это означает, что модуль (его интерфейс – с точки зрения скрытия информации) уже имеет строго определенное окончательное описание. На уровне реализации закрытое состояние модуля означает, что модуль можно компилировать, сохранять в библиотеке и делать его доступным для использования другими модулями (его клиентами).

Исходный посыл Мейера заключается не столько в расширяемости системы, сколько в управляемости процесса разработки: «необходимо, особенно с точки зрения руководителя проекта, закрывать модули. В системе, состоящей из многих модулей, большинство модулей зависимы. Например, модуль интерфейса пользователя может зависеть от модуля синтаксического разбора – синтаксического анализатора и модуля графики. Синтаксический анализатор может зависеть от модуля лексического анализа, и так далее. Если не закрывать модуль до тех пор, пока не будет уверенности, что он уже содержит все необходимые компоненты, то невозможно будет завершить разработку многомодульной программы: каждый из разработчиков будет вынужден ожидать, когда же завершит свою работу все остальные».

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

image

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

В результате добавления наследника интерфейс и поведение нашего исходного класса (класса А) остается неизменным, что гарантирует правильное функционирование его старых клиентов, а новые клиенты используют модифицированную версию класса (класс А’).

Но всегда ли нам нужно создавать наследника? Нет, не всегда! Даже такой ОО-гуру как Мейер, который описывает в своей книге 12 видов наследования, относится к расширяемости модулей путем создания наследников с разумным прагматизмом: «Если имеется возможность переписать исходную программу так, чтобы она, без излишнего усложнения, смогла удовлетворять потребности нескольких разновидностей клиентов, то следует это сделать».

Естественно, модуль должен модифицироваться при наличии в нем ошибок: «Как принцип Открыт-Закрыт, так и переопределение в механизме наследования не позволяет справиться с дефектами разработки, не говоря уже об ошибках в программе. Если в модуле что-то не в порядке, то следует это сразу исправить в исходной программе, не пытаясь разбираться возникающей проблемой в производном модуле.»

(Ради справки, Мейер трактует понятия «модуля» и «класса» одинаковым образом!)

Принцип единственного выбора

Давайте рассмотрим такой пример. Предположим, у нас есть иерархия классов, предназначенных для импортирования данных из файлов разных форматов. Также у нас есть фабричный метод, который создает нужный Importer в зависимости от расширения файла:

abstract class Importer
{
   
public abstract void
ImportData();
}

static class ImporterFactory
{
   
public static Importer Create(string
fileName)
    {
       
Contract.Requires(!string.
IsNullOrEmpty(fileName));
       
Contract.Ensures(Contract.Result<Importer>() != null
);

       
var extension = Path.
GetExtension(fileName);
       
switch
(extension)
        {
           
case "json"
:
               
return new JsonImporter
();
           
case "xls"
:
           
case "xlsx"
:
               
return new XlsImporter
();
           
default
:
               
throw new InvalidOperationException(
"Extension is not supported");
        }
    }
}

Отвечает ли реализация такой фабрики принципу Открыт/Закрыт? Или для соответствия этому принципу нам нужно вводить интерфейс IImporterFactory и еще одну иерархию – иерархию фабрик? А кто будет создавать фабрику? С помощью фабрики фабрик?

Фабричный метод сам по себе и так уже прячет от своих клиентов способ получения нужного Importer-а и дополнительный уровень косвенности здесь не нужен.

Вот что пишет Бертран Мейер по этому поводу: «необходимо допускать возможность того, что список вариантов, заданных и известных на некотором этапе разработки программы, может в последующем быть изменен путем добавления или удаления вариантов. Чтобы обеспечить реализацию такого подхода к процессу разработки программного обеспечения, нужно найти способ защитить структуру программы от воздействия подобных изменений. Отсюда следует принцип Единственного Выбора»:

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

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

Какую проблему призван решить принцип Открыт/Закрыт?

Смысл принципа OCP довольно прост: дизайн системы должен быть простым и устойчивым к изменениям.

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

Как этого добиться?

Во-первых, за счет абстракции и инкапсуляции. Мы выделяем существенные части системы в виде интерфейсов и абстрактных классов, не задумываясь о реализации, которая будет скрыта от клиентов. Но даже конкретный класс, унаследованный от System.Object представляет собой абстракцию. Класс String абстрагирует нас от конкретного представления строки и многих других подробностей, хотя и не реализует интерфейс IString.

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

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

ПРИМЕЧАНИЕ
Абстракция не подразумевает наличие наследования. Абстракция не может существовать без инкапсуляции! Если не верите, то загляните во вторую главу книги Гради Буча «Объектно-ориентированный анализ и проектирование».

Давайте рассмотрим конкретный пример того, как иерархия наследования может упростить систему и сделать ее более расширяемой. Для этого мы снова вернемся к плагину для упрощения контрактного программирования. С точки зрения библиотеки Code Contracts существует несколько способов проверки предусловий:

  • С помощью Contract.Requires.
  • С помощью if-throw с последующим вызовом метода Contract.EndContractBlock, или
  • С помощью кастомных валидаторов – методов, помеченных атрибутом CustomArgumentValidator.

Все эти виды предусловий выражены в виде иерархии классов:

image

В большинстве случаев, при работе с контрактами не важно, какой вид проверки предусловия используется в коде. Например, мое расширение позволяет добавлять Contract.Requires(arg != null), но только если аргумент arg еще не проверен на null каким-либо образом (не важно каким, с помощью if-throw, Guard-а или Requires).

private bool IsArgumentAlreadyVerifiedByPrecondition(
   
ICSharpFunctionDeclaration functionDeclaration, string
parameterName)
{
   
return functionDeclaration.
GetPreconditions()
       
.Any(p => p.ChecksForNotNull(parameterName));
}

GetPreconditions возвращает все предусловия метода, не зависимо от вида, что позволяет думать о задаче более абстрактно, а также добавлять новые виды предусловий не ломая существующий код (например, существует еще один вид предусловий – guard-based предусловия, добавить которые будет довольно просто именно благодаря OCP).

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

Заключение

Что такое OCP? Это фиксация интерфейса класса/модуля, и возможность изменения или подмены реализации/поведения.

Цели OCP: борьба со сложностью и ограничение изменений минимальным числом модулей.

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

Дополнительные ссылки

4 комментария:

  1. Спасибо! Наконец-то всё стало понятно.

    ОтветитьУдалить
  2. Читаю ваши статьи. Пока ваши мысли насчет SRP и OCP совпадают с мыслями Марка Симана :)

    ОтветитьУдалить
  3. Неплохое описание, но все же не хватает фундаментальности.
    Очень советую почитать по этой теме Craig Larman Applying UML And Patterns - Jon Skeet в своей статье как раз его и упоминал помимо Р.Мартина
    По существу. Инкапсуляция и наследование - это далеко НЕ единственные методы/техники помогающие достичь OcP. Даже если брать сугубо OOA/D (хотя OcP применим и на других уровнях, например на уровне системной архитектуры в целом). Например, соответствие закону Деметры тоже помогает достичь OcP - код становится защищен (closed) от изменений в структуре данных (глава 25.4 третьего издания книги Лармана).
    Ну и вообще у OcP крайне тесная связь с принципом Protected Variations и Information Hiding

    ОтветитьУдалить
  4. У OcP много определений (вызывающих споры) и еще одно, данное Ларманом, намного точнее того, что дал Мартин в любом из своих вариантов (даже от 2014г http://blog.8thlight.com/uncle-bob/2014/05/12/TheOpenClosedPrinciple.html)

    Modules should be both open (for extension; adaptable) and closed (the module is closed to modification in ways that affect clients). In the context of OCP, the phrase "closed with respect to X" means that clients are not affected if X changes.

    Второе предложение очень четко и лаконично определяет понятие closed. Совсем не так размыто как у Р.Мартина

    ОтветитьУдалить