Введение
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 и разработки адаптивного программного обеспечения.