SOLID: OCP — Принцип открытости/закрытости
Принцип открытости/закрытости (Open/Closed Principle, OCP) гласит:
“Программные компоненты должны быть открыты для расширения, но закрыты для изменения”
Проще говоря, добавление новой функциональности не должно требовать изменения существующих классов или функций. Вместо этого новые возможности добавляются через расширение (наследование, внедрение интерфейсов, композицию и другие механизмы).
Примеры OCP из реальной жизни и ПО
Рассмотрим некоторые примеры:
- Розетки и приборы: Электрическая розетка — классический пример принципа OCP в реальной жизни. Когда вы покупаете новый прибор (чайник, кофемолку, зарядное устройство), вы не меняете саму розетку — ее конструкция остается неизменной (закрыта для изменений). В то же время розетка “открыта для расширения”, т.к. поддерживает широкий спектр устройств с совместимыми вилками. Если устройство не соответствует стандарту розетки (например, европейская розетка и американская вилка), можно использовать адаптер или переходник, не модифицируя розетку. Электротехнические стандарты (тип разъема, напряжение) обеспечивают стабильный интерфейс между розеткой и устройствами. Т.о., электрическая розетка “закрыта для изменений” — вы не меняете ее каждый раз при покупке нового прибора. Но розетка “открыта для расширения” — можно подключать новые устройства через совместимый адаптер.
- Платежные системы: Добавление нового метода оплаты (например, Apple Pay) не требует изменений в существующих модулях системы. Просто добавляется новый обработчик платежей.
- Фреймворки и библиотеки: Например, Spring Framework позволяет разработчикам добавлять новые компоненты экосистемы без изменения ядра фреймворка.
- Плагины в браузерах: Браузер предоставляет интерфейс для плагинов. Добавление новых расширений не требует изменения исходного кода браузера.
Почему важно соблюдать OCP
OCP снижает риски и стоимость изменений. Когда новые требования или бизнес-изменения приводят к минимальным модификациям существующего кода, это снижает вероятность ошибок, упрощает тестирование, поддержку и развитие системы в целом.
Примеры нарушения OCP
Самое частое нарушение OCP — когда поведение программы различается в зависимости от типа объекта или значения перечисления (enum
) через цепочку if
или switch
:
1
2
3
4
5
6
7
8
9
10
11
public double calculateDiscount(Customer customer) {
if (customer.getType().equals("Regular")) {
return 0.05;
} else if (customer.getType().equals("VIP")) {
return 0.1;
} else if (customer.getType().equals("Employee")) {
return 0.15;
} else {
return 0.0;
}
}
Каждый раз, когда добавляется новый тип клиента (например, Partner
), нужно менять существующий метод calculateDiscount
. Это прямое нарушение OCP: метод должен быть закрыт для изменений. Чем больше типов клиентов, тем длиннее становится цепочка условий, повышая сложность кода и риск ошибок. При таком подходе расширение функциональности превращается в модификацию существующего кода.
⚠️ Такой подход часто встречается в калькуляторах цен, обработчиках платежей и генераторах отчетов. С увеличением числа типов он приводит к постоянным изменениям существующего кода.
Другой пример - обработчик событий:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class NotificationService {
public void sendNotification(String channel, String message) {
if (channel.equals("email")) {
// отправить email
System.out.println("Sending Email: " + message);
} else if (channel.equals("sms")) {
// отправить SMS
System.out.println("Sending SMS: " + message);
} else if (channel.equals("push")) {
// отправить push-уведомление
System.out.println("Sending Push Notification: " + message);
}
}
}
Если нужно добавить новый канал (например, WhatsApp), придется менять метод sendNotification
. Это нарушение OCP — обработчик событий не должен изменяться при добавлении новых каналов уведомлений.
И чем дальше, тем хуже. Каждый новый канал увеличивает количество ветвлений. Код становится трудно тестировать. Малейшее изменение может сломать работу всех каналов.
⚠️ Нарушение OCP в обработчиках событий — частая проблема в системах отправки уведомлений, роутинга сообщений, логирования и обработки пользовательских действий в UI.
Рефакторинг к OCP на Java
Рассмотрим систему управления заказами (Order
).
Исходная модель заказа:
1
2
3
4
5
6
7
8
9
10
11
public class Order {
// "standard" или "bulky"
private String type;
// Другие поля: стоимость, вес, адрес и тд.
public String getType() {
return type;
}
}
Класс ShippingCostCalculator
- исходная реализация калькулятора доставки, рассчитывает ее стоимость. Поддерживаются только два типа заказов, стандартный и крупногабаритный:
1
2
3
4
5
6
7
8
9
10
11
12
public class ShippingCostCalculator {
public double calculateShippingCost(Order order) {
if (order.getType().equals("standard")) {
return 5.0;
} else if (order.getType().equals("bulky")) {
return 15.0;
} else {
throw new IllegalArgumentException("Unknown shipping type");
}
}
}
Что здесь не так. При добавлении новых типов заказов (например, “срочная доставка” - express
) придется менять сам калькулятор, добавляя еще один if
или else if
— это нарушение OCP, поскольку класс должен быть закрыт от изменений, но открыт для расширения без изменения существующего кода.
Чем больше типов доставки, тем длиннее будет цепочка условий и выше риск ошибок.
Проведем рефакторинг к OCP с использованием шаблона “Стратегия” (Strategy Pattern
):
- Интерфейс
ShippingType
будет определять контракт для алгоритмов расчета. - Реализации интерфейса инкапсулируют поведение.
- Сам калькулятор будет работать с абстракцией (интерфейсом), а не с конкретными реализациями.
Введем абстракцию - новый интерфейс ShippingType
:
1
2
3
public interface ShippingType {
double calculateCost();
}
Данный интерфейс задает общий контракт для всех типов доставки. Каждый тип будет реализовывать свой алгоритм расчета.
Реализация типов доставки:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class StandardShipping implements ShippingType {
@Override
public double calculateCost() {
return 5.0;
}
}
public class BulkyShipping implements ShippingType {
@Override
public double calculateCost() {
return 15.0;
}
}
Теперь для каждого типа доставки создается отдельный класс, который инкапсулирует собственную логику расчета.
Обновленный калькулятор:
1
2
3
4
5
public class ShippingCostCalculator {
public double calculateShippingCost(ShippingType shippingType) {
return shippingType.calculateCost();
}
}
Калькулятор больше не зависит от конкретных типов доставки. Он работает только с интерфейсом ShippingType
. Чтобы добавить новый тип, например, ExpressShipping
, нужно просто создать новый класс — изменять ShippingCostCalculator
не потребуется.
Итого, ShippingCostCalculator
теперь закрыт для изменений. Расширение функциональности достигается созданием новых реализаций интерфейса ShippingType
, т.е. изменения минимизированы. При этом мы получили высокую когезию в системе и низкую связанность - каждый класс отвечает только за свою задачу, классы слабо зависят друг от друга. Такой код проще покрыть тестами, т.к. поведение каждого такого типа можно тестировать отдельно.
Статьи серии
- SOLID: серия материалов о принципах проектирования
- SRP — Single Responsibility Principle
- LSP — Liskov Substitution Principle (в разработке)
- ISP — Interface Segregation Principle (в разработке)
- DIP — Dependency Inversion Principle (в разработке)