Посетитель (Visitor)
Новые операции над набором классов без правки самих классов: каждая операция живёт в своём объекте, а узел только говорит, какой он.
Проблема
Редактор схем хранит фигуры: круги и прямоугольники. Сначала нужна площадь, потом экспорт в SVG, потом периметр, проверка пересечений, экспорт в PDF. Если каждую операцию класть методом в фигуры, классы Circle и Rectangle превращаются в свалку: геометрия рядом с разметкой SVG и форматом PDF, и каждая новая выгрузка — правка всех фигур.
Вынести операцию в отдельную функцию мешает другое: функция получает «какую-то фигуру» и вынуждена выяснять её тип через instanceof. Такие проверки расползаются по коду, и когда появится треугольник, никто не подскажет, где его забыли.
Решение
Операция становится отдельным объектом — посетителем с методом на каждый вид узла: visitCircle, visitRectangle. Узел реализует один метод accept(visitor) и вызывает в нём «свой» метод посетителя. Это двойная диспетчеризация: первый вызов выбирает реализацию по типу узла (circle.accept), второй — по типу посетителя (AreaCalculator.visitCircle). Проверок типа нет ни в одном месте.
ShapeVisitor перечисляет все виды узлов. Поэтому новая операция — новый класс-посетитель, фигуры не меняются. А новый вид фигуры — новый метод в интерфейсе, и компилятор показывает каждого посетителя, который его ещё не реализовал. В этом и сделка приёма: операции добавлять дёшево, узлы — дорого.
Структура
Узлы — неизменяемые данные с одним методом accept. Вся их «логика» — одна строка: передать посетителю себя через метод своего типа. Узел не знает, что посетитель посчитает, и не зависит ни от одного конкретного посетителя, только от интерфейса.
Как это собирается
Фигуры создаёт загрузчик документа: он читает строки сохранённого файла и по полю типа собирает Circle или Rectangle. Это единственное место, где тип выбирается по данным, и здесь же решается, что делать с незнакомой фигурой: старый редактор открыл файл с треугольником, пропустил его и предупредил, а не упал.
Операции живут отдельно от фигур. Меню экспорта держит карту "формат -> посетитель": для svg есть SvgExporter, для pdf посетителя ещё нет, и меню отвечает "не поддерживается", не трогая фигуры. Строка состояния считает площадь тем же обходом с AreaCalculator. Новый формат - новый посетитель и строка в карте.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
AreaCalculator и SvgExporter — две операции над одними и теми же фигурами. Геометрия и разметка не встречаются в одном классе, а результат у каждой операции свой: число и строка. Обобщённый ShapeVisitor<T> позволяет accept вернуть именно его, без приведения типов.
| Язык | Особенность |
|---|---|
| PHP | Интерфейсы и final readonly узлы; тип результата задают @template T и @implements ShapeVisitor<float> для PHPStan |
| Go | Методы не бывают обобщёнными, поэтому Accept ничего не возвращает, а посетитель копит результат в своих полях |
| Python | Protocol с параметром ShapeVisitor[T] и обобщённый метод accept[T] (синтаксис 3.12+); узлы — frozen dataclass |
| TypeScript | accept<T>(visitor: ShapeVisitor<T>): T; поле kind у классов заодно делает фигуры дискриминированным объединением |
Запуск: make test T=patterns/behavioral/visitor
Альтернатива: сопоставление с образцом
Во многих языках двойную диспетчеризацию заменяет разбор типа прямо в функции. perimeter написан так в каждом языке, и разница в том, кто заметит забытый вид фигуры:
- TypeScript — объединение
Circle | Rectangleс полемkindиswitch. В веткеdefaultпеременная имеет типnever; добавьтеTriangleв объединение, иtscукажет на этотswitch. Проверка полноты такая же строгая, как у посетителя, а кода меньше. - Python —
matchс образцами классов иassert_neverв конце. Полноту проверяют mypy и pyright, сам Python — только во время выполнения (AssertionError). Для открытого набора типов естьfunctools.singledispatch: регистрация обработчика на класс, но без проверки полноты. - Go —
switch s := s.(type). Компилятор полноту не проверяет, забытый тип попадает вdefault, поэтому функция возвращает ошибку. Классический посетитель в Go даёт проверку при компиляции через интерфейс, поэтому его берут, когда видов узлов много и пропуск дорог. В стандартной библиотеке выбран компромисс:ast.Walkведёт обход, аast.Visitorс одним методомVisit(node)разбирает тип сам через type switch. - PHP —
match (true)сinstanceof. Ни язык, ни PHPStan без дополнительной разметки не знают, что других фигур нет:defaultбросаетLogicExceptionво время выполнения. Здесь классический посетитель — единственный способ получить проверку от статического анализа.
Классический посетитель всё же лучше, когда язык не проверяет полноту разбора (PHP, Go), когда операция должна жить в объекте со своим состоянием и зависимостями (накопить итог по дереву, держать соединение, конфиг) и когда обход дерева нужно отделить от операций: узел-контейнер сам ведёт посетителя по детям.
Плюсы и минусы
Тесты проверяют обе операции на круге и прямоугольнике одной таблицей и прогоняют список разных фигур через один и тот же вызов accept. Третий тест — альтернатива: периметр через разбор типа и неизвестная фигура, которую разбор ловит только во время выполнения (в TypeScript та же фигура не проходит tsc, поэтому строка помечена @ts-expect-error).
- ✅ Новая операция — новый класс, узлы и старые операции не трогаются.
- ✅ Связанный код одной операции в одном месте: весь экспорт в SVG в одном классе.
- ✅ Проверки типов нет, а забытый вид узла ловит компилятор или анализатор.
- ✅ Посетитель может копить состояние по ходу обхода (сумма, построитель строки).
- ❌ Новый вид узла ломает всех посетителей сразу: приём для стабильной иерархии.
- ❌ Посетитель видит только публичное состояние узлов: инкапсуляция ослабевает.
- ❌ Двойная диспетчеризация непривычна: вызов
shape.accept(visitor)читается хуже, чемarea(shape).
Когда не применять
- Виды узлов меняются чаще, чем операции: каждая новая фигура — правка всех посетителей. Тогда операция — метод узла, обычный полиморфизм.
- Операция одна или две и не растёт: метод в узле проще.
- Язык проверяет полноту разбора (TypeScript, Rust, Kotlin
sealed, Scala): объединение иswitch/matchдают то же безaccept. - Иерархия открыта для расширения снаружи (плагины добавляют свои узлы): посетитель с закрытым списком методов им не подходит.
Отличия между языками
- PHP — классика:
interface ShapeVisitorиaccept()в каждом узле. Обобщения только в PHPDoc (@template), поэтомуaccept()в коде возвращаетmixed, а точный тип результата знает PHPStan. - Go — методы интерфейса не могут быть обобщёнными,
Accept[T]не написать. Посетитель — указатель со своим состоянием (Total,strings.Builder), результат забирают после обхода. Проверкаvar _ ShapeVisitor = (*AreaCalculator)(nil)ломает сборку, если в интерфейсе появился метод, которого нет у посетителя. - Python —
Protocolпроверяется только анализатором; во время выполнения посетитель безvisit_rectangleупадёт на первом прямоугольнике.matchиsingledispatchчасто удобнее самого приёма. - TypeScript — структурные типы: посетителем годится любой объект с нужными методами, даже литерал. Дискриминированное объединение с проверкой
never— идиоматичная замена посетителю, если узлы — простые данные.
Практика
Учебный посетитель вычисляет по одному значению для каждой фигуры и не показывает, во что обходится приём. Практика: НДС и чек по заказу обходит заказ из товарных строк, скидок, доставки и упаковки тремя посетителями с состоянием (НДС по ставкам, строки фискального чека, проверка заказа), добавляет четвёртую операцию в тесте без правки узлов и показывает, как каждый язык отказывается принять посетителя, забывшего про один вид узла.
Связанные темы
- Практика: НДС и чек по заказу — посетители с состоянием, новая операция без правки узлов и цена нового узла
- Компоновщик (Composite) — дерево, по которому обычно ходит посетитель; операции над деревом уходят из узлов в посетителей.
- Интерпретатор (Interpreter) — дерево выражений, над которым операции (вычисление, печать, оптимизация) часто оформляют посетителями.
- Итератор (Iterator) — тоже обходит структуру, но отдаёт элементы одного вида и не выбирает операцию по типу узла.
- Стратегия (Strategy) — тоже выносит алгоритм в объект, но для одного типа данных, без выбора по виду узла.