Wzorzec Visitor, czyli separacja danych i zachowania

Wzorzec visitor oddziela dane od algorytmu. Poka偶臋 na przyk艂adach, kiedy warto stosowa膰 ten wzorzec, a kiedy nie. Warto wspomnie膰, 偶e nie zawsze chcemy takiej separacji.

Za艂贸偶my, 偶e piszemy modu艂 dla jakiego艣 procesora tekstu. Mamy nast臋puj膮ce 3 struktury:

struct Element {};
struct Paragraph : Element {
    std::string text;
};
struct Link : Element {
    std::string url;
    std::string text;
};
struct List : Element {
    std::vector<std::string> items;
};

Nasz program ma wypisa膰 powy偶sze struktury w formacie Markdown i HTML.

(uwaga, dla uproszczenia przyk艂ad贸w stosuj臋 wsz臋dzie struct zamiast class)

Przyk艂ad 1. Jedna klasa, kilka algorytm贸w

Je艣li jedna klasa potrzebuje kilku zachowa艅, to nie musimy stosowa膰 wzorca visitor. Za艂贸偶my, 偶e mamy tylko klas臋 Paragraph, a Link i List nie istniej膮. Wtedy wystarczy zwyk艂y polimorficzny printer.

struct Printer {
    virtual void print(const Paragraph& paragraph) = 0;
};
struct HtmlPrinter : Printer {
    void print(const Paragraph& paragraph) override {
        std::cout << "<p>" << paragraph.text << "</p>";
    }
};
struct MarkdownPrinter : Printer {
    void print(const Paragraph& paragraph) override {
        std::cout << paragraph.text << "\n\n";
    }
};

U偶ycie mog艂oby wygl膮da膰 tak:

int main() {
    Paragraph paragraph{"Hello, world!"};
    Printer* printer = getPrinter();
    printer->print(paragraph);
}

Nie wnikajmy w to jak dok艂adnie dzia艂a getPrinter, bo nie jest to istotne dla tego przyk艂adu. Wa偶ne, 偶e funkcja ta podejmuje jak膮艣 decyzj臋 i zwraca abstrakcyjny printer. Mamy zatem odseparowan膮 logik臋 wyboru zachowania od danych i od samego wypisywania.

Je艣li chcemy doda膰 wsparcie dla nowego formatu, wystarczy doda膰 now膮 klas臋 dziedzicz膮c膮 po Printer i zaimplementowa膰 metod臋 print dla typu Paragraph. Nie ma potrzeby stosowania wzorca visitor, bo mamy pe艂n膮 separacj臋 danych i zachowania. Kod jest rozszerzalny i 艂atwy do utrzymania.

Wyst臋puje tutaj tzw. “dynamic dispatch”, czyli po prostu polimorfizm. Wyb贸r odpowiedniej metody print jest podejmowany w czasie wykonywania programu.

Gdyby艣my chcieli doda膰 wsparcie dla nowego elementu, np. Link, to b臋dzie trzeba doda膰 now膮 metod臋 do klasy Printer i do wszystkich klas dziedzicz膮cych. Ale wcale nie b臋dzie to takie proste jak si臋 na pierwszy rzut oka wydaje. W kolejnym przyk艂adzie zobaczymy dlaczego.

Przyk艂ad 2. Wiele klas, jeden algorytm

Teraz mamy kilka klas, ale wszystkie z nich b臋d膮 si臋 wypisywa膰 do Markdownu.

struct MarkdownPrinter {
    void print(const Paragraph& paragraph) {
        std::cout << paragraph.text << "\n\n";
    }
    void print(const Link& link) {
        std::cout << "[" << link.text << "](" << link.url << ")\n";
    }
    void print(const List& list) {
        for (const auto& item : list.items) {
            std::cout << "- " << item << "\n";
        }
    }
};

Tym razem u偶ycie wygl膮da tak:

int main() {
    MarkdownPrinter printer;
    Element* element = getElement();

    // Au膰, to nie wygl膮da dobrze!
    if (auto paragraph = dynamic_cast<Paragraph*>(element)) {
        printer.print(*paragraph);
    } else if (auto link = dynamic_cast<Link*>(element)) {
        printer.print(*link);
    } else if (auto list = dynamic_cast<List*>(element)) {
        printer.print(*list);
    }
}

Mamy tutaj tzw. “static dispatch”, czyli wyb贸r metody print jest podejmowany w czasie kompilacji. Ale jest to mo偶liwe dopiero po uzyskaniu konkretnego elementu poprzez rzutowanie dynamiczne.

Je艣li widzimy tego typu rzutowanie, to od razu wiadomo, 偶e co艣 jest nie tak. Widz膮c taki kod cz臋艣膰 os贸b uzna, 偶e funkcja print powinna by膰 metod膮 wirtualn膮 w klasie Element implementowan膮 przez Paragraph, Link i List. Wtedy nie b臋dzie rzutowania, a polimorfizm za艂atwi spraw臋.

struct Element {
    virtual void printInMarkdown() = 0;
};
struct Paragraph : Element {
    std::string text;
    void printInMarkdown() override {
        std::cout << text << "\n\n";
    }
};
struct Link : Element {
    std::string url;
    std::string text;
    void printInMarkdown() override {
        std::cout << "[" << text << "](" << url << ")\n";
    }
};
struct List : Element {
    std::vector<std::string> items;
    void printInMarkdown() override {
        for (const auto& item : items) {
            std::cout << "- " << item << "\n";
        }
    }
};

Nie mamy ju偶 dedykowanego printera, a kod wygl膮da tak:

int main() {
    Element* element = getElement();
    element->printInMarkdown();
}

I rzeczywi艣cie, to cz臋sto jest wystarczaj膮ce rozwi膮zanie, cho膰 ma ono jedn膮 zasadnicz膮 wad臋: implementacja wypisywania do Markdown-u b臋dzie teraz rozrzucona po kilku klasach, zamiast by膰 zebrana w jednym miejscu.

Co wi臋cej zachowanie, kt贸re dodali艣my do klasy Element wcale nie musi tam pasowa膰. Proste kontenery na dane sta艂y si臋 nagle odpowiedzialne za wypisywanie siebie.

Z pomoc膮 przychodzi wzorzec visitor, kt贸ry pozwoli nam na odseparowanie danych od zachowania.

struct Element {
    virtual void print(MarkdownPrinter* printer) override = 0;
};
struct Paragraph : Element {
    std::string text;
    void print(MarkdownPrinter* printer) override {
        printer->print(*this);
    }
};
struct Link : Element {
    std::string url;
    std::string text;
    void print(MarkdownPrinter* printer) override {
        printer->print(*this);
    }
};
struct List : Element {
    std::vector<std::string> items;
    void print(MarkdownPrinter* printer) override {
        printer->print(*this);
    }
};

I teraz jest ca艂kiem nie藕le, bo mamy pe艂n膮 separacj臋 danych i zachowania. Dok艂adnie do tego s艂u偶y visitor.

int main() {
    Element* element = getElement();
    MarkdownPrinter printer;
    element->print(&printer);
}

Uzyskali艣my tutaj tzw. “double dispatch”, czyli wyb贸r metody Element::print jest dynamiczny (polimorfizm), a wyb贸r metody MarkdownPrinter::print jest statyczny (przeci膮偶enie nazwy funkcji).

Najpowa偶niejszy problem pojawi si臋, gdy poza Markdown-em zechcemy doda膰 wsparcie dla HTML-a. Podobnie jak w przyk艂adzie 1, to wcale nie jest takie proste jak si臋 mo偶e wydawa膰. W tym celu b臋dziemy musieli zmodyfikowa膰 zar贸wno wszystkie podklasy Element, jak i doda膰 now膮 klas臋 HtmlPrinter (to jest akurat zrozumia艂e). A je艣li w og贸le nie skorzystali艣my z visitora, to uzyskamy prawdopodobnie kod podobny do tego:

int main() {
    Element* element = getElement();

    // Znowu wracamy do serii warunk贸w
    if (shouldPrintInMarkdown()) {
        element->printInMarkdown();
    } else if (shouldPrintInHtml()) {
        element->printInHtml();
    }
}

Nie rzuca si臋 to a偶 tak w oczy jak dynamic_cast, ale nadal otrzymali艣my kod, kt贸ry jest trudniejszy do rozszerzenia ni偶 m贸g艂by by膰. Dodanie nowego formatu wymaga dodania metody printInXXX i pami臋tania o dopisaniu kolejnego if-a w kodzie powy偶ej.

Oczywi艣cie powy偶sze przyk艂ady s膮 bardzo uproszczone, aby da艂o si臋 je zrozumie膰 bez wi臋kszego wysi艂ku. W du偶ym systemie problemy, kt贸re tu pokazuj臋 maj膮 du偶o powa偶niejsze konsekwencje.

Czy da si臋 lepiej? Oczywi艣cie! Z pomoc膮 przychodzi abstrakcyjny visitor.

Przyk艂ad 3. Wiele klas, wiele algorytm贸w

Przejd藕my od razu do kodu:

struct Element {
    virtual void print(Printer*) = 0;
};
struct Paragraph : Element {
    std::string text;
    void print(Printer* printer) override {
        printer->print(*this);
    }
};
struct Link : Element {
    std::string url;
    std::string text;
    void print(Printer* printer) override {
        printer->print(*this);
    }
};
struct List : Element {
    std::vector<std::string> items;
    void print(Printer* printer) override {
        printer->print(*this);
    }
};

Widzimy, 偶e klasy nadal wiedz膮 o tym, 偶e maj膮 by膰 wypisywane, ale logika, kt贸ra to robi jest gdzie艣 indziej.

struct Printer {
    virtual void print(const Paragraph& paragraph) = 0;
    virtual void print(const Link& link) = 0;
    virtual void print(const List& list) = 0;
};
struct HtmlPrinter : Printer {
    void print(const Paragraph& paragraph) override {
        std::cout << "<p>" << paragraph.text << "</p>";
    }
    void print(const Link& link) override {
        std::cout << "<a href=\"" << link.url << "\">" << link.text << "</a>";
    }
    void print(const List& list) override {
        std::cout << "<ul>\n";
        for (const auto& item : list.items) {
            std::cout << "  <li>" << item << "</li>\n";
        }
        std::cout << "</ul>\n";
    }
};
struct MarkdownPrinter : Printer {
    void print(const Paragraph& paragraph) override {
        std::cout << paragraph.text << "\n\n";
    }
    void print(const Link& link) override {
        std::cout << "[" << link.text << "](" << link.url << ")\n";
    }
    void print(const List& list) override {
        for (const auto& item : list.items) {
            std::cout << "- " << item << "\n";
        }
    }
};

Mamy teraz dwie implementacje Printer-a. Ka偶da z nich skupia si臋 na jednym formacie.

Pora na u偶ycie tego kodu:

int main() {
    Element* element = getElement();
    Printer* printer = getPrinter();

    element->print(printer);
}

No cudo! My艣l臋, 偶e powy偶szy kod sam si臋 broni. Po艂膮czyli艣my wiele typ贸w danych z wieloma zachowaniami zachowuj膮c przy tym pe艂n膮 separacj臋. Je艣li trzeba co艣 poprawi膰 w sk艂adni HTML, to mamy jedn膮 klas臋, kt贸ra zajmuje si臋 tylko tym - wypisywaniem wszystkich element贸w do HTML-a.

Dodanie nowego formatu nie wymaga modyfikacji Element-贸w.

Dodanie nowego elementu, poza trywialn膮 implementacj膮 metody Element::print, wymaga dopisania nowej metody Printer::print do ka偶dego Printer-a. To jest akurat zrozumia艂e, nikt tego za nas nie zrobi. Plus jest taki, 偶e je艣li zapomnimy o dopisaniu jednej z metod, to kompilacja programu si臋 nie powiedzie. Dla por贸wnania, wcze艣niej mogli艣my zapomnie膰 o dopisaniu kolejnego if-a i program by si臋 uruchomi艂, ale dzia艂a艂by niepoprawnie.

Uzyskali艣my po艂膮czenie zalet z obu poprzednich przyk艂ad贸w.

Na koniec zmapujmy nazwy klas i metod do terminologii wzorca visitor:

Klasa/Metoda Terminologia wzorca visitor
Printer Visitor
Printer::print() Metoda visit
Element::print() Metoda accept

Osobi艣cie nie jestem fanem u偶ywania tej terminologii w kodzie. Do艣wiadczony programista i tak szybko zorientuje si臋, 偶e ma do czynienia z visitorem, a nazywanie funkcji i klas w spos贸b, kt贸ry jest zgodny z ich rol膮 w kodzie jest dla mnie bardziej naturalne.