Посетитель

Посетитель (Visitor) — это поведенческий паттерн проектирования, который позволяет добавлять новые операции над набором разнородных классов, не изменяя сами эти классы, — операция выносится в отдельный объект, а элементы лишь помогают ему определить, к какому из них он обращается.
Проблема
Позиции заказ-наряда бывают трёх видов: запчасть, работа и скидка:
interface WorkOrderItem
{
public function getAmount(): int;
}
final class PartItem implements WorkOrderItem
{
public function __construct(
public readonly string $article,
public readonly int $price,
) {}
public function getAmount(): int { return $this->price; }
}
final class LaborItem implements WorkOrderItem
{
public function __construct(
public readonly string $operation,
public readonly int $hours,
public readonly int $rate,
) {}
public function getAmount(): int { return $this->hours * $this->rate; }
}
final class DiscountItem implements WorkOrderItem
{
public function __construct(
public readonly int $amount,
) {}
public function getAmount(): int { return -$this->amount; }
}Нужно напечатать чек — построчно и по-разному для каждого вида позиции:
final class ReceiptPrinter
{
public function print(array $items): string
{
$lines = [];
foreach ($items as $item) {
$lines[] = match (true) {
$item instanceof PartItem => sprintf('Запчасть %s: %d ₽', $item->article, $item->price),
$item instanceof LaborItem => sprintf('%s: %d ч × %d ₽', $item->operation, $item->hours, $item->rate),
$item instanceof DiscountItem => sprintf('Скидка: −%d ₽', $item->amount),
default => throw new \LogicException('Неизвестный тип позиции'),
};
}
return implode("\n", $lines);
}
}Дальше нужен расчёт налога — НДС начисляется на запчасти и работы, но не на скидки:
final class TaxCalculator
{
public function calculate(array $items): int
{
$tax = 0;
foreach ($items as $item) {
$tax += match (true) {
$item instanceof PartItem => (int) round($item->price * 0.2),
$item instanceof LaborItem => (int) round($item->getAmount() * 0.2),
$item instanceof DiscountItem => 0, // скидка не облагается налогом
default => throw new \LogicException('Неизвестный тип позиции'),
};
}
return $tax;
}
}Следующей понадобится выгрузка в 1С — со своим собственным instanceof-переключением по тем же самым трём типам. Каждая новая операция повторяет один и тот же перебор одних и тех же классов, и все эти места узнают друг о друге только через совпадающий список веток. Появится четвёртый вид позиции — например, комиссия за срочность, — и придётся вспомнить и поправить каждый match во всех операциях сразу; забытая ветка не вызовет ошибку компиляции, только неверный результат в рантайме или неожиданное исключение в default.
Альтернатива — перенести каждую операцию методом прямо в классы позиций (getReceiptLine(), getTax(), toOneC() на PartItem, LaborItem, DiscountItem) — не спасает: теперь каждый класс позиции обрастает методами под все возможные операции сразу, а новая операция всё равно требует правки каждого из трёх классов, просто с другой стороны.
Решение
Посетитель выносит каждую операцию в отдельный класс, реализующий общий интерфейс с одним методом на каждый тип позиции. Классы позиций получают единственный, стабильный метод accept(), который вызывает подходящий метод переданного посетителя, — этот механизм называется двойной диспетчеризацией: конкретный тип элемента и конкретный посетитель определяются вместе, в одном вызове.
interface WorkOrderItemVisitor
{
public function visitPart(PartItem $item): mixed;
public function visitLabor(LaborItem $item): mixed;
public function visitDiscount(DiscountItem $item): mixed;
}
interface WorkOrderItem
{
public function accept(WorkOrderItemVisitor $visitor): mixed;
}Классы позиций почти не меняются — вместо getAmount() они реализуют accept(), который просто вызывает соответствующий метод посетителя:
final class PartItem implements WorkOrderItem
{
public function __construct(
public readonly string $article,
public readonly int $price,
) {}
public function accept(WorkOrderItemVisitor $visitor): mixed
{
return $visitor->visitPart($this);
}
}
final class LaborItem implements WorkOrderItem
{
public function __construct(
public readonly string $operation,
public readonly int $hours,
public readonly int $rate,
) {}
public function accept(WorkOrderItemVisitor $visitor): mixed
{
return $visitor->visitLabor($this);
}
}
final class DiscountItem implements WorkOrderItem
{
public function __construct(
public readonly int $amount,
) {}
public function accept(WorkOrderItemVisitor $visitor): mixed
{
return $visitor->visitDiscount($this);
}
}Каждая операция — отдельный класс-посетитель:
final class ReceiptPrinter implements WorkOrderItemVisitor
{
public function visitPart(PartItem $item): string
{
return sprintf('Запчасть %s: %d ₽', $item->article, $item->price);
}
public function visitLabor(LaborItem $item): string
{
return sprintf('%s: %d ч × %d ₽', $item->operation, $item->hours, $item->rate);
}
public function visitDiscount(DiscountItem $item): string
{
return sprintf('Скидка: −%d ₽', $item->amount);
}
/** @param WorkOrderItem[] $items */
public function print(array $items): string
{
return implode("\n", array_map(fn (WorkOrderItem $item) => $item->accept($this), $items));
}
}
final class TaxCalculator implements WorkOrderItemVisitor
{
private const RATE = 0.2;
public function visitPart(PartItem $item): int
{
return (int) round($item->price * self::RATE);
}
public function visitLabor(LaborItem $item): int
{
return (int) round($item->hours * $item->rate * self::RATE);
}
public function visitDiscount(DiscountItem $item): int
{
return 0; // скидка не облагается налогом
}
/** @param WorkOrderItem[] $items */
public function calculate(array $items): int
{
return array_sum(array_map(fn (WorkOrderItem $item) => $item->accept($this), $items));
}
}$items = [
new PartItem('Тормозные колодки', 4_500),
new LaborItem('Замена колодок', hours: 1, rate: 2_000),
new DiscountItem(500),
];
$receipt = (new ReceiptPrinter())->print($items);
$tax = (new TaxCalculator())->calculate($items);Появится комиссия за срочность — придётся один раз добавить visitFee() в интерфейс WorkOrderItemVisitor и реализовать его в каждом существующем посетителе. Но это отличается от забытой ветки instanceof: типизированный интерфейс заставит PHP выдать ошибку, если реализация метода пропущена, а не тихо промолчит, — и каждый посетитель получает ровно то, что нужно для его собственной операции, ни строчки лишнего.
Из чего состоит паттерн
- Посетитель (
WorkOrderItemVisitor) — интерфейс с методомvisitX()на каждый конкретный тип элемента. - Конкретный посетитель (
ReceiptPrinter,TaxCalculator) — одна операция, применённая к каждому типу элемента. - Элемент (
WorkOrderItem) — интерфейс с методомaccept(), чья реализация в каждом конкретном элементе вызывает подходящий метод посетителя. - Конкретный элемент (
PartItem,LaborItem,DiscountItem) — стабильный, почти не меняющийся класс: новая операция не требует его трогать вообще. - Клиент — обходит коллекцию элементов, вызывая
accept()на каждом с нужным посетителем.
Посетитель vs Итератор
Оба паттерна часто работают над одной и той же коллекцией, но решают разные задачи и хорошо дополняют друг друга. Итератор (см. отдельную статью) поставляет обход — какой элемент следующий, — не зная и не заботясь о том, что с этим элементом будут делать. Посетитель поставляет операцию — что сделать с элементом, когда он уже получен, — не занимаясь вопросом, как коллекция вообще устроена и в каком порядке отдаёт свои элементы. В примерах выше array_map по $items играет роль простого обхода; будь внутреннее устройство коллекции позиций скрыто за собственным классом, Итератор оформил бы этот обход отдельно, а Посетитель по-прежнему отвечал бы только за операцию над каждым полученным элементом.
| Посетитель | Итератор | |
|---|---|---|
| Задача | Добавить новую операцию над разнородными типами элементов | Обойти коллекцию, не раскрывая её внутреннее устройство |
| Что не меняется при расширении | Классы элементов — новая операция это новый посетитель | Коллекция — новый способ обхода это новый итератор |
| Типичная связка | Применяется к каждому элементу, полученному в ходе обхода | Часто отдаёт элементы, которые затем обходятся посетителем |
Когда применять
- Нужно часто добавлять новые операции над фиксированным набором разнородных типов, а сами эти типы должны оставаться стабильными и не разрастаться методом на каждую новую операцию.
- Несколько разных операций дублируют один и тот же
instanceof-чейн по одним и тем же типам — верный признак, что структуру пора инвертировать. - Набор типов элементов, наоборот, стабилен и меняется редко — Посетитель делает добавление нового типа дорогим (нужно поправить каждого посетителя), поэтому платит именно за частые новые операции при редких новых типах.
Плюсы и минусы
Посетитель позволяет добавлять новую операцию новым классом, не трогая ни один из существующих классов элементов, а логика, связанная с одной операцией для всех типов элементов, собрана в одном месте, а не размазана по методам разных классов.
Минус — добавление нового типа элемента требует правки интерфейса WorkOrderItemVisitor и каждой его реализации: паттерн буквально переворачивает удобство местами — то, что было дёшево (новый тип элемента), становится дорого, а то, что было дорого (новая операция), становится дёшево. Двойная диспетчеризация через accept()/visitX() при этом менее очевидна при чтении кода, чем прямой вызов метода на объекте.
Итог
Посетитель нужен, когда набор типов элементов стабилен, а операций над ними становится всё больше, и каждая новая иначе повторяла бы один и тот же перебор типов. Он меняет местами то, что легко и что дорого менять, по сравнению с обычным добавлением методов в классы, — и это осознанный компромисс под конкретную форму роста системы, а не универсальное улучшение.