В идеальном мире
В идеальном мире при изменении бизнес-требований код модулей менять не приходится, а для реализации новых требований достаточно добавить новую сущность.
Нарушение 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)
}
}