Перейти к содержимому
Фёдор Башинский

Шаблонный метод

5 min read
Обложка статьи «Шаблонный метод»

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

Проблема

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

final class EngineDiagnosticProcedure
{
    public function run(ObdConnection $obd): DiagnosticReport
    {
        $obd->connect();
        $codes = $obd->readErrorCodes();
 
        $checks = $this->runEngineSpecificChecks($obd);
 
        $report = new DiagnosticReport($codes, $checks);
        $obd->disconnect();
 
        return $report;
    }
 
    private function runEngineSpecificChecks(ObdConnection $obd): array
    {
        // проверки компрессии, давления масла, свечей зажигания...
    }
}
 
final class BrakeDiagnosticProcedure
{
    public function run(ObdConnection $obd): DiagnosticReport
    {
        $obd->connect();
        $codes = $obd->readErrorCodes();
 
        $checks = $this->runBrakeSpecificChecks($obd);
 
        $report = new DiagnosticReport($codes, $checks);
        $obd->disconnect();
 
        return $report;
    }
 
    private function runBrakeSpecificChecks(ObdConnection $obd): array
    {
        // проверки толщины колодок, давления в тормозном контуре...
    }
}

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

Решение

Шаблонный метод переносит общий скелет в абстрактный базовый класс. Метод run() реализован там целиком и объявлен final, чтобы порядок шагов нельзя было случайно нарушить в подклассе, — а варьирующийся шаг остаётся абстрактным:

abstract class AbstractDiagnosticProcedure
{
    final public function run(ObdConnection $obd): DiagnosticReport
    {
        $obd->connect();
        $codes = $obd->readErrorCodes();
 
        $checks = $this->runSpecificChecks($obd);
 
        $report = new DiagnosticReport($codes, $checks);
        $obd->disconnect();
 
        return $report;
    }
 
    abstract protected function runSpecificChecks(ObdConnection $obd): array;
}

Конкретные классы теперь содержат только то, что действительно у них своё:

final class EngineDiagnosticProcedure extends AbstractDiagnosticProcedure
{
    protected function runSpecificChecks(ObdConnection $obd): array
    {
        // проверки компрессии, давления масла, свечей зажигания...
    }
}
 
final class BrakeDiagnosticProcedure extends AbstractDiagnosticProcedure
{
    protected function runSpecificChecks(ObdConnection $obd): array
    {
        // проверки толщины колодок, давления в тормозном контуре...
    }
}
$procedure = new EngineDiagnosticProcedure();
$report = $procedure->run($obd); // подключение, чтение кодов, отчёт и отключение — из общего скелета

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

Не все дополнительные шаги обязательны для всех подклассов — для них в базовый класс добавляют необязательные шаги-хуки с поведением по умолчанию:

abstract class AbstractDiagnosticProcedure
{
    final public function run(ObdConnection $obd): DiagnosticReport
    {
        $obd->connect();
        $this->beforeChecks($obd); // хук — по умолчанию ничего не делает
 
        $codes = $obd->readErrorCodes();
        $checks = $this->runSpecificChecks($obd);
 
        $report = new DiagnosticReport($codes, $checks);
        $obd->disconnect();
 
        return $report;
    }
 
    protected function beforeChecks(ObdConnection $obd): void
    {
        // необязательный шаг — подклассы переопределяют по необходимости
    }
 
    abstract protected function runSpecificChecks(ObdConnection $obd): array;
}

BrakeDiagnosticProcedure может переопределить beforeChecks(), чтобы, например, дождаться остывания тормозных дисков перед измерением, — а EngineDiagnosticProcedure может вообще не трогать этот метод: унаследованная пустая реализация не изменит его поведение.

Из чего состоит паттерн

  • Абстрактный класс (AbstractDiagnosticProcedure) — определяет шаблонный метод с фиксированным порядком шагов, часть из которых реализована сразу, а часть оставлена абстрактной.
  • Шаблонный метод (run()) — объявлен final, чтобы подклассы не могли изменить порядок или пропустить шаги.
  • Абстрактные шаги (runSpecificChecks()) — обязаны быть реализованы каждым подклассом.
  • Хуки (beforeChecks()) — необязательные шаги с поведением по умолчанию, которые подкласс может переопределить, а может не трогать.
  • Конкретный класс (EngineDiagnosticProcedure, BrakeDiagnosticProcedure) — реализует только то, что действительно отличается от других видов диагностики.

Шаблонный метод vs Стратегия

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

Шаблонный методСтратегия
МеханизмНаследование — шаг переопределяется в подклассеКомпозиция — алгоритм целиком передаётся объектом
Гибкость выбораФиксируется выбором класса, не меняется на летуМожно подменить в рантайме
Что переиспользуетсяОбщий скелет алгоритма в базовом классеНичего — стратегии независимы друг от друга

Когда применять

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

Плюсы и минусы

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

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

Итог

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