Введение
В этой статье разберём два паттерна одновременно — Template Method (определение общего «скелета» алгоритма с настраиваемыми шагами через переопределение в подклассах) и Visitor (добавление новых операций к иерархии классов без изменения самих этих классов), поскольку оба, при структурном различии, решают похожую по духу задачу гибкого расширения поведения существующей структуры.
Концепция
Template Method определяет в базовом классе общую последовательность шагов алгоритма (через невиртуальный метод, вызывающий несколько виртуальных, переопределяемых «крючков»-шагов), фиксируя общую структуру алгоритма, но позволяя подклассам настраивать отдельные, конкретные шаги этой структуры без необходимости полностью переопределять весь алгоритм целиком. Visitor решает иную, но концептуально связанную задачу — добавление НОВОЙ операции, применимой к целой иерархии разнородных классов, без модификации самих этих классов, через «двойную диспетчеризацию» (double dispatch) — объект, реализующий новую операцию (Visitor), посещает каждый элемент иерархии, и каждый элемент сам «принимает» посетителя, вызывая соответствующий, специфичный для своего конкретного типа метод посетителя.
Пример кода
#include <iostream>
// Template Method — общий АЛГОРИТМ обработки документа с настраиваемыми шагами
class DocumentProcessor
{
public:
void process() // НЕ виртуальный — фиксирует ОБЩУЮ структуру алгоритма для ВСЕХ подклассов
{
openDocument();
readContent();
closeDocument();
}
virtual ~DocumentProcessor() = default;
protected:
virtual void openDocument() { std::cout << "Открытие документа..." << std::endl; } // ШАГ с поведением по умолчанию
virtual void readContent() = 0; // ОБЯЗАТЕЛЬНЫЙ для переопределения шаг (статья 538 цикла синтаксиса)
virtual void closeDocument() { std::cout << "Закрытие документа..." << std::endl; }
};
class PdfProcessor : public DocumentProcessor
{
protected:
void readContent() override { std::cout << "Чтение PDF-содержимого" << std::endl; } // ТОЛЬКО этот шаг отличается
};
// Visitor — добавление НОВОЙ операции к существующей иерархии без её изменения
#include <memory>
#include <vector>
class Circle; class Square; // предварительные объявления
class ShapeVisitor // интерфейс посетителя — ОДИН метод для КАЖДОГО конкретного типа иерархии
{
public:
virtual void visit(Circle &circle) = 0;
virtual void visit(Square &square) = 0;
virtual ~ShapeVisitor() = default;
};
class Shape
{
public:
virtual void accept(ShapeVisitor &visitor) = 0; // КАЖДЫЙ элемент "принимает" посетителя — двойная диспетчеризация
virtual ~Shape() = default;
};
class Circle : public Shape
{
public:
void accept(ShapeVisitor &visitor) override { visitor.visit(*this); } // вызывает ИМЕННО visit(Circle&)
double radius = 5.0;
};
class Square : public Shape
{
public:
void accept(ShapeVisitor &visitor) override { visitor.visit(*this); } // вызывает ИМЕННО visit(Square&)
double side = 3.0;
};
class AreaCalculatorVisitor : public ShapeVisitor // НОВАЯ операция — БЕЗ изменения Circle/Square!
{
public:
void visit(Circle &circle) override { std::cout << "Площадь круга: " << 3.14159 * circle.radius * circle.radius << std::endl; }
void visit(Square &square) override { std::cout << "Площадь квадрата: " << square.side * square.side << std::endl; }
};
int main()
{
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Square>());
AreaCalculatorVisitor areaCalculator;
for (auto &shape : shapes) {
shape->accept(areaCalculator); // ДВОЙНАЯ диспетчеризация — точный тип определяется ДВАЖДЫ
}
return 0;
}
Пояснения к коду
DocumentProcessor::process() — невиртуальный метод, фиксирующий неизменную, общую последовательность шагов (openDocument → readContent → closeDocument) — PdfProcessor переопределяет только readContent() (единственный шаг, реально специфичный для PDF), наследуя поведение по умолчанию для остальных шагов, что показывает суть Template Method — общая структура алгоритма зафиксирована раз и навсегда в базовом классе, и подклассы настраивают лишь конкретные, нужные им детали. Shape::accept(visitor), вызывающий visitor.visit(*this), показывает механизм двойной диспетчеризации Visitor — первый виртуальный вызов (accept) определяет реальный, конкретный тип Shape (Circle или Square), и внутри этой конкретной реализации *this имеет точный, известный компилятору тип, что позволяет вызвать правильную, специфичную перегрузку visitor.visit() именно для этого конкретного типа — без этого механизма единственный виртуальный вызов не мог бы одновременно учесть и конкретный тип Shape, и конкретный тип Visitor.
Подводные камни
- Чрезмерное количество переопределяемых «шагов» в Template Method, делающее структуру алгоритма избыточно гибкой и сложной для понимания — если почти каждый шаг алгоритма переопределяется в каждом подклассе, это говорит о том, что реальной общей, неизменной структуры между подклассами не так много, и возможно, более простая, явная композиция отдельных функций (без иерархии наследования) была бы понятнее, чем Template Method с большим количеством независимо настраиваемых «крючков».
- Добавление нового конкретного типа в иерархию, для которой уже существует Visitor, требующее изменения ВСЕХ существующих реализаций интерфейса Visitor (добавления нового метода
visit()для нового типа) — это прямое следствие структуры Visitor: паттерн оптимизирован для добавления НОВЫХ ОПЕРАЦИЙ без изменения иерархии классов, но платит за это противоположной «негибкостью» — добавление нового типа В иерархию требует изменения каждого существующего, уже написанного Visitor, что является осознанным архитектурным компромиссом, который стоит явно учитывать при выборе этого паттерна для проекта с потенциально часто расширяемой иерархией классов. - Незнание о существовании более простой альтернативы для не самых сложных случаев «посещения» иерархии — для относительно простых задач без необходимости в нескольких разных, независимо добавляемых операциях обычный виртуальный метод непосредственно в каждом классе иерархии (без отдельного интерфейса Visitor) часто достаточен и значительно проще полной инфраструктуры Visitor с двойной диспетчеризацией.
- Путаница в порядке вызовов при отладке Template Method, особенно для разработчика, впервые встречающего конкретный подкласс и не знающего о существовании общей, фиксированной структуры алгоритма в (возможно, далеко в иерархии расположенном) базовом классе — без явного знания паттерна Template Method и его применения в конкретном случае, разработчик может быть удивлён, обнаружив вызов метода (
process()), реализация которого находится в совершенно ином, не сразу очевидном месте кодовой базы по сравнению с местом вызова конкретного шага алгоритма, что подчёркивает важность ясной документации применения этого паттерна в конкретном проекте.