Wiosenna WYPRZEDAŻ

Odwiedzający

Znany też jako: Visitor

Cel

Odwiedzający to behawioralny wzorzec projektowy pozwalający oddzielić algorytmy od obiektów na których pracują.

Wzorzec projektowy Odwiedzający

Problem

Wyobraź sobie, że twój zespół opracowuje aplikację korzystającą z danych geograficznych ustrukturyzowanych w jeden wielki graf. Każdy węzeł grafu odpowiada złożonemu podmiotowi jak miasto, ale również bardziej szczegółowym elementom, takim jak obiekty przemysłowe, atrakcje turystyczne, itp. Węzły są połączone ze sobą jeśli istnieje droga pomiędzy faktycznymi obiektami jakie reprezentują. Za kulisami każdy typ węzła reprezentowany jest przez osobną klasę, zaś poszczególne węzły to ich obiekty.

Eksportowanie grafu do XML

Eksportowanie grafu do XML.

W którymś momencie otrzymujesz zadanie implementacji eksportu grafu do formatu XML. Początkowo zadanie wydało ci się proste. Planujesz dodanie metody eksportującej do każdej klasy węzła, a następnie rekursywne wywołanie jej w każdym z węzłów. Rozwiązanie wydaje się proste i eleganckie: dzięki polimorfizmowi nie doszło do sprzęgnięcia kodu wywołującego metodę eksportującą z konkretnymi klasami węzłów.

Niestety architekt systemu odmówił zgody na modyfikację istniejących klas węzłów. Stwierdził, że kod jest już na etapie produkcji i nie może sobie pozwolić na ryzyko związane z wprowadzeniem ewentualnego błędu wraz z twoimi zmianami.

Do wszystkich klas węzłów trzeba było dodać metodę eksportu do XML

Do wszystkich klas węzłów trzeba było dodać metodę eksportu do XML. Z wdrażaniem zmian wiązało się ryzyko wprowadzenia błędu do aplikacji.

Architekt systemu miał również wątpliwości co do sensu umieszczania kodu eksportu do XML w klasach węzłów. Przecież głównym zadaniem tych klas jest działanie na danych geograficznych, a obecność kodu eksportu do XML wyglądałaby nie na miejscu.

Istniał też jeszcze jeden powód odmowy. Było bowiem bardzo możliwe, że po implementacji tej funkcjonalności, ktoś z działu marketingu poprosiłby o dodanie możliwości eksportu do jakiegoś innego formatu, lub o inne dziwactwa. Zmusiłoby to ciebie do ponownych zmian w tych drogocennych, delikatnych klasach.

Rozwiązanie

Wzorzec projektowy Odwiedzający proponuje umieszczenie nowych obowiązków w osobnej klasie zwanej odwiedzającym, zamiast próbować zintegrować je z istniejącymi klasami. Pierwotny obiekt, który miał wykonywać te obowiązki, teraz jest przekazywany do jednej z metod odwiedzającego w charakterze argumentu. Daje to metodzie dostęp do wszystkich potrzebnych danych znajdujących się w obiekcie.

Ale co jeśli te czynności można wykonać także na obiektach-węzłach innych klas? W naszym przykładzie z eksportem do XML, faktyczna implementacja zapewne będzie się nieco różnić pomiędzy poszczególnymi klasami węzłów. Dlatego też klasa odwiedzający może definiować nie jedną, ale cały zestaw metod, z których każda przyjmuje argumenty różnych typów, jak na poniższym przykładzie:

class ExportVisitor implements Visitor is
    method doForCity(City c) { ... }
    method doForIndustry(Industry f) { ... }
    method doForSightSeeing(SightSeeing ss) { ... }
    // ...

Ale jak właściwie wywoływalibyśmy te metody, zwłaszcza mając do czynienia z całym grafem? Metody z zestawu mają różne sygnatury, więc nie możemy zastosować polimorfizmu. Aby wybrać taką metodę odwiedzającego, która będzie w stanie obsłużyć dany obiekt, trzeba znać jego klasę. Brzmi koszmarnie, prawda?

foreach (Node node in graph)
    if (node instanceof City)
        exportVisitor.doForCity((City) node)
    if (node instanceof Industry)
        exportVisitor.doForIndustry((Industry) node)
    // ...
}

Być może zastanawiasz się, dlaczego nie zastosujemy w tym miejscu przeciążania metod? Polegałoby to na nadaniu wszystkim metodom tej samej nazwy, mimo że przyjmują inne parametry. Niestety, nawet zakładając że stosowany język programowania posiada tę funkcjonalność (jak Java i C#), w niczym nam to nie pomoże. Klasa konkretnego obiektu węzła jest zawczasu nieznana, więc mechanizm przeciążania nie będzie w stanie określić właściwej metody którą trzeba wywołać. Domyślnym działaniem w takim przypadku będzie wywołanie metody która przyjmuje obiekt klasy bazowej Węzeł.

Wzorzec projektowy Odwiedzający odwołuje się właśnie do takiego problemu. Stosuje technikę zwaną Podwójną dyspozycją, która pozwala wywołać odpowiednią metodę obiektu bez uciekania się do instrukcji warunkowych. Zamiast pozwalać klientowi wybrać odpowiednią wersję metody, można oddelegować ten wybór samym obiektom przekazywanym odwiedzającemu w charakterze argumentu. Skoro obiekty wiedzą jakiej są klasy, będą w stanie wybrać właściwą metodę odwiedzającego w naturalniejszy sposób. “Przyjmują” one odwiedzającego i informują którą jego metodę należy wywołać.

// Kod klienta
foreach (Node node in graph)
    node.accept(exportVisitor)

// Miasto
class City is
    method accept(Visitor v) is
        v.doForCity(this)
    // ...

// Przemysł
class Industry is
    method accept(Visitor v) is
        v.doForIndustry(this)
    // ...

Przyznaję — jednak musieliśmy zmienić klasy węzłów. Ale za to zmiana jest trywialna i umożliwia dalsze ewentualne dodawanie obowiązków już bez konieczności ponownej zmiany klas.

Jeśli uda się wyekstrahować wspólny interfejs wszystkich odwiedzających, wszystkie istniejące węzły będą mogły współdziałać z dowolnym odwiedzającym jakiego dodamy do aplikacji. Jeśli będzie trzeba wprowadzić jakieś nowe czynności związane z węzłami, wystarczy zaimplementować nową klasę odwiedzającego.

Analogia do prawdziwego życia

Agent ubezpieczeniowy

Dobry agent ubezpieczeniowy jest gotów zawsze zaoferować polisę odpowiednią do danej organizacji.

Wyobraźmy sobie doświadczonego agenta ubezpieczeniowego który chce pozyskać nowych klientów. Może odwiedzić wszystkie budynki danej dzielnicy, próbując sprzedać polisy każdemu kogo napotka. Zależnie od rodzaju organizacji znajdującej się w budynku, może zaoferować specjalistyczne polisy:

  • Jeśli jest to obiekt mieszkalny, sprzedaje ubezpieczenie zdrowotne.
  • Jeśli jest to bank, sprzedaje ubezpieczenie od kradzieży.
  • Jeśli jest to kawiarnia, sprzedaje ubezpieczenie od ognia i wody.

Struktura

Struktura wzorca projektowego OdwiedzającyStruktura wzorca projektowego Odwiedzający
  1. Interfejs Odwiedzający deklaruje zestaw metod odwiedzania które przyjmują w charakterze argumentów konkretne elementy struktury obiektu. Metody te mogą mieć takie same nazwy jeśli stosowany jest język programowania obsługujący przeciążanie, ale typ parametrów musi być różny.

  2. Każdy Konkretny Odwiedzający implementuje wiele wersji tego samego zachowania, dostosowane do różnych konkretnych klas elementów.

  3. Interfejs Element deklaruje metodę służącą “przyjmowaniu” odwiedzających. Typem przyjmowanego parametru takiej metody powinien być interfejs odwiedzającego.

  4. Każdy Konkretny Element musi implementować metodę przyjmowania. Zadaniem tej metody jest przekierowanie wywołania do właściwej metody odwiedzającego, odpowiadającej bieżącej klasie elementu. Trzeba pamiętać, że nawet jeśli bazowa klasa elementu implementuje tę metodę, wszystkie podklasy muszą ją nadpisywać w swoich klasach i wywoływać stosowną metodę obiektu odwiedzającego.

  5. Klient to na ogół kolekcja lub inny złożony obiekt (na przykład drzewo Kompozytowe). Na ogół klienci nie są świadomi wszystkich konkretnych klas swoich elementów, gdyż współpracują z nimi za pośrednictwem jakiegoś abstrakcyjnego interfejsu.

Pseudokod

W poniższym przykładzie, wzorzec Odwiedzający służy wyposażaniu hierarchii klas figur geometrycznych we wsparcie eksportu do XML.

Struktura przykładu użycia wzorca Odwiedzający

Eksport różnych typów obiektów do formatu XML za pomocą obiektu odwiedzającego.

// Interfejs elementu deklaruje metodę `accept` która przyjmuje
// argument typu interfejs bazowy odwiedzającego.
interface Shape is
    method move(x, y)
    method draw()
    method accept(v: Visitor)

// Każda konkretna klasa elementu musi implementować metodę
// `accept` w taki sposób, aby wywoływała tę metodę
// odwiedzającego, która odpowiada klasie elementu.
class Dot implements Shape is
    // ...

    // Zwróć uwagę, że wywołujemy `visitDot`, a więc metodę
    // zgodną z nazwą bieżącej klasy. Dzięki temu informujemy
    // odwiedzającego o klasie elementu z jakim współpracuje.
    method accept(v: Visitor) is
        v.visitDot(this)

class Circle implements Shape is
    // ...
    method accept(v: Visitor) is
        v.visitCircle(this)

class Rectangle implements Shape is
    // ...
    method accept(v: Visitor) is
        v.visitRectangle(this)

class CompoundShape implements Shape is
    // ...
    method accept(v: Visitor) is
        v.visitCompoundShape(this)


// Interfejs odwiedzającego deklaruje zestaw metod służących
// odwiedzaniu, które odpowiadają poszczególnym klasom
// elementów. Sygnatura metody odwiedzającej pozwala
// odwiedzającemu określić dokładną klasę elementu z jakim ma do
// czynienia.
interface Visitor is
    method visitDot(d: Dot)
    method visitCircle(c: Circle)
    method visitRectangle(r: Rectangle)
    method visitCompoundShape(cs: CompoundShape)

// Konkretni odwiedzający implementują wiele wersji tego samego
// algorytmu, które mogą działać ze wszystkimi konkretnymi
// klasami elementów.
//
// Zaobserwuje się najwięcej korzyści ze stosowania wzorca
// Odwiedzający, gdy ma się do czynienia ze złożoną strukturą
// obiektów, taką jak drzewo kompozytowe. W takim przypadku
// pomocne może być przechowanie jakiegoś pośredniego stanu
// algorytmu w czasie uruchamiania metod odwiedzającego na
// kolejnych obiektach struktury.
class XMLExportVisitor implements Visitor is
    method visitDot(d: Dot) is
        // Eksportuj ID i współrzędne środka kropki.

    method visitCircle(c: Circle) is
        // Eksportuj ID okręgu, współrzędne środka i promień.

    method visitRectangle(r: Rectangle) is
        // Eksportuj ID prostokąta, współrzędne lewego górnego
        // wierzchołka, szerokość i wysokość.

    method visitCompoundShape(cs: CompoundShape) is
        // Eksportuj ID figury oraz listę ID składających się na
        // nią.


// Kod klienta może uruchamiać działania odwiedzającego na
// dowolnym zestawie elementów bez konieczności ustalania ich
// konkretnych klas. Operacja przyjmująca kieruje wywołanie do
// odpowiedniego działania obiektu odwiedzającego.
class Application is
    field allShapes: array of Shapes

    method export() is
        exportVisitor = new XMLExportVisitor()

        foreach (shape in allShapes) do
            shape.accept(exportVisitor)

Jeśli zastanawiasz się nad celowością metody przyjmującej w tym przykładzie, mój artykuł Odwiedzający i podwójna dyspozycja szczegółowo tłumaczy tę kwestię.

Zastosowanie

Stosuj wzorzec Odwiedzający gdy istnieje potrzeba wykonywania jakiegoś działania na wszystkich elementach złożonej struktury obiektów (jak drzewo obiektów).

Wzorzec Odwiedzający pozwala wykonać jakieś działanie na zestawie obiektów różnych klas dzięki istnieniu obiektu odwiedzającego. On z kolei implementuje wiele wariantów tego działania, odpowiadających poszczególnym klasom docelowym.

Stosowanie Odwiedzającego pozwala uprzątnąć logikę biznesową czynności pomocniczych.

Wzorzec Odwiedzający daje możliwość ograniczenia zakresu obowiązków głównych klas aplikacji tylko do tych najważniejszych poprzez ekstrakcję wszystkich innych obowiązków do zestawu klas odwiedzających.

Warto stosować ten wzorzec gdy jakieś zachowanie ma sens tylko w kontekście niektórych klas wchodzących w skład hierarchii klas, ale nie wszystkich.

Możesz wyekstrahować główne obowiązki do osobnej klasy odwiedzający i zaimplementować tylko te metody odwiedzania, które przyjmują obiekty istotnych klas, zaś resztę metod pozostawić pustą.

Jak zaimplementować

  1. Zadeklaruj interfejs odwiedzający z zestawem metod “odwiedzania”, po jednej na każdą konkretną klasę elementu istniejącą w programie.

  2. Zadeklaruj interfejs elementu. Jeśli pracujesz z istniejącym elementem hierarchii klas, dodaj abstrakcyjną metodę “przyjmującą” do klasy bazowej hierarchii. Metodzie tej będzie się przekazywać w charakterze argumentu obiekt odwiedzającego.

  3. Zaimplementuj metody przyjmujące we wszystkich konkretnych klasach elementów. Metody te muszą przekierowywać wywołanie do tej metody odwiedzania otrzymanego obiektu odwiedzającego, która odpowiada klasie bieżącego elementu.

  4. Klasy elementów powinny współpracować z odwiedzającymi wyłącznie za pośrednictwem interfejsu odwiedzającego. Odwiedzający jednak muszą być świadomi wszystkich klas konkretnych elementów, do których odnoszą się typy parametrów metod odwiedzających.

  5. Dla każdego zachowania którego nie da się zaimplementować w obrębie hierarchii elementów, należy utworzyć nową konkretną klasę odwiedzającego i zaimplementować wszystkie metody odwiedzania.

    Możesz natknąć się na sytuację w której odwiedzający będzie potrzebował dostępu do jakichś prywatnych pól klasy elementu. W takim przypadku można albo uczynić te pola i metody publicznymi, psując jednak tym samym hermetyzację elementu, albo zagnieździć klasę odwiedzającego w klasie elementu. To drugie możliwe jest tylko wtedy gdy (szczęśliwie) masz do czynienia z językiem programowania obsługującym zagnieżdżanie klas.

  6. Klient musi tworzyć obiekty odwiedzającego i przekazywać je elementom za pośrednictwem metod “przyjmujących”.

Zalety i wady

  • Zasada otwarte/zamknięte. Pozwala wprowadzać nowe zachowanie odnoszące się do obiektów różnych klas bez konieczności zmiany tych klas.
  • Zasada pojedynczej odpowiedzialności. Można przenieść kilka wersji danego zachowania do jednej klasy.
  • Obiekt odwiedzający może zebrać użyteczne informacje współpracując z różnymi obiektami. Może się to przydać, gdy zaistnieje potrzeba przejrzenia złożonej struktury danych element po elemencie (takiej jak drzewo obiektów) i zastosowania odwiedzającego do każdego obiektu struktury.
  • Trzeba zaktualizować wszystkich odwiedzających za każdym razem gdy hierarchia elementów zyskuje nową klasę lub którąś traci.
  • Odwiedzający mogą nie mieć dostępu do prywatnych pól i metod elementów z którymi mają współpracować.

Powiązania z innymi wzorcami

  • Wzorzec Odwiedzający można traktować jak potężniejszą wersję Polecenia. Jego obiekty mogą wykonywać różne polecenia na obiektach różnych klas.

  • Odwiedzający może wykonać działanie na całym drzewie Kompozytowym.

  • Połączenie Odwiedzającego z Iteratorem może służyć sekwencyjnemu przeglądowi elementów złożonej struktury danych i wykonaniu na nich jakiegoś działania, nawet jeśli te elementy są obiektami różnych klas.

Przykłady kodu

Odwiedzający w języku C# Odwiedzający w języku C++ Odwiedzający w języku Go Odwiedzający w języku Java Odwiedzający w języku PHP Odwiedzający w języku Python Odwiedzający w języku Ruby Odwiedzający w języku Rust Odwiedzający w języku Swift Odwiedzający w języku TypeScript

Dodatek

  • Jeśli nie rozumiesz dlaczego nie możemy po prostu stosować przeciążania metod zamiast wprowadzać wzorzec Odwiedzający, przeczytaj mój artykuł Odwiedzający i podwójna dyspozycja aby zapoznać się ze szczegółami.