Любите загадки? Событие еще доступно на сайте.
Объектно-ориентированные

SOLID принципы

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

0
Введение

SOLID - это акроним для первых пяти принципов объектно-ориентированного проектирования от Роберта Мартина.

SOLID – это основа объектно-ориентированного проектирования (ООП), которая помогает создавать гибкие, расширяемые и поддерживаемые программы. Когда ты понимаешь эти принципы, ты можешь писать код, который легко изменять и дополнять.

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

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

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

Класс должен решать только одну задачу (иметь одну ответственность).

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

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

class Report
{
    public function title(): string
    {
        return 'Report Title';
    }

    public function date(): string
    {
        return '2016-04-21';
    }

    public function contents(): array
    {
        return [
            'title' => $this->title(),
            'date' => $this->date(),
        ];
    }

    public function formatJson(): string
    {
        return json_encode($this->contents());
    }
}

Проблема здесь в том, что класс Report занимается слишком многим – он не только предоставляет данные, но и форматирует их в JSON. Допустим, тебе потребуется отформатировать данные в HTML. В таком случае, класс Report придется изменять, нарушая принцип SRP.

Чтобы исправить это, мы можем разделить ответственности. Давай создадим новый класс JsonReportFormatter, который будет отвечать только за форматирование данных в JSON:

class Report
{
    public function title(): string
    {
        return 'Report Title';
    }

    public function date(): string
    {
        return '2016-04-21';
    }

    public function contents(): array
    {
        return [
            'title' => $this->title(),
            'date' => $this->date(),
        ];
    }
}

interface ReportFormattable
{
    public function format(Report $report);
}

class JsonReportFormatter implements ReportFormattable
{
    public function format(Report $report)
    {
        return json_encode($report->contents());
    }
}

Теперь класс Report отвечает только за предоставление данных, а класс JsonReportFormatter занимается только их форматированием в JSON. Таким образом, каждый класс имеет только одну причину для изменения, что соответствует принципу SRP.

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

Классы должны предоставлять интерфейсы для их использования, все остальное должно быть закрыто.

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

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

Давай посмотрим на пример, чтобы понять это.

class Programmer
{
    public function code()
    {
        return 'coding';
    }
}

class Tester
{
    public function test()
    {
        return 'testing';
    }
}

class ProjectManagement
{
    public function process($member)
    {
        if ($member instanceof Programmer) {
            $member->code();
        } elseif ($member instanceof Tester) {
            $member->test();
        };

        throw new Exception('Invalid input member');
    }
}

Этот код нарушает принцип открытости/закрытости, потому что для добавления новых видов сотрудников (например, дизайнеров) нам придется изменять класс ProjectManagement.

Давай исправим это, используя интерфейсы:

interface Workable
{
    public function work();
}

class Programmer implements Workable
{
    public function work()
    {
        return 'coding';
    }
}

class Tester implements Workable
{
    public function work()
    {
        return 'testing';
    }
}

class ProjectManagement
{
    public function process(Workable $member)
    {
        return $member->work();
    }
}

Теперь мы используем интерфейс Workable, который гарантирует, что все классы, реализующие этот интерфейс, будут иметь метод work(). Таким образом, мы можем добавлять новые типы сотрудников, реализующих интерфейс Workable, без изменения кода класса ProjectManagement. Это соответствует принципу открытости/закрытости.

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

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

Принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP) утверждает, что поведение подклассов должно быть совместимо с поведением их суперклассов. Другими словами, объекты подтипов должны быть заменяемыми экземплярами своих супертипов без изменения ожидаемого поведения программы. Давай разберемся с примером, чтобы лучше понять этот принцип:

<?php

// Нарушение принципа подстановки Барбары Лисков
// Проблема квадрата и прямоугольника
class Rectangle
{
    protected $width;
    protected $height;

    public function setHeight($height)
    {
        $this->height = $height;
    }

    public function getHeight()
    {
        return $this->height;
    }

    public function setWidth($width)
    {
        $this->width = $width;
    }

    public function getWidth()
    {
        return $this->width;
    }

    public function area()
    {
         return $this->height * $this->width;
    }
}

class Square extends Rectangle
{
    public function setHeight($value)
    {
        $this->width = $value;
        $this->height = $value;
    }

    public function setWidth($value)
    {
        $this->width = $value;
        $this->height = $value;
    }
}

class RectangleTest
{
    private $rectangle;

    public function __construct(Rectangle $rectangle)
    {
        $this->rectangle = $rectangle;
    }

    public function testArea()
    {
        $this->rectangle->setHeight(2);
        $this->rectangle->setWidth(3);
        // Ожидаем, что площадь прямоугольника будет 6
    }
}

В данном примере класс Square наследуется от Rectangle, что кажется логичным, так как квадрат является частным случаем прямоугольника. Однако, нарушается принцип подстановки Барбары Лисков из-за того, что Square переопределяет методы setHeight() и setWidth() так, чтобы они всегда делали высоту равной ширине.

Что делает этот пример нарушением LSP? Дело в том, что ожидается, что при вызове setHeight() и setWidth() объекта Rectangle сначала будет изменяться одно измерение, а потом другое. Однако в случае Square эти методы нарушают это ожидание, что может привести к непредсказуемому поведению в программах, которые используют Rectangle или его подтипы.

Как исправить это? Один из способов – пересмотреть архитектуру классов так, чтобы Square не наследовался от Rectangle, так как это нарушает LSP. Вместо этого, можно использовать композицию или выделить общий интерфейс для обоих классов и разработать их независимо.

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

Интерфейсов должно быть много.

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

Давай разберем, как это работает на примере:

// Нарушение принципа разделения интерфейса
interface Workable
{
    public function canCode();
    public function code();
    public function test();
}

class Programmer implements Workable
{
    public function canCode()
    {
        return true;
    }

    public function code()
    {
        return 'coding';
    }

    public function test()
    {
        return 'testing in localhost';
    }
}

class Tester implements Workable
{
    public function canCode()
    {
        return false;
    }

    public function code()
    {
         throw new Exception('Opps! I can not code');
    }

    public function test()
    {
        return 'testing in test server';
    }
}

class ProjectManagement
{
    public function processCode(Workable $member)
    {
        if ($member->canCode()) {
            $member->code();
        }
    }
}

В этом примере интерфейс Workable содержит методы canCode(), code() и test(). Проблема в том, что не все классы, реализующие этот интерфейс, могут выполнять все эти действия. Например, класс Tester не может кодировать, но он должен реализовать метод code(), потому что интерфейс Workable требует это.

Чтобы исправить это, мы можем разделить интерфейс на более мелкие и специализированные интерфейсы:

// Улучшенный вариант
interface Codeable
{
    public function code();
}

interface Testable
{
    public function test();
}

class Programmer implements Codeable, Testable
{
    public function code()
    {
        return 'coding';
    }

    public function test()
    {
        return 'testing in localhost';
    }
}

class Tester implements Testable
{
    public function test()
    {
        return 'testing in test server';
    }
}

class ProjectManagement
{
    public function processCode(Codeable $member)
    {
        $member->code();
    }
}

Теперь интерфейсы Codeable и Testable более специализированы. Класс Programmer реализует оба интерфейса, потому что он может кодировать и тестировать. Класс Tester реализует только интерфейс Testable, потому что он может только тестировать. Таким образом, классы могут реализовывать только те методы, которые им нужны, что соответствует принципу разделения интерфейса.

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

Любые более высокие (дочерние) классы всегда должны зависеть от абстракций, а не от деталей.

Принцип инверсии зависимостей (Dependency Inversion Principle, DIP) гласит, что модули высокого уровня не должны зависеть от модулей низкого уровня, а оба типа модулей должны зависеть от абстракций. Также он утверждает, что абстракции не должны зависеть от деталей, а детали должны зависеть от абстракций.

Давай разберемся, что это означает на примере:

<?php
// Нарушение принципа инверсии зависимостей
class Mailer
{

}

class SendWelcomeMessage
{
    private $mailer;

    public function __construct(Mailer $mailer)
    {
        $this->mailer = $mailer;
    }
}

Здесь класс SendWelcomeMessage зависит от конкретной реализации Mailer, что нарушает принцип инверсии зависимостей. Если мы захотим изменить способ отправки сообщений, нам придется изменять и класс SendWelcomeMessage.

Для исправления этой проблемы мы можем использовать абстракцию в виде интерфейса Mailer:

interface Mailer
{
    public function send();
}

class SmtpMailer implements Mailer
{
    public function send()
    {
        // Реализация отправки через SMTP
    }
}

class SendGridMailer implements Mailer
{
    public function send()
    {
        // Реализация отправки через SendGrid
    }
}

class SendWelcomeMessage
{
    private $mailer;

    public function __construct(Mailer $mailer)
    {
        $this->mailer = $mailer;
    }
}

Теперь класс SendWelcomeMessage зависит от абстракции Mailer, а не от конкретной реализации. Мы можем легко изменить способ отправки сообщений, просто передавая нужную реализацию интерфейса Mailer в конструктор SendWelcomeMessage. Это соответствует принципу инверсии зависимостей.