Conceptual article

SOLID: 5 принципов объектно-ориентированного программирования

Введение

SOLID — это аббревиатура, обозначающая первые пять принципов объектно-ориентированного программирования, сформулированные Робертом С. Мартином (также известным как дядя Боб).

Примечание. Хотя эти принципы применимы к разным языкам программирования, в этой статье мы приведем примеры для языка PHP.

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

SOLID включает следующие принципы:

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

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

Принцип единственной ответственности (SRP) гласит:

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

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

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

В случае квадратов необходимо знать длину стороны:

class Square
{
    public $length;

    public function construct($length)
    {
        $this->length = $length;
    }
}

В случае кругов необходимо знать радиус:

class Circle
{
    public $radius;

    public function construct($radius)
    {
        $this->radius = $radius;
    }
}

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

class AreaCalculator
{
    protected $shapes;

    public function __construct($shapes = [])
    {
        $this->shapes = $shapes;
    }

    public function sum()
    {
        foreach ($this->shapes as $shape) {
            if (is_a($shape, 'Square')) {
                $area[] = pow($shape->length, 2);
            } elseif (is_a($shape, 'Circle')) {
                $area[] = pi() * pow($shape->radius, 2);
            }
        }

        return array_sum($area);
    }

    public function output()
    {
        return implode('', [
          '',
              'Sum of the areas of provided shapes: ',
              $this->sum(),
          '',
      ]);
    }
}

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

Вот пример с набором из трех фигур:

  • круг радиусом 2
  • квадрат с длиной стороны 5
  • второй квадрат с длиной стороны 6
$shapes = [
  new Circle(2),
  new Square(5),
  new Square(6),
];

$areas = new AreaCalculator($shapes);

echo $areas->output();

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

Давайте рассмотрим сценарий, в котором вывод необходимо конвертировать в другой формат, например, JSON.

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

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

class SumCalculatorOutputter
{
    protected $calculator;

    public function __constructor(AreaCalculator $calculator)
    {
        $this->calculator = $calculator;
    }

    public function JSON()
    {
        $data = [
          'sum' => $this->calculator->sum(),
      ];

        return json_encode($data);
    }

    public function HTML()
    {
        return implode('', [
          '',
              'Sum of the areas of provided shapes: ',
              $this->calculator->sum(),
          '',
      ]);
    }
}

Класс SumCalculatorOutputter должен работать следующим образом:

$shapes = [
  new Circle(2),
  new Square(5),
  new Square(6),
];

$areas = new AreaCalculator($shapes);
$output = new SumCalculatorOutputter($areas);

echo $output->JSON();
echo $output->HTML();

Логика, необходимая для вывода данных пользователю, обрабатывается классом SumCalculatorOutputter.

Это соответствует принципу единственной ответственности.

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

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

Объекты или сущности должны быть открыты для расширения, но закрыты для изменения.

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

Давайте вернемся к классу AreaCalculator и посмотрим на метод sum:

class AreaCalculator
{
    protected $shapes;

    public function __construct($shapes = [])
    {
        $this->shapes = $shapes;
    }

    public function sum()
    {
        foreach ($this->shapes as $shape) {
            if (is_a($shape, 'Square')) {
                $area[] = pow($shape->length, 2);
            } elseif (is_a($shape, 'Circle')) {
                $area[] = pi() * pow($shape->radius, 2);
            }
        }

        return array_sum($area);
    }
}

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

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

Вот метод area, определенный в классе Square:

class Square
{
    public $length;

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

    public function area()
    {
        return pow($this->length, 2);
    }
}

Вот метод area, определенный в классе Circle:

class Circle
{
    public $radius;

    public function construct($radius)
    {
        $this->radius = $radius;
    }

    public function area()
    {
        return pi() * pow($shape->radius, 2);
    }
}

В этом случае метод sum класса AreaCalculator можно переписать так:

class AreaCalculator
{
    // ...

    public function sum()
    {
        foreach ($this->shapes as $shape) {
            $area[] = $shape->area();
        }

        return array_sum($area);
    }
}

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

Однако при этом возникает другая проблема. Как определить, что передаваемый в класс AreaCalculator объект действительно является фигурой, или что для этой фигуры задан метод area?

Кодирование в интерфейс является неотъемлемой частью принципов SOLID.

Создайте ShapeInterface, поддерживающий метод area:

interface ShapeInterface
{
    public function area();
}

Измените классы фигур, чтобы реализовать интерфейс ShapeInterface.

Вот обновление класса Square:

class Square implements ShapeInterface
{
    // ...
}

А вот обновление класса Circle:

class Circle implements ShapeInterface
{
    // ...
}

В методе sum класса AreaCalculator вы можете проверить, являются ли фигуры экземплярами ShapeInterface; а если это не так, программа выдаст исключение:

 class AreaCalculator
{
    // ...

    public function sum()
    {
        foreach ($this->shapes as $shape) {
            if (is_a($shape, 'ShapeInterface')) {
                $area[] = $shape->area();
                continue;
            }

            throw new AreaCalculatorInvalidShapeException();
        }

        return array_sum($area);
    }
}

Это соответствует принципу открытости/закрытости.

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

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

Пусть q(x) будет доказанным свойством объектов x типа T. Тогда q(y) будет доказанным свойством объектов y типа S, где S является подтипом T.

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

Возьмем класс AreaCalculator из нашего примера и рассмотрим новый класс VolumeCalculator, расширяющий класс AreaCalculator:

class VolumeCalculator extends AreaCalculator
{
    public function construct($shapes = [])
    {
        parent::construct($shapes);
    }

    public function sum()
    {
        // logic to calculate the volumes and then return an array of output
        return [$summedData];
    }
}

Помните, что класс SumCalculatorOutputter выглядит примерно так:

class SumCalculatorOutputter {
    protected $calculator;

    public function __constructor(AreaCalculator $calculator) {
        $this->calculator = $calculator;
    }

    public function JSON() {
        $data = array(
            'sum' => $this->calculator->sum();
        );

        return json_encode($data);
    }

    public function HTML() {
        return implode('', array(
            '',
                'Sum of the areas of provided shapes: ',
                $this->calculator->sum(),
            ''
        ));
    }
}

Если мы попробуем выполнить такой пример:

$areas = new AreaCalculator($shapes);
$volumes = new VolumeCalculator($solidShapes);

$output = new SumCalculatorOutputter($areas);
$output2 = new SumCalculatorOutputter($volumes);

Когда мы вызовем метод HTML для объекта $output2, мы получим сообщение об ошибке E_NOTICE, информирующее нас о преобразовании массива в строку.

Чтобы исправить это, вместо вывода массива из метода sum класса VolumeCalculator мы будем возвращать $summedData:

class VolumeCalculator extends AreaCalculator
{
    public function construct($shapes = [])
    {
        parent::construct($shapes);
    }

    public function sum()
    {
        // logic to calculate the volumes and then return a value of output
        return $summedData;
    }
}

Значение $summedData может быть дробным числом, двойным числом или целым числом.

Это соответствует принципу подстановки Лисков.

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

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

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

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

Давайте посмотрим, что произойдет, если мы изменим ShapeInterface, чтобы добавить новый контракт:

interface ShapeInterface
{
    public function area();

    public function volume();
}

Теперь все создаваемые фигуры должны иметь метод volume, но мы знаем, что квадраты — двухмерные фигуры, и у них нет объема. В результате этот интерфейс принуждает класс Square реализовывать метод, который он не может использовать.

Это нарушает принцип разделения интерфейса. Вместо этого мы можем создать новый интерфейс ThreeDimensionalShapeInterface, в котором имеется контракт volume, и трехмерные фигуры смогут реализовывать этот интерфейс:

interface ShapeInterface
{
    public function area();
}

interface ThreeDimensionalShapeInterface
{
    public function volume();
}

class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface
{
    public function area()
    {
        // calculate the surface area of the cuboid
    }

    public function volume()
    {
        // calculate the volume of the cuboid
    }
}

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

Так мы получим единый API для управления фигурами:

interface ManageShapeInterface
{
    public function calculate();
}

class Square implements ShapeInterface, ManageShapeInterface
{
    public function area()
    {
        // calculate the area of the square
    }

    public function calculate()
    {
        return $this->area();
    }
}

class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface, ManageShapeInterface
{
    public function area()
    {
        // calculate the surface area of the cuboid
    }

    public function volume()
    {
        // calculate the volume of the cuboid
    }

    public function calculate()
    {
        return $this->area();
    }
}

Теперь в классе AreaCalculator мы можем заменить вызов метода area вызовом метода calculate и проверить, является ли объект экземпляром класса ManageShapeInterface, а не ShapeInterface.

Это соответствует принципу разделения интерфейса.

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

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

Сущности должны зависеть от абстракций, а не от чего-то конкретного. Это означает, что модуль высокого уровня не должен зависеть от модуля низкого уровня, но они оба должны зависеть от абстракций.

Этот принцип открывает возможности разъединения.

Вот пример модуля PasswordReminder, подключаемого к базе данных MySQL:

class MySQLConnection
{
    public function connect()
    {
        // handle the database connection
        return 'Database connection';
    }
}

class PasswordReminder
{
    private $dbConnection;

    public function __construct(MySQLConnection $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }
}

Во-первых, MySQLConnection — это модуль низкого уровня, а PasswordReminder — модуль высокого уровня, однако определение D в принципах SOLID гласит: зависимость от абстракций, а не от чего-то конкретного. В приведенном выше фрагменте этот принцип нарушен, потому что класс PasswordReminder вынужденно зависит от класса MySQLConnection.

Если впоследствии вам потребуется изменить систему базы данных, вам также будет нужно изменить класс PasswordReminder, а это нарушит принцип открытости/закрытости.

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

interface DBConnectionInterface
{
    public function connect();
}

Интерфейс содержит метод connect, и класс MySQLConnection реализует этот интерфейс. Вместо того, чтобы прямо указывать тип класса MySQLConnection в конструкторе PasswordReminder, мы указываем тип класса DBConnectionInterface, и в этом случае, какую бы базу данных ни использовало ваше приложение, класс PasswordReminder сможет подключиться к этой базе данных без каких-либо проблем, и принцип открытости/закрытости не будет нарушен.

class MySQLConnection implements DBConnectionInterface
{
    public function connect()
    {
        // handle the database connection
        return 'Database connection';
    }
}

class PasswordReminder
{
    private $dbConnection;

    public function __construct(DBConnectionInterface $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }
}

В этом коде модули высокого уровня и модули низкого уровня зависят от абстракции.

Заключение

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

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

Creative Commons License