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

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

UPD2: Сразу предостерегаю горение на тему “Википедия, ты серьёзно, это же свалка чьего-то мнения и никак не коррелируется с реальностью”, так вот – читайте приветствие.

UPD3: Мой мануал должен быть под лозунгом цитаты Хоя (Сектор Газа): “Три месяца назад я написал эту песню, но с еб*чим темпом инфляции я заеб**ся тексты переделывать, так что пусть в этой песне все остается как было”. В моём случае уже года полтора – два я пытаюсь закончить CMS, которая меня бы полностью устраивала и одновременно закончить, конечно, этот текст про SOLID. 


Что такое SOLID?

SOLID — это 5 основных принципов объектно-ориентированного программирования, мнемонический акроним, состоящий из первых 5 букв названий тех самых принципов (определение скомпоновано мной из нескольких источников).

Принципы SOLID: 

  • S — Single responsibility principle (Принцип единственной ответственности, SRP);
  • O — Open–closed principle (Принцип открытости/закрытости, OCP);
  • L — Liskov substitution principle (Принцип подстановки Барбары Лисков, LSP);
  • I — Interface segregation principle (Принцип разделения интерфейса, ISP);
  • D — Dependency inversion principle (Принцип инверсии зависимостей, DIP).

Когда я начал собирать информацию о принципах, чтобы их описать здесь для будущего меня, я понял на сколько каждый сайт со статьями о SOLID пытается запутать меня своей трактовкой определений. Исходя из вышесказанного, буду приводить по несколько определений каждого, и, по возможности, попытаюсь привести примеры из реальных задач или близких к этому, чтобы понимать где он (принцип) находит своё применение, изложение на “кошках и собаках” не всегда информативны. Забегая вперёд, как для меня, принцип подстановки Лисков наиболее плохо описан интернете, постараюсь это исправить.

 

Принцип единственной ответственности

Что о SRP говорит Википедия:

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

Должен заметить, что формулировка выше составлена Томом ДеМарко и Мейлиром Пейдж-Джонсом, однако в трактовке ниже, сформулированной Робертом С. Мартином, прицип попал в список SOLID:

Должна быть одна и только одна причина для изменения класса.

Кстати, нашёл ещё пост за 20ый год в блоге создателя SOLID, где Роберт С. Мартин пересказал принцип другими словами:

Gather together the things that change for the same reasons. Separate things that change for different reasons.

Мне кажется последнее объяснение самое точное, ну, на мой взгляд. Тут я начал ресёрчить в своей памяти какой бы пример подвести под SPR, выбор пал на Data Mapper Pattern, который используется в популярном Doctrine. Пока я упарывался поиском примера, а он должен соответствовать не только моим представлениям и знаниям по теме (они могут быть ошибочными), я читал и узнал много о Repository Pattern, в чём разница между Active Record и Data Mapper и почему последний мне подойдёт. Так вот, представим задачу: нужно описать сущность “Пользователь”, сделать двунаправленную связь передачи данных между постоянным хранилищем данных (база данных) и представлением данных в памяти (экземпляр сущности) и, допустим, совершить какие-то логические операции. Проще говоря, описать класс “Пользователь” и связать его с БД, так, что мы могли бы выполнить поиск поиск по ключу (найти конкретного пользователя) и, например, сделать добавление нового в таблицу. Говно вопрос, погнали.

class User
{
    private $id;
    private $name;
    private $email;
    private $db;
    private $table = 'users';

    public function __construct()
    {
        $this->db = new mysqli('localhost', 'user', 'pass', 'database');
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function setId($id): User
    {
        $this->id = $id;
        return $this;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName($name): User
    {
        $this->name = $name;
        return $this;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail($email): User
    {
        $this->email = $email;
        return $this;
    }

    public function findById($id): User
    {
        $query = <<<SQL
            SELECT * 
            FROM $this->table 
            WHERE id = $id
SQL;

        $result = (object) $this->db->query($query)
            ->fetch_assoc();

        $this->setId($result->id)
            ->setName($result->name)
            ->setEmail($result->email);
        return $this;
    }

    public function save(): void
    {
        $query = <<<SQL
            INSERT INTO $this->table (name, email) 
            VALUES ('{$this->getName()}', '{$this->getEmail()}');
SQL;

        $this->db->query($query);
    }
}

Как использовать этот класс на примере добавления нового пользователя:

$user = new User();

//добавление нового пользователя
$user->setName('Евгений')
    ->setEmail('darkkemper@gmail.com')
    ->save();

Далее используя метод findById() можно найти пользователя по $id:

$user = new User();

//поиск пользователя по id
$user->findById(1);
echo $user->getName();

В итоге, получается Active Record Pattern, который разрешает совмещать в себе как представление, так и механизмы CRUD, но класс User в таком виде нарушает принцип SPR, потому что он описывает не только сущность “Пользователь” но и работу с хранилищем. Пытаюсь запомнить: если я буду менять таблицу “users” и добавлю туда новое поле, то нужно будет отразить изменения в классе User, дописав новое свойство, но, к тому же, мне надо будет изменить запрос в методе save(), а это уже вторая причина для изменения класса. Вывод: это нам не подходит. Решение проблемы я уже описал выше — Data Mapper Pattern. Для начала нужно убрать всю логику из класса User, которая не отвечает принципу. Листинг ниже.

class User
{
    private $id;
    private $name;
    private $email;

    public function getId(): int
    {
        return $this->id;
    }

    public function setId($id): User
    {
        $this->id = $id;
        return $this;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName($name): User
    {
        $this->name = $name;
        return $this;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail($email): User
    {
        $this->email = $email;
        return $this;
    }
}

Теперь мне нужно инкапсулировать логику работы с хранилищем в другой класс, пусть будет UserMapper.

class UserMapper
{
    private $db;
    private $table = 'users';

    public function __construct()
    {
        $this->db = new mysqli('localhost', 'user', 'pass', 'database');
    }

    public function findById($id): User
    {
        $query = <<<SQL
            SELECT * 
            FROM $this->table 
            WHERE id = $id
SQL;

        $result = (object) $this->db->query($query)
            ->fetch_assoc();

        $user = new User();

        $user->setId($result->id)
            ->setName($result->name)
            ->setEmail($result->email);

        return $user;
    }

    public function save(User $user): void
    {
        $query = <<<SQL
            INSERT INTO $this->table (name, email) 
            VALUES ('{$user->getName()}', '{$user->getEmail()}');
SQL;

        $this->db->query($query);
    }
}

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

Добавление пользователя:

$userMapper = new UserMapper();

//добавление нового пользователя
$user = new User();
$user->setName('Евгений')
    ->setEmail('darkkemper@gmail.com');

$userMapper->save($user);

Поиск пользователя по $id:

$userMapper = new UserMapper();

//поиск пользователя по id
$user = $userMapper->findById(1);
echo $user->getName();

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

 

Принцип открытости/закрытости

Что о OCP говорит Википедия:

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

Основоположник термина Бертран Мейер. В параграфе об SRP я упоминал блог за авторством Роберта С. Мартина, формулировкам там такая же как на Вики, поэтому цитировать здесь я её не буду.

Прочитав впервые определение мне стало очень интересно, но ничего не понятно. Стал курить по этой теме разные статьи умных людей и вроде бы преисполнился на столько, что теперь смогу объяснить принцип “на пальцах”: нужно проектировать классы и методы так, чтобы я мог поменять их поведение без изменения исходного кода (уже вызвал пояснительную бригаду). Без примера не обойтись и он, как оказалось, частично есть в параграфе об SRP. К проблематике, есть UserMapper из прошлого примера, он не удовлетворяет OCP.

class UserMapper
{
    private $db;

    public function __construct()
    {
        $this->db = new mysqli('localhost', 'user', 'pass', 'database');
    }
}

Пользоваться этим классом можно так же, как и в примерах выше. Сейчас для управления данными используется MySQL, но как быть если нужно переехать на PostgreSQL? Вариант, который мне не подходит, — переписать все классы *Mapper и в конструкторе заменить соединение, то есть поменять поведение класса отредактировав исходный код, нарушив OCP. Строго говоря, скорее всего, вариант, описанный выше подойдёт из-за минимальных трудозатрат на реализацию, потому что соблюдение принципа подразумевает ещё то, что его будут использовать на этапе проектирования. Хочу рассмотреть решение проблемы только на моменте “что не так с __construct()” и сознательно опустить остальные методы, потому что нужно применять OCP и для findById(), и для save(), но сейчас это не важно, так как примера с __construct() вполне хватит для понимания. Теперь как нужно было проектировать: создать интерфейс (назвал я его DatabaseAdapterInterface), который будут реализовывать классы работы с MySQL и PostgreSQL

interface DatabaseAdapterInterface
{
    public function connection();
}

В реализации интерфейса классами MySQLConnection и PostgreSQLConnection метод connection будет содержать свою логику для подключение к хранилищу. Класс MySQLConnection:

class MySQLConnection implements DatabaseAdapterInterface
{
    public function connection()
    {
        return new mysqli('localhost', 'user', 'pass', 'database');
    }
}

И класс PostgreSQLConnection:

class PostgreSQLConnection implements DatabaseAdapterInterface
{
    public function connection()
    {
        return pg_connect("host=localhost port=5432 dbname=database user=user password=pass");
    }
}

И конечно нужно изменить конструктор в UserMapper:

class UserMapper
{
    private $db;

    public function __construct(\DatabaseAdapterInterface $db)
    {
        $this->db = $db->connection();
    }
}

И как это всё использовать:

//используем в проекте MySQL
$userMapper = new UserMapper(new MySQLConnection());

//используем в проекте PostgreSQL
$userMapper = new UserMapper(new PostgreSQLConnection());

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

 

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

По традиции иду сразу в Википедию, но тут на меня вывалилось совсем не дружелюбное определение от самой Барбары Лисков, по этому перейду сразу к трактовке сформулированной Робертом С. Мартином:

Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.

Описание выше мне всё ещё не понятно (как для первого раза), но ясность вносит определение которое я опять взял из блога Роберта С. Мартина:

A program that uses an interface must not be confused by an implementation of that interface.

Нет, не вносит…

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

Наследующий класс должен дополнять, а не замещать поведение базового класса.

Ладно-ладно, с формулировками я закончу. Хочу придумать пример, но тут я понял, что в одном я не умещу все подробности LSP и придётся делать много (примеров). Ещё боль в том, что в случае моего фиаско с пониманием принципа, я мог бы надёргать код из разных мануалов, но какой мне смысл от, например, “примера с квадратом и прямоугольником”, если он (пример) работает в “лабораторных условиях” и, во-первых, ничего общего с реальным миром не имеет, во-вторых, противоречит идее создания этого блога. Имея такие вводные моя мораль падает в ноль.

Вернулся я в блог Роберта Мартина, прочитал следующее (мой вольный пересказ/перевод опуская подробности):

Многие люди совершают ошибку думая, что этот прицип о наследовании, на самом деле речь идет о типах и подтипах. Реализация интерфейса или дочернего класса являются подтипами интерфейса или родительского класса соответственно. Если реализация или дочерний класс вводит в заблуждение пользователя интерфейса или родительского класса, то использование конструкций if / switch и не избежно, что будут постоянно распространяться в коде.

Попробую нарушить LSP. Напишу абстрактный класс User у которого будут два метода: getSessionId() и getUserProfilePhotoName(). Первый возвратит условную сессию, второй – название файла фотографии пользователя, вместе они это путь к изображению + создадим функцию, в которой будем с ним (изображением) работать. 

abstract class User
{
    public function getSessionId() {}

    public function getUserProfilePhotoName() {}
}

class CommonUser extends User
{
    public function getSessionId(): string
    {
        return "айди_сессии";
    }

    public function getUserProfilePhotoName(): string
    {
        return "имя_изображения";
    }
}

Очень нужная функция:

$user = new CommonUser();

print resize($user);

function resize(User $user): string
{
    $photo = $user->getSessionId() . DIRECTORY_SEPARATOR . $user->getUserProfilePhotoName();

    //
    // логика работы над изображением
    //

    return $photo;
}

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

class GuestUser extends User
{
    public function getSessionId(): string
    {
        return "айди_сессии";
    }

    public function getUserProfilePhotoName(): string
    {
        throw new \Exception("У гостя не может быть фотографии");
    }
}

Что пошло не так? Метод getUserProfilePhotoName() подрузомевает наличие записи в БД о пользователе, с фотографией которого мы будем работать. Класс GuestUser не имеет профиля, у него нет записи в БД, но метод resize() об этом не знает, что возвращает меня к первому определению принципа. А что, если я изменю функцию resize() например вот так:

$user = new GuestUser();

print resize($user);

function resize(User $user): ?string
{
    if (!$user instanceof GuestUser) {

        return $user->getSessionId() . DIRECTORY_SEPARATOR . $user->getUserProfilePhotoName();

        //
        // логика работы над изображением
        //

    } else {
        return null;
    }
}

Можно конечно и Exception отловить, но зачем, ситуацию уже не исправить, я сделаю ещё хуже, не только нарушу принцип подстановки, но и принцип открытости/закрытости с тех пор когда появилось условие с instanceof. То есть правильно писал Роберт Мартин: 

Если реализация или дочерний класс вводит в заблуждение пользователя интерфейса или родительского класса, то использование конструкций if / switch и не избежно.

+ ещё одно пояснение с принципом открытоски/закрытости

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

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

Tеперь как стоило проектировать. Вообще LSP тесно связано с контрактным программированием, согласно Вики это:

Контрактное программирование предполагает, что проектировщик должен определить формальные, точные и верифицируемые спецификации интерфейсов/классов для компонентов системы.

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

Контракт определён публичными методами, любыми ограничениями, наложенными на их входные параметры и любыми изменениями состояния, через которые проходит объект и побочными эффектами исполнения методов.

+ добавлю интерфейс (контракт) AuthorizableUserImpl, который будут реализовывать все типы пользователей, что имеют профиль на ресурсе.

abstract class User
{
    public function getSession() {}
}

interface AuthorizableUserImpl
{
    public function getUserProfilePhotoPath(): string;
}

class CommonUser extends User implements AuthorizableUserImpl
{
    public function getSession(): string
    {
        return "сессия";
    }

    public function getUserProfilePhotoPath(): string
    {
        return "путь_к_фото";
    }
}

class GuestUser extends User
{
    public function getSession(): string
    {
        return "сессия";
    }
}

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

$user = new CommonUser();

print resize($user);

function resize(AuthorizableUserImpl $user): string
{
    $photo = $user->getSession() . DIRECTORY_SEPARATOR . $user->getUserProfilePhotoPath();

    //
    // логика работы над изображением
    //

    return $photo;
}

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

 

Принцип разделения интерфейса

Лезу в Вики:

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

С определением всё просто, нет никаких лишних имён, формулировка Роберта Мартина, а вот с концепцией всё немного глобальней, если так можно сказать. Нарушение принципа ведёт не только к проблемам с клиентским кодом, а ещё, как пишет автор, лишними затратами на ре-компиляцию кода и/или ре-деплой лишнего (статья была написана в 2020 году). Уровень моего развития не позволяет ощутить всю проблематику последнего. С клиентским кодом всё проще, отойду от своих примеров и позаимствую чужой (мой блог, что хочу – то и делаю, читайте приветствие), но не менее реальный. У меня есть в системе механизм нотификаций, опишу интерфейс сообщения для отправки конечному пользователю.

interface Message
{
    public function setText(string $text);

    public function setSubject(string $subject);

    public function setProducer(string $producer);

    public function setConsumer(string $consumer);
}

Использую email нотификацию и реализую интерфейс Message

class EmailMessage implements Message
{
    public function setText(string $text) {}

    public function setSubject(string $subject) {}

    public function setProducer(string $producer) {}

    public function setConsumer(string $consumer) {}
}

Чтобы нарушить принцип, добавлю ещё два типа нотификации: SMS и voice сообщения, они ведь тоже сообщения, да 🤔? Как оказалось да, с небольшой только разницей: 

  • у всех есть отправитель и получатель;
  • в SMS нет темы сообщения;
  • Voice не содержит текста и темы сообщения.

Что в итоге: описать два класса SmsMessage и VoiceMessage, в интерфейс Message нужно добавить новый сеттер setVoice(), который придётся добавивть ещё и в EmailMessage.

interface Message
{
    public function setText(string $text);

    public function setSubject(string $subject);

    public function setProducer(string $producer);

    public function setConsumer(string $consumer);

    public function setVoice(string $voice);
}

class EmailMessage implements Message
{
    public function setText(string $text) {}

    public function setSubject(string $subject) {}

    public function setProducer(string $producer) {}

    public function setConsumer(string $consumer) {}

    public function setVoice(string $voice) {
        throw new \Exception("Email сообщение не содержит голоса");
    }
}

class SmsMessage implements Message
{
    public function setText(string $text) {}

    public function setSubject(string $subject)
    {
        throw new \Exception("СМС сообщение не содержит тему");
    }

    public function setProducer(string $producer) {}

    public function setConsumer(string $consumer) {}

    public function setVoice(string $voice)
    {
        throw new \Exception("СМС сообщение не содержит голоса");
    }
}

class VoiceMessage implements Message
{
    public function setText(string $text) {
        throw new \Exception("Голосовое сообщение не содержит текст");
    }

    public function setSubject(string $subject)
    {
        throw new \Exception("Голосовое сообщение не содержит тему");
    }

    public function setProducer(string $producer) {}

    public function setConsumer(string $consumer) {}

    public function setVoice(string $voice) {}
}

Теперь классы EmailMessage, SmsMessage и VoiceMessage вынуждены зависит от методов, которые они не используют, в добавок ко всему есть нарушение принципов единой ответственности (методы в VoiceMessage не подчиненны одной задаче) и подстановки Барбары Лисков (реализация должна дополнять а не замещать поведение базового типа).

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

interface MessageImpl
{
    public function setProducer(string $producer);

    public function setConsumer(string $consumer);
}

interface EmailMessageImpl extends MessageImpl
{
    public function setText(string $text);

    public function setSubject(string $subject);
}

interface SmsMessageImpl extends MessageImpl
{
    public function setText(string $text);
}

interface VoiceMessageImpl extends MessageImpl
{
    public function setVoice(string $voice);
}

class EmailMessage implements EmailMessageImpl
{
    public function setText(string $text) {}

    public function setSubject(string $subject) {}

    public function setProducer(string $producer) {}

    public function setConsumer(string $consumer) {}
}

class SmsMessage implements SmsMessageImpl
{
    public function setText(string $text) {}

    public function setProducer(string $producer) {}

    public function setConsumer(string $consumer) {}
}

class VoiceMessage implements VoiceMessageImpl
{
    public function setProducer(string $producer) {}

    public function setConsumer(string $consumer) {}

    public function setVoice(string $voice) {}
}

С такой реализацией поставленной задачи я больше не нарушаю принцип разделения интерфейса.

 

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

Википедия:

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

Так же там пояснение Роберта С. Мартина

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

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

class UserMapper
{
    private $db;

    public function __construct()
    {
        $this->db = new mysqli('localhost', 'user', 'pass', 'database');
    }
}

Здесь модуль верхнего уровня, тут UserMapper, зависит от модуля нижнего уровня, он же mysqli. Проблема станет явной при переходе на использование другой СУБД, допустим PostgreSQL, всё как в принципе открытости/закрытости. Решение, описанное в том же принципе выше (открытости/закрытости) закрывает проблему. Теперь UserMapper не зависит от mysqli, оба типа зависят от абстракции (DatabaseAdapterInterface).

 

Резюме

Самым объёмным и самым мало описанным мною оказался принцип подстановки Барбары Лисков, хочу потом расписать его подробнее.