Visitor to jeden z trudniejszych wzorców GoF do zrozumienia, ale rozwiązuje bardzo konkretny problem: chcesz dodać nową operację do grupy powiązanych klas bez modyfikowania tych klas. Zamiast dodawać metodę do każdej klasy, tworzysz Visitora który „odwiedza” każdą z nich. Szczególnie przydatny przy AST (drzewach składniowych), formatowaniu danych i eksporcie do różnych formatów.
Problem który rozwiązuje Visitor
<?php
// Masz hierarchię klas elementów zamówienia
abstract class OrderElement {}
class ProductItem extends OrderElement {}
class ServiceItem extends OrderElement {}
class DiscountItem extends OrderElement {}
class ShippingItem extends OrderElement {}
// Chcesz dodać WIELE nowych operacji: export do PDF, eksport do CSV,
// obliczenie podatku, walidację, serializację...
// Dodawanie każdej operacji jako metody do KAŻDEJ klasy narusza SRP i OCP
// Visitor przenosi operacje poza hierarchię klas
Implementacja wzorca
<?php
declare(strict_types=1);
// Interfejs Visitora - metoda visit dla każdego typu elementu
interface OrderElementVisitorInterface
{
public function visitProduct(ProductItem $item): mixed;
public function visitService(ServiceItem $item): mixed;
public function visitDiscount(DiscountItem $item): mixed;
public function visitShipping(ShippingItem $item): mixed;
}
// Interfejs elementu - metoda accept dla podwójnej dyspozycji
interface OrderElementInterface
{
public function accept(OrderElementVisitorInterface $visitor): mixed;
}
// Konkretne elementy - każdy deleguje do właściwej metody visitora
class ProductItem implements OrderElementInterface
{
public function __construct(
public readonly string $sku,
public readonly string $name,
public readonly float $price,
public readonly int $qty,
public readonly float $taxRate = 0.23
) {}
public function accept(OrderElementVisitorInterface $visitor): mixed
{
return $visitor->visitProduct($this);
}
public function rowTotal(): float
{
return $this->price * $this->qty;
}
}
class ServiceItem implements OrderElementInterface
{
public function __construct(
public readonly string $name,
public readonly float $price,
public readonly float $taxRate = 0.23
) {}
public function accept(OrderElementVisitorInterface $visitor): mixed
{
return $visitor->visitService($this);
}
}
class DiscountItem implements OrderElementInterface
{
public function __construct(
public readonly string $code,
public readonly float $amount,
public readonly bool $isPercent = false
) {}
public function accept(OrderElementVisitorInterface $visitor): mixed
{
return $visitor->visitDiscount($this);
}
}
class ShippingItem implements OrderElementInterface
{
public function __construct(
public readonly string $method,
public readonly float $price,
public readonly float $taxRate = 0.23
) {}
public function accept(OrderElementVisitorInterface $visitor): mixed
{
return $visitor->visitShipping($this);
}
}
<?php
declare(strict_types=1);
// Visitor #1 - obliczanie podatku VAT
class TaxCalculatorVisitor implements OrderElementVisitorInterface
{
private float $totalTax = 0.0;
public function visitProduct(ProductItem $item): float
{
$tax = $item->rowTotal() * $item->taxRate;
$this->totalTax += $tax;
return $tax;
}
public function visitService(ServiceItem $item): float
{
$tax = $item->price * $item->taxRate;
$this->totalTax += $tax;
return $tax;
}
public function visitDiscount(DiscountItem $item): float
{
return 0.0; // rabaty nie mają podatku
}
public function visitShipping(ShippingItem $item): float
{
$tax = $item->price * $item->taxRate;
$this->totalTax += $tax;
return $tax;
}
public function getTotalTax(): float { return round($this->totalTax, 2); }
}
// Visitor #2 - eksport do CSV
class CsvExportVisitor implements OrderElementVisitorInterface
{
private array $rows = [['type', 'name', 'qty', 'price', 'total']];
public function visitProduct(ProductItem $item): string
{
$row = ['product', "{$item->name} ({$item->sku})", $item->qty, $item->price, $item->rowTotal()];
$this->rows[] = $row;
return implode(',', $row);
}
public function visitService(ServiceItem $item): string
{
$row = ['service', $item->name, 1, $item->price, $item->price];
$this->rows[] = $row;
return implode(',', $row);
}
public function visitDiscount(DiscountItem $item): string
{
$value = $item->isPercent ? "-{$item->amount}%" : "-{$item->amount}";
$row = ['discount', "Rabat: {$item->code}", 1, $value, $value];
$this->rows[] = $row;
return implode(',', $row);
}
public function visitShipping(ShippingItem $item): string
{
$row = ['shipping', $item->method, 1, $item->price, $item->price];
$this->rows[] = $row;
return implode(',', $row);
}
public function getCsv(): string
{
return implode("\n", array_map(fn($row) => implode(',', $row), $this->rows));
}
}
// Visitor #3 - walidacja
class ValidationVisitor implements OrderElementVisitorInterface
{
private array $errors = [];
public function visitProduct(ProductItem $item): bool
{
if ($item->qty <= 0) {
$this->errors[] = "Produkt {$item->sku}: ilość musi być większa od 0";
return false;
}
if ($item->price < 0) {
$this->errors[] = "Produkt {$item->sku}: cena nie może być ujemna";
return false;
}
return true;
}
public function visitService(ServiceItem $item): bool
{
if (empty($item->name)) {
$this->errors[] = "Usługa: nazwa jest wymagana";
return false;
}
return true;
}
public function visitDiscount(DiscountItem $item): bool
{
if ($item->isPercent && ($item->amount <= 0 || $item->amount > 100)) {
$this->errors[] = "Rabat {$item->code}: procent musi być między 1 a 100";
return false;
}
return true;
}
public function visitShipping(ShippingItem $item): bool
{
if ($item->price < 0) {
$this->errors[] = "Wysyłka: cena nie może być ujemna";
return false;
}
return true;
}
public function isValid(): bool { return empty($this->errors); }
public function getErrors(): array { return $this->errors; }
}
<?php
// Kolekcja elementów i użycie wielu visitorów
class OrderItemCollection
{
/** @var OrderElementInterface[] */
private array $items = [];
public function add(OrderElementInterface $item): void
{
$this->items[] = $item;
}
public function accept(OrderElementVisitorInterface $visitor): array
{
return array_map(fn($item) => $item->accept($visitor), $this->items);
}
}
// Użycie
$order = new OrderItemCollection();
$order->add(new ProductItem('SKU-001', 'Widget Pro', 29.99, 2, 0.23));
$order->add(new ProductItem('SKU-002', 'Gadget Plus', 49.99, 1, 0.23));
$order->add(new ServiceItem('Montaż', 15.0, 0.23));
$order->add(new DiscountItem('SAVE10', 10, isPercent: true));
$order->add(new ShippingItem('Kurier DPD', 9.99, 0.23));
// Walidacja - nowa operacja, zero zmian w klasach elementów
$validator = new ValidationVisitor();
$order->accept($validator);
if (!$validator->isValid()) {
foreach ($validator->getErrors() as $error) {
echo "Błąd: {$error}\n";
}
}
// Podatek - inna operacja, ten sam mechanizm
$taxCalc = new TaxCalculatorVisitor();
$order->accept($taxCalc);
echo "Łączny VAT: " . $taxCalc->getTotalTax() . " PLN\n";
// Eksport CSV - kolejna operacja, zero zmian w klasach
$csvExport = new CsvExportVisitor();
$order->accept($csvExport);
echo $csvExport->getCsv();
Visitor vs alternatywy
| Podejście | Zalety | Wady |
|---|---|---|
| Visitor | Łatwe dodawanie operacji, OCP dla operacji | Trudne dodawanie nowych typów elementów |
| Metody w klasach | Proste, OCP dla typów | Klasy rosną przy każdej nowej operacji |
| Instanceof + match | Prosto napisać | Naruszenie OCP, trudne utrzymanie |
Visitor ma odwróconą elastyczność względem dziedziczenia: łatwo dodać nową operację (nowy Visitor), trudno dodać nowy typ elementu (trzeba dodać metodę do każdego Visitora). Wybierz Visitor gdy hierarchia typów jest stabilna, ale operacje rosną.
Podsumowanie
Visitor to wzorzec który błyszczy przy drzewach obiektów z wieloma operacjami – AST kompilatorów, dokumenty XML/HTML, struktury zamówień. Kluczem jest mechanizm podwójnej dyspozycji (double dispatch) przez metodę accept() – element decyduje jaką metodę visitora wywołać, visitor decyduje co z nim zrobić. Dodanie nowej operacji (nowego Visitora) nie wymaga zmian w żadnej z istniejących klas elementów.
