Принципы SOLID в Java
Применяйте принципы SOLID — SRP, OCP, LSP, ISP, DIP — в проектировании на Java.
SOLID — это набор пяти принципов объектно-ориентированного проектирования, популяризованных Робертом К. Мартином, которые помогают сохранять Java-код удобным для изменения, тестирования и расширения по мере его роста. Это не синтаксические правила, которые проверяет компилятор; это рекомендации о том, где проводить границы между классами, чтобы одно изменение не распространялось по всей кодовой базе. Аббревиатура расшифровывается как Single Responsibility (Единственная ответственность), Open/Closed (Открытость/Закрытость), Liskov Substitution (Подстановка Лисков), Interface Segregation (Разделение интерфейсов) и Dependency Inversion (Инверсия зависимостей).
Эти принципы строятся на основах объектно-ориентированного программирования: вы будете видеть интерфейсы, наследование и полиморфизм на протяжении всего материала. Если эти темы кажутся неуверенными, сначала повторите их — SOLID — это в основном здравый смысл в вопросе, где их применять.
Пять принципов в двух словах
Каждая буква нацелена на определённый вид проблем проектирования. Держите эту таблицу под рукой при чтении остальной части главы:
| Буква | Принцип | Цель в одной строке |
|---|---|---|
| S | Single Responsibility | Класс должен иметь одну причину для изменения |
| O | Open/Closed | Открыт для расширения, закрыт для изменения |
| L | Liskov Substitution | Подтипы должны быть применимы везде, где используется базовый тип |
| I | Interface Segregation | Много маленьких интерфейсов лучше одного большого |
| D | Dependency Inversion | Зависеть от абстракций, а не от конкретных классов |
Принципы усиливают друг друга. В хорошо спроектированном коде вы редко применяете лишь один из них — маленький интерфейс (ISP), от которого зависит высокоуровневый код (DIP), — это именно то, что позволяет добавить новую реализацию (OCP), не трогая вызывающий код.
S — Принцип единственной ответственности
Класс должен делать одно дело и иметь одну причину для изменения. Когда не связанные между собой задачи — бизнес-правила и доставка сообщений, например — находятся в одном классе, изменение любой из них вынуждает перетестировать обе. Разделение их изолирует изменения.
// Mixes WHEN to alert with HOW to deliver -- two reasons to change.
class BadAlertService {
void raise(String user, int errors) {
if (errors > 0) {
// ...build an email, open an SMTP connection, send...
}
}
}
// One responsibility: deciding when to alert. Delivery lives elsewhere.
class AlertService {
private final Notifier notifier;
AlertService(Notifier notifier) { this.notifier = notifier; }
void raise(String user, int errors) {
if (errors > 0) notifier.send(user, errors + " error(s) detected");
}
}O — Принцип открытости/закрытости
Программные сущности должны быть открыты для расширения, но закрыты для изменения. Вы должны иметь возможность добавлять новое поведение, написав новый код, а не редактируя — и рискуя повредить — уже работающий код. В Java обычным инструментом для этого является стабильный интерфейс плюс новые реализации.
interface Notifier { void send(String to, String message); }
class EmailNotifier implements Notifier { /* ... */ }
class SmsNotifier implements Notifier { /* ... */ } // new feature = new class
// AlertService never changes when a new channel appears.Добавление push-уведомлений в будущем означает написание PushNotifier implements Notifier — AlertService остаётся нетронутым, поэтому не требует повторного анализа и не несёт риска регрессии.
L — Принцип подстановки Лисков
Если S является подтипом T, то объекты типа T можно заменить объектами типа S без нарушения работы программы. Подкласс должен соблюдать контракт своего родителя — те же ожидания, никаких неожиданных исключений, никаких более строгих предусловий.
abstract class Shape { abstract double area(); }
class Rectangle extends Shape { /* area() = w * h */ }
class Circle extends Shape { /* area() = PI * r * r */ }
// Works for ANY Shape, present or future, without inspecting the concrete type.
double totalArea(List<Shape> shapes) {
return shapes.stream().mapToDouble(Shape::area).sum();
}Классический пример нарушения — Square extends Rectangle: если установка ширины также изменяет высоту, код, написанный для Rectangle, ломается при передаче Square. Решение — смоделировать их как «братьев» под Shape, а не как пару родитель-потомок. (См. абстрактные классы для базового класса Shape, используемого здесь.)
I — Принцип разделения интерфейсов
Клиенты не должны быть вынуждены зависеть от методов, которые они не используют. Предпочитайте несколько маленьких, сфокусированных интерфейсов вместо одного большого — иначе реализатор вынужден добавлять заглушки для методов, которые не может поддержать.
// Fat interface: a read-only source is forced to implement write().
interface Storage { String read(); void write(String data); }
// Segregated: implement only what you can honor.
interface Readable { String read(); }
interface Writable { void write(String data); }
class ConfigFile implements Readable { // no empty write() stub
public String read() { return "mode=prod"; }
}D — Принцип инверсии зависимостей
Модули высокого уровня не должны зависеть от модулей низкого уровня; оба должны зависеть от абстракций. На практике: пишите код против интерфейсов и внедряйте конкретную реализацию (внедрение через конструктор — самая простая форма). Именно это позволяет другим принципам работать — и делает класс тестируемым, поскольку вы можете передать поддельную реализацию.
// AlertService depends on the Notifier interface, not EmailNotifier.
AlertService alerts = new AlertService(new EmailNotifier());
// In a test, inject a fake Notifier and assert on what it recorded.Практический пример: все пять принципов в одной программе
Эта программа объединяет все принципы вместе — единственный AlertService (SRP) взаимодействует с внедрённым Notifier (DIP), переключается между EmailNotifier и SmsNotifier без изменений (OCP), читает из доступного только для чтения ConfigFile (ISP) и суммирует площади подтипов Shape единообразно (LSP). Программа проверяет собственные результаты, чтобы вы могли убедиться, что каждый принцип соблюдается.
Что следует вынести из запуска:
email sent: [EMAIL -> alice: 3 error(s) detected]содержит только одну запись — уbobбыло ноль ошибок, поэтомуraiseничего не отправил.AlertServiceнесёт единственную ответственность за решение о том, когда отправлять уведомление (SRP); он никогда не создаёт тело сообщения и не открывает соединение.- Один и тот же класс
AlertServiceуправлял какEmailNotifier, так иSmsNotifier, поскольку зависимость передавалась через конструктор (DIP). Высокоуровневая логика уведомлений зависит только от интерфейсаNotifier, никогда от конкретного отправителя. OCP check : ... unchanged = trueподтверждает, что оба объекта уведомлений являются одним и тем же классомAlertService: добавление поддержки SMS означало написание новогоSmsNotifier, без каких-либо правок вAlertService— открыт для расширения, закрыт для изменения.ISP check : is Writable? falseпоказывает, чтоConfigFileреализует толькоReadable. Поскольку интерфейсы разделены, источник только для чтения никогда не был вынужден предоставлять бессмысленную заглушкуwrite.LSP area : 9.142— это сумма прямоугольника 2×3 (6.0) и круга радиуса 1 (≈3.142).totalAreaперебирал ссылки наShapeи вызывалarea(), не проверяя, какой конкретный подтип используется — каждый подтип мог заменить свой базовый тип (LSP).