Visitor (Odwiedzający)

Visitor UML

Opis

Visitor (Odwiedzający) należy do grupy wzorców behawioralnych. Pozwala oddelegować pewne zachowania do osobnego obiektu. Zamiast w każdym elemencie powiązanego zbioru klas dodawać specyficzne zachowanie, można nauczyć go rozmawiać z Gościem (moja własna alternatywna nazwa wzorca).

Rozwiązanie może okazać się pomocne, między innymi, w sytuacjach gdzie istnieje potrzeba wykonania operacji na kilku powiązanych obiektach, a modyfikacja istniejących klas z jakiegoś powodu jest skomplikowana. Oczywiście, wprowadzenie wzorca projektowego Visitor także wymaga takiego rozszerzenia. Tym razem jednak tylko o prostą metodę delegującą realizację do wizytatora, a nie o metodę implementującą skomplikowany algorytm.

Problem i rozwiązanie

Zdarza się, że istnieje pewna struktura obiektów i dość ciężko jest ją zmodyfikować. Istnieje jednak potrzeba dołożenia kolejnych funkcjonalności w tych elementach. Czy da się to zrobić bez modyfikacji istniejących klas? Nie do końca. Można jednak ograniczyć modyfikację do minimum. Sprowadzi się to wówczas do dołożenia metody, która przyjmuje zależność i to jej deleguje wykonanie tego zadania przekazując siebie samego jako argument. Opisany mechanizm to właśnie wzorzec projektowy Visitor.

Rozwiązanie ma sens szczególnie wtedy, kiedy mowa o logice występującej gdzieś na styku obiektów. Wyciąganie głównych odpowiedzialności klasy do wizytatora nie brzmi jak najlepszy plan. Kiedy więc istnieje dana struktura (na przykład drzewiasta) i w każdym z elementów trzeba dołożyć dane zachowanie, może okazać się zasadne przygotowanie na przyjęcie gościa. Problem, który można napotkać to potrzeba dołożenia nowego elementu do zbioru. Nie ma rady, trzeba będzie przygotować wizytatora na jego obsługę.

Plusy i minusy

Sam wzorzec, w założeniach, nie jest bardzo skomplikowany. Chociaż jego utrzymanie może być trudne. Przede wszystkim, nie warto go stosować w momencie, gdy algorytm stanowi jedną z głównych odpowiedzialności obiektu. Nie powinno się wówczas takiej logiki delegować do innej klasy. Użycie zbyt dużej liczby wizytatorów spowoduje, że ciężko będzie tak naprawdę dojść jakie reguły posiada odwiedzana klasa.

Interfejs gościa jest niewątpliwie niezgodny z regułą segregacji (interface segregation). Kiedy wizytator byłby w stanie odwiedzić 10 obiektów to będzie wymuszał 10 metod. Przez to klasa szybko puchnie i staje się trudniejsza w utrzymaniu. Dodatkowo, gość musi odczytać potrzebne dane, przez co obiekt odwiedzany jest zmuszony wystawić mu metody, które mu to umożliwią. Zamiast enkapsulować logikę wewnątrz obiektu, z racji jej delegacji trzeba też odkryć własny stan. W wielu przypadkach może to nie być najcelniejsze z rozwiązań.

Wzorzec projektowy Visitor żyje w zgodzie z trzema regułami SOLID: pojedyncza odpowiedzialność (single responsibility), otwarty na rozszerzenia i zamknięty na modyfikacje (open-closed) oraz odwracanie zależności (dependency inversion). Gwarantuje też wymienialność rozwiązań, zatem w połączeniu ze strategią może stanowić bardzo elastyczny duet.

Wzorca Visitor można użyć też jako technikę refaktoryzacyjną. W momencie, gdy świadomie przestaje się rozszerzać już wystarczająco zaśmiecone i nieczytelne obiekty. Tak, by docelowo kiedyś się ich pozbyć. Nowe klasy są łatwo rozszerzalne, testowalne i utrzymywalne. Niestety, zbyt duże rozmycie logiki może powodować trudność w utrzymaniu spójności reguł i odpowiedzialności.

Przykładowa implementacja w PHP

W prezentowanym przykładzie, słabo widać korzyść użycia wzorca. Nie ma jednak sensu dodawać zbędnego kodu, który tylko może utrudnić zrozumienie konceptu. O co w nim chodzi? PriceCalculatorInterface to interfejs Visitora. Nazwanie go VisitorInterface mogłoby prowadzić do konfliktów nazw, a jeden interfejs nie jest w stanie obsłużyć wszystkich istniejących przypadków, chyba że bez wskazanego typu zwracanego.

Pierwszy interfejs, jak sama nazwa wskazuje, to abstrakcja dla kalkulatora cen. W tym przykładzie dla konferencji. Tworzy kontrakt dla trzech operacji: cena dla uczestnika, cena dla prelegenta i w końcu cena dla sponsora.

Następny interfejs to z kolei abstrakcja dla klas, które będą w stanie przyjąć odwiedzającego. Tutaj nazewnictwo, które można spotkać to VisiteeInterface, czy VisitableInterface. U mnie wymusza jedną metodę, która pozwoli obliczyć cenę konkretnego obiektu.

Przykładowa implementacja wizytatora może prezentować się w ten sposób jak kalkulator cen last minute. Cena dla uczestnika obliczana jest na podstawie jego zniżki. Prelegent ma zawsze darmowy bilet. Za to sponsor w zależności od swojego pakietu musi zapłacić za możliwość promocji.

Teraz czas na konkretne klasy reprezentujące odwiedzane obiekty. Zaczynając od sponsora, który dla uproszczenia przyjmuje swój status oraz informację, czy przysługuje mu wystawa. Interesująca jest jednak metoda calculatePrice, która przyjmuje wizytatora i wywołuje odpowiednią dla siebie metodę. Proste rozszerzenie klasy o kolejną funkcjonalność.

Użyty mechanizm to tak zwany double dispatch, czyli Sponsor przyjmuje PriceCalculatorInterface, a metoda PriceCalculatorInterface przyjmuje obiekt typu Sponsor. Analogicznie klasy reprezentujące innych uczestników konferencji. Tak samo wywołują odpowiednie dla siebie metody na odwiedzającym.

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor;

interface PriceCalculatorInterface
{
    public function calculateForParticipant(Participant $participant): float;

    public function calculateForSpeaker(Speaker $speaker): float;

    public function calculateForSponsor(Sponsor $sponsor): float;
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor;

interface VisitableByCalculatorInterface
{
    public function calculatePrice(PriceCalculatorInterface $visitor): float;
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor;

final class LastMinuteCalculator implements PriceCalculatorInterface
{
    private const float EXHIBITION_PRICE = 2000.00;

    public function __construct(
        private float $price
    ) {}

    public function calculateForParticipant(Participant $participant): float
    {
        return $this->price * (100 - $participant->getPercentDiscount()) * 0.01;
    }

    public function calculateForSpeaker(Speaker $speaker): float
    {
        return 0.00;
    }

    public function calculateForSponsor(Sponsor $sponsor): float
    {
        return match ($sponsor->getStatus()) {
            Status::STATUS_BRONZE => 5 * ($sponsor->withExhibition() ? (self::EXHIBITION_PRICE + $this->price) : $this->price),
            Status::STATUS_SILVER => 10 * ($sponsor->withExhibition() ? (self::EXHIBITION_PRICE + $this->price) : $this->price),
            Status::STATUS_GOLDEN => $sponsor->withExhibition() ? 200000.00 : 150000.00
        };
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor;

enum Status: string
{
    case STATUS_BRONZE = 'bronze';
    case STATUS_SILVER = 'silver';
    case STATUS_GOLDEN = 'golden';
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor;

final class Sponsor implements VisitableByCalculatorInterface
{
    public function __construct(
        private Status $status,
        private bool $withExhibition
    ) {}

    public function calculatePrice(PriceCalculatorInterface $visitor): float
    {
        return $visitor->calculateForSponsor($this);
    }

    public function getStatus(): Status
    {
        return $this->status;
    }

    public function withExhibition(): bool
    {
        return $this->withExhibition;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor;

final class Speaker implements VisitableByCalculatorInterface
{
    public function calculatePrice(PriceCalculatorInterface $visitor): float
    {
        return $visitor->calculateForSpeaker($this);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor;

final class Participant implements VisitableByCalculatorInterface
{
    public function __construct(
        private bool $isVIP
    ) {}

    public function calculatePrice(PriceCalculatorInterface $visitor): float
    {
        return $visitor->calculateForParticipant($this);
    }

    public function getPercentDiscount(): int
    {
        return $this->isVIP ? 10 : 0;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor\Test;

use DesignPatterns\Behavioral\Visitor\LastMinuteCalculator;
use DesignPatterns\Behavioral\Visitor\Sponsor;
use DesignPatterns\Behavioral\Visitor\Status;
use PHPUnit\Framework\TestCase;

final class SponsorTest extends TestCase
{
    public function testCanCalculateLastMinutePriceForBronzeSponsor(): void
    {
        $sponsor = new Sponsor(Status::STATUS_BRONZE, false);

        self::assertSame(2500.00, $sponsor->calculatePrice(new LastMinuteCalculator(500.00)));
    }

    public function testCanCalculateLastMinutePriceForBronzeSponsorWithExhibition(): void
    {
        $sponsor = new Sponsor(Status::STATUS_BRONZE, true);

        self::assertSame(12500.00, $sponsor->calculatePrice(new LastMinuteCalculator(500.00)));
    }

    public function testCanCalculateLastMinutePriceForSilverSponsor(): void
    {
        $sponsor = new Sponsor(Status::STATUS_SILVER, false);

        self::assertSame(5000.00, $sponsor->calculatePrice(new LastMinuteCalculator(500.00)));
    }

    public function testCanCalculateLastMinutePriceForSilverSponsorWithExhibition(): void
    {
        $sponsor = new Sponsor(Status::STATUS_SILVER, true);

        self::assertSame(25000.00, $sponsor->calculatePrice(new LastMinuteCalculator(500.00)));
    }

    public function testCanCalculateLastMinutePriceForGoldenSponsor(): void
    {
        $sponsor = new Sponsor(Status::STATUS_GOLDEN, false);

        self::assertSame(150000.00, $sponsor->calculatePrice(new LastMinuteCalculator(500.00)));
    }

    public function testCanCalculateLastMinutePriceForGoldenSponsorWithExhibition(): void
    {
        $sponsor = new Sponsor(Status::STATUS_GOLDEN, true);

        self::assertSame(200000.00, $sponsor->calculatePrice(new LastMinuteCalculator(500.00)));
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor\Test;

use DesignPatterns\Behavioral\Visitor\LastMinuteCalculator;
use DesignPatterns\Behavioral\Visitor\Speaker;
use PHPUnit\Framework\TestCase;

final class SpeakerTest extends TestCase
{
    public function testCanCalculateLastMinutePriceForSpeaker(): void
    {
        $speaker = new Speaker();

        self::assertSame(0.00, $speaker->calculatePrice(new LastMinuteCalculator(500.00)));
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Visitor\Test;

use DesignPatterns\Behavioral\Visitor\LastMinuteCalculator;
use DesignPatterns\Behavioral\Visitor\Participant;
use PHPUnit\Framework\TestCase;

final class ParticipantTest extends TestCase
{
    public function testCanCalculateLastMinutePriceForParticipant(): void
    {
        $participant = new Participant(false);

        self::assertSame(500.00, $participant->calculatePrice(new LastMinuteCalculator(500.00)));
    }

    public function testCanCalculateLastMinutePriceForVipParticipant(): void
    {
        $participant = new Participant(true);

        self::assertSame(450.00, $participant->calculatePrice(new LastMinuteCalculator(500.00)));
    }
}

Visitor – podsumowanie

Kolejny wzorzec behawioralny, który wcale nie jest tak często widywany. Ma konkretne przeznaczenie i pewnie nie ma co wrzucać go na siłę w inne miejsca. Ciekawym podejściem jest jego użycie w systemach typu legacy, gdzie modyfikowane obiekty są już wystarczająco skomplikowane. Nowa klasa to zawsze możliwość przygotowania jej zgodnie z najlepszymi, aktualnie obowiązującymi praktykami.

Visitor to wzorzec projektowy, który jest ciekawym konceptem. Mimo to nie używa się go aż tak często, a jeżeli już to w uproszczonej wersji. Takiej, gdzie odwiedzający umie obsłużyć tylko jednego odwiedzanego. W tej formie jest prostszy i sprawdzi się dużo lepiej. Oryginalny koncept zakłada, że odwiedzanych jest więcej.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.