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:

  1. Lokalność funkcji i danych. Powiązane operacje i logika trafiają do jednej klasy visitora, zamiast rozpraszać się po klasach danych.

  2. Ł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 klasa To 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ą).

  3. 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

  4. 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

Mermaid diagram
Diagram klas wzorca Visitor

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

Mermaid diagram

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. XmlVisitor moż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::visit jest 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

Implementacje w różnych językach