1. Definicja
Visitor to behawioralny wzorzec projektowy, który pozwala dodawać nowe operacje do istniejącej hierarchii klas, bez modyfikowania tych klas.
Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates. — Gang of Four, Design Patterns
Najprościej rzecz ujmując: oddzielamy dane (strukturę obiektów) od operacji, które na nich wykonujemy.
Zamiast dopisywać nową metodę do każdej klasy w hierarchii za każdym razem, gdy potrzebujemy nowej operacji, tworzymy osobną klasę – odwiedzającego (visitor) – która "przychodzi" do każdego obiektu i wykonuje na nim odpowiednią czynność, dopasowaną do jego konkretnego typu.
Kluczowy mechanizm, który to umożliwia, nazywa się podwójnym dyspozytywem (double dispatch) – za chwilę zobaczymy dokładnie, na czym polega i dlaczego jest tak istotny.
2. Przykład z życia: zobaczmy jak to zrozumieć?
Wyobraźmy sobie sklep internetowy, w którym koszyk klienta może zawierać różne typy produktów:
- książki,
- elektronikę,
- żywność.
Na początku potrzebujemy tylko jednej operacji: obliczenia całkowitej ceny koszyka. Nic prostszego – dopisujemy metodę getCena() do każdej klasy produktu i sumujemy wyniki.
Ale biznes się rozwija. Po miesiącu księgowość mówi: "potrzebujemy też obliczać VAT, a stawka VAT jest inna dla książek (5%), inna dla elektroniki (23%) i inna dla żywności (8%)". Dopisujemy getVat() do każdej klasy produktu.
Kolejny miesiąc: dział logistyki potrzebuje obliczyć wagę przesyłki, bo książki i elektronika pakowane są inaczej niż żywność. Dopisujemy getWagaPrzesylki().
Potem marketing chce generować opis produktu do newslettera w formacie HTML. Dopisujemy generujOpisHtml().
Ćwiczenie myślowe: zatrzymajmy się na chwilę. Co się dzieje z klasami Ksiazka, Elektronika i Zywnosc po czterech miesiącach takiego rozwoju?
Każda z nich puchnie o kolejną metodę, mimo że te metody nie mają ze sobą nic wspólnego – jedna dotyczy cen, inna podatków, inna logistyki, inna marketingu. To trochę tak, jakby każdy nowy dział firmy miał prawo dopisać swoją procedurę bezpośrednio do umowy o pracę każdego pracownika, zamiast przeprowadzić własny, niezależny audyt.
Wyobraźmy sobie teraz alternatywę: zamiast obciążać samych pracowników (produkty) kolejnymi obowiązkami, do firmy przychodzi zewnętrzny audytor. Jeden audytor zajmuje się liczeniem podatków, inny – wagą i logistyką, jeszcze inny – przygotowaniem materiałów marketingowych. Każdy audytor wie, jak inaczej potraktować dział księgowości, dział magazynu czy dział produkcji – ale żaden z tych działów nie musi niczego wiedzieć o audytorach ani zmieniać swojej wewnętrznej struktury, żeby audyt się odbył.
Pracownik (produkt) musi zrobić tylko jedno: wpuścić audytora i pozwolić mu się "obejrzeć" – czyli powiedzieć "jestem książką, zastosuj do mnie swoją procedurę dla książek". To właśnie jest istota wzorca Visitor.
3. Przykład - naruszenie (bez Visitor)
Załóżmy, że faktycznie idziemy drogą "dopisywania metod" opisaną powyżej.
abstract class Produkt {
protected double cenaBazowa;
public abstract double getVat(); // powód do zmiany #1: dział księgowości
public abstract double getWagaPrzesylki(); // powód do zmiany #2: dział logistyki
public abstract String generujOpisHtml(); // powód do zmiany #3: dział marketingu
}
class Ksiazka extends Produkt {
private double waga;
@Override
public double getVat() {
return cenaBazowa * 0.05; // stawka VAT dla książek
}
@Override
public double getWagaPrzesylki() {
return waga + 0.1; // dodatkowe opakowanie ochronne
}
@Override
public String generujOpisHtml() {
return "<div class='ksiazka'>...</div>";
}
}
class Elektronika extends Produkt {
private double waga;
private boolean wymagaBaterii;
@Override
public double getVat() {
return cenaBazowa * 0.23; // stawka VAT dla elektroniki
}
@Override
public double getWagaPrzesylki() {
return waga + 0.5; // wzmocnione opakowanie
}
@Override
public String generujOpisHtml() {
return "<div class='elektronika'>...</div>";
}
}
class Zywnosc extends Produkt {
private double waga;
private boolean wymagaChlodzenia;
@Override
public double getVat() {
return cenaBazowa * 0.08; // stawka VAT dla żywności
}
@Override
public double getWagaPrzesylki() {
return wymagaChlodzenia ? waga + 1.0 : waga + 0.2;
}
@Override
public String generujOpisHtml() {
return "<div class='zywnosc'>...</div>";
}
}
Co jest nie tak z tym podejściem?
- Klasa
Produkti wszystkie jej podklasy mają wiele powodów do zmiany – naruszamy tu też zasadę SRP. Zmiana stawki VAT, zmiana zasad pakowania i zmiana szablonu HTML to trzy zupełnie różne "aktory" wymuszające modyfikację tych samych klas. - Każda nowa operacja (np. "oblicz punkty lojalnościowe") wymaga dodania metody do każdej istniejącej klasy produktu – w dużym systemie z kilkunastoma typami produktów to kilkanaście miejsc do zmiany, w kilkunastu różnych plikach.
- Logika księgowa, logistyczna i marketingowa jest rozproszona po całej hierarchii, zamiast być zebrana w jednym miejscu – trudno o niej myśleć całościowo albo ją przetestować w izolacji.
- Klasy produktów przestają być prostymi nośnikami danych, a stają się "wszystko-robiącymi" bytami, silnie związanymi z logiką biznesową wielu niepowiązanych działów.
4. Przykład - zgodny z Visitor
Wracamy do metafory audytora. Zamiast wpisywać procedury audytowe bezpośrednio do klas produktów, tworzymy interfejs odwiedzającego oraz osobną implementację dla każdej operacji.
Każdy produkt musi umieć zrobić tylko jedną rzecz: przyjąć odwiedzającego i przekazać mu informację o samym sobie.
// Interfejs odwiedzającego - definiuje, co odwiedzający potrafi zrobić
// z każdym konkretnym typem produktu
interface ProduktVisitor {
double visit(Ksiazka ksiazka);
double visit(Elektronika elektronika);
double visit(Zywnosc zywnosc);
}
// Element hierarchii - jedyny obowiązek to "przyjęcie" odwiedzającego
abstract class Produkt {
protected double cenaBazowa;
public abstract double accept(ProduktVisitor visitor);
}
class Ksiazka extends Produkt {
private double waga;
public double getCenaBazowa() { return cenaBazowa; }
public double getWaga() { return waga; }
@Override
public double accept(ProduktVisitor visitor) {
// książka "wie", że ma się przedstawić jako książka
return visitor.visit(this);
}
}
class Elektronika extends Produkt {
private double waga;
private boolean wymagaBaterii;
public double getCenaBazowa() { return cenaBazowa; }
public double getWaga() { return waga; }
public boolean isWymagaBaterii() { return wymagaBaterii; }
@Override
public double accept(ProduktVisitor visitor) {
return visitor.visit(this);
}
}
class Zywnosc extends Produkt {
private double waga;
private boolean wymagaChlodzenia;
public double getCenaBazowa() { return cenaBazowa; }
public double getWaga() { return waga; }
public boolean isWymagaChlodzenia() { return wymagaChlodzenia; }
@Override
public double accept(ProduktVisitor visitor) {
return visitor.visit(this);
}
}
Teraz "audytor podatkowy" i "audytor logistyczny" stają się osobnymi, w pełni niezależnymi klasami:
// Audytor #1: dział księgowości - liczy VAT
class VatVisitor implements ProduktVisitor {
@Override
public double visit(Ksiazka ksiazka) {
return ksiazka.getCenaBazowa() * 0.05;
}
@Override
public double visit(Elektronika elektronika) {
return elektronika.getCenaBazowa() * 0.23;
}
@Override
public double visit(Zywnosc zywnosc) {
return zywnosc.getCenaBazowa() * 0.08;
}
}
// Audytor #2: dział logistyki - liczy wagę przesyłki
class WagaPrzesylkiVisitor implements ProduktVisitor {
@Override
public double visit(Ksiazka ksiazka) {
return ksiazka.getWaga() + 0.1;
}
@Override
public double visit(Elektronika elektronika) {
return elektronika.getWaga() + 0.5;
}
@Override
public double visit(Zywnosc zywnosc) {
return zywnosc.isWymagaChlodzenia()
? zywnosc.getWaga() + 1.0
: zywnosc.getWaga() + 0.2;
}
}
Użycie wygląda tak:
List<Produkt> koszyk = List.of(new Ksiazka(...), new Elektronika(...), new Zywnosc(...));
ProduktVisitor kalkulatorVat = new VatVisitor();
double sumaVat = 0;
for (Produkt produkt : koszyk) {
sumaVat += produkt.accept(kalkulatorVat); // podwójny dyspozytyw w akcji
}
Krok po kroku - co się właściwie dzieje?
- Wywołujemy
produkt.accept(kalkulatorVat)na obiekcie typuProdukt(referencja jest ogólna, konkretny typ nieznany na etapie kompilacji). - Polimorfizm sprawia, że wykonuje się
accept()konkretnej klasy (np.Ksiazka) - to pierwszy dyspozytyw. - Wewnątrz
accept()wywołujemyvisitor.visit(this), gdziethisma już konkretny, znany typ (Ksiazka), więc kompilator wybiera przeciążoną metodęvisit(Ksiazka)- to drugi dyspozytyw. - Efekt:
VatVisitordokładnie wie, że ma do czynienia z książką, mimo że pętla operowała na ogólnym typieProdukt.
Dlaczego każda klasa audytora jest węższa i osobna?
VatVisitorzawiera wyłącznie logikę podatkową - zero wiedzy o wadze czy HTML-u.WagaPrzesylkiVisitorzawiera wyłącznie logikę logistyczną.- Chcemy dodać "audytora punktów lojalnościowych"? Tworzymy nową klasę
PunktyLojalnoscioweVisitor implements ProduktVisitor- żadna z klasKsiazka,Elektronika,Zywnoscnie zostaje ruszona. - Klasy produktów wracają do swojej pierwotnej roli: przechowują dane i wiedzą, jak "się przedstawić" - nic więcej.
5. Diagram struktury
┌──────────────────────┐ ┌───────────────────────────┐ │ <<interface>> │ │ <<interface>> │ │ ProduktVisitor │◄────────┤ Produkt │ │ │ używa │ + accept(ProduktVisitor) │ │ + visit(Ksiazka) │ └────────────┬──────────────┘ │ + visit(Elektronika) │ │ │ + visit(Zywnosc) │ ┌──────────┼────────────┐ └──────────┬───────────┘ │ │ │ │ ┌────▼───┐ ┌────▼──────┐ ┌───▼─────┐ │ implementuje │Ksiazka │ │Elektronika│ │Zywnosc │ ┌──────┼───────┐ └────────┘ └───────────┘ └─────────┘ │ │ │ ┌───▼──┐ ┌─▼────────────────┐ ┌──────────────────────┐ │VatVis│ │WagaPrzesylkiVisit│ │PunktyLojalnoscioweVis│ ← nowy audytor, │itor │ │or │ │itor (nowa operacja) │ zero zmian w Produkt! └──────┘ └──────────────────┘ └──────────────────────┘ Przepływ wywołania (podwójny dyspozytyw): klient produkt.accept(visitor) visitor.visit(produkt) │ ────────────────► │ │ │ │ 1. polimorfizm wybiera │ │ │ konkretny accept() │ │ │ ────────────────────────────► │ │ │ │ 2. przeciążenie wybiera │ │ │ konkretny visit() │ ◄────────────────────────────────────────────────── │ │ wynik (np. kwota VAT dla tego produktu) │
6. Dlaczego Visitor jest ważne? Kiedy NIE stosować?
Korzyści:
- Nowa operacja = nowa klasa, a nie modyfikacja istniejących klas produktów - zgodnie z zasadą otwarte-zamknięte (OCP), którą Ania zna z SOLID.
- Grupowanie logiki: cała logika liczenia VAT-u siedzi w jednym miejscu (
VatVisitor), zamiast być rozproszona po kilkunastu klasach - łatwiej to przetestować i zrozumieć jako całość. - Mniej konfliktów w zespole: dział księgowości pracuje nad
VatVisitor, dział logistyki nadWagaPrzesylkiVisitor- to osobne pliki, więc szansa na konflikt przy mergu drastycznie maleje. - Separacja odpowiedzialności między zespołami: każdy "audytor" może być rozwijany, testowany i wdrażany niezależnie od pozostałych.
Kiedy NIE stosować:
- Gdy hierarchia klas elementów często się zmienia (dodajemy nowe typy produktów) - każdy nowy typ wymaga dopisania metody
visit()do wszystkich istniejących odwiedzających. Visitor odwraca problem: łatwo dodawać operacje, trudno dodawać nowe typy elementów. - Gdy mamy tylko jedną lub dwie operacje i hierarchia jest stabilna - narzut w postaci dodatkowych interfejsów i klas może być nieuzasadniony; zwykłe metody polimorficzne wystarczą.
- Gdy elementy hierarchii muszą ujawniać szczegóły swojego stanu wewnętrznego odwiedzającemu (przez gettery), co czasem osłabia hermetyzację - warto to świadomie rozważyć.
7. Kiedy stosować w praktyce?
Realne scenariusze:
- Kompilatory i analiza kodu: drzewo składniowe (AST) ma stabilną strukturę węzłów (wyrażenie, instrukcja, deklaracja), ale operacji na nim jest wiele - analiza typów, optymalizacja, generowanie kodu wynikowego. Klasyczny podręcznikowy przykład zastosowania Visitor.
- Eksport danych do wielu formatów: struktura dokumentu (nagłówek, akapit, tabela) jest stabilna, ale chcemy dodawać kolejne eksportery (do PDF, HTML, JSON) bez ruszania klas dokumentu.
- Systemy fakturowania i raportowania (jak w naszym przykładzie) - stabilna struktura produktów, zmienna liczba operacji biznesowych.
W popularnych frameworkach:
- Java Compiler API (
javax.lang.model.element) wykorzystuje interfejsElementVisitordo przechodzenia po drzewie elementów języka Java (klasy, metody, pola) - dokładnie ten sam mechanizmvisit(), który tu zbudowaliśmy. - javac / narzędzia do analizy kodu (np. wtyczki IDE) budują własne odwiedzające drzewa AST, żeby dodawać kolejne reguły statycznej analizy bez modyfikowania klas węzłów drzewa.
- Biblioteki serializacji (np. przetwarzanie drzew JSON/XML) często udostępniają mechanizm odwiedzającego do niestandardowego przetwarzania węzłów drzewa dokumentu.
"Element wpuszcza gościa, gość wie, co robić" - element (produkt) tylko przyjmuje odwiedzającego (
accept), a to odwiedzający (visitor) niesie ze sobą całą logikę operacji. Podwójny dyspozytyw = element wybiera metodę po swoim typie, a przeciążenie w visitorze wybiera właściwy wariant operacji.