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

Посетитель

7 min read
Обложка статьи «Посетитель»

Посетитель (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() при этом менее очевидна при чтении кода, чем прямой вызов метода на объекте.

Итог

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