Visitor Pattern w C# — anatomia GoF i double dispatch

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.
PoleVisitorliczy 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 (Accept → Visit) 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.
🚀 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.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
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ę →