By Samuel Oloruntoba
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
, нужно создать экземпляр класса, передать в него массив фигур и вывести результат внизу страницы.
Вот пример с набором из трех фигур:
$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 и разработки адаптивного программного обеспечения.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Hi! Maybe it need to change class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface, ManageShapeInterface { public function area() { // calculate the surface area of the cuboid }
}
to: … public function calculate() { return $this->volume(); } …