W3docs

Принципы SOLID в Java

Применяйте принципы SOLID — SRP, OCP, LSP, ISP, DIP — в проектировании на Java.

SOLID — это набор пяти принципов объектно-ориентированного проектирования, популяризованных Робертом К. Мартином, которые помогают сохранять Java-код удобным для изменения, тестирования и расширения по мере его роста. Это не синтаксические правила, которые проверяет компилятор; это рекомендации о том, где проводить границы между классами, чтобы одно изменение не распространялось по всей кодовой базе. Аббревиатура расшифровывается как Single Responsibility (Единственная ответственность), Open/Closed (Открытость/Закрытость), Liskov Substitution (Подстановка Лисков), Interface Segregation (Разделение интерфейсов) и Dependency Inversion (Инверсия зависимостей).

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

Пять принципов в двух словах

Каждая буква нацелена на определённый вид проблем проектирования. Держите эту таблицу под рукой при чтении остальной части главы:

БукваПринципЦель в одной строке
SSingle ResponsibilityКласс должен иметь одну причину для изменения
OOpen/ClosedОткрыт для расширения, закрыт для изменения
LLiskov SubstitutionПодтипы должны быть применимы везде, где используется базовый тип
IInterface SegregationМного маленьких интерфейсов лучше одного большого
DDependency 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 NotifierAlertService остаётся нетронутым, поэтому не требует повторного анализа и не несёт риска регрессии.

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). Программа проверяет собственные результаты, чтобы вы могли убедиться, что каждый принцип соблюдается.

java— editable, runs on the server

Что следует вынести из запуска:

  • 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).

Практика

Практика
Класс ReportGenerator и форматирует данные отчёта, и записывает их на диск, поэтому любое изменение правил форматирования или структуры файла вынуждает изменять и перетестировать один и тот же класс. Какой принцип SOLID это нарушает в первую очередь?
Класс ReportGenerator и форматирует данные отчёта, и записывает их на диск, поэтому любое изменение правил форматирования или структуры файла вынуждает изменять и перетестировать один и тот же класс. Какой принцип SOLID это нарушает в первую очередь?
Was this page helpful?