Принципы SOLID с примерами на Java

User Rating: 5 / 5

Доброго времени суток, друзья. Многим из вас тема принципов SOLID уже известна, а кто-то, возможно, сталкивается с ней впервые и хочет лучше понять, что это за принципы, в чём их суть и назначение, для чего их вообще придумали и почему существуют рекомендации от различных признанных экспертов в области разработки следовать этим принципам. Кроме этого, часто разработчикам задают на собеседованиях вопросы о принципах SOLID и просят привести примеры, поэтому знать про них просто необходимо, а лучше - применять на практике при написании кода ваших программ.

Поэтому, несмотря на довольно большое количество статей о SOLID на других сайтах и ресурсах, я решил отразить собственное видение и понимание принципов SOLID - как их вижу и понимаю я, плюс сопроводить примерами кода. Сразу хочу отметить характер данной статьи - она ни в коей мере не претендует на роль "академической" или какой-то "эталонной", поэтому относитесь к информации из статьи с осторожностью - сверяйте её с другими источниками, а также пишите в комментариях свои мысли, если вы с чем-то не согласны или найдете в статье ошибки/неточности, подискутируем вместе.

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

Итак, начнём. Сперва немного теории и посмотрим на расшифровку для каждой из букв акронима SOLID.

Что означает акроним SOLID?

SOLID - это набор принципов, которые признанные в мире эксперты призывают соблюдать при разработке программного обеспечения. Почему они призывают? На мой взгляд, потому что:

  • программы при соблюдении этих фундаментальных принципов (и других немаловажных факторов при разработке ПО) будут более надёжными и устойчивыми к изменениям при их развитии и дальнейших доработках
  • программы будут обладать более слаженной и стройной архитектурой решения, позволяющей поддерживать код как самим авторам, так и другим разработчикам, которые впоследствии будут его поддерживать
  • при должном подходе и применении эти принципы позволят избежать фатальных ошибок при начальном проектирования программы, которые затем болезненно скажутся на более поздних этапах её развития и поддержки

Каждая буква аббревиатуры обозначает конкретный принцип, а всего их 5:

  • S - Single Responsibility Principle (Принцип единственной ответственности). Краткая суть принципа: класс должен обладать единственной ответственностью и иметь одну и лишь одну причину для его изменения. Проектируя новый класс в приложении, важно задать себе внутренний вопрос: "По какой причине мне может потребоваться в дальнейшем изменение этого класса?". Если причин нашлось несколько, то это повод задуматься над тем, чтобы разделить эти причины для изменения в разные отдельные классы, каждый из которых будет нести свою единственную ответственность. 
  • O - Open-Closed Principle (Принцип открытости/закрытости). Краткая суть принципа: программные сущности должны быть открыты для расширения и закрыты для изменения. Когда сущности открыты для расширения, то это значит, что поведение сущности может быть расширено через создание новых типов сущностей (к примеру, через создание классов-наследников от текущего родительского класса или же создание интерфейсов, наследующих родительский интерфейс). При этом сущности должны быть закрыты для изменения (модификации). Это значит, что после расширения поведения сущности любой другой код и остальные компоненты программы, которые уже используют эту сущность, не должны "пострадать" или неизбежно перерабатываться, а уже работающая логика программы в этих компонентах системы не должна сломаться.
  • L - Liskov Substitution Principle (Принцип подстановки Барбары Лисков). Краткая суть принципа: функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом. Принцип был предложен в 1987 году Барбарой Лисков, в честь которой он и был назван. Поведение классов-наследников классов не должно противоречить поведению, заданному базовым классом. Пример: если есть класс с именем Base и есть наследующий его класс Child, а у базового класса есть метод getCalculatedValue(), который возвращает некоторое расчётное значение и переопределяется в классе-наследнике Child (в Java через аннотацию @Override), то при замене участков кода, работающих с базовым типом Base, на класс-наследник Child, это не должно вызывать каких-то непредсказуемых, непредвиденных результатов для работы программы и/или ломать её, в частности, изменять поведение метода getCalculatedValue() таким образом, что это будет противоречить логике, исходно заложенной в него базовым классом.
  • I - Interface Segregation Principle (Принцип разделения интерфейса). Краткая суть принципа: программные сущности не должны зависеть от методов, которые они не используют; много узконаправленных интерфейсов - это лучше для клиента этого интерфейса, чем один интерфейс общего назначения, "умеющий всё". Следование данному принципу в Java-программах на практике означает, что в программе не должно быть ситуаций, при которых классы, реализующие интерфейс, вынуждены выбрасывать из методов интерфейса исключение вида UnsupportedOperationException(), говорящее о том, что объекты таких классов просто "не умеют или не знают, как выполнить данную конкретную операцию", или же оставлять реализацию таких методов "пустой". Класс всегда должен быть способен реализовать любой метод своего интерфейса.
  • D - Dependency Inversion Principle (Принцип инверсии зависимостей). Краткая суть принципа: 1) модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций. 2) Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций. Краткий пример для демонстрации этого принципа: предположим, есть класс верхнего уровня Window, описывающий окно программы операционной системы с находящимися внутри элементами управления. Есть другой класс верхнего уровня Control, который описывает какой-то абстрактный элемент управления в окне программы. А есть классы нижнего уровня - Button и DropDownBox, представляющие собой расширения класса Control (т.е. его классы-наследники). Так вот, класс верхнего уровня Window, согласно принципу инверсии зависимостей, не должен зависеть от классов нижнего уровня Button и DropDownBox. Вместо этого класс Window должен оперировать абстракциями (а ни в коем случае не деталями!), т.е. в нашем примере он может иметь список всех расположенных в окне элементов - список из элементов типа Control.

Теперь, когда стала понятна расшифровка каждой буквы и стоящий за ней принцип, перейдем к более детальному рассмотрению каждого принципа на конкретных примерах Java-кода.

В листингах тестовых примеров ниже по тексту будет использоваться библиотека Lombok, которая позволяет Java-разработчику при помощи специальных аннотаций сократить время, в частности, на написание кода "геттеров" и "сеттеров" для полей класса. Она довольно проста и удобна в использовании, и с деталями можно ознакомиться на официальном сайте библиотеки. А здесь я очень кратко и бегло поясню, что делают некоторые её аннотации:

  • @AllArgsConstructor - добавит к классу параметризованный конструктор, который будет ожидать параметры, соответствующие заданным в классе полям (и, само собой, инициализирует их при создании объекта класса)
  • @NoArgsConstructor - сгенерирует для класса конструктор без параметров
  • @Data - добавит к полям класса соответствующие "геттеры" и "сеттеры". Теперь не надо писать их вручную.
  • @NonNull - добавит к полю класса маркер того, что поле не может принимать null значения. Lombok автоматически добавит в класс код, который будет выбрасывать NullPointerException, если в поле будет передано значение null. Сообщение при выполнении программы будет примерно таким: "<имя NonNull-поля> is marked non-null but is null".
  • @Slf4j - добавит к классу логгер с именем поля log, при условии наличия зависимости на библиотеку slf4j в проекте. SLF4J расшифровывается как "Simple Logging Facade For Java", т.е. "простой фасад для логирования в Java". Теперь не нужно вручную добавлять логгер в класс, можно сразу вызывать методы логирования из методов класса.

S - Single Responsibility Principle. Принцип единственной ответственности.

Принцип единственной ответственности гласит: каждый класс должен иметь одну и только одну причину для его изменения.

Предположим, мы пишем приложение для управления данными о различных сайтах. И мы захотели создать класс WebSite, описывающий какой-то сайт.

У класса есть всего 3 нестатических поля:

  • address - адрес сайта (URL), строка. Пример: "http://mywonderfulsite.ru"
  • title - название сайта, строка. Пример: "Мой чудо-сайт"
  • admin - имя пользователя-админа для сайта, строка. Пример: "admin_user"
Антипаттерн ("как не надо делать")

Предположим, мы захотели поместить в наш класс WebSite некоторые "геттеры" и "сеттеры", которые нам скажут, через какой протокол (HTTP/HTTPS) работает сайт, а также мы хотели бы иметь три метода по распечатке информации об адресе, названии сайта и его администраторе в логи приложения.

Также мы решили, что для протоколов HTTP, HTTPS мы создадим два приватных статических поля (HTTP_PROTOCOL, HTTPS_PROTOCOL).

Ниже показан код такого класса:

package ru.allineed.examples.solid.single_responsibility.anti_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;

import java.util.Objects;

/**
 * [RU] Пример класса WebSite, который является антипаттерном, 
 *      поскольку нарушает принцип единственной ответственности класса
 *      (Single Responsibility)
 * [EN] Example of class WebSite which is an anti pattern because it
 *      violates Single Responsibility principle
 */
@AllArgsConstructor
@Data
@Slf4j
public class WebSite {
    private static final String HTTPS_PROTOCOL = "https://";
    private static final String HTTP_PROTOCOL = "http://";

    private String address;
    private String title;
    private String admin;

    public boolean isHttpsProtocol() {
        return isAddressNotEmpty() && address.startsWith(HTTPS_PROTOCOL);
    }

    public boolean isHttpProtocol() {
        return isAddressNotEmpty() && address.startsWith(HTTP_PROTOCOL);
    }

    public boolean isAddressNotEmpty() {
        return !isAddressEmpty();
    }

    public boolean isAddressEmpty() {
        return Objects.isNull(address) || "".equals(address);
    }

    public boolean isTitleNotEmpty() {
        return !isTitleEmpty();
    }

    public boolean isTitleEmpty() {
        return Objects.isNull(title) || "".equals(title);
    }

    public void printTitle() {
        log.info("Site title: {}", title);
    }

    public void printAddress() {
        log.info("Site address: {}", address);
    }

    public void printAdmin() {
        log.info("Site admin: {}", admin);
    }
}

Давайте подумаем, что же с этим классом не так? Вроде бы он выглядит довольно просто, не так ли? Тем не менее, он нарушает первый принцип SOLID - "каждый класс должен иметь единственную ответственность и только одну причину для его изменения".

Зададим себе вопрос: какие у нас могут быть причины для изменения этого класса в дальнейшем?

А их несколько:

  • если мы решим впоследствии добавить новые поля прямо на уровень нашего существующего класса WebSite - к примеру, мы захотим хранить Email-адрес админа сайта в новом строковом поле adminEmail, - то нам придётся доработать логирование - завести новый метод printAdminEmail() в этом же классе. Нюанс: может сразу нас никто и не заставит логировать email админа (до поры не будет в этом нужды), но вот когда нам это потребуется - изменять мы будем именно наш класс WebSite, ведь он ещё и отвечает за логирование своих полей!
  • если мы решим в целом изменить механизм логирования данных о сайте - не выводить их просто в логи, а, например, отправлять в специфичный файл или же отправлять их вообще на FTP, в очередь, по Email и т.д., то нам придётся изменять сам класс WebSite, не имеющий к процессу логирования прямого отношения. 
  • если мы решим поддержать какие-то другие протоколы, помимо HTTP, HTTPS, то... нам снова необходимо изменять класс WebSite! Заводить в нём очередные константы для новых протоколов и геттеры для определения того, через какой же протокол работает наш сайт. Изменение/добавление протоколов в программу вновь повлекут к необходимости менять класс WebSite...

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

Как же исправить ситуацию на этапе проектирования нашего класса WebSite?

Пример, не нарушающий принцип Single Responsibility

Очевидно, напрашивается мысль: нужно декомпозировать текущий класс, выделив из него все "лишние" ответственности в другие, новые классы. Попробуем это сделать.

Сначала определим класс WebSiteAddress. В нашем простом примере, он будет содержать URL для адреса сайта и те самые константы, задающие протоколы HTTP/HTTPS и геттеры для них. Сюда же выносятся геттеры, определяющие является ли адрес сайта пустым или нет:

package ru.allineed.examples.solid.single_responsibility.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.Objects;

@AllArgsConstructor
@Data
public class WebSiteAddress {
    private static final String HTTPS_PROTOCOL = "https://";
    private static final String HTTP_PROTOCOL = "http://";

    private String address;

    public boolean isHttpsProtocol() {
        return isAddressNotEmpty() && address.startsWith(HTTPS_PROTOCOL);
    }

    public boolean isHttpProtocol() {
        return isAddressNotEmpty() && address.startsWith(HTTP_PROTOCOL);
    }

    public boolean isAddressNotEmpty() {
        return !isAddressEmpty();
    }

    public boolean isAddressEmpty() {
        return Objects.isNull(address) || "".equals(address);
    }
}

Теперь, с учётом того, что адрес и протоколы выделены в новый класс WebSiteAddress, нам нужно удалить из класса WebSite всё, что относится к логированию.

Посмотрим на то, как в этом случае преобразится наш класс WebSite:

package ru.allineed.examples.solid.single_responsibility.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NonNull;

import java.util.Objects;

@AllArgsConstructor
@Data
public class WebSite {
    @NonNull
    private WebSiteAddress webSiteAddress;
    private String title;
    private String admin;

    public boolean isTitleNotEmpty() {
        return !isTitleEmpty();
    }

    public boolean isTitleEmpty() {
        return Objects.isNull(title) || "".equals(title);
    }
}

Надо сказать, что класс стал короче - нет лишних методов по логированию полей, а также теперь мы можем не волноваться о том, что при поддержке иных протоколов, кроме HTTP/HTTPS для работы с сайтом, нам потребуется как-то изменять наш класс WebSite.

Осталась последняя деталь: так как мы "потеряли" логирование, нам нужно создать отдельный класс WebSitePrinter, который будет уметь печатать данные о сайте. Сам же WebSitePrinter теперь зависит от экземпляра сайта, данные которого необходимо логировать:

package ru.allineed.examples.solid.single_responsibility.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@AllArgsConstructor
@Slf4j
public class WebSitePrinter {
    @NonNull
    private WebSite website;

    public void printTitle() {
        log.info("Site title: {}", website.getTitle());
    }

    public void printAddress() {
        log.info("Site address: {}", website.getWebSiteAddress());
    }

    public void printAdmin() {
        log.info("Site admin: {}", website.getAdmin());
    }
}

За счёт аннотации @AllArgsConstructor объект класса WebSitePrinter может быть создан только при передаче конкретного объекта класса WebSite. Нельзя создать объект класса WebSitePrinter без привязки к объекту сайта.

Также, за счёт аннотации @NonNull библиотека Lombok предотвратит передачу пустой ссылки на сайт при создании объекта-принтера.

O - Open-Closed Principle. Принцип открытости/закрытости.

Теперь рассмотрим второй принцип SOLID на примере. Он гласит: программные сущности должны быть открыты для расширения и закрыты для изменения.

На практике это означает, что при наращивании функциональности вашей программы нужно стараться избегать (а лучше - вовсе не допускать) изменений в уже существующих сущностях (часть принципа "закрыты для изменения").

Представьте себе, что уже готовые и спроектированные интерфейсы и классы верхних уровней упаковываются в отдельную .jar-библиотеку L. Затем появляются новые интерфейсы и классы более низких уровней, использующие уже готовые сущности из библиотеки L и так далее. Очевидно, что при увеличении количества модулей приложения мы не хотим иметь нужды постоянно перекомпилировать классы библиотеки L и другие, аналогичные ей, каждый раз внося правки в уже ранее созданные сущности.

Вместо изменений уже существующих сущностей программы необходимо расширять их функциональность доступными средствами ООП (например, через создание дочерних классов и наследование уже существующих классов или через введение интерфейсов).

Представим, что мы пишем приложение, которое управляет процессами покупки и продажи автомобилей.

На этапе проектирования нашего приложения мы хотим объявить класс Car, который бы содержал поля, отражающие основные характеристики автомобиля:

  • model - модель автомобиля, строка. Например: "Megamodel Sedan Car"
  • color - цвет автомобиля, строка. Например: "Black"
  • price - цена автомобиля, десятичное дробное число. Например: 1500.75

Также мы хотим поддержать в нашей программе различные типы автомобилей, в зависимости от их кузова. И хотим разработать сервис по вычислению скидок на автомобили, в зависимости от их типа.

Рассмотрим возможные подходы к реализации класса Car.

Антипаттерн ("как не надо делать")

Посмотрим на следующий вариант класса Car:

package ru.allineed.examples.solid.open_closed.anti_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Data
public class Car {
    private String model;
    private String color;
    private double price;

    private CarType type;

    public enum CarType {
        /**
         * [RU] Тип машины - грузовики
         */
        TRUCK,

        /**
         * [RU] Тип машины - спорткар
         */
        SPORT_CAR,

        /**
         * [RU] Тип машины - пикап
         */
        PICKUP,

        /**
         * [RU] Тип машины - седан
         */
        SEDAN
    }
}

Как видно, здесь мы решили поместить внутрь класса перечисляемый тип (enum) с именем CarType, а также поле type этого типа. Теперь наши объекты класса Car будут иметь какой-то определённый тип кузова.

Вроде бы, всё здорово, но в чём проблема этого класса Car? При необходимости поддержать в дальнейшем другие типы автомобилей (к примеру, минивэны, кабриолеты и т. д.) нам неизбежно придётся вносить изменения в enum-тип CarType, а значит, и в сам класс Car. Тем самым мы будем нарушать второй принцип SOLID, поскольку, согласно ему, сущности должны быть закрыты для изменения, но открыты для расширения.

Чтобы лучше осознать проблематику, предположим, что мы всё же выбрали этот вариант реализации класса Car и также создали класс CarDiscountService, который рассчитывает скидки на автомобили, в зависимости от их типа.

Наш класс CarDiscountService выглядит следующим образом:

package ru.allineed.examples.solid.open_closed.anti_pattern.payment;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NonNull;
import ru.allineed.examples.solid.open_closed.anti_pattern.core.Car;

@AllArgsConstructor
@Data
public class CarDiscountService {
    @NonNull
    private Car car;

    /**
     * [RU] Метод вычисляет скидку на автомобиль, в зависимости от типа машины (типа кузова)
     * [EN] Method calculates discount for this car instance depending on car type
     */
    public double getCarPriceWithDiscount() {
        double discountCoefficient = 1;
        Car.CarType type = car.getType();

        if (type == Car.CarType.SEDAN) {
            discountCoefficient = 0.95;
        } else if (type == Car.CarType.PICKUP) {
            discountCoefficient = 0.92;
        } else if (type == Car.CarType.TRUCK) {
            discountCoefficient = 0.98;
        } else if (type == Car.CarType.SPORT_CAR) {
            discountCoefficient = 0.93;
        }

        return car.getPrice() * discountCoefficient;
    }
}

Давайте разберём, что делает наш класс CarDiscountService.

Во-первых, у него есть поле car, т.е. на вход при создании экземпляра сервиса подаётся экземпляр автомобиля (геттеры и сеттеры, а также конструктор класса скрыты, но они есть, за счёт аннотаций Lombok). На поле есть аннотация @NonNull, то есть нельзя создать сервис, подав в него пустую ссылку на автомобиль, иначе будет вызвано исключение.

Идём далее - в классе есть метод getCarPriceWithDiscount(). Из его названия видно, что он считает цену автомобиля с учётом скидки. Изначально мы считаем, что коэффициент скидки discountCoefficient равен 1. На него мы будем умножать текущую цену автомобиля и возвращать из метода новую цену авто - уже со скидкой. Когда коэффициент равен единице, то скидки на авто нет. Далее мы проверяем с помощью развилок if-else конкретные типы кузовов и устанавливаем значение скидочного коэффициента: для седанов скидка будет 5%, для пикапов 8%, для грузовиков 2%, а для спорткаров 7%. На выходе из условий мы возвращаем цену автомобиля с учётом скидки, вычисляя её как произведение текущей цены авто на скидочный коэффициент.

Теперь проблема классов Car и CarDiscountService становится чуть более очевидной: когда мы будем вносить новые типы кузовов автомобилей и изменять enum-тип CarType, мы тем самым будем влиять на уже написанные, стабильные части нашего приложения. В этом простом примере мы будем вынуждены дорабатывать сервис CarDiscountService, добавляя каждый раз новые развилки else if для каждого нового типа авто.

Представим, что наше приложение будет развиваться с течением времени, и в нём появятся сотни, потом тысячи классов... Появятся другие подобные развилки с завязкой на тип автомобиля - это будут какие-то другие сервисы, также рассчитывающие какую-то свою логику, в зависимости от типа авто. И так мы будем искать по всему нашему приложению все классы, методы и участки кода, которые тесно завязаны на логику и операции с типом кузова автомобиля.

Вывод: изменение только в одном месте (в типе CarType) приводит нас к существенным трудозатратам по поиску всех других частей нашей программы, которые нужно также изменять. В некоторых случаях такие "цепочечные изменения" могут протягиваться дальше - т.е. начали менять CarType, затем нужно поменять участки кода, от него зависящие - в нашем примере это CarDiscountService, затем другие участки кода, зависящие от этих и так далее. Цепочка где-то завершится, но она может быть довольно длинной. И чем она длиннее, тем больше рисков дестабилизировать работавшее ранее приложение, внеся в него дефекты.

В лучшем случае мы получим логику программы: "по умолчанию скидки на новые типы кузовов не предоставляется" - т.к. в примере выше коэффициент скидки "по умолчанию" равен 1. Но что если код будет другим, и он будет вызывать непредвиденный выброс каких-то исключений в программе? Или не будет бросать исключения, но логика расчётов где-то "поплывёт" и неявно принесёт с собой нежелательные баги. Хорошо, если у нас есть хотя бы тестовое покрытие на такие случаи, но гораздо хуже - если тестов, способных "отловить" все участки программы, требующие доработки, попросту нет или тестовое покрытие недостаточно хорошее.

Мы видим последствия нарушения принципа Open-Closed: цена, которую нам приходится "платить" за изменения только лишь в типе CarType, может быть очень высокой.

Теперь посмотрим, как же справиться с задачей, не нарушая 2-й принцип SOLID открытости/закрытости.

Пример, не нарушающий принцип Open-Closed

Чтобы добавление новых типов кузовов автомобилей не сказывалось на необходимости дорабатывать все связанные участки кода, нам нужно вспомнить снова, что гласит принцип: программные сущности должны быть открыты для расширения 

Попробуем, с учётом этого, рассмотреть вариант расширения сущностей, вместо их изменения. Перепроектируем наш класс Car, теперь он будет выглядеть так:

package ru.allineed.examples.solid.open_closed.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Data
public class Car {
    private String model;
    private String color;
    private double price;
}

Как видим, теперь в нём нет завязок на тип машины и вложенного enum-типа CarType. Когда нам требуются разные типы машин, можно реализовать их в виде дочерних классов для класса Car, а заодно и наделить эти дочерние классы какими-то полями, специфичными для каждого типа машины.

Так выглядит класс-наследник Pickup для пикапов:

package ru.allineed.examples.solid.open_closed.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data()
public class Pickup extends Car {

    /**
     * [RU] Высота бортов у пикапа
     * [EN] Side height for this pickup
     */
    private int sideHeight;

    public Pickup(String model, String color, double price, int sideHeight) {
        super(model, color, price);
        this.sideHeight = sideHeight;
    }
}

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

Класс-наследник Sedan - для седанов:

package ru.allineed.examples.solid.open_closed.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;


@AllArgsConstructor
@Data()
public class Sedan extends Car {

    /**
     * [RU] Объем багажника у седана
     * [EN] Trunk volume for this sedan
     */
    private int trunkVolume;

    public Sedan(String model, String color, double price, int trunkVolume) {
        super(model, color, price);
        this.trunkVolume = trunkVolume;
    }
}

Класс SportCar для спорткаров:

package ru.allineed.examples.solid.open_closed.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data()
public class SportCar extends Car {
    /**
     * [RU] Входит ли спорткар в "Club 300"
     * [EN] Flag indicates that this sport car is included in "Club 300"
     */
    private boolean isClub300;

    public SportCar(String model, String color, double price, boolean isClub300) {
        super(model, color, price);
        this.isClub300 = isClub300;
    }
}

Класс Truck для грузовиков:

package ru.allineed.examples.solid.open_closed.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data()
public class Truck extends Car {

    /**
     * [RU] Количествой осей у грузовика
     * [EN] Number of axles this truck instance has
     */
    private int axlesNumber;

    public Truck(String model, String color, double price, int axlesNumber) {
        super(model, color, price);
        this.axlesNumber = axlesNumber;
    }
}

Как видим, во всех классах добавлены различные поля, которые демонстрируют, что поведение машины (класс Car) расширяется за счёт появления полей и методов, специфичных для каждого типа авто.

К слову, если бы мы всё же остались на варианте класса из антипаттерна (с enum-типом CarType), то мы бы получили и другую неприятную ситуацию: оттого, что в классе Car задавались бы типы различных машин, нам пришлось бы добавлять в класс Car также поля для разнородных категорий машин, что привело бы к "раздуванию" ответственности класса Car.

Теперь посмотрим, как будет выглядеть реализация сервиса CarDiscountService для расчёта скидки.

Зададим новый интерфейс DiscountCar ("автомобиль со скидкой"):

package ru.allineed.examples.solid.open_closed.valid_pattern.discountcar;

public interface DiscountCar {
    double getPrice();
    double getDiscountCoefficient();
}

У него два метода, которые нужны для расчёта цены авто с учётом скидки - getPrice() будет возвращать цену автомобиля, а getDiscountCoefficient() будет возвращать специфичный скидочный коэффициент по каждому типу автомобиля.

Давайте также представим, что все вышеперечисленные классы - Car, Pickup, Truck, SportCar, Sedan вынесены у нас в отдельный пакет с названием ru.allineed.examples.solid.open_closed.valid_pattern.core. Пакет будет демонстрировать концепцию того, что эти классы могли бы находиться в отдельной .jar-библиотеке (условное имя core-module.jar)

Итак, это стабильные классы, уже созданные сущности. Изменять мы их не хотим, а хотим лишь расширять, чтобы не нарушать принцип открытости/закрытости SOLID.

Поэтому наш интерфейс DiscountCar заведём в отдельном пакете ru.allineed.examples.solid.open_closed.valid_pattern.discountcar. Это как бы наша вторая .jar-библиотека (условное имя discountcar-module.jar)

Теперь введём классы для автомобилей, которые реализуют этот интерфейс, а также расширяют ранее созданные нами классы автомобилей.

Класс DiscountSedan - седаны со скидкой:

package ru.allineed.examples.solid.open_closed.valid_pattern.discountcar;

import ru.allineed.examples.solid.open_closed.valid_pattern.core.Sedan;

public class DiscountSedan extends Sedan implements DiscountCar {

    public DiscountSedan(int trunkVolume) {
        super(trunkVolume);
    }

    public DiscountSedan(String model, String color, double price, int trunkVolume) {
        super(model, color, price, trunkVolume);
    }

    @Override
    public double getDiscountCoefficient() {
        return 0.95;
    }
}

Класс DiscountSportCar - спорткары со скидкой:

package ru.allineed.examples.solid.open_closed.valid_pattern.discountcar;


import ru.allineed.examples.solid.open_closed.valid_pattern.core.SportCar;

public class DiscountSportCar extends SportCar implements DiscountCar {

    public DiscountSportCar(boolean isClub300) {
        super(isClub300);
    }

    public DiscountSportCar(String model, String color, double price, boolean isClub300) {
        super(model, color, price, isClub300);
    }

    @Override
    public double getDiscountCoefficient() {
        return 0.93;
    }
}

Класс DiscountTruck - грузовики со скидкой:

package ru.allineed.examples.solid.open_closed.valid_pattern.discountcar;

import ru.allineed.examples.solid.open_closed.valid_pattern.core.Truck;

public class DiscountTruck extends Truck implements DiscountCar {

    public DiscountTruck(int axlesNumber) {
        super(axlesNumber);
    }

    public DiscountTruck(String model, String color, double price, int axlesNumber) {
        super(model, color, price, axlesNumber);
    }

    @Override
    public double getDiscountCoefficient() {
        return 0.98;
    }
}

Класс DiscountPickup - пикапы со скидкой:

package ru.allineed.examples.solid.open_closed.valid_pattern.discountcar;

import ru.allineed.examples.solid.open_closed.valid_pattern.core.Pickup;

public class DiscountPickup extends Pickup implements DiscountCar {

    public DiscountPickup(int sideHeight) {
        super(sideHeight);
    }

    public DiscountPickup(String model, String color, double price, int sideHeight) {
        super(model, color, price, sideHeight);
    }

    @Override
    public double getDiscountCoefficient() {
        return 0.92;
    }
}

Итак, реализации классов-наследников для машин, по которым может рассчитываться скидка, готовы. 

Теперь напишем нашу новую реализацию для CarDiscountService:

package ru.allineed.examples.solid.open_closed.valid_pattern.payment;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NonNull;
import ru.allineed.examples.solid.open_closed.valid_pattern.discountcar.DiscountCar;

@AllArgsConstructor
@Data
public class CarDiscountService {
    @NonNull
    private DiscountCar car;

    /**
     * [RU] Метод вычисляет скидку на автомобиль, в зависимости от типа машины (типа кузова)
     * [EN] Method calculates discount for this car instance depending on car type
     */
    public double getCarPriceWithDiscount() {
        return car.getPrice() * car.getDiscountCoefficient();
    }
}

Видим, что теперь наш сервис CarDiscountService для расчёта скидок зависит от интерфейса DiscountCar, который предоставляет 2 необходимых метода для выполнения задачи по расчёту скидок на любые типы автомобилей - как текущие, так и те, что будут появляться в дальнейшем в нашем приложении.

Наш класс CarDiscountService поместим в отдельный пакет ru.allineed.examples.solid.open_closed.valid_pattern.payment. Представим, что это наш третий отдельный .jar-модуль с условным именем payment-service.jar

Теперь проверим гипотезу: что будет происходить при заведении нового типа автомобиля? как изменится код для расчёта скидки?

У нас вышло три условных библиотеки:

  • core-module.jar - содержит базовые классы Car, Sedan, Truck, Pickup, SportCar.
  • discountcar-module.jar - содержит интерфейс DiscountCar и его реализации, одновременно являющиеся наследниками базовых классов - DiscountSedan, DiscountTruck, DiscountPickup, DiscountSportCar.
  • payment-service.jar - содержит класс сервиса по расчёту скидок на автомобили CarDiscountService.

Зависимость между библиотеками получилась следующая:

core-module.jar → discountcar-module.jar → payment-service.jar

То есть core-module.jar является модулем верхнего уровня, от него зависят все остальные. Модуль payment-service.jar зависит от модуля discountcar-module.jar, а также - транзитивно - от модуля core-module.jar. От payment-service.jar пока не зависит ни один другой модуль нашего тестового приложения.

Перед нами стоит задача: нам нужно в программу добавить теперь поддержку минивэнов.

У нас есть 2 варианта, оба из них не требуют изменений в уже существующих классах программы, а лишь будут их расширять:

  • доработать 1-ю библиотеку core-module.jar: добавить в неё новый класс-наследник MiniVan от класса Car, а затем в модуле discountcar-module.jar добавить класс-наследник DiscountMiniVan, реализующий интерфейс DiscountCar, как все остальные классы машин этого модуля.
  • не трогать вовсе 1-ю библиотеку core-module.jar, а сразу расширить класс Car классом DiscountMiniVan во 2-й библиотеке. Этот класс DiscountMiniVan также должен реализовать интерфейс DiscountCar, чтобы мы могли считать скидки на минивэны в том числе.

К примеру, выберем 2-й вариант. Наш класс DiscountMiniVan в библиотеке discountcar-module.jar в этом случае будет выглядеть так:

package ru.allineed.examples.solid.open_closed.valid_pattern.discountcar;

import lombok.AllArgsConstructor;
import lombok.Data;
import ru.allineed.examples.solid.open_closed.valid_pattern.core.Car;

@Data
@AllArgsConstructor
public class DiscountMiniVan extends Car implements DiscountCar {
    private String subtype;

    public DiscountMiniVan(String model, String color, double price, String subtype) {
        super(model, color, price);
        this.subtype = subtype;
    }

    @Override
    public double getDiscountCoefficient() {
        return 0.50;
    }
}

Когда мы пересоберём модуль discountcar-module.jar, то работа нашего сервиса по расчёту скидок CarDiscountService никак не нарушится: он зависит только от интерфейса DiscountCar и ничего "не знает" про существование нового класса DiscountMiniVan. При этом дорабатывать метод сервиса getCarPriceWithDiscount() нам не придётся, поскольку он оперирует абстракциями - методами getPrice() и getDiscountCoefficient() интерфейса DiscountCar, а они, в свою очередь, реализованы в нашем классе минивэна DiscountMiniVan.

Таким образом, мы видим, что когда мы расширяем сущности и следуем второму принципу открытости/закрытости SOLID, то наша программа становится устойчивой к изменениям, и нам не требуется перерабатывать остальные части уже работающего приложения.

L - Liskov Substitution Principle. Принцип подстановки Барбары Лисков.

Третий принцип SOLID - это принцип подстановки Лисков, который был назван в честь его открывателя - американского учёного в области информатики Барбары Лисков.

Ещё раз кратко опишем его суть: поведение классов-наследников классов не должно противоречить поведению, заданному базовым классом.

Рассмотрим его на примере классов Java, как и предыдущие принципы SOLID, и начнём снова с антипаттерна, т.е. примера, который нарушает этот принцип и чего не следует делать в ваших программах.

Антипаттерн ("как не надо делать")

Предположим, что у нас есть класс Rectangle, который описывает прямоугольник:

package ru.allineed.examples.solid.liskov_substitution.anti_pattern;

import lombok.Data;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Data
public class Rectangle {
    protected int width;
    protected int height;

    public int calculateArea() {
        return width * height;
    }
}

У этого класса есть поля width и height, которые задают ширину и высоту прямоугольника, а также метод calculateArea(), который рассчитывает площадь прямоугольника, как произведение его ширины на высоту.

Теперь представим, что мы создаём новый класс Square для описания квадрата. И мы захотели сделать его наследником нашего класса Rectangle:

package ru.allineed.examples.solid.liskov_substitution.anti_pattern;

import lombok.NoArgsConstructor;

@NoArgsConstructor
public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
        this.width = height;
    }
}

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

Обратите внимание, что при помощи аннотации Lombok @NoArgsConstructor, который есть и в классе-родителе Rectangle, и в классе-наследнике Square, мы указываем, что у этих классов будет только конструктор без параметров.

Соответственно, для установки ширины и высоты прямоугольника и квадрата нам придётся использовать доступные методы setWidth(int width) или setHeight(int height)

Но здесь же мы видим, что в переопределённых методах класса Square мы одновременно переписываем оба поля! Не важно, какой именно сеттер будет вызван - обе стороны будут установлены в одно и то же значение.

А теперь давайте напишем простой Unit-тест, который демонстрирует, где же идёт нарушение принципа Лисков:

package ru.allineed.examples.solid.liskov_substitution.anti_pattern;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class FiguresTest {

    @DisplayName("Test Figures Area")
    @Test
    void testFiguresArea() {
        Rectangle r = new Rectangle();
        r.setWidth(2);
        r.setHeight(5);
        assertEquals(10, r.calculateArea());

        r = new Square();
        r.setWidth(2);
        r.setHeight(5);

        // [RU] Проверка ниже никогда не выполнится, поскольку нарушен принцип подстановки Барбары Лисков.
        //      Площадь квадрата будет равна 25, т.к. последним сеттером устанавливается сторона квадрата, равная 5.

        // [EN] The following check will never be true because Liskov Substitution principle is violated.
        //      Square area will be 25 because the last setter method sets the square side equal to 5

        //assertEquals(10, r.calculateArea());
    }
}

Итак, мы видим, что для прямоугольника со сторонами 2 и 5 его вычисленная площадь будет равна 10, и здесь нет никаких проблем, тест проходит успешно.

Но когда мы инициализируем стороны квадрата этими же значениями, то его площадь никогда не будет равна 10, поскольку "окажется прав" последний вызываемый нами сеттер - в нашем примере, это строка r.setHeight(5).

То есть обе стороны квадрата будут установлены в 5, значит, вычисленная площадь квадрата будет равна 25, а не 10!

Этим самым мы нарушили принцип подстановки Лисков - при присваивании переменной r (с типом Rectangle) ссылки на объект дочернего класса Square поведение программы начинает меняться!

То есть поведение класса-наследника Square противоречит поведению, заданному базовым классом Rectangle, и здесь мы нарушили принцип подстановки Лисков.

Теперь посмотрим, как же выйти из ситуации, не нарушая 3-й принцип SOLID.

Пример, не нарушающий принцип подстановки Лисков

Введём новый интерфейс Figure для описания какой-то фигуры, и пусть он будет иметь единственный метод calculateArea() для вычисления площади этой фигуры:

package ru.allineed.examples.solid.liskov_substitution.valid_pattern;

public interface Figure {
    int calculateArea();
}

Базовый класс Rectangle напишем следующим образом:

package ru.allineed.examples.solid.liskov_substitution.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data
public class Rectangle implements Figure {

    protected int width;
    protected int height;

    @Override
    public int calculateArea() {
        return width * height;
    }
}

Теперь класс проаннотирован через @AllArgsConstructor и @Data, а значит, у него будет конструктор, принимающий ширину и высоту прямоугольника, а также геттеры и сеттеры для установки ширины и высоты.

Класс также реализует новый интерфейс Figure и его метод calculateArea(), где происходит вычисление площади нашей фигуры (прямоугольника).

Далее напишем класс Square для квадрата:

package ru.allineed.examples.solid.liskov_substitution.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data
public class Square implements Figure {
    protected int side;

    @Override
    public int calculateArea() {
        return side * side;
    }
}

Как видим, на нём есть те же самые аннотации Lombok, а значит, у него будет параметризованный конструктор, принимающий длину стороны квадрата, 1 геттер для получения стороны квадрата и 1 сеттер - для установки стороны квадрата.

Этот класс также реализует интерфейс Figure и его метод для расчёта площади фигуры (квадрата).

Заметьте, что мы избавились от наследования - теперь класс Square не является потомком класса Rectangle, это два независимых класса. Но они оба реализуют интерфейс Figure, который в примере умеет считать площадь фигуры.

И снова напишем Unit-тест для проверки работы наших классов и интерфейса:

package ru.allineed.examples.solid.liskov_substitution.valid_pattern;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;


import static org.junit.jupiter.api.Assertions.assertEquals;

class FiguresTest {
    @DisplayName("Test Rectangle Area")
    @Test
    void testRectangleArea() {
        Figure rectangle = new Rectangle(2, 5);
        assertEquals(10, rectangle.calculateArea());
    }

    @DisplayName("Test Square Area")
    @Test
    void testSquareArea() {
        Figure square = new Square(5);
        assertEquals(25, square.calculateArea());
    }
}

Как видим, теперь у нас два тестовых метода testRectangleArea() и testSquareArea(). Каждый из них создает свой экземпляр фигуры и присваивает его в переменную с типом Figure.

И теперь нет проблемы, где бы мы нарушали принцип подстановки Лисков: между классами Square и Rectangle отсутствует наследование, каждый класс является независимым. При этом оба класса реализуют интерфейс, способный вычислять площадь фигуры.

I - Interface Segregation Principle. Принцип разделения интерфейса.

4-й принцип SOLID - это принцип разделения интерфейса. Его краткая суть в том, что лучше иметь много узконаправленных интерфейсов в программе, чем один интерфейс, который "умеет всё".

Предположим, что мы пишем с вами игру, в которой происходят битвы роботов. Роботы у нас могут быть летающими по воздуху, стреляющими, а какие-то из них могут передвигаться только по земле: ходить, бегать и ездить.

Рассмотрим сначала пример, нарушающий принцип разделения интерфейса.

Антипаттерн ("как не надо делать")

Мы хотим задать интерфейс Robot, который бы описывал действия некоторого абстрактного робота. Поскольку в нашей игре роботы могут летать, бегать, ходить, ездить и стрелять, зададим все эти действия робота в интерфейсе в виде отдельных соответствующих методов:

package ru.allineed.examples.solid.interface_segregation.anti_pattern;

/**
 * [RU] Интерфейс описывает робота, который может выполнять различного рода действия
 * [EN] Interface describes robot which can perform different actions
 */
public interface Robot {
    void fly();
    void run();
    void walk();
    void ride();
    void fire();
}

Теперь мы должны спроектировать классы самих роботов. Первый робот, которого мы хотим описать - это боевой робот. Пусть он будет представлен новым классом FightingRobot и пусть он будет уметь стрелять и передвигаться разными способами по земле:

package ru.allineed.examples.solid.interface_segregation.anti_pattern;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class FightingRobot implements Robot {
    @Override
    public void fly() {
        log.error("[RU] Я лишь боевой робот. Простите, я не умею летать. " + 
"[EN] I'm just fighting robot. Sorry, I cannot fly.");
        throw new UnsupportedOperationException();
    }

    @Override
    public void run() {
        log.info("[RU] Я боевой робот. Сейчас я бегу... " + 
"[EN] I'm fighting robot. Now running...");
    }

    @Override
    public void walk() {
        log.info("[RU] Я боевой робот. Сейчас я иду... " + 
"[EN] I'm fighting robot. Now walking...");
    }

    @Override
    public void ride() {
        log.info("[RU] Я боевой робот. Сейчас я еду... " + 
"[EN] I'm fighting robot. Now riding...");
    }

    @Override
    public void fire() {
        log.info("[RU] Я боевой робот. Сейчас я стреляю... " + 
"[EN] I'm fighting robot. Now firing...");
    }
}

Обратите внимание, что в методе fly() мы выбрасываем исключение UnsupportedOperationException - поскольку наш боевой робот не умеет летать, мы всё равно вынуждены реализовать метод интерфейса в классе. Однако всё, что мы можем в нём сделать, - это вывести в лог ошибку о том, что робот не умеет летать, а также выбросить исключение.

Давайте создадим другого робота. Это будет боевой летающий робот. Он будет уметь летать и стрелять, однако не сможет передвигаться по земле. Класс FlyingFightingRobot для описания робота представлен ниже:

package ru.allineed.examples.solid.interface_segregation.anti_pattern;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class FlyingFightingRobot implements Robot {
    @Override
    public void fly() {
        log.info("[RU] Я летающий боевой робот. Сейчас я лечу... " + 
"[EN] I'm flying fighting robot. Now flying...");
    }

    @Override
    public void run() {
        log.error("[RU] Я летающий боевой робот. Простите, я не умею бегать. " + 
"[EN] I'm flying fighting robot. Sorry, I cannot run.");
        throw new UnsupportedOperationException();
    }

    @Override
    public void walk() {
        log.error("[RU] Я летающий боевой робот. Простите, я не умею ходить. " + 
"[EN] I'm flying fighting robot. Sorry, I cannot walk.");
        throw new UnsupportedOperationException();
    }

    @Override
    public void ride() {
        log.error("[RU] Я летающий боевой робот. Простите, я не умею ездить. " + 
"[EN] I'm flying fighting robot. Sorry, I cannot ride.");
        throw new UnsupportedOperationException();
    }

    @Override
    public void fire() {
        log.info("[RU] Я летающий боевой робот. Сейчас я стреляю... " + 
"[EN] I'm flying fighting robot. Now firing...");
    }
}

И снова мы вынуждены реализовать в этом классе абсолютно ненужные нам методы для действий робота. На этот раз мы логируем ошибки из методов run(), walk(), ride() и выбрасываем из них исключение.

Этих двух примеров достаточно, чтобы понять, что интерфейс Robot, который мы ввели ранее - "умеет всё", но в классах FightingRobot и FlyingFightingRobot, его реализующих, мы будем вынуждены констатировать, что определённые операции из этого интерфейса не поддерживаются.

Мы нарушили 4-й принцип SOLID, гласящий, что лучше иметь много узконаправленных интерфейсов в программе, чем один интерфейс, который "умеет всё".

Можно задаться вопросом: а что, собственно, плохого в том, что методы "ничего не делают" или же выбрасывают исключение? А плохо то, что программные компоненты, которые будут производить какие-то действия с экземплярами роботов "никогда  не будут уверены" в том, что операция выполнима или нет. Эти компоненты будут вынуждены защищаться от выброса исключения UnsupportedOperationException в момент выполнения нашей игры с роботами, а хуже всего то, что неясно как обработать такое исключение: мы что, выведем в нашей игре ошибку вида "извините, внезапно выяснилось, что именно этот робот не умеет летать!.."? Мне кажется, что любой игрок сразу же забудет про нашу игру с роботами, будет крайне разочарован и перестанет в неё играть.

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

Пример, не нарушающий принцип разделения интерфейса

Раз принцип разделения интерфейса говорит нам о том, что лучше иметь много узконаправленных интерфейсов, чем один универсальный, то попробуем разбить наш интерфейс Robot на отдельные интерфейсы.

Заведём отдельный интерфейс FightingRobot для действий боевого робота - он будет уметь только стрелять:

package ru.allineed.examples.solid.interface_segregation.valid_pattern;

public interface FightingRobot {
    void fire();
}

Далее, создадим интерфейс FlyingRobot для описания тех роботов, которые будут уметь только летать:

package ru.allineed.examples.solid.interface_segregation.valid_pattern;

public interface FlyingRobot {
    void fly();
}

Наконец, создадим интерфейс GroundMovingRobot, который будет описывать всех роботов, которые двигаются по земле разными способами. Такие роботы будут уметь бегать, ходить и ездить:

package ru.allineed.examples.solid.interface_segregation.valid_pattern;

public interface GroundMovingRobot {
    void run();
    void walk();
    void ride();
}

Теперь пришло время создать классы наших роботов.

Создадим летающего боевого робота - он будет уметь и летать, и стрелять. Создадим для него класс FlyingFightingRobot:

package ru.allineed.examples.solid.interface_segregation.valid_pattern;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class FlyingFightingRobot implements FlyingRobot, FightingRobot {
    @Override
    public void fire() {
        log.info("[RU] Я летающий боевой робот. Сейчас я стреляю... " + 
"[EN] I'm flying fighting robot. Now firing...");
    }

    @Override
    public void fly() {
        log.info("[RU] Я летающий боевой робот. Сейчас я лечу... " + 
"[EN] I'm flying fighting robot. Now flying...");
    }
}

Обратите внимание, что теперь мы реализуем сразу 2 интерфейса - FlyingRobot и FightingRobot. Каждый из них поддерживает по одному специфичному для них действию, а поскольку класс реализует сразу оба интерфейса, то и робот поддерживает две конкретные операции - fire() и fly().

Создадим другого робота, который на этот раз представлен классом GroundMovingFightingRobot. Он, в свою очередь, реализует интерфейсы, необходимые ему для поддержки его конкретных действий. Это интерфейсы GroundMovingRobot и FightingRobot:

package ru.allineed.examples.solid.interface_segregation.valid_pattern;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class GroundMovingFightingRobot implements GroundMovingRobot, FightingRobot {
    @Override
    public void fire() {
        log.info("[RU] Я боевой робот, передвигающийся по земле. Сейчас я стреляю... "
+ "[EN] I'm ground moving fighting robot. Now firing...");
    }

    @Override
    public void run() {
        log.info("[RU] Я боевой робот, передвигающийся по земле. Сейчас я бегу... "
+ "[EN] I'm ground moving fighting robot. Now running...");
    }

    @Override
    public void walk() {
        log.info("[RU] Я боевой робот, передвигающийся по земле. Сейчас я иду... " 
+ "[EN] I'm ground moving fighting robot. Now walking...");
    }

    @Override
    public void ride() {
        log.info("[RU] Я боевой робот, передвигающийся по земле. Сейчас я еду... "
+ "[EN] I'm ground moving fighting robot. Now riding...");
    }
}

Думаю, что идея соблюдения 4-го принципа SOLID теперь понятна - за счёт разделения одного интерфейса Robot, который "умел делать всё" на более мелкие, узконаправленные, а также за счёт реализации классами роботов сразу нескольких нужных им интерфейсов, мы с вами избавились от необходимости вынужденно реализовать те методы "всемогущего" интерфейса, в которых наши роботы не нуждаются и не умеют выполнять каких-то действий.

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

D - Dependency Inversion Principle. Принцип инверсии зависимостей.

Теперь рассмотрим последний, 5-й принцип SOLID - принцип инверсии зависимостей. Согласно этому принципу:

1) модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций. 

2) Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций. 

Для демонстрации принципа на примере давайте снова обратимся к теме автомобилей, но на этот раз представим, что мы проектируем игру с гонками на спорткарах.

Аналогично всем предыдущим рассмотренным примерам по принципам SOLID мы начнём с того, как делать не нужно, т.е. посмотрим на пример реализации, нарушающий принцип инверсии зависимостей.

Антипаттерн ("как не надо делать")

Давайте для начала опишем восьмицилиндровый двигатель (V8) для спорткара, он будет иметь единственное поле power, задающее мощность двигателя в лошадиных силах. Назовём наш класс для двигателя V8Engine:

package ru.allineed.examples.solid.dependency_inversion.anti_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * [RU] Класс описывает восьмицилиндровый двигатель
 * [EN] Class describes eight-cylinder engine
 */
@AllArgsConstructor
@Data
public class V8Engine {
    /**
     * [RU] Мощность двигателя (л.с.)
     * [EN] Engine power (h.p.)
     */
    private int power;
}

Теперь мы хотим поддержать класс для шестиступенчатой коробки передач нашего будущего спорткара. В ней пока будет одно поле currentGear, задающее текущую включенную передачу. Назовём этот класс SixSpeedGearBox:

package ru.allineed.examples.solid.dependency_inversion.anti_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * [RU] Класс описывает 6-тиступенчатую коробку передач
 * [EN] Class describes six-speed gearbox
 */
@AllArgsConstructor
@Data
public class SixSpeedGearBox {
    /**
     * [RU] Текущая передача
     * [EN] Current gear
     */
    private int currentGear;
}

Далее мы можем написать класс SportCar для нашего спортивного автомобиля, который будем использовать в нашей игре с гонками:

package ru.allineed.examples.solid.dependency_inversion.anti_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data
public class SportCar {
    /**
     * [RU] Двигатель, установленный в спорткар
     * [EN] Engine installed into this sport car
     */
    private V8Engine engine;

    /**
     * [RU] Коробка передач, установленная в спорткар
     * [EN] Speed gearbox installed into this sport car
     */
    private SixSpeedGearBox gearBox;
}

Посмотрев на эти классы внимательно и перечитав, что гласит 5-й принцип SOLID, можно понять, что мы его уже нарушили, а именно: в классе SportCar мы с вами "завязались" на V8 двигатель и шестиступенчатую коробку передач. Но эти классы представляют собой детали. А мы поместили их в класс верхнего уровня SportCar, который в дальнейшем будет описывать разные типы спорткаров в нашей игре - в частности, те, у которых будет совсем другой двигатель (например, 10-цилиндровый V10), а также, возможно, будет установлена другая коробка передач - не обязательно шестиступенчатая.

Главное - своевременное осознание ошибки. Теперь, когда мы осознали, что на самых первых шагах нарушили принцип SOLID при проектировании игры, давайте исправим ситуацию, чтобы не нарушать принцип инверсии зависимостей.

Пример, не нарушающий принцип инверсии зависимостей

Итак, согласно принципу инверсии зависимостей, модули верхних уровней не должны зависеть от деталей (т.е. от модулей нижних уровней). Оба типа модулей (верхнего и нижнего уровня) должны зависеть от абстракций.

Ввёдем нашу первую абстракцию - интерфейс CarEngine для описания двигателя автомобиля:

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;

/**
 * [RU] Интерфейс описывает двигатель автомобиля
 * [EN] Class describes a car engine
 */
public interface CarEngine {
    /**
     * [RU] Метод возвращает мощность двигателя (л.с.)
     * [EN] Method returns the engine power (h.p.)
     */
    int getPower();

    /**
     * [RU] Метод устанавливает мощность двигателя (л.с.)
     * [EN] Method sets the engine power (h.p.)
     */
    void setPower(int power);
}

Далее введём вторую абстракцию - интерфейс CarGearBox для описания коробки передач:

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;

/**
 * [RU] Интерфейс описывает коробку передач автомобиля
 * [EN] Class describes a gearbox for the car
 */
public interface CarGearBox {
    /**
     * [RU] Метод возвращает значение текущей передачи
     * [EN] Method returns a value for current gear
     */
    int getCurrentGear();

    /**
     * [RU] Метод устанавливает значение текущей передачи
     * [EN] Method sets a new value for current gear
     */
    void setCurrentGear(int gear);
}

Далее давайте спроектируем первую реализацию для интерфейса CarEngine - пусть это будет пока абстрактный класс AbstractCarEngine для любого двигателя спорткара. Он будет хранить поле power для задания мощности двигателя спорткара (вспомним, что в примере-антипаттерне это поле было у нас в классе V8Engine):

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;


/**
 * [RU] Класс описывает абстрактный автомобильный двигатель с заданной мощностью
 * [EN] Class describes abstract car engine with predefined engine power
 */
public abstract class AbstractCarEngine implements CarEngine {
    /**
     * [RU] Мощность двигателя (л.с.)
     * [EN] Engine power (h.p.)
     */
    private int power;

    @Override
    public int getPower() {
        return power;
    }

    @Override
    public void setPower(int power) {
        this.power = power;
    }
}

Теперь мы можем создать различные детализированные классы для описания разных двигателей спорткаров.

Давайте опишем класс V8EngineConfiguration - он будет содержать в себе возможные конфигурации V8 двигателя - с плоским коленвалом или с крестообразным коленвалом:

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data
public class V8EngineConfiguration {

    public enum V8EngineConfigurationType {
        /**
         * [RU] Конфигурация двигателя с плоским коленвалом
         * [EN] Flat crankshaft configuration
         */
        FLAT_CRANKSHAFT_CONFIGURATION,

        /**
         * [RU] Конфигурация двигателя с крестообразным коленвалом
         * [EN] Cruciform crankshaft configuration
         */
        CRUCIFORM_CRANKSHAFT_CONFIGURATION
    }

    private V8EngineConfigurationType configurationType;
}

Теперь опишем сам класс для 8-цилиндрового двигателя спорткара. Он будет наследовать ранее созданный абстрактный класс AbstractCarEngine:

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * [RU] Класс описывает восьмицилиндровый двигатель
 * [EN] Class describes eight-cylinder engine
 */
@AllArgsConstructor
@Data
public class V8Engine extends AbstractCarEngine {

    private V8EngineConfiguration configuration;

    public V8Engine(V8EngineConfiguration configuration, int power) {
        setPower(power);
        this.configuration = configuration;
    }
}

Аналогичным образом зададим класс для конфигурации 10-цилиндрового двигателя спорткара - класс V10EngineConfiguration, который в нашем тестовом примере будет содержать единственный метод, который вернёт признак того, что двигатель не является внутренне сбалансированным:

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * [RU] Класс описывает конфигурацию десятицилиндрового двигателя
 * [EN] Class describes the configuration of ten-cylinder engine
 */
@AllArgsConstructor
@Data
public class V10EngineConfiguration {
    /**
     * [RU] Является ли двигатель внутренее сбалансированным
     * [EN] Indicates whether engine is internally balanced
     * @return [RU] всегда false; [EN] always false
     */
    public boolean isInternallyBalanced() {
        return false;
    }
}

Создадим класс V10Engine, который опишет 10-цилиндровый двигатель спорткара:

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * [RU] Класс описывает десятицилиндровый двигатель
 * [EN] Class describes ten-cylinder engine
 */
@AllArgsConstructor
@Data
public class V10Engine extends AbstractCarEngine {

    private V10EngineConfiguration configuration;

    public V10Engine(V10EngineConfiguration configuration, int power) {
        setPower(power);
        this.configuration = configuration;
    }
}

Для шестиступенчатой коробки передач опишем класс SixSpeedGearBox, но на этот раз он реализует наш интерфейс CarGearBox:

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * [RU] Класс описывает 6-тиступенчатую коробку передач
 * [EN] Class describes six-speed gearbox
 */
@AllArgsConstructor
@Data
public class SixSpeedGearBox implements CarGearBox {
    /**
     * [RU] Текущая передача
     * [EN] Current gear
     */
    private int currentGear;

    @Override
    public int getCurrentGear() {
        return currentGear;
    }

    @Override
    public void setCurrentGear(int gear) {
        currentGear = gear;
    }
}

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

package ru.allineed.examples.solid.dependency_inversion.valid_pattern;

import lombok.AllArgsConstructor;
import lombok.Data;

@AllArgsConstructor
@Data
public class SportCar {
    /**
     * [RU] Двигатель, установленный в спорткар
     * [EN] Engine installed into this sport car
     */
    private CarEngine engine;

    /**
     * [RU] Коробка передач, установленная в спорткар
     * [EN] Speed gearbox installed into this sport car
     */
    private CarGearBox gearBox;
}

На этот раз мы не нарушили те постулаты, о которых нам говорит 5-й принцип SOLID:

1) Абстракции не должны зависеть от деталей: в нашем случае это так, поскольку наш класс SportCar теперь зависит от абстракций - от интерфейса CarEngine для двигателей и интерфейса CarGearBox для коробок передач. Теперь он не зависит от конкретных деталей, как было в примере-антипаттерне.

2) Детали должны зависеть от абстракций: в нашем случае это так, поскольку наши классы V8Engine и V10Engine ("детали") наследуют абстрактный интерфейс AbstractCarEngine, то есть наши "детали" зависят от абстракций.

3) Модули верхних уровней не должны зависеть от модулей нижних уровней: снова в нашем случае это так, по сути это отсылка к пункту 1 выше. Наши классы и интерфейсы верхних уровней (SportCar, CarEngine, CarGearBox) не зависят от классов нижних уровней (V8Engine, V10Engine, V8EngineConfiguration, V10EngineConfiguration и SixSpeedGearBox).

4) Оба типа модулей должны зависеть от абстракций: снова в нашем случае это так - модули верхнего уровня зависят от абстракций, а модули нижнего уровня в нашем примере довольно просты и инкапсулируют лишь специфичные для них поля. Если бы нам пришлось и в классах "деталей" углубляться ещё в большие детали, то иерархию этих новых "деталей" следовало бы тоже проектировать, придерживаясь 5-го принципа SOLID.

Заключение

В этой статье мы разобрали с вами все пять принципов SOLID, посмотрели на то, как делать нельзя и что нарушает принципы, к каким последствиям это может привести. Мы также постарались разобрать примеры кода, которые не нарушают каждый из принципов SOLID.

Внизу этой статьи вы найдете ссылку на архив с тестовым проектом, содержащий все рассмотренные выше классы и примеры кода (включая Unit-тесты).

Требования к сборке тестового проекта и запуску примеров из него:

  • установленная Java версии не ниже 11-й
  • установленный Maven версии 3.8.1 - тестовый проект собирается при помощи системы сборки Maven, которая загрузит все необходимые для проекта зависимости (библиотеки Lombok и JUnit и другие).

В моём случае для создания тестового проекта я использовал среду разработки IntelliJ IDEA от компании JetBrains, поэтому рекомендую использовать её для открытия и сборки тестового проекта.

Для сборки используйте команду Maven clean install.

На этом всё, спасибо за внимание, надеюсь, статья будет полезна. Если нашли ошибку в статье или у вас есть вопросы - напишите о них в комментариях внизу.

Удачи!

Ссылка на тестовый проект с примерами

https://allineed.ru/our-products/download/6-allineed-ru-java-examples/16-solid-principles-java-examples

 

 

Яндекс.Метрика