handbookНаписать

patterns/behavioral/visitor/README.md · тема · 4 языка · 19 тестов

+ практика: НДС и чек по заказу

Посетитель (Visitor) ​

Новые операции над набором классов без правки самих классов: каждая операция живёт в своём объекте, а узел только говорит, какой он.

Проблема ​

Редактор схем хранит фигуры: круги и прямоугольники. Сначала нужна площадь, потом экспорт в SVG, потом периметр, проверка пересечений, экспорт в PDF. Если каждую операцию класть методом в фигуры, классы Circle и Rectangle превращаются в свалку: геометрия рядом с разметкой SVG и форматом PDF, и каждая новая выгрузка — правка всех фигур.

Вынести операцию в отдельную функцию мешает другое: функция получает «какую-то фигуру» и вынуждена выяснять её тип через instanceof. Такие проверки расползаются по коду, и когда появится треугольник, никто не подскажет, где его забыли.

Решение ​

Операция становится отдельным объектом — посетителем с методом на каждый вид узла: visitCircle, visitRectangle. Узел реализует один метод accept(visitor) и вызывает в нём «свой» метод посетителя. Это двойная диспетчеризация: первый вызов выбирает реализацию по типу узла (circle.accept), второй — по типу посетителя (AreaCalculator.visitCircle). Проверок типа нет ни в одном месте.

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

Структура ​

visitCircle(this)

visitRectangle(this)

«interface»

Shape

+accept(visitor) : T

«interface»

ShapeVisitor<T>

+visitCircle(circle) : T

+visitRectangle(rectangle) : T

Circle

+radius: int

+accept(visitor) : T

Rectangle

+width: int

+height: int

+accept(visitor) : T

AreaCalculator

SvgExporter

Узлы — неизменяемые данные с одним методом 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 ничего не возвращает, а посетитель копит результат в своих полях
PythonProtocol с параметром ShapeVisitor[T] и обобщённый метод accept[T] (синтаксис 3.12+); узлы — frozen dataclass
TypeScriptaccept<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) — тоже выносит алгоритм в объект, но для одного типа данных, без выбора по виду узла.
© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS