В этой статье я постараюсь рассказать о принципе проектирования, называемом Inversion of Control / Инверсия управления (IoC), называемом еще Голливудским принципом. Покажу, какое отношение это имеет к принципу подстановки Барбары Лисково (LSP), а также внесу свою лепту в священную войну private vs protected.



В качестве предисловия хочу сказать несколько слов о себе. По образованию я инженер-программист, в индустрии IT работаю уже более 10 лет и в последнее время увлекаюсь написанием тематических профессиональных статей. Некоторые из них были удачными. Ранее я публиковалась на другом ресурсе, к сожалению, недоступном на территории России (привет Роскомнадзору). Если кто-то захочет с ними ознакомиться — вы знаете что делать.

Все примеры кода, как обычно, представлены в статье псевдокодом, стилизованным под “ненавистный php”.

Исходная задача


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

Мы решаем эту задачу с помощью трех команд, периодически запускаемых по расписанию:

  • MonthlyReportCommand
  • DailyReportCommand
  • HourlyRerortCommand

Нам понадобятся интерфейсы:

interface ReportCommandInterface {
    public function createReport(): Money;
}

interface MoneyRepositoryInterface {
    /** @return Money[] */
    public function getMoney(Period $period): array;
}

interface MetricRepositoryInterface {
    public function saveMoneyMetric(Period $period, Money $amount, string $metricType);
}

Пишем команды репортов (последняя опущена, в качестве практического упражнения для тех, кто хочет хорошенько разобраться и потренироваться написать ее самостоятельно):

class MonthlyReportCommand implements ReportCommandInterface {
    //lets assume constructor is already here

    public function createReport(): Money {
        $period = new Period(new DateTime('first day of previous month'), new DateTime('last day of previous month'));
        $moneyRecords = $this->moneyRepository->getMoney($period);
        $amount = $this->calculateTotals($moneyRecords);
        $this->metricRepository->saveMoneyMetric($period, $amount, 'monthly income');
    }

    /** @param Money[] $moneyRecords */
    private function calculateTotals(array $moneyRecords): Money {
        //here is calculating sum of money records
    }
}

class DailyReportCommand implements ReportCommandInterface {
    //lets assume constructor is already here

    public function createReport(): Money {
        $period = new Period(new DateTime('yesterday'), new DateTime('today'));
        $moneyRecords = $this->moneyRepository->getMoney($period);
        $amount = $this->calculateTotals($moneyRecords);
        $this->metricRepository->saveMoneyMetric($period, $amount, 'daily income');
    }

    /** @param Money[] $moneyRecords */
    private function calculateTotals(array $moneyRecords): Money {
        //here calculates sum of money records
    }
}

class HourlyReportCommand ... {
    //the same as previous two but hourly
}

И видим, что код метода calculateTotals() будет совершенно одинаковый во всех случаях. Первое, что приходит в голову — это положить дублирующийся код в общий абстрактный класс. Вот так:



abstract class AbstractReportCommand {
    protected function calculateTotals(array $moneyRecords): Money {
        //here calculates sum of money records
    }
}

class MonthlyReportCommand extends AbstractReportCommand implements ReportCommandInterface {
    public function createReport(): Money {
        //realization is here, calls calculateTotals($moneyRecords)
    }
}

class DailyReportCommand extends AbstractReportCommand implements ReportCommandInterface {
    //the same as previous two but daily
}

class HourlyReportCommand ... {
    //the same as previous two but hourly
}

Метод calculateTotals() является частью внутренних механизмов нашего класса. Мы предусмотрительно закрываем его, т.к. он не должен быть вызван посторонними внешними клиентами — мы не для того его проектируем. Мы объявляем этот метод protected, т.к. планируем вызывать его в наследниках — вот наша цель. Очевидно, что такой абстрактный класс очень похож на нечто вроде библиотеки — он просто предоставляет какие-то методы (для php-знатоков: т.е. работает как Trait).

Секрет абстрактных классов


Настало время немного отвлечься от примера и вспомнить назначение абстрактных классов:

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

Абстракция (лат. abstractio — отвлечение) — это отвлечение от деталей и обобщение. На данный момент класс AbstractReportCommand обобщает для всех репортов только подсчет денег. Но мы можем сделать нашу абстракцию эффективнее, воспользовавшись Голливудским принципом, который звучит так:

“Не вызывайте нас, мы вызовем вас сами”

Чтобы увидеть, как это работает, давайте поместим в AbstractReportCommand общий механизм функционирования отчетов:



abstract class AbstractReportCommand implements ReportCommandInterface {
    /** @var MoneyRepositoryInterface */
    private $moneyRepository;
    /** @var MetricRepositoryInterface */
    private $metricRepository;

    //lets assume constructor is already here

    public function createReport(): Money {
        $period = $this->getPeriod();
        $metricType = $this->getMetricType();

        $moneyRecords = $this->moneyRepository->getMoney($period);
        $amount = $this->calculateTotals($moneyRecords);

        $this->metricRepository->saveMoneyMetric($period, $amount, $metricType);
    }

    abstract protected function getPeriod(): Period;
    abstract protected function getMetricType(): string;

    private function calculateTotals(array $moneyRecords): Money {
        //here calculates sum of money records
    }
}

class MonthlyReportCommand extends AbstractReportCommand {
    protected function getPeriod(): Period  {
        return new Period(new DateTime('first day of previous month'), new DateTime('last day of previous month'));
    }

    protected function getMetricType(): string {
        return 'monthly income';
    }
}

class DailyReportCommand extends AbstractReportCommand {
    protected function getPeriod(): Period  {
        return new Period(new DateTime('yesterday'), new DateTime('today'));
    }

    protected function getMetricType(): string {
        return 'daily income';
    }
}

class HourlyReportCommand ... {
    //the same as previous two but hourly
}

Что у нас получилось. Более ни один из наследников абстрактного класса не обращается к общим механизмам (не вызывайте нас). Вместо этого абстракция задает своим наследникам общую схему функционирования и требует от них реализовать частные особенности поведения, пользуясь только результатами (мы вызовем вас сами).

А как же обещанные IoC, LSP, private vs protected?


Так при чем тут Инверсия управления? Откуда такое название? Очень просто: сначала мы задаем последовательность вызовов непосредственно в конечных реализациях, управляя тем, что и когда будет сделано. А в дальнейшем, мы перенесли эту логику в общую абстракцию. Теперь абстракция управляет тем, что и когда будет вызвано, а реализации просто подчиняются этому. Т.е мы инвертировали управление.

Чтобы закрепить такое поведение и избежать проблем с принципом подстановки Барбары Лисков (LSP), можно закрыть метод createReport() с помощью включения final в объявление метода. Ведь всем известно, что LSP имеет прямое отношение к наследованию.

abstract class AbstractReportCommand implements ReportCommandInterface {
    final public function createReport(): Money {
        //bla-bla realization
    }
    ...
}

Тогда все наследники класса AbstractReportCommand становятся жестко подчинены единой логике, которую невозможно переопределить. Железная дисциплина, порядок, светлое будущее.

По той же причине становится очевидным преимущество private перед protected. Все, что относится к общим механизмам функционирования, должно быть зашито в абстрактном классе и недоступно к переопределению — private. Все, что должно быть переопределено/реализовано в частных случаях — abstract protected. Любые методы конструируются для конкретных целей. И если вы не знаете, какую именно область видимости задать методу — это значит, что вы не знаете, зачем вы его создаете. Такой дизайн стоит пересмотреть.

Выводы


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

Если взглянуть шире, то наше местечковое противостояние Голливудского принципа и абстрактного класса-библиотечки превращается в спор: фреймворк (IoC по-взрослому) vs библиотека. Нет смысла доказывать, что из них лучше — каждый создается с определенной целью. Единственно важное — осознанное создание подобных структур.

Спасибо всем, кто внимательно прочитал от начала до конца — вы мои любимые читатели.

Комментарии (11)


  1. MonkAlex
    27.11.2019 13:33

    А я слышал ещё одну штуку, которую стараюсь применять.
    Предпочитайте композицию наследованию.
    Вынесите общий код в отдельный класс и не делайте наследование совсем.


    1. NatalieNyshta Автор
      28.11.2019 18:14

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


    1. NatalieNyshta Автор
      28.11.2019 18:37
      +1

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


      1. MonkAlex
        28.11.2019 22:25

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

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


        1. NatalieNyshta Автор
          29.11.2019 00:20

          Композиция прекрасна. Как и грамотно организованное наследование, когда оно нужно. Мы снова приходим к спору о здравом смысле.


  1. oxidmod
    27.11.2019 13:49
    +1

    А можно вообще одну команду, которая стартовую и конечную дату принимает, и не плодить наследование ради наследования


    1. NatalieNyshta Автор
      28.11.2019 18:15

      Конечно можно. Но мне нужен был лаконичный пример для демонстрации данного принципа проектирования


      1. oxidmod
        29.11.2019 13:28

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


        1. NatalieNyshta Автор
          29.11.2019 14:13

          «Не надо быть умным, надо быть понятным». Я стараюсь ориентироваться на флагманов нашей индустрии — все приличные авторы обьясняют сложные понятия на максимально простых примерах, т.к. целью ставится объяснить, а не показать собственную крутость.

          > потом начинают применять где надо и где не надо
          Такое поведение говорит о том, что человек действительно понял какую-то конкретную тему, но не потрудился разобраться во всем остальном. У меня был коллега, который все принципы проектирования сводил к SRP. Показываешь ему Инверсию зависимостей — так это же SRP! Как раскладывать на уровни абстракций — так это же SRP! Сегрегация интерфейсов — о, так это совсем SRP! И так во всём


  1. PowerMetall
    28.11.2019 09:28

    Да ладно вам ))

    улыбочку =)
    public report SuperPooperTrashNoobReportMethod(bool? type){
    	if (type) // true 
    	{		
    		return MonthlyReport();
    	}
    	else // false
    	{
    		return DailyReport();
    	}
    	// another
    	return HourlyReport();
    }


    1. oxidmod
      28.11.2019 11:05

      Почти
      public Report SuperPooperSimpleReportMethod(int fromTimestamp, int toTimestamp)