Архитектурные принципы SOLID

31 декабря 2023
Архитектурные принципы SOLID были обозначены разработчиком Робертом Мартиным (Robert C. Martin) в начале 2000-х годов (как он сам утверждает) и описаны в книге «Чистая архитектура». Аббревиатуру SOLID предложил в 2003 году знакомый Мартина - Майкл Физерс (Michael Feathers). Принципы носят теоретико-рекомендательный характер и описывают относительно оптимальный с точки зрения архитектора подход к разработке программы.

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

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

SOLID состоит из пяти принципов:
  • Единственная ответственность;
  • Открытость-закрытость;
  • Подстановка Барбары Лисков;
  • Разделение интерфейсов;
  • Инверсия зависимости.

Далее поясню каждый из принципов.


Единственная ответственность

У функции, класса и модуля должна быть только одна ответственность (роль). 

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

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

Если модуль используется для учёта рекламы и erid-кодов, то модуль не должен заниматься хранением новостей. Потому что хранение новостей это зона ответственности другого модуля.  


Открытость-закрытость

Программные сущности (функции, классы, модули) должны быть открыты для расширения, но закрыты для изменения.

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

Например, у вас есть класс Robot для работы с роботом. Робот умеет прыгать с помощью метода jump(int height), где в аргументе height задаётся высота прыжка. А что если нужно сделать прыжок не в высоту, а в горизонталь? Потребуется либо добавлять новый метод, расширяя класс Robot, либо изменять текущий метод jump. Заранее предугадать будущие идеи нельзя, поэтому крупные функциональные переделки это обычная рабочая ситуация. Нормальным решением будет убрать метод jump и вместо него добавить метод action(RobotAction action), который будет выполнять действия, переданные в виде объекта action нового класса «Действия робота» RobotAction. Таким образом, при появлении нового действия мы просто расширим класс RobotAction и не станем затрагивать класс Robot.


Подстановка Барбары Лисков

В своей работе от октября 1987 года под названием «Абстракция данных и иерархия» Барбара Лисков сформировала понятие иерархии, которое легло в основу принципа: если q(x) верно для объекта x типа T, тогда q(y) тоже будет верно для объекта y типа S, при этом S является подтипом типа T.

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

Принцип призывает в дочерних классах не изменять поведение базового класса. 


Разделение интерфейсов

Класс не должен зависеть от методов, которые он не использует.  

Это достигается путём разделения общих сущностей (в частности на уровне интерфейса класса) на более мелкие и специфичные к выбранному объекту. Например, у нас есть интерфейс «Предмет» с методами «Достать», «Посмотреть», «Подарить», «Использовать», «Помыть» и «Убрать». Этот интерфейс используется только в реализации классов «Часы» и «Подарок». Для класса «Часы» не используются методы «Подарить» (из-за вероятных суеверий) и «Использовать», а для класса «Подарок» не используются методы «Использовать» и «Помыть». Правильным было бы разделение интерфейса «Предмет» на два новых конкретных интерфейса: «Часы» и «Подарок».

После разделения новый интерфейс «Часы» будет содержать методы «Достать», «Посмотреть», «Помыть» и «Убрать». А интерфейс «Подарок» будет содержать методы «Достать», «Посмотреть», «Подарить» и «Убрать».

Тем самым мы сокращаем избыточность и лишние зависимости.


Инверсия зависимости

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

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

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

Пример в коде: 
interface MediaFile
{
    public function play();
}

class MediaPlayer
{
    private $file;
 
    public function __construct(MediaFile $file)
    {
        $this->file = $file;
    }
 
    public function play()
    {
        return $this->file->play();
    }
 
}
 
class MP3File implements MediaFile
{
    public function play()
    {
        return "Играет файл MP3";
    }
}
 
class WMAFile implements MediaFile
{
    public function play()
    {
        return "Играет файл WMA";
    }
}