Введение

Проектируя систему, мы занимаемся моделированием, а значит решаем инженерную задачу. Мы строим гипотезу о том, каковы отношения между сущностями в этой системе.

Однако бизнес-требования не вечны, они могут (и будут) меняться. Хорошо спроектированная система способна пережить эти изменения, отразить их в себе и продолжить функционировать.

Основная причина, по которой вносить изменения бывает трудно или дорого — когда небольшое изменение в одной части системы вызывает лавину изменений в других частях. Грубо и утрировано: если в программе для изменения цвета кнопки надо поправить 15 модулей, такая система спроектирована плохо.

Принцип открытости-закрытости

Принцип открытости-закрытости (Open-Closed Principle, OCP) помогает исключить такую проблему. Согласно ему модули должны быть открыты для расширения, но закрыты для изменения.

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

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

Модули, которые удовлетворяют OCP:

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

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

Нарушение принципа открытости-закрытости приводит к ситуациям, когда изменение в одном модуле вынуждает менять другие, связанные с ним. Это в свою очередь нарушает принцип единой ответственности, SRP, потому что весь код, который меняется по какой-то одной причине, должен быть собран в одном модуле. (Разные модули — разные причины для изменения.)

На примере

На схеме ниже объект Client непосредственно связан с объектом Server. Если нам вдруг понадобится, чтобы Client мог работать с разными объектами Server, нам придётся поменять его код.

Структура, нарушающая OCP

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

Абстракция помогает развязать модули

В коде

Если попробовать выразить этот принцип в коде, то самым простым примером будет замена множественных проверок на принадлежность к типу на абстракцию.

Например, мы пишем сервис рассылки сообщений. Он принимает текст, который надо выслать, и сервис стороннего API для отправки СМС, пушей или электронных писем. Плохо спроектированный сервис мог бы выглядеть так:

class SmsSender {
  sendSms(message: MessageText) { /* ... */ }
}

class PushSender {
  sendPush(message: MessageText) { /* ... */ }
}

class EmailSender {
  sendEmail(message: MessageText) { /* ... */ }
}

class Notifier {
  constructor(private api: SmsSender | PushSender | EmailSender) {}

  notify(): void {
    const message = 'Some user notification';

    if (this.api instanceof SmsSender) {
      this.api.sendSms(message)
    } else if (this.api instanceof PushSender) {
      this.api.sendPush(message)
    } else if (this.api instanceof EmailSender) {
      this.api.sendEmail(message)
    }
  }
}

Проблема этого кода в том, что при добавлении нового типа стороннего API — голубиной почты — нам придётся менять уже существующий код.

// ...Предыдущие классы.

// Добавили новый тип стороннего API:

class DoveSender {
  sendDove(message: MessageText) { /* ... */ }
}

class Notifier {
  constructor(private api: SmsSender | PushSender | EmailSender | DoveSender) {}

  notify(): void {
    const message = 'Some user notification';

    if (this.api instanceof SmsSender) {
      this.api.sendSms(message)
    } else if (this.api instanceof PushSender) {
      this.api.sendPush(message)
    } else if (this.api instanceof EmailSender) {
      this.api.sendEmail(message)
    } else if (this.api instanceof DoveSender) { // Последние 3 строчки —
      this.api.sendDove(message)                 // ...это новый код,
    }                                            // ...который пришлось добавить.
  }
}

Вместо этого

OCP же предлагает не проверять конкретные типы, а использовать абстракцию, которая позволит не менять код класса Notifier. Для этого мы создадим интерфейс Sender, который будут реализовывать классы SmsSender, PushSender и EmailSender:

// Интерфейс будет абстракцией, которая описывает контракт,
// по которому должны работать классы, реализующий этот интерфейс:

interface Sender {
  sendMessage(message: MessageText): void
}

class SmsSender implements Sender {
  sendMessage(message: MessageText) {
    /* То, что раньше было внутри метода `sendSms`. */
  }
}

class PushSender implements Sender {
  sendMessage(message: MessageText) {
    /* То, что раньше было внутри метода `sendPush`. */
  }
}

class EmailSender implements Sender {
  sendMessage(message: MessageText) {
    /* То, что раньше было внутри метода `sendEmail`. */
  }
}

Тогда классу Notifier перестанет быть нужно проверять конкретный тип, и он сможет положиться на контракт, описанный в интерфейсе:

class Notifier {
  constructor(private api: Sender) {}

  notify(): void {
    const message = 'Some user notification';
    this.api.sendMessage(message);
  }
}

В результате

Теперь при добавлении голубиной почты, нам уже не потребуется менять код класса Notifier, зависящего от интерфейса Sender:

class DoveSender implements Sender {
  sendMessage(message: MessageText) {
    /* То, что раньше было внутри метода `sendDove`. */
  }
}

// Код класса `Notifier` останется тем же.

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

Коротко

Принцип открытости-закрытости:

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

Материалы к разделу

Вопросы