Conceptual Article

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

Published on February 20, 2021
    authorauthor

    Samuel Oloruntoba and Bradley Kouchi

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

    Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

    Learn more about our products

    About the authors
    Default avatar
    Samuel Oloruntoba

    author



    Still looking for an answer?

    Ask a questionSearch for more help

    Was this helpful?
     
    1 Comments
    

    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 }

    public function volume()
    {
        // calculate the volume of the cuboid
    }
    
    public function calculate()
    {
        return $this->area();
    }
    

    }

    to: … public function calculate() { return $this->volume(); } …

    Try DigitalOcean for free

    Click below to sign up and get $200 of credit to try our products over 60 days!

    Sign up

    Join the Tech Talk
    Success! Thank you! Please check your email for further details.

    Please complete your information!

    Become a contributor for community

    Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

    DigitalOcean Documentation

    Full documentation for every DigitalOcean product.

    Resources for startups and SMBs

    The Wave has everything you need to know about building a business, from raising funding to marketing your product.

    Get our newsletter

    Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.

    New accounts only. By submitting your email you agree to our Privacy Policy

    The developer cloud

    Scale up as you grow — whether you're running one virtual machine or ten thousand.

    Get started for free

    Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

    *This promotional offer applies to new accounts only.