Wzorzec Visitor pozwala dodawać nowe operacje na obiektach różnych typów bez zmiany tych typów. Należy do wzorców behawioralnych Gang of Four i sprawdza się, gdy trzeba wykonać logikę zależną od typu na zbiorze heterogenicznych obiektów.
Motywacja
Metody wirtualne: tradycyjne podejście
W językach obiektowych podstawowym sposobem dodania zachowania do obiektów są metody wirtualne. Każda klasa zawiera dane i operacje, które na nich działają. To podejście ma jednak ograniczenia zależne od scenariusza:
Scenariusz 1: zamknięty zbiór operacji, otwarty zbiór klas
Gdy masz ustalony zbiór operacji, ale spodziewasz się dodania nowych podklas:
Sytuacja bieżąca:
- Masz hierarchię kształtów: Shape, Circle, Rectangle, Triangle
- Operacje takie jak draw() są zdefiniowane jako metody wirtualne w klasie bazowej
- Każda podklasa implementuje operację
Gdy dodajesz nową podklasę (np. Pentagon):
- Musisz zaimplementować metodę wirtualną w nowej klasie
- Istniejący kod nadal działa
Gdy dodajesz nową operację (np. calculateArea()):
- Musisz dodać metodę wirtualną do klasy bazowej
- Musisz ją zaimplementować we wszystkich podklasach, nawet jeśli nie każda jej potrzebuje
- To ingerencja w całą hierarchię
Scenariusz 2: zamknięte klasy, otwarty zbiór operacji
Gdy masz ustalony zbiór klas, ale potrzebujesz wielu różnych operacji:
Sytuacja bieżąca: - Struktura klas jest stabilna (np. węzły AST, kształty geometryczne) - Implementujesz operacje za pomocą metod wirtualnych
Problem: - Każda nowa operacja wymaga modyfikacji klasy bazowej - Musisz dodać nową metodę wirtualną - Musisz ją zaimplementować w każdej podklasie - Klasy zapełniają się implementacjami metod - Klasy rosną i trudniej je utrzymać - Dodawanie operacji jest ściśle powiązane z definicjami klas
Jeśli potrzebujesz draw(), serialize(), calculateArea(), calculatePerimeter(), export(), validate() itp., klasy zaczynają mieć odpowiedzialności, które nie mają ze sobą związku.
Problem ponownego wykorzystania: dane powiązane z zachowaniem
Załóż, że chcesz wykorzystać te klasy w innym projekcie:
Obecne podejście (metody wirtualne):
- Klasy mają metody do renderowania (draw()), serializacji XML (toXML()) i obliczeń (calculateArea())
- Przenosisz kod do nowego projektu
- Nowy projekt potrzebuje struktur tylko do obliczeń matematycznych, nie renderowania ani serializacji
- Musisz ciągnąć wszystkie nieużywane metody i ich zależności
- Klasy zawierają zachowanie nieistotne dla tego projektu
- Jeśli renderowanie korzysta z biblioteki graficznej niedostępnej w nowym projekcie, pojawia się problem zależności
To naruszenie Zasady Pojedynczej Odpowiedzialności. Klasy odpowiadają za więcej niż za swoje dane.
Rozwiązanie: oddzielenie zachowania od danych
Wzorzec Visitor rozwiązuje te problemy, rozdzielając operacje od struktur danych, na których działają.
Zamiast dodawać metody do klasy:
1. Struktura danych pozostaje prosta: tylko dane i minimalne zachowanie.
2. Operacje trafiają do osobnych klas visitorów.
3. Visitor trafia do obiektu przez metodę accept().
4. Każdy visitor wie, jak wykonać swoją operację na każdym typie.
Zalety:
-
Lokalność funkcji i danych. Powiązane operacje i logika trafiają do jednej klasy visitora, zamiast rozpraszać się po klasach danych.
-
Łatwe dodawanie operacji. Nowa operacja to nowa klasa visitora. Nie musisz ruszać istniejących klas danych:
new DrawingVisitor() // Nowa operacja, nowa klasa new SerializationVisitor() // Inna operacja, inna klasa new ValidationVisitor() // Jeszcze inna operacja, jeszcze inna klasaTo zgadza się z Zasadą Otwarte/Zamknięte (OCP) z SOLID. Klasy są otwarte na rozszerzenie (nowe visitory), ale zamknięte na modyfikację (klasy danych się nie zmieniają). -
Ponowne wykorzystanie bez balastu. Struktury danych są czyste i lekkie. Po przeniesieniu do nowego projektu nie ciągną niepotrzebnych operacji ani zależności. Bierzesz tylko visitory, których faktycznie potrzebujesz:
Projekt A: Element + DrawingVisitor + SerializationVisitor Projekt B: Element + ValidationVisitor -
Jasny podział odpowiedzialności. Visitor ma jeden powód do zmiany, czyli logikę operacji. Klasa danych też ma jeden, czyli strukturę danych.
Dodanie operacji przestaje więc być zmianą w wielu klasach. Staje się dodaniem jednej klasy visitora.
Problem
Masz hierarchię klas (np. węzły AST, widgety interfejsu, kształty geometryczne) i musisz dodać operacje: serializację, generowanie kodu, testowanie trafienia. Każda taka operacja jako metoda wirtualna zaśmieca wszystkie klasy i narusza zasadę Open/Closed. Visitor przenosi operację poza klasy.
Struktura
Uczestnicy
| Rola | Odpowiedzialność |
|---|---|
| Visitor | Interfejs deklarujący przeciążenia visit dla każdego konkretnego typu elementu |
| ConcreteVisitor | Implementuje jeden algorytm na wszystkich typach elementów |
| Element | Interfejs deklarujący accept(Visitor&) |
| ConcreteElement | Implementuje accept, wywołując pasujące przeciążenie visit na visitorze i przekazując *this |
Klasyczna podwójna dyspozycja (accept-visit)
Funkcje wirtualne w C++ dają pojedynczą dyspozycję. Cel wywołania zależy od dynamicznego typu this, a nie od typów argumentów. Sztuczka z accept() daje podwójną dyspozycję. Dynamiczny typ elementu wybiera implementację accept, a ta wybiera właściwe przeciążenie visit na podstawie dynamicznego typu visitora.
Kod
#include <iostream>
#include <vector>
#include <memory>
// ── Deklaracje forward ────────────────────────────────────
class ElementA;
class ElementB;
// ── Interfejs Visitor ─────────────────────────────────────
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(ElementA&) = 0;
virtual void visit(ElementB&) = 0;
};
// ── Interfejs Element ─────────────────────────────────────
class Element {
public:
virtual ~Element() = default;
virtual void accept(Visitor&) = 0;
};
// ── Konkretne elementy ────────────────────────────────────
class ElementA : public Element {
public:
void accept(Visitor& v) override {
v.visit(*this); // podwójna dyspozycja
}
std::string kind() const { return "ElementA"; }
};
class ElementB : public Element {
public:
void accept(Visitor& v) override {
v.visit(*this);
}
std::string kind() const { return "ElementB"; }
};
// ── Konkretni visitory ────────────────────────────────────
class XmlVisitor : public Visitor {
public:
void visit(ElementA& e) override {
std::cout << "<element type=\"" << e.kind() << "\"/>\n";
}
void visit(ElementB& e) override {
std::cout << "<element type=\"" << e.kind() << "\"/>\n";
}
};
class JsonVisitor : public Visitor {
public:
void visit(ElementA& e) override {
std::cout << "{ \"kind\": \"" << e.kind() << "\" }\n";
}
void visit(ElementB& e) override {
std::cout << "{ \"kind\": \"" << e.kind() << "\" }\n";
}
};
int main() {
std::vector<std::unique_ptr<Element>> elements;
elements.push_back(std::make_unique<ElementA>());
elements.push_back(std::make_unique<ElementB>());
XmlVisitor xml;
JsonVisitor json;
for (auto& e : elements) {
e->accept(xml);
e->accept(json);
}
}
Jak działa podwójna dyspozycja
Dynamiczny typ e wybiera accept, a dynamiczny typ xml, znany wewnątrz ElementA::accept, wybiera visit. Dwa kroki wirtualnej dyspozycji prowadzą do jednego wywołania funkcji. To właśnie podwójna dyspozycja.
Kiedy używać formy klasycznej
- Masz hierarchię elementów i możesz dodać do niej
accept. - Potrzebujesz visitorów stanowych.
XmlVisitormoże na przykład akumulować bufor łańcuchów. - Zbiór elementów jest stabilny, bo dodanie nowego typu zmusza każdego visitora do dodania nowego przeciążenia
visit.
Nowoczesna alternatywa: std::visit
C++17 dodał std::visit, który działa z std::variant, czyli bezpieczną unią. Zamiast hierarchii polimorficznej elementy są alternatywami variant, a visitory to obiekty funkcyjne z operator() dla każdej alternatywy.
Kod
#include <iostream>
#include <vector>
#include <variant>
// ── Typy elementów (żadna klasa bazowa nie jest potrzebna) ──
struct ElementA {
std::string kind() const { return "ElementA"; }
};
struct ElementB {
std::string kind() const { return "ElementB"; }
};
// ── Wariant ──────────────────────────────────────────────
using Element = std::variant<ElementA, ElementB>;
// ── Visitory (zwykłe callable) ───────────────────────────
struct XmlVisitor {
void operator()(ElementA& e) const {
std::cout << "<element type=\"" << e.kind() << "\"/>\n";
}
void operator()(ElementB& e) const {
std::cout << "<element type=\"" << e.kind() << "\"/>\n";
}
};
struct JsonVisitor {
void operator()(ElementA& e) const {
std::cout << "{ \"kind\": \"" << e.kind() << "\" }\n";
}
void operator()(ElementB& e) const {
std::cout << "{ \"kind\": \"" << e.kind() << "\" }\n";
}
};
int main() {
std::vector<Element> elements;
elements.push_back(ElementA{});
elements.push_back(ElementB{});
for (auto& e : elements) {
std::visit(XmlVisitor{}, e);
std::visit(JsonVisitor{}, e);
}
}
Zalety std::visit
| Aspekt | Klasyczna (accept/visit) | std::visit |
|---|---|---|
| Boilerplate | Klasa bazowa, przeciążenie accept na element |
Brak, wystarczą zwykłe struktury |
| Pamięć | Heap (wskaźniki) | Stack (semantyka wartości) |
| Szybkość | Dwa pośrednie wywołania | Tablica skoków (zwykle szybciej) |
| Rozszerzalność | Trudno dodać nowe typy elementów | Tak samo. Wszystkie visitory muszą obsługiwać wszystkie alternatywy |
| Visitory stanowe | Łatwo (zmienne członkowskie) | Łatwo (capture lub funktor z członkami) |
Haczyk
std::variant przechowuje jeden typ naraz, więc zbiór typów jest zamknięty w czasie kompilacji. Nowego typu elementu nie dodasz bez zmiany definicji variant i każdego wywołania visit. Klasyczny Visitor ma to samo ograniczenie, ale z polimorfizmem możesz przynajmniej dodać nową podklasę i fallback z dynamic_cast, brzydki, ale działający. Z std::variant decyzja zapada raz, w czasie kompilacji.
Podsumowanie
- Visitor oddziela operacje od struktury obiektu.
- Klasyczne podejście accept/visit działa z hierarchiami polimorficznymi i pozwala na stanowe algorytmy oraz łatwe rozszerzanie.
std::visitjest czystsze i szybsze, a dane trzyma na stosie, gdy typy elementów są znane w czasie kompilacji.- Obie formy mają ten sam kompromis. Nowe operacje dodaje się łatwo, nowe typy elementów trudno, i to jest problem wyrażenia.
Dalsze czytanie
- Design Patterns: Elements of Reusable Object-Oriented Software (Gang of Four)
- Boost.Variant, prekursor
std::variant - 《Patterns for Fault Tolerant Software》 Roberta Hanmera, o Visitorze w systemach krytycznych pod względem bezpieczeństwa