В идеальном мире

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

Нарушение OCP и конкретика

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

Допустим, у нас есть класс прямоугольника Rectangle:

class Rectangle {
  width: number
  height: number

  constructor(width: number, height: number) {
    this.width = width
    this.height = height
  }
}

Следуя SRP подсчёт площади всех фигур мы вынесем в отдельный класс AreaCalculator. Вначале напишем его, не следуя принципу открытости-закрытости:

class AreaCalculator {
  shapes: Rectangle[]

  constructor(shapes: Rectangle[]) {
    this.shapes = shapes
  }

  totalAreaOf(): number {
    return this.shapes.reduce((tally: number, shape: Rectangle) => {
      return tally += (shape.width * shape.height)
    }, 0)
  }
}

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

class Circle {
  radius: number

  constructor(radius: number) {
    this.radius = radius
  }
}

class AreaCalculator {
  // 1. Приходится менять тип:
  shapes: [Rectangle|Circle]

  constructor(shapes: [Rectangle|Circle]) {
    this.shapes = shapes
  }

  totalAreaOf(): number {
    return this.shapes.reduce((tally: number, shape: Rectangle | Circle) => {
      // 2. Приходится проверять, какой тип,
      //    чтобы применить правильный расчёт:
      if (shape instanceof Rectangle) {
        return tally += (shape.width * shape.height)
      }
      else if (shape instanceof Circle) {
        return tally += (shape.radius ** 2 * Math.PI)
      }
      else return tally
    }, 0)
  }
}

И подобные изменения придётся проводить для каждой новой фигуры.

Основной индикатор проблемы с принципом открытости-закрытости — появление проверки на instanceof. Если внутри кода модуля проверяется реализация, значит модуль жёстко привязан к другому, и изменения в требованиях заставят менять код этого модуля.

Применение OCP и абстракции

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

interface AreaCalculatable {
  areaOf(): number
}

Это по сути ограничение на поведение сущностей внутри системы — гипотеза того, как они друг с другом взаимодействуют. В целом люди плохо умеют прогнозировать и предсказывать. И хотя опытный проектировщик, имея достаточно знаний о проектируемой системе, может сделать хорошее предположение, OCP всё же предлагает методику Just-in-time design. Она предполагает внесение изменений и добавление сущностей по мере необходимости, но не раньше.

Вернёмся к примеру. Сейчас классы фигур подчиняются новому ограничению и реализуют интерфейс AreaCalculatable:

// 1. Указываем, что класс реализует интерфейс,
//    это задаст ограничение на методы класса:

class Rectangle implements AreaCalculatable {
  width: number
  height: number

  constructor(width: number, height: number) {
    this.width = width
    this.height = height
  }

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

  areaOf(): number {
    return this.width * this.height
  }
}

// Те же изменения проводим для круга:

class Circle implements AreaCalculatable {
  radius: number

  constructor(radius: number) {
    this.radius = radius
  }

  areaOf(): number {
    return Math.PI * (this.radius ** 2)
  }
}

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

class AreaCalculator {
  // 1. Теперь тип абстрактный,
  //    мы можем указывать какие угодно фигуры
  //    при условии, что они реализуют `AreaCalculatable`:
  shapes: AreaCalculatable[]

  constructor(shapes: AreaCalculatable[]) {
    this.shapes = shapes
  }

  totalAreaOf(): number {
    return this.shapes.reduce((tally: number, shape: AreaCalculatable) => {
      // 2. Никаких проверок на классы, только вызов `areaOf`.
      //    Если даже мы добавим треугольник,
      //    нам не придётся менять код калькулятора:
      return tally += shape.areaOf()
    }, 0)
  }
}

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

Вопросы