Написание SOLID-кода на Java
Как стать лучшим Java-разработчиком следуя пяти SOLID принципам объектно-ориентированного проектирования.
SOLID — это скорее про проектирование, нежели про программирование. Чтобы даже чисто функциональные программисты мыслили объектами, давая сущностям и компонентам имена, которые описывают не только то, что они делают, но и то, чем они являются. Например, у вас есть компонент TradingEngine, Orchestrator или UserManager.

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

  • SRP: Принцип единственной ответственности.

  • OCP: Принцип открытости-закрытости.

  • LSP: Принцип подстановки Лискова.

  • ISP: Принцип разделения интерфейсов.

  • DIP: Принцип инверсии зависимостей.

Далее рассмотрим эти принципы в контексте разработки на Java.

Статью в оригинале можно прочитать здесь
Принцип единственной ответственности (SRP)

SRP утверждает, что класс должен иметь единственную ответственность или цель.Ничто другое не должно требовать изменения класса за пределами этой цели. Возьмем, к примеру, класс Java Date. Обратите внимание, что в этом классе нет методов форматирования. Кроме того, старые методы, близкие к форматированию, такие как toLocaleString, устарели. Это хороший пример SRP.

В классе Date есть лаконичные методы для создания объектов, представляющих дату, а также методы для сравнения двух разных объектов Date (и их репрезентативные даты по времени). Для форматирования и отображения объекта Date необходимо использовать класс DateFormat, который несет ответственность за форматирование заданной даты в соответствии с набором гибких критериев. Например, для следующего кода,


Date d = new Date();
String s = DateFormat.getDateInstance().format(d);
System.out.println(s);
на выходе получим:

July 4, 2023
Если необходимо изменить способ представления дат в системе, изменяется только класс Date. Если необходимо изменить способ форматирования дат для отображения, то изменяется только класс DateFormat, а не класс Date. Объединение этой функциональности в одном классе привело бы к созданию монолитного гиганта, который был бы подвержен побочным эффектам и связанным с ними ошибкам при изменении одной области ответственности в рамках единой кодовой базы. Следование SRP из SOLID позволяет избежать подобных проблем.

Принцип открытости-закрытости (OCP)

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

Если посмотреть Javadoc класса DateFormat, то можно увидеть, что DateFormat — это абстрактный базовый класс, который эффективно обеспечивает OCP . В то время как у DateFormat есть методы для указания часового пояса, указания, куда вставить или добавить отформатированную дату в заданный StringBuffer, или обработка типов календарей, SimpleDateFormat расширяет DateFormat, чтобы добавить более сложное форматирование на основе шаблонов. Переход от DateFormat к SimpleDateFormat предоставляет вам все, что было до этого, и многое другое. Например, для следующего кода,


Date d = new Date();
SimpleDateFormat sdf = 
    new SimpleDateFormat("YYYY-MM-dd HH:MM:SS (zzzz)");
String s = sdf.format(d);
System.out.println(s);
на выходе получим:

2023-07-04 09:07:722 (Eastern Daylight Time)
Важный момент заключается в том, что класс DateFormat остается нетронутым с точки зрения исходного кода, что исключает возможность негативного влияния на зависимый от него код. Вместо этого, расширяя класс, вы можете добавить новую функциональность, изолировав изменения в новом классе, в то время как исходный класс остается доступным и нетронутым для использования как существующим, так и новым кодом.

Принцип подстановки Лискова

LSP, разработанный Барбарой Лисков, утверждает, что если код работает с данным классом, то он должен продолжать корректно работать и с подклассами этого базового класса. Хотя LSP звучит просто, существует множество примеров, показывающих, насколько сложно обеспечить соблюдение и тестирование LSP.

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

Используя библиотеки Java в качестве примера, семейство классов коллекций Java Queue выглядит многообещающим для соблюдения LSP. Начав с абстрактного базового класса AbstractQueue, а также подклассов ArrayBlockingQueue и DelayQueue, для примера создадим следующее простое тестовое приложение, полностью ожидая, что оно будет соответствовать LSP:


public class LSPTest {
    static AbstractQueue<MyDataClass> q = 
            new ArrayBlockingQueue(100);
            //new DelayQueue();
    
    public static void main(String[] args) throws Exception {
        for ( int i = 0; i < 10; i++ ) {
            q.add( getData(i+1) );
        }

        MyDataClass first = q.element();
        System.out.println("First element data: " +first.val3);
        
        int i = 0;
        for ( MyDataClass data: q ) {
            if ( i++ == 0 ) {
                test(data, first);
            }

            System.out.println("Data element: " + data.val3);
        }
        
        MyDataClass data = q.peek();
        test(data, first);
        int elements = q.size();
        data = q.remove();
        test(data, first);
        if ( q.size() != elements-1 ) {
            throw new Exception("Failed LSP test!");
        }
        
        q.clear();
        if ( ! q.isEmpty() ) {
            throw new Exception("Failed LSP test!");
        }
    }
    
    public static MyDataClass getData(int i) {
        Random rand = new Random(i); 
        MyDataClass data = new MyDataClass();
        data.val1 = rand.nextInt(100000);
        data.val2 = rand.nextLong(100000);
        data.val3 = ""+data.val1+data.val2;
        return data;
    }
    
    public static void test(MyDataClass d1, MyDataClass d2) throws Exception{
        if ( ! d1.val3.equals(d2.val3) ) {
            throw new Exception("Failed LSP test!");
        }
    }
}
Но этот код не проходит тест LSP! И не проходит по двум причинам: поведение метода add в классе DelayQueue требует, чтобы элементы реализовывали интерфейс Delayed, и даже после исправления этой проблемы реализация метода remove была, как бы это сказать, удалена. И то, и другое нарушает LSP.

Принцип разделения интерфейсов (ISP)

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

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

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

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

Есть еще один побочный эффект — это также означает, что другим разработчикам не нужно реализовывать интерфейсы, которые они никогда не собираются поддерживать.. Например, вы можете создать какой-то слайд-одностраничник, а затем настроить его так, чтобы он передавался только по электронной почте и никогда не распечатывался. Или, в то же время, вы можете захотеть создать простой текстовый файл readme, а не документ с богатым форматированием, используемый в качестве маркетингового материала.

Хорошим примером применения ISP на практике в JDK является набор интерфейсов java.awt. Этот интерфейс очень сфокусирован и прост в использовании. Он содержит методы, которые проверяют пересечение, содержание и поддержку итерации пути по точкам фигуры. Однако фактическая работа по итерации пути для фигуры определяется интерфейсом PathIterator. Кроме того, методы для рисования объектов Shape определены в других интерфейсах и абстрактных базовых классах, таких как Graphics2D.

В этом примере java.awt не указывает Shape рисовать себя или выбирать цвет (что само по себе является богатой областью реализации); это хороший рабочий пример ISP.

Принцип инверсии зависимостей (DIP)

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

Вместо этого DIP утверждает, что конструкции как нижнего, так и верхнего уровня в коде никогда не должны напрямую зависеть друг от друга. Лучше всего поместить между ними абстракции в виде более общих интерфейсов. Например, в JDK обратите внимание на пакет java.sql.* или на API JMS и набор классов.

При использовании JDBC вы можете писать код для интерфейса Connection, не зная точно, к какой базе данных вы подключаетесь, а утилиты более низкого уровня, такие как DriverManager или DataSource, скрывают от вашего приложения детали, связанные с конкретной базой данных. В сочетании с такими фреймворками внедрения зависимостей, как Spring или Micronaut, можно даже откладывать привязку и изменять тип базы данных без изменения какого-либо кода.

В JMS API для подключения к JMS-серверу используется та же парадигма, но она идет еще дальше. Ваше приложение может использовать интерфейс Destination для отправки и получения сообщений, не зная, доставляются ли эти сообщения через Topic или Queue. Код нижнего уровня может быть написан или сконфигурирован для выбора парадигмы сообщений (Topic или Queue) с соответствующими деталями и характеристиками доставки сообщений, при этом не нужно менять код приложения, ведь он абстрагирован интерфейсом Destination, который находится между ними.

Вы должны писать надежный SOLID-код

SOLID — это обширная и глубокая тема; здесь есть еще много, что можно узнать и куда копать. Вы можете начать с малого. Найдите утешение в том, что JDK воплощает множество принципов ООП, которые делают написание кода более эффективным и продуктивным.