
«Посетитель» (visitor) — один из самых сложных паттернов Банды Четырёх.
На языке C# для него можно создать множество реализаций, однако все они так или иначе имеют ограничения из-за возникающего динамического приведения типов.
В рамках статьи вы погрузитесь в проблематику мультиметодов и увидите новую реализацию паттерна, лишённую озвученных недостатков и открывающую возможность к написанию по-настоящему гибкого и типобезопасного кода!
Поскольку ты читаешь эту статью, то я уверен, что ты знаком с такими вещами, как ООП, паттерны, полиморфизм и хотя бы раз в жизни слышал слово Visitor.
Казалось бы, паттерн «Посетитель», да что тут можно придумать? Прямо сейчас я покажу тебе, как изобрёл его новую и абсолютно уникальную реализацию.
Пара слов о Visitor
Где вы столкнётесь с необходимостью использовать этот паттерн?
- Самое очевидное применение — конструирование компиляторов, поскольку это самый лучший способ обрабатывать Abstract Syntax Tree.
- Работа с Expressions API или Roslyn API — опять же прямая работа с объектной моделью синтаксиса.
- Паттерн «Компоновщик», он же Composite — рекурсивная древовидная структура данных.
Отдельным пунктом выделю создание DSL, поскольку здесь стоит прикрепить интересный доклад о практическом применении DSL в кровавом энтерпрайзе:
Я столкнулся с ними при обучении в ВУЗе. Дело в том, что моя кафедра, ИУ-9 МГТУ им. Н. Э. Баумана, специализируется на конструировании компиляторов, что определило тему диплома — создание интерпретируемого языка программирования.
Соответственно, я брал некий исходный код, парсил его в AST, а потом обходил дерево и генерировал список инструкций для виртуальной машины, совершая приблизительно такое преобразование:

Вообще, AST в этой предметной области — такой же базовый кирпич, как JSON в промышленной разработке — вокруг него слишком много фундаментальных задач:
- Инициализация областей видимости и таблиц символов
- Статический анализ
- Предварительная оптимизация
- Кодогенерация
- И многое другое
Поэтому вопрос удобной и эффективной работы с подобными структурами данных стоит остро!
▍ Интрузивный подход
Это причина, по которой паттерн появился. Такой ошибочный подход означает расширение функционала рекурсивной структуры данных через наследование и полиморфизм подтипов:
public abstract class AbstractSyntaxTreeNode { public virtual List<Instruction> ToInstructions(int start) => new(); }
Последствиями такого недальновидного проектирования будут трудноисправляемые и неотлаживаемые баги. Также ограничивается возможность развития системы — сложность реализации новых фич растёт экспоненциально из-за неправильного распределения ответственностей компонентов.
Например, в моём случае интрузивно реализованная кодогенерация присваивала инструкциям некорректный адрес, из-за чего виртуальная машина либо попадала в бесконечный цикл, либо создавался runtime exception:

Насколько всё плохо, станет понятно, как только вы посмотрите на код, генерирующий некоторые инструкции до «Посетителя»:
public override List<Instruction> ToInstructions(int start, string temp) { var instructions = new List<Instruction>(); (IValue left, IValue right) right = (null, null); if (_expression.Primary()) { right.right = ((PrimaryExpression) _expression).ToValue(); } else { instructions.AddRange(_expression.ToInstructions(start, temp)); if (_expression is MemberExpression member && member.Any()) { var i = start + instructions.Count; var dest = "_t" + i; var src = instructions.Any() ? instructions.OfType<Simple>().Last().Left : member.Id; var instruction = member.AccessChain.Tail switch { DotAccess dot => new Simple(dest, (new Name(src), new Constant(dot.Id, dot.Id)), ".", i), IndexAccess index => new Simple(dest, (new Name(src), index.Expression.ToValue()), "[]", i), _ => throw new NotImplementedException() }; instructions.Add(instruction); } right.right = new Name(instructions.OfType<Simple>().Last().Left); } var number = instructions.Any() ? instructions.Last().Number + 1 : start; instructions.Add(new Simple( temp + number, right, _operator, number )); return instructions; }
А как хорошо стало после применения паттерна, видно в следующем сниппете — код читается подобно тексту на английском языке:
public AddressedInstructions Visit(UnaryExpression visitable) { if (visitable.Expression is PrimaryExpression primary) return [new Simple(visitable.Operator, _valueDtoConverter.Convert(primary.ToValueDto()))]; var result = visitable.Expression.Accept(This); var last = new Name(result.OfType<Simple>().Last().Left!); result.Add(new Simple(visitable.Operator, last)); return result; }
В чём же магия новой архитектуры и почему это работает? Вернёмся к истокам и освежим в памяти
▍ Классический Visitor
Иллюстрировать это проще в виде картинок, чтобы ставить друг напротив друга по��ещаемые элементы и их посетители. Есть некая иерархия элементов, и её нужно обработать. Для этого в каждом члене иерархии добавляется виртуальный метод обработки. Этот метод принимает на вход обработчика, который имеет метод обработки элемента с перегрузкой на каждый тип элемента:

Ключевые особенности этого паттерна можно обобщить следующим образом:
- Решает проблему интрузивного подхода — операция отделена от данных через класс «Посетителя».
- Работает по схеме double dispatch — полиморфизм подтипов + перегрузка методов (ad-hoc полиморфизм). То есть сначала на абстрактном элементе выбирается нужное переопределение в иерархии, а затем внутри конкретной реализации определяется конкретная перегрузка.
- Возможно, неочевидно, но можно возвращать значения. Вместо
voidникто не запрещает написать, например,string.
▍ Недостатки классического Visitor
Нетрудно заметить, что мы мигом получили циклическую зависимость — класс
Visitor знает всё про иерархию наследников Element.
Из этого вытекает необходимость постоянного доступа к исходникам обеих иерархий, поскольку любое изменение иерархии посетителей повлечёт за собой изменение иерархии посетителей. Почему я говорю про иерархию посетителей, если на картинке один класс
Visitor?abstract class Element { abstract void Accept(IVisitor visitor); } interface IVisitor { void Visit(ElementA elementA); void Visit(ElementB elementB); } class VisitorOne : IVisitor { public void Visit(ElementA elementA) {} public void Visit(ElementB elementB) {} } class VisitorTwo : IVisitor { public void Visit(ElementA elementA) {} public void Visit(ElementB elementB) {} }
▍ Ациклический Visitor
Устраняет недостатки классического варианта, связанные с возникающей циклической зависимостью. Вводятся две новые абстракции для посетителя, которые проводят архитектурную границу, отделяя контракт от реализации. Одна абстракция используется на принимающей стороне, а вторая нужна для «сборки» своего кастомного посетителя. Такой декларативный подход позволяет с помощью системы типов описать, какие элементы умеет посещать тот или иной посетитель. Буквально, конструктор LEGO.

Теперь стало гораздо лучше — иерархия элементов ничего не знает про иерархию обработчиков и то, как её могут обрабатывать, а конкретные обработчики выбирают только определённые элементы. Однако мы получаем новую проблему.
public class ElementA : Element { public override void Accept(IVisitor visitor) { if (visitor is IVisitor<ElementA> typed) typed.Visit(this); } }
Конечно же, речь про потерю типобезопасности в compile-time и перенос проверки типов в runtime.
▍ Проблема downcasting
Игнорируя полиморфизма подтипов, мы теряем его суть — отсутствие необходимости задумываться о конкретном типе объекта. Подобные явные приведения сигнализируют о возможно плохой архитектуре (недостаточно объектно-ориентированной или слишком противоречивой). Ну и, конечно, такой код является лазейкой для трудноуловимых багов, которые проявляются только при запуске приложения.
При каких обстоятельствах стоит рассмотреть downcasting?
- Есть 100% корректное знание о конкретном типе объекта в указанном месте программы во время её выполнения (нет).
- Знание этого факта позволит получить преимущество за счёт использования возможностей объекта, недоступных на этапе компиляции (отчасти).
- Привести тип на порядок быстрее и проще, чем отрефакторить код с целью удаления явного приведения (нет).
Также появляется и другая проблема — отсутствие возможности юнит-тестирования подобной системы. Например, в метод
Accept определённого элемента добавился сквозной функционал, однако в такой конфигурации мокирование просто не скомпилируется:
Дело в том, что обработчик данного элемента неприводим к типу
IVisitor, а сам IVisitor является маркерным интерфейсом без каких-либо членов:
Возникает болезненная ситуация.
Язык ОО, паттерн ОО, а код не ОО
(ОО — объектно-ориентированный)
Почему всё ломается, когда хочется немного выйти за рамки учебников и немного обобщить существующий подход? Поставим задачу это починить. Требования следующие:
- Сохранить все преимущества ациклического варианта (гибкость, избирательность, декларативность, удовлетворение OCP и LSP и так далее).
- Оставить все проверки типов на этапе компиляции (воспользоваться полноценно преимуществами статической типизации).
Внезапно, в решении поставленной задачи нам поможет вариантность формальных типовых параметров в C#.
▍ Контравариантность
Возможность подставить более базовый тип на место формального типового параметра, который используется для объявления аргументов функций.
interface IFooIn<in TInputType> { void Bar(TInputType input); }
Пример из стандартной библиотеки —
IComparable, где благодаря контравариантности можно сделать так:IComparable<IEnumerable<char>> charEnumerableComparable = // ...; // ... IComparable<string> stringComparable = charEnumerableComparable;
▍ Ковариантность
Возможность подставить более производный тип на место формального типового параметра, который используется для объявления возврата значений из функций.
interface IFooOut<out TOutputType> { TOutputType Baz(); }
Пример из стандартной библиотеки —
IEnumerable, где благодаря ковариантности можно сделать так:IEnumerable<Task<object>> tasksWithResults = // ...; // ... IEnumerable<Task> tasks = tasksWithResults;
▍ Чем это полезно?
Во-первых, первую и вторую вариантность можно совмещать в рамках одного типа данных:
interface IFooInOut<in TInputType, out TOutputType> { TOutputType Foo(TInputType input); }
Во-вторых, это, конечно же, производительность при вариантном приведении типов, как было показано в примерах со стандартной библиотекой. Дело в том, что при использовании вариантности никаких дополнительных IL инструкций не генерируется.
Вообще, какие способы приведения типов у нас есть?
- Прямое (Direct) приведение, когда оба выражения имеют одинаковый тип.
- Неявное (Implicit) приведение, когда тип одного из выражений эквивалентен типу другого выражения.
- Явное (Explicit) приведение через оператор явного приведения
(Type). В таком случае в IL генерируется инструкцияcastclass. - Безопасное (As) приведение через оператор
as, когда в отличие от явного приведения при неудаче получимnullвместо исключения. В таком случае в IL генерируется инструкцияisinst. - Динамическое (Dynamic) приведение через определение типа во время выполнения программы с помощью ключевого слова
dynamic. Соответственно, определение типа переходит на уровень IL.
Мне стало интересно, какова производительность каждого из указанных сценариев приведения типов? Я составил бенчмарк, который её проверяет, код можно найти под спойлером:
Код бенчмарка
using System.Diagnostics.CodeAnalysis; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; BenchmarkRunner.Run<CastingBenchmarks>(); [SuppressMessage("ReSharper", "ReturnValueOfPureMethodIsNotUsed")] [SuppressMessage("Performance", "CA1822:Пометьте члены как статические")] #pragma warning disable CA1050 public class CastingBenchmarks #pragma warning restore CA1050 { private static readonly ICovariant<Task<object>> SpecificCovariant = new Covariant<Task<object>>(); private static readonly ICovariant<Task> GeneralCovariant = SpecificCovariant; private static readonly IContravariant<IEnumerable<char>> GeneralContravariant = new Contravariant<IEnumerable<char>>(); private static readonly IContravariant<string> SpecificContravariant = GeneralContravariant; [Benchmark(Baseline = true)] public void Direct() { SpecificCovariantMethod(SpecificCovariant); GeneralContravariantMethod(GeneralContravariant); } [Benchmark] public void Implicit() { GeneralCovariantMethod(SpecificCovariant); SpecificContravariantMethod(GeneralContravariant); } [Benchmark] public void Explicit() { SpecificCovariantMethod((ICovariant<Task<object>>)GeneralCovariant); GeneralContravariantMethod((IContravariant<IEnumerable<char>>)SpecificContravariant); } [Benchmark] public void As() { SpecificCovariantMethod((GeneralCovariant as ICovariant<Task<object>>)!); GeneralContravariantMethod((SpecificContravariant as IContravariant<IEnumerable<char>>)!); } [Benchmark] public void Dynamic() { SpecificCovariantMethod((dynamic)GeneralCovariant); GeneralContravariantMethod((dynamic)SpecificContravariant); } // ReSharper disable once UnusedTypeParameter private interface ICovariant<out T>; private class Covariant<T> : ICovariant<T>; private static void SpecificCovariantMethod(ICovariant<Task<object>> input) => input.ToString(); private static void GeneralCovariantMethod(ICovariant<Task> input) => input.ToString(); // ReSharper disable once UnusedTypeParameter private interface IContravariant<in T>; private class Contravariant<T> : IContravariant<T>; private static void SpecificContravariantMethod(IContravariant<string> input) => input.ToString(); private static void GeneralContravariantMethod(IContravariant<IEnumerable<char>> input) => input.ToString(); }
Моя гипотеза была следующей: Dynamic самый медленный, Implicit и Direct чуть быстрее Explicit и As. Результат её подтвердил:
BenchmarkDotNet v0.13.11, macOS Ventura 13.7.2 (22H313) [Darwin 22.6.0] Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores .NET SDK 9.0.200 [Host] : .NET 9.0.2 (9.0.225.6610), Arm64 RyuJIT AdvSIMD ShortRun : .NET 9.0.2 (9.0.225.6610), Arm64 RyuJIT AdvSIMD Job=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3
| Method | Mean | Ratio |
| Direct | 5.226 ns | 1.00 |
| Implicit | 5.234 ns | 1.00 |
| Explicit | 5.266 ns | 1.01 |
| As | 5.261 ns | 1.01 |
| Dynamic | 14.278 ns | 2.73 |
Решение — Visitor.NET
Совмещение контравариантности и ковариантности открыло для меня новые возможности в проектировании абстракций. Я пошёл дальше оригинальной идеи ациклической версии паттерна и ввёл абстракцию для посещаемого элемента —
IVisitable. Итоговые контракты вышли следующими:public interface IVisitable<out TVisitable> where TVisitable : IVisitable<TVisitable> { TReturn Accept<TReturn>(IVisitor<TVisitable, TReturn> visitor); } public interface IVisitor<in TVisitable, out TReturn> where TVisitable : IVisitable<TVisitable> { TReturn Visit(TVisitable visitable); }
Во-первых, теперь элемент может принимать только тот обработчик, который умеет его обрабатывать — больше никакого downcasting.
Во-вторых, благодаря контравариантности мы можем оперировать через публичные базовые контракты, исключая необходимость знания об обоих иерархиях.
Получается схема triple dispatch:
- В иерархии элементов полиморфно выбирается нужный наследник
Accept. - В Accept был передан «базовый» визитор для корня иерархии, и благодаря контравариантности он отправляется в перегрузку
Acceptнаследника, где он превратится в посетителя этого конкретного наследника. - Внутри конкретной перегрузки
Acceptвызывается перегрузкаVisitсогласно типу элемента.
public abstract class Element : IVisitable<Element> { public abstract TReturn Accept<TReturn>( IVisitor<Element, TReturn> visitor); } public class ElementA : Element, IVisitable<ElementA> { public override TReturn Accept<TReturn>( IVisitor<Element, TReturn> visitor) => Accept(visitor); public TReturn Accept<TReturn>( IVisitor<ElementA, TReturn> visitor) => visitor.Visit(this); }
Мне даже не нужно описывать пример посетителя, чтобы донести суть — всё строится на контрактах и абстракциях. Всё же, с точки зрения самого визитора ничего не поменялось, всё такой же ациклический.
public class ElementVisitor : VisitorNoReturnBase<Element>, IVisitor<ElementA> { public VisitUnit Visit(ElementA visitable) { return default; } }
▍ Visitor.NET + SourceGenerators!
Вы могли заметить недостаток — для каждого элемента иерархии необходимо реализовывать две перегрузки метода
Accept с достаточно трудночитаемой сигнатурой из-за дженериков. Однако этот недостаток уже решён с помощью Source Generators!Теперь на наследников достаточно повесить атрибут
[AutoVisitable] с указанием корня иерархии в угловых скобках:[AutoVisitable<Element>] public partial class ElementA : Element; [AutoVisitable<Element>] public partial class ElementB : Element;
Можно ли без указания корня иерархии? Нет, поскольку это сильно затруднило бы разработку генератора. Дело в том, что разработчик может проектировать «промежуточные ступени» иерархии, вынося некий общий функционал в абстрактные подклассы:
[AutoVisitable<Element>] public partial class ElementA : ParticularElement; public abstract class ParticularElement : Element;
▍ А мультиметоды?
Когда я рассказал про библиотеку в своём Телеграм-канале, один подписчик задал интересный вопрос:

Что такое вообще мультиметоды? Мультиметод — это механизм динамического определения нужной функции в зависимости от типа переданных значений, расширяющий полиморфизм подтипов.
Начнём с того, что в C# такой функции нет, её можно эмулировать с помощью ключевого слова
dynamic. Допустим есть некая абстракция над числом, тогда мультиметод обеспечит работу абстрактного метода сложения.public interface INumber { INumber Add(INumber number); } record MyInt(int Val) : INumber; record MyRational(int Num, int Den) : INumber;
По сути, нам нужно подобрать перегрузку в рантайме, что можно было бы сделать так:
record MyInt(int Val) : INumber { public INumber Add(INumber number) => Add((dynamic)number); private INumber Add(MyInt myInt) => new MyInt(Val + myInt.Val); private INumber Add(MyRational myRational) => myRational with { Num = Val * myRational.Den + myRational.Num }; }
Этот подход не удовлетворяет поставленным условиям задачи, а также имеет низкую производительность из-за динамического приведения типов, как было показано в бенчмарке выше. Поэтому подобный вариант мной не рассматривается.
Итоги
- Освежили свои знания о паттерне Visitor.
- Познакомились с кейсами его применения.
- Увидели абсолютно новую ациклическую версию паттерна без downcasting’а.
- Погрузились в проблематику мультиметодов.
- Вспомнили, что вариантность работает быстро, так как отсутствует приведение типов.
- Ну и, конечно, на Джаве такого не напишешь!
Поставить звезду GitHub репозиторию проекта можно по ссылке: github.com/stepami/visitor-net.
Ссылки на NuGet пакеты:
- Контракты — www.nuget.org/packages/Visitor.NET
- Генератор — www.nuget.org/packages/Visitor.NET.AutoVisitableGen
Ещё я веду Telegram-канал StepOne, куда выкладываю много интересного контента про коммерческую разработку на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT-индустрии.
© 2025 ООО «МТ ФИНАНС»
Telegram-канал со скидками, розыгрышами призов и новостями IT 💻

