🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Visitor Pattern w C# — anatomia GoF i double dispatch

Visitor Pattern — double dispatch, operacja poza klasą elementu na hierarchii kształtów w C#

Ostatni wzorzec z serii opartej na Anatomii wzorca — 8 elementach opisu GoF. Visitor — najbardziej podchwytliwy z GoF: pozwala dodawać nowe operacje do stabilnej hierarchii klas bez ich dotykania, kosztem odwrócenia trudności (nowy typ elementu staje się drogi).

1. Intent (Zamierzenie)

Reprezentuje operację do wykonania na elementach struktury obiektów. Pozwala zdefiniować nową operację bez zmiany klas elementów, na których działa.

Krótko: zamiast dopisywać kolejną metodę do każdej klasy hierarchii, zbierasz nową operację w jednym obiekcie-gościu (Visitor), który „odwiedza” każdy typ elementu.

2. Also Known As (Inne nazwy)

Wzorzec nie ma powszechnej drugiej nazwy — po polsku Odwiedzający.

3. Motivation (Motywacja)

Masz stabilną hierarchię — np. węzły drzewa składniowego (AST) kompilatora albo kształty geometryczne. Chcesz wykonywać na niej wiele różnych operacji: policzyć pole, wyrysować, wyeksportować do JSON, zwalidować. Dokładanie każdej z nich jako metody do wszystkich klas kształtów rozdmuchuje je i miesza niepowiązane odpowiedzialności (kształt nie powinien wiedzieć, jak się eksportuje do JSON).

Visitor odwraca to: operacja żyje w osobnej klasie, a każdy element „przyjmuje” gościa i mówi mu, jakim jest typem. Nowa operacja to nowy Visitor — zero zmian w klasach elementów.

4. Applicability (Kiedy stosować)

Stosuj Visitor gdy:

  • struktura obiektów jest stabilna (rzadko dochodzą nowe typy elementów), ale operacji na niej przybywa,
  • chcesz trzymać powiązane operacje razem (cała logika eksportu w jednym miejscu), zamiast rozsiewać je po klasach elementów,
  • operacje muszą działać różnie w zależności od konkretnego typu elementu, a nie chcesz drabinek is/rzutowań.

5. Structure (Struktura)

┌──────────────────┐          ┌──────────────────────┐
│   <<interface>>  │          │    <<interface>>     │
│      Element      │          │        Visitor        │
│──────────────────│          │──────────────────────│
│ +Accept(v:Visitor)│         │ +VisitKolo(k)         │
└──────────────────┘          │ +VisitProstokat(p)     │
        △                      └──────────────────────┘
   ┌────┴────┐                          △
┌──────────┐ ┌────────────┐    ┌────────┴────────┐
│   Kolo    │ │ Prostokat   │  ┌──────────────┐ ┌──────────────┐
│ +Accept() │ │ +Accept()   │  │ PoleVisitor   │ │ JsonVisitor   │
└──────────┘ └────────────┘   └──────────────┘ └──────────────┘
   │  Accept(v) => v.VisitKolo(this)

Sedno to Accept: element wywołuje na gościu metodę odpowiadającą swojemu typowi (v.VisitKolo(this)). To daje double dispatch — o wykonanym kodzie decyduje jednocześnie typ elementu i typ visitora.

6. Participants (Uczestnicy)

  • Visitor — interfejs z metodą Visit... dla każdego typu elementu (VisitKolo, VisitProstokat).
  • ConcreteVisitor — implementuje operację dla każdego typu (np. PoleVisitor liczy pole).
  • Element — interfejs z metodą Accept(Visitor).
  • ConcreteElement — implementuje Accept, wołając odpowiednią metodę gościa (v.VisitKolo(this)).
  • ObjectStructure — kolekcja elementów, po której przechodzi visitor.

7. Collaborations (Współpraca)

Klient tworzy ConcreteVisitor i przechodzi strukturę, wywołując Accept na każdym elemencie. Element woła zwrotnie metodę Visitora odpowiadającą swojej klasie, przekazując siebie — dzięki temu Visitor otrzymuje dostęp do konkretnego typu.

To zwrotne wywołanie (AcceptVisit) jest istotą wzorca — bez niego trzeba by ręcznie sprawdzać typ.

8. Consequences (Konsekwencje)

Plus: dodanie nowej operacji to jeden nowy Visitor, bez ruszania klas elementów. Plus: powiązana logika jednej operacji jest w jednym miejscu. Minus: dodanie nowego typu elementu wymaga zmiany interfejsu Visitor i wszystkich jego implementacji — odwrotność zwykłego polimorfizmu. Minus: Visitor często potrzebuje dostępu do wnętrza elementów, co osłabia enkapsulację.

Visitor w C# — od diagramu do kodu

### Scenariusz

Hierarchia kształtów (Kolo, Prostokat) jest stabilna, ale chcemy dokładać operacje: liczenie pola, potem eksport do JSON, potem obwód. Nie chcemy za każdym razem dopisywać metody do każdego kształtu.

### Element i Accept

public interface IKsztalt
{
    T Accept<T>(IKsztaltVisitor<T> visitor);
}

public class Kolo : IKsztalt
{
    public double Promien { get; init; }
    public T Accept<T>(IKsztaltVisitor<T> v) => v.VisitKolo(this); // mówi gościowi: jestem Kolo
}

public class Prostokat : IKsztalt
{
    public double Szerokosc { get; init; }
    public double Wysokosc { get; init; }
    public T Accept<T>(IKsztaltVisitor<T> v) => v.VisitProstokat(this);
}

### Visitor i konkretna operacja

public interface IKsztaltVisitor<T>
{
    T VisitKolo(Kolo k);
    T VisitProstokat(Prostokat p);
}

public class PoleVisitor : IKsztaltVisitor<double>
{
    public double VisitKolo(Kolo k) => Math.PI * k.Promien * k.Promien;
    public double VisitProstokat(Prostokat p) => p.Szerokosc * p.Wysokosc;
}
var ksztalty = new List<IKsztalt> { new Kolo { Promien = 2 }, new Prostokat { Szerokosc = 3, Wysokosc = 4 } };
var pole = new PoleVisitor();

double suma = ksztalty.Sum(k => k.Accept(pole)); // 12.566... + 12 = 24.566...

Nowa operacja — np. ObwodVisitor — to jedna nowa klasa; Kolo i Prostokat pozostają nietknięte. Na tym polega przewaga Visitora.

Idiom C# — pattern matching jako alternatywa

C# ma broń, której Java z czasów GoF nie miała: pattern matching. Dla tego samego problemu często wystarczy switch po typie, bez Accept i całej maszynerii double dispatch:

double Pole(IKsztalt k) => k switch
{
    Kolo kolo          => Math.PI * kolo.Promien * kolo.Promien,
    Prostokat prostokat => prostokat.Szerokosc * prostokat.Wysokosc,
    _ => throw new ArgumentException("Nieznany kształt")
};

To rozwiązanie oparte na pattern matchingu jest krótsze i czytelniejsze dla kilku typów. Pełny Visitor wybierasz, gdy operacji jest wiele i chcesz je grupować w osobnych klasach, albo gdy zależy Ci na wymuszeniu obsługi każdego typu przez kompilator (interfejs Visitora nie skompiluje się bez metody na każdy element).

Pułapka: problem ekspresji (expression problem)

Visitor rozwiązuje jeden wymiar rozszerzalności kosztem drugiego — to klasyczne pytanie na rozmowie:

  • Zwykły polimorfizm (metoda w każdej klasie): łatwo dodać typ (nowa klasa z metodą), trudno dodać operację (metoda w każdej istniejącej klasie).
  • Visitor: łatwo dodać operację (nowy Visitor), trudno dodać typ (nowa metoda w interfejsie Visitor i we wszystkich implementacjach).

Nie ma rozwiązania dobrego w obu wymiarach naraz — to „problem ekspresji”. Visitor wybierasz tylko wtedy, gdy typy są stabilne, a operacje się mnożą. Gdy dochodzą nowe typy, Visitor stanie się kulą u nogi.

Wariant: double dispatch bez generyków

Powyżej użyliśmy generycznego Accept<T>, żeby visitor mógł zwracać dowolny typ. Klasyczna wersja GoF jest bez generyków — Accept(IVisitor) zwraca void, a wynik visitor gromadzi w swoim stanie wewnętrznym. Wersja generyczna jest w C# wygodniejsza (czysty zwracany wynik), ale mechanizm double dispatch — element woła metodę gościa odpowiadającą swojemu typowi — jest w obu identyczny.

Podsumowanie

Visitor wyciąga operacje ze stabilnej hierarchii klas do osobnych obiektów-gości, dzięki czemu nową operację dodajesz jako nowy Visitor, nie dotykając klas elementów. Działa to przez double dispatch: element w Accept woła metodę gościa odpowiadającą swojemu typowi. Cena jest odwrotna do zwykłego polimorfizmu — dodanie nowego typu elementu wymaga zmiany wszystkich visitorów (problem ekspresji), więc wzorzec ma sens tylko przy stabilnych typach i rosnącej liczbie operacji. W C# dla prostszych przypadków pattern matching jest zwięźlejszą alternatywą, którą warto rozważyć, zanim sięgniesz po pełnego Visitora.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

Dodaj komentarz

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →