Вот о чем пойдет речь в этой статье: принципы SOLID с акцентом на принцип открытости/закрытости (Open/Closed), наследование, паттерны проектирования.

Позвольте мне сначала немного рассказать о себе. Это ненадолго, обещаю (по крайней мере, постараюсь 😉).

Я работаю в Believe в качестве Software Engineer уже 8 лет. До этого у меня был разнообразный профессиональный опыт в структурах самого разного типа.

Я использовал титул « Software Engineer », но на самом деле это не совсем так.

Я никакой не инженер. Я не заканчивал инженерный вуз. Вместо этого я научился программировать, пройдя эффективные, но относительно короткие курсы, и просто получая удовольствие от написания кода.

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

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

Что ж… Вы бы ошиблись 😉 Я постараюсь рассказать почему и привести примеры.

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

«Чистый» и «стандартный». Запомним эти ключевые слова на потом.

Другой аргумент связан с тем, как мне нужно было обеспечивать достойный результат от моего маленького бизнеса. Создание универсальных компонентов, которые можно было бы переиспользовать из одного проекта в другой, было одним из способов добиться этого. А как создавать компоненты, способные адаптироваться под разные контексты? Придерживаясь принципов.

«Принципы». Еще одна ключевая концепция, которую пока отложим в сторону.

Последний аргумент кроется в самой ее красоте. Красоте чего именно? Подождите, мы к этому подходим.

1. Чистота, стандарты, принципы и красота

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

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

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

Красота кода, которым нам хотелось бы обучать ИИ.

Красота… чистого кода, как его определял Мартин К. Фаулер (Martin C. Fowler).

Такой код всегда строится на принципах акронима SOLID, следует стандартам (таким как PHP-FIG PSR для PHP) и в целом сильно опирается на проверенные паттерны проектирования (design patterns).

Как вы, наверное, уже знаете, SOLID выступает за изоляцию зон ответственности (Single Responsibility Principle), защиту существующей ответственности от изменений (Open/Closed Principle), сохранение стабильности ответственности независимо от деталей реализации (Liskov Substitution Principle), правильную гранулярность разделения ответственности (Interface Segregation Principle) и обеспечение того, чтобы связи между зонами ответственности были абстрагированы в нисходящие потоки управления (Dependency Inversion Principle).

Но «нет никакой нужды заботиться о SOLID, чтобы расширить существующий код!» — может возразить кто-нибудь. — «Для этого есть наследование!». Вот только…

2. Что может пойти не так с наследований?

Давайте рассмотрим пример.

В команде создается базовый класс для удовлетворения бизнес-требования:

<?php

class MyGenericClass
{
public function sayHello(): void
{
echo 'Hello';
}
}

Разработчик А создает конкретный класс:

<?php

class MyConcreteClassA extends MyGenericClass
{
}

// Calling sayHello() will print:
> Hello

Но для особых сценариев использования, возникающих чуть позже, Разработчик B и Разработчик C создают другие конкретные классы.

Вот класс Разработчика B:

<?php

class MyConcreteClassB extends MyGenericClass
{
public function sayHello(): void
{
echo 'Hi';
}
}

// Calling sayHello() will print:
> Hi

А вот класс Разработчика C:

<?php

class MyConcreteClassC extends MyGenericClass
{
public function sayHello(): void
{
echo 'Hey';
}
}

// Calling sayHello() will print:
> Hey

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

Через некоторое время представители бизнеса запрашивают несколько улучшений:

<?php

class MyGenericClass
{
private bool $hasAlreadySaidHello = false;

public function sayHello(): void
{
echo($this->hasAlreadySaidHello ? 'Hello again' : 'Hello');
$this->hasAlreadySaidHello = true;
}

public function sayGoodbye(): void
{
echo 'Bye';
$this->hasAlreadySaidHello = false;
}
}

Бац! Вот тут-то и начинаются проблемы. Чтобы убедиться, что это новое требование применяется в каждой реализации, у вас есть три варианта.

1️⃣ Выбор 1: залезть в каждый переопределенный метод sayHello() и применить новое поведение (⚠️ и это утомительная работа)

<?php

// choice 1: implement the new behavior

class MyConcreteClassB extends MyGenericClass
{
private bool $hasAlreadySaidHello = false;

public function sayHello(): void
{
echo ($this->hasAlreadySaidHello ? 'Hi again' : 'Hi');
$this->hasAlreadySaidHello = true;
}

public function sayGoodbye(): void
{
parent::sayGoodbye();
$this->hasAlreadySaidHello = false;
}
}

2️⃣ Выбор 2: запустить каскадный эффект и контролировать вывод из дочернего класса (⚠️ и это рискованная работа)

<?php

// choice 2: trigger a cascading effect and control output from child

class MyConcreteClassB extends MyGenericClass
{
public function sayHello(): void
{
// Because our example is about printing stuff,
// we need to use buffering
ob_start();
parent::sayHello();
$str = ob_get_contents();
ob_end_clean();

echo str_replace('Hello', 'Hi', $str);
}
}

3️⃣ Выбор 3: изменить родительский класс в угоду нашему удобству (⚠️ и это рискованно и (должно быть) ❌ запрещено)

<?php

// choice 3: change parent

class MyGenericClass
{
// Nobody will see it, I will change this visibility LOL
protected bool $hasAlreadySaidHello = false;

public function sayHello(): void
{
echo ($this->hasAlreadySaidHello ? 'Hello again' : 'Hello');
$this->hasAlreadySaidHello = true;
}
}
<?php

// choice 3: now that parent has been changed I can do what I want

class MyConcreteClassC extends MyGenericClass
{
public function sayHello(): void
{
echo ($this->hasAlreadySaidHello ? 'Hey again' : 'Hey');
$this->hasAlreadySaidHello = true;
}
}

Если вы выберете третий вариант, ждите, что Разработчик B скоро придет за вами… 😉 (потому что вы только что вызвали краш ее класса).

Как видите, даже маленький пример с «hello world» отлично показывает, как использование наследования может быстро превратиться в кошмар (и это мы еще не ввели внучатые классы… 🤯).

И всё это объясняется очень просто: была проигнорирована буква O в SOLID.

3. Буква O в SOLID

Ранее мы говорили, что «O» означает принцип открытости/закрытости (Open/Closed Principle). И что он выполняет роль защиты существующей ответственности.

Что это значит?

Это значит, что разработчики A, B и C должны иметь возможность настраивать поведение, заложенное в родительском классе. Но только в тех пределах, которые родительский класс — как главный авторитет — считает допустимыми для изменения.

Судя по нашему примеру, в какой-то момент бизнесу потребовалось изменить формулировку. В одних сценариях они хотят, чтобы приложение говорило пользователям «Hello», в других — «Hi» или «Hey».

По сути, бизнес, сам того не зная, дал ценную подсказку о том, как реализовать эту фичу «устойчивым к O» образом.

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

+=======================================+==============+==================+
| Responsibilities | Parent class | Children classes |
+=======================================+==============+==================+
| Construct the object | ✅ | ⛔ |
+---------------------------------------+--------------+------------------+
| Describe behavior (print a greeting) | ✅ | ⛔ |
+---------------------------------------+--------------+------------------+
| Provide details (which greeting word) | ⛔ | ✅ |
+---------------------------------------+--------------+------------------+

Концепция, конечно, симпатичная, но… как это сделать?

Нужно сделать несколько вещей:

  • Найти способ помешать дочерним классам отклоняться от логики конструирования объекта
  • Найти способ помешать дочерним классам менять поведение
  • Найти способ заставить дочерние классы предоставлять приветственное слово
  • Найти способ гарантировать, что сами дочерние классы защищены по принципу «O»

_ 3a. Как помешать дочерним классам отклоняться в конструировании объекта

Что нужно сделать:
☑️ Убедитесь, что конструктор родительского класса объявлен как final.

<?php

class MyGenericClass
{
private bool $hasAlreadySaidHello = false;

final public function __construct()
{
}

public function sayHello(): void
{
echo($this->hasAlreadySaidHello ? 'Hello again' : 'Hello');
$this->hasAlreadySaidHello = true;
}

public function sayGoodbye(): void
{
echo 'Bye';
$this->hasAlreadySaidHello = false;
}
}

_ 3b. Как помешать дочерним классам менять поведение

Что нужно сделать:
☑️ Убедитесь, что методы родительского класса являются final

<?php

class MyGenericClass
{
private bool $hasAlreadySaidHello = false;

final public function __construct()
{
}

final public function sayHello(): void
{
echo ($this->hasAlreadySaidHello ? 'Hello again' : 'Hello');
$this->hasAlreadySaidHello = true;
}

final public function sayGoodbye(): void
{
echo 'Bye';
$this->hasAlreadySaidHello = false;
}
}

_ 3c. Как заставить дочерние классы предоставлять приветственное слово

Что нужно сделать:
☑️ Сделайте родительский класс абстрактным (abstract)
☑️ Объявите абстрактные методы

<?php

abstract class MyGenericClass
{
private bool $hasAlreadySaidHello = false;

final public function __construct()
{

}

final public function sayHello(): void
{
$greetingWord = $this->getGreetingWord();
$greeting = $this->hasAlreadySaidHello ?
$greetingWord .' again' :
$greetingWord;

echo $greeting;
$this->hasAlreadySaidHello = true;
}

final public function sayGoodbye(): void
{
echo 'Bye';
$this->hasAlreadySaidHello = false;
}

abstract protected function getGreetingWord(): string;
}

☑️ Реализуйте методы в дочерних классах

<?php 

class MyConcreteClassB extends MyGenericClass
{
protected function getGreetingWord(): string
{
return 'Hi';
}
}
<?php 

class MyConcreteClassC extends MyGenericClass
{
protected function getGreetingWord(): string
{
return 'Hey';
}
}

_ 3d. Как гарантировать, что сами дочерние классы защищены по принципу «O»

Что нужно сделать:
☑️ Сделайте весь класс final

<?php 

final class MyConcreteClassC extends MyGenericClass
{
protected function getGreetingWord(): string
{
return 'Hey';
}
}

Это всего лишь мое мнение, но как только вы обнаруживаете, что полагаетесь на внучатые классы (grand-children), это многое говорит о слишком сложной архитектуре, которая потенциально превратится в трудный в поддержке бардак… 🫣 Ни один класс не должен оспаривать решения, принятые в другом классе.

4. Стоп. Разве мы только что не «закрыли» классы? Где же здесь «открытость»?

Вы абсолютно правы! Кажется, пока мы позаботились только о букве «C» (Closed) из принципа открытости/закрытости.

Буква «O» в SOLID означает открытость для расширения и закрытость для модификации. Давайте посмотрим, как мы можем разрешить дочерним классам расширять поведение родительского.

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

_ 4a. Как (не) следует открывать дочерние классы для расширения

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

  • В итоге вы получите повсеместное использование уродливого и опасного parent::__construct() в своих подклассах.
  • Вы потенциально внесете несогласованность в процесс создания объектов в зависимости от того, на какой конкретный класс они опираются (поскольку изменение конструктора часто делается с целью внедрения другого типа данных).

Вот пример:

<?php

// Parent constructor takes one argument

abstract class AbstractDivergentConstructorExample
{
public function __construct(
private readonly int $someInteger
)
{
echo 'My integer is ' . $someInteger;
}
}

А дочерние классы могут легко сделать так:

<?php

// Child constructor takes two arguments

final class DivergentConstructorExample1 extends AbstractDivergentConstructorExample
{
public function __construct(
private readonly int $someInteger,
private readonly string $someString
)
{
// OK, do your stuff, parent
parent::__construct($someInteger);

// But I am doing mine too
echo 'My string is ' . $someString;
}
}

Или даже (еще хуже):

<?php

// Child constructor takes no argument at all

final class DivergentConstructorExample2 extends AbstractDivergentConstructorExample
{
public function __construct()
{
// Please be silent, parent
}
}

Так делать вполне допустимо (начиная с PHP 8.4), однако это ведет к следующим подводным камням:

  • родительский класс теряет контроль над тем, будут ли выполнены задачи по его инициализации;
  • дочерний класс вслепую запускает все, что прописано у его родителя;
  • клиентский код будет создавать объекты по-разному в зависимости от выбранной реализации.

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

Но мы все еще не решили вопрос с открытием дочерних классов для расширения 😉

Что ж, без лишних слов перейдем к тому, как именно мы это сделаем!

_ 4b. Как правильно открыть дочерние классы для расширения

Вернемся к нашему примеру с sayHello():

Что нужно сделать:
☑️ Добавить в родительский класс «хуки» (hooks), которые дочерние классы смогут использовать для собственной логики.

<?php 

abstract class MyGenericClass
{
private bool $hasAlreadySaidHello = false;

final public function __construct()
{
// First hook
$this->initialize();
}

final public function sayHello(): void
{
// ...

// Second hook
$this->prepareSayingHello($greeting);

echo $greeting;
// ...
}

// ...

abstract protected function initialize(): void;
abstract protected function getGreetingWord(): string;
abstract protected function prepareSayingHello(string &$greeting): void;
}

☑️ Использовать хуки в дочерних классах для добавления собственной логики.

<?php 

final class MyConcreteClassC extends MyGenericClass
{
protected function initialize(): void
{
// Whatever would be needed here...
}

protected function getGreetingWord(): string
{
return 'Hey';
}

protected function prepareSayingHello(string &$greeting): void
{
$greeting = '<p>' . $greeting . '</p>';
}
}

Конечно, наш пример выглядит немного наивно.

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

Ну… знаете что? Мы также подошли очень близко к одному прекрасному паттерну 😉

5. Встречайте: Шаблонный метод (Template Method)

Определение с сайта refactoring.guru:

Шаблонный метод (Template Method) — это поведенческий паттерн проектирования, который определяет скелет алгоритма, перекладывая ответственность за некоторые его шаги на подклассы. Паттерн позволяет подклассам переопределять шаги алгоритма без изменения его общей структуры.

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

Родительский класс абстрагирует поведение, а дочерние — реализуют его.

Это основано на той же логике, о которой мы говорим с самого начала этой статьи 😉

_ 5a. Простой пример

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

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

Кроме того, при входе в мини-игры выводится специальное приветственное сообщение.

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

  1. проверяется количество ваших кредитов;
  2. если кредитов не осталось и их нельзя сбросить, выводится сообщение и игра останавливается, в противном случае:
  3. устанавливается целевой результат;
  4. выдерживается пауза (время ожидания);
  5. генерируется случайный результат;
  6. целевой результат сравнивается с вашим случайным выпадением;
  7. выводится сообщение о победе или поражении;
  8. количество ваших кредитов уменьшается.

Как уже упоминалось, единственными изменяющимися элементами в описанном алгоритме являются:

  • ваше начальное количество кредитов на шаге 1;
  • возможность сброса кредитов на шаге 2;
  • тип результата на шагах 3 и 5
    * если это строка, то что это: цвет? известная цитата? имя? страна?
    * если это целое число, то это любое число от 0 до 10? случайное четное число? кратное 3?
  • способ достижения случайности на тех же шагах 3 и 5;
  • продолжительность времени ожидания на шаге 4.

Конечно, использование только «старого [или наиболее спонтанного]» наследования сработало бы. Значения по умолчанию можно было бы задать в абстрактном классе с помощью protected-свойств, а конкретные классы переопределяли бы их при необходимости. Но мы хотим избежать этого по всем тем причинам, которые мы рассмотрели 😉.

Вместо этого вот базовый класс и представление алгоритма в методе play():

<?php

namespace App;

abstract class AbstractLuckGame
{
private const int MAX_ATTEMPTS = 10;

private int $numberOfCredits;
private \DateTimeImmutable $resetCreditsLastDate;

final public function __construct(
protected readonly string $username
)
{
echo $this->greetings();
$this->initialize();
$this->resetCreditsLastDate = new \DateTimeImmutable();
}

private function initialize(): void
{
$numberOfCredits = $this->getInitialNbOfCredits();

if ($numberOfCredits < 1) {
throw new \InvalidArgumentException(
'Number of credits must be greater than 0'
);
}

$maxCredits = AbstractLuckGame::MAX_ATTEMPTS;
if ($numberOfCredits > $maxCredits) {
throw new \InvalidArgumentException(
'Number of credits must be smaller than ' . $maxCredits
);
}

$this->numberOfCredits = $numberOfCredits;
}

final public function play(): void
{
if ($this->numberOfCredits <= 0 && !$this->resetCredits()) {
echo 'Game over!';
return;
}

$expected = $this->getRandomResult();
echo "The expected result is: " . $expected;

echo "Shaking hands...";
sleep($this->getSuspenseTimeInSeconds());

$attempt = $this->getRandomResult();
echo "You got: " . $attempt;

echo ($expected === $attempt ? ' You won!' : ' Sorry, you lost!');

$this->numberOfCredits--;
}

private function resetCredits(): bool
{
$now = new \DateTimeImmutable();
$interval = $this->getResetCreditsInterval();

if ($this->resetCreditsLastDate->add($interval) > $now) {
return false;
}

$this->resetCreditsLastDate = $now;
$this->numberOfCredits = $this->getInitialNbOfCredits();
return true;
}

abstract protected function greetings(): string;
abstract protected function getInitialNbOfCredits(): int;
abstract protected function getRandomResult(): string|int;
abstract protected function getSuspenseTimeInSeconds(): int;
abstract protected function getResetCreditsInterval(): \DateInterval;
}

А это класс конкретной мини-игры:

<?php

namespace App;

class ColourLuckyGame extends AbstractLuckGame
{
private const array COLOURS = ['blue', 'yellow', 'pink', 'orange'];

protected function greetings(): string
{
return sprintf(
'Hello user %s! Welcome in the Colour Lucky Game!',
$this->username
);
}

protected function getInitialNbOfCredits(): int
{
return 5;
}

protected function getRandomResult(): string|int
{
return self::COLOURS[array_rand(self::COLOURS)];
}

protected function getSuspenseTimeInSeconds(): int
{
return 3;
}

protected function getResetCreditsInterval(): \DateInterval
{
// 1 hour
return new \DateInterval('P1H');
}
}

Ниже приводится краткая сводка основных проектных решений, принятых для этого примера: почему они соответствуют принципу открытости/закрытости и как они реализуют паттерн «Шаблонный метод».

Проектное решение №1:
Конструктор в базовом классе сделан финальным (final).
Предотвращает изменение логики создания экземпляра и аргументов со стороны конкретного класса.

Проектное решение №2:
Свойство $username сделано доступным только для чтения (readonly) с видимостью protected в базовом классе.
Защищает значение от изменения конкретным классом, сохраняя при этом возможность доступа к нему.

Проектное решение №3:
Свойства $numberOfCredits и $resetCreditsLastDate оставлены приватными (private) в базовом классе.
Базовый класс полностью отвечает за них, получая их значения из конкретного класса через абстрактные геттеры, когда это необходимо.

Проектное решение №4:
Установка начального количества кредитов в конкретном классе обязательна благодаря использованию абстрактного метода.
Валидация данных остается в базовом классе, а их установка — в конкретном.

Проектное решение №5:
Алгоритм игры описан в базовом классе, а сам метод сделан финальным (final).
Не позволяет конкретному классу менять шаги местами, сохраняя их абсолютно одинаковыми во всех конкретных классах.

Проектное решение №6:
Генерация случайного результата в конкретном классе обязательна благодаря абстрактному методу.
Базовый класс знает, что делать с результатом, а конкретный — как его получить.

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

_ 5b. Сценарии использования в финансовой сфере

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

_ 5c. Сценарии использования для уведомлений

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

Очень часто формирование сообщения остается неизменным, но канал отправки варьируется. При этом только тот класс, который непосредственно отправляет сообщение, знает, как обрабатывать ошибки.

_ 5d. Хуки движка

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

Приятного чтения!

Надеюсь, это дало вам хорошее представление о том, что такое паттерн Template Method и как его использовать.

И в целом, надеюсь, это укрепило вашу страсть к чистому и надежному коду 😉

++

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

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