Patterns in der Softwareentwicklung: Das Besuchermuster

Das Besuchermuster kapselt eine auf einer Objekthierarchie ausgeführte Operation als Objekt und erlaubt, einfach neue Operation hinzuzufügen.

In Pocket speichern vorlesen Druckansicht 7 Kommentare lesen
Handschlag

(Bild: gemeinfrei)

Lesezeit: 7 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Patterns sind eine wichtige Abstraktion in der modernen Softwareentwicklung. Sie bieten eine klar definierte Terminologie, eine saubere Dokumentation und das Lernen von den Besten. Das Besuchermuster aus dem Buch "Design Patterns: Elements of Reusable Object-Oriented Software" ist aus zwei Gründen legendär. Erstens, weil es so kompliziert ist, und zweitens wegen einer Technik namens Double Dispatch.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

Double Dispatch beschreibt den Prozess der Auswahl der Mitgliedsfunktion auf der Grundlage des Objekts und der Funktionsargumente. Dass das Besuchermuster so kompliziert ist, liegt natürlich vor allem daran, dass Double Dispatch in C++ nicht von Haus aus unterstützt wird, so wie in Eiffel.

Bevor ich auf Single Dispatch und Double Dispatch eingehe, möchte ich zunächst auf das Besuchermuster eingehen.

Zweck

  • Kapselt eine Operation, die auf einer Objekthierarchie ausgeführt wird, in einem Objekt
  • Ermöglicht es, neue Operationen zu definieren, ohne die Objekthierarchie zu verändern

Anwendungsfall

  • Operationen sollen auf einer Objekthierarchie ausgeführt werden
  • Die Operationen ändern sich häufig
  • Die Objekthierarchie ist stabil

Struktur

Visitor

  • Definiert die visit-Operation auf der Objektstruktur

ConcreteVisitor

  • Implementiert die visit-Operation

Element

  • Definiert die accept-Operation, die einen Besucher als Argument annimmt

ConcreteElement

  • Implementiert die accept-Operation

Ich nehme an, diese Darstellung war zu einfach. Das folgende Bild aus dem Besuchermuster auf Wikipedia stellt die Objekthierarchie und die Operationshierarchie gut dar.

UML-Diagramm des Besuchermusters

(Bild: CC BY-SA 3.0)

Dieses Bild vermittelt einen sehr guten Eindruck vom Besuchermuster. Ich möchte es daher nutzen, um die Struktur und das dynamische Verhalten des Besuchermusters zu erklären.

  • Struktur

Das Besuchermuster besitzt zwei Typenhierarchien. Die Objekthierarchie (CarElement) und die Operationshierarchie (CarElementVisitor). Die Objekthierarchie ist stabil, die Operationshierarchie soll hingegen neue Operationen unterstützen. Beide Klassen, CarElement und CarElementVisitor, fungieren als Schnittstellen. Das bedeutet, dass jedes konkrete Autoelement Wheel, Engine, Body und Car die Memberfunktion accept(CarElementVisitor) implementieren muss. Entsprechend muss jede konkrete Operation CarElementDoVisitor und CarElementPrintVisitor die vier Überladungen visit(Wheel), visit(Engine), visit(Body) und visit(Car) implementieren.

  • Dynamisches Verhalten

Gehen wir davon aus, dass die Operation CarElementPrintVisitor auf die Objekthierarchie angewendet wird. Die Aufgabe von CarElementPrintVisitor könnte sein, den Namen des besuchten Autoteils zu drucken. Zunächst akzeptiert ein Autoelement wie Motor den Besucher (accept(CarElementVisitor)) und verwendet den Besucher, um mit sich selbst als Argument in die Operationshierarchie zurückzurufen (visitor.visit(this)). Dadurch wird sichergestellt, dass die visit(Engine)-Überladung des CarElementPrintVisitor aufgerufen wird. Der Besuch eines Autos ist etwas Besonderes. Ein Auto besteht aus verschiedenen Autobestandteilen. Folglich delegiert die accept-Mitgliedsfunktion von car den accept-Aufruf an alle seine Autoteile.

Dies ist die entscheidende Beobachtung des Besuchers. Es hängt von zwei Objekten ab, welche Operation durchgeführt wird: dem Besucher und dem besuchten Objekt.

Das folgende Beispiel übersetzt das vorherige Bild direkt in Code.

// visitor.cpp

#include <iostream>
#include <string>
#include <vector>

class CarElementVisitor;

class CarElement {                                     // (5)
 public:
    virtual void accept(CarElementVisitor& visitor) const = 0;
    virtual ~CarElement() = default;
};

class Body;
class Car;
class Engine;
class Wheel;

class CarElementVisitor {                              // (6)
 public:
    virtual void visit(Body body) const = 0;
    virtual void visit(Car car) const = 0;
    virtual void visit(Engine engine) const = 0;
    virtual void visit(Wheel wheel) const = 0;
    virtual ~CarElementVisitor() = default;
};

class Wheel: public CarElement {
 public:
    Wheel(const std::string& n): name(n) { }

    void accept(CarElementVisitor& visitor) const override {
        visitor.visit(*this);
    }

    std::string getName() const {
        return name;
    }
 private:
    std::string name;
};

class Body: public CarElement {
 public:
    void accept(CarElementVisitor& visitor) const override {
        visitor.visit(*this);
    }
};

class Engine: public CarElement {
 public:
    void accept(CarElementVisitor& visitor) const override {
        visitor.visit(*this);
    }
};

class Car: public CarElement {
 public:
    Car(std::initializer_list<CarElement*> carElements ):
      elements{carElements} {}
   
    void accept(CarElementVisitor& visitor) const override {
        for (auto elem : elements) {
            elem->accept(visitor);
        }
        visitor.visit(*this);
    }
 private:
    std::vector<CarElement*> elements;                   // (7)
};

class CarElementDoVisitor: public CarElementVisitor {
   
    void visit(Body body) const override {
        std::cout << "Moving my body" << '\n';
    }

     void visit(Car car) const override {
        std::cout << "Starting my car" << '\n';
    }

    void visit(Wheel wheel) const override {
        std::cout << "Kicking my " << wheel.getName() 
          << " wheel" << '\n';
    }

    void visit(Engine engine) const override {
        std::cout << "Starting my engine" << '\n';
    }
};

class CarElementPrintVisitor: public CarElementVisitor {
   
    void visit(Body body) const override {
        std::cout << "Visiting body" << '\n';
    }

     void visit(Car car) const override {
        std::cout << "Visiting car" << '\n';
    }

    void visit(Wheel wheel) const override {
        std::cout << "Visiting " << wheel.getName() 
          << " wheel" << '\n';
    }

    void visit(Engine engine) const override {
        std::cout << "Visiting engine" << '\n';
    }
};

int main() {

    std::cout << '\n';

    Wheel wheelFrontLeft("front left");        
    Wheel wheelFrontRight("front right");
    Wheel wheelBackLeft("back left");
    Wheel wheelBackRight("back right");
    Body body;
    Engine engine;
    Car car {&wheelFrontLeft, &wheelFrontRight, 
             &wheelBackLeft, &wheelBackRight,
             &body, &engine};

    CarElementPrintVisitor carElementPrintVisitor; 

    engine.accept(carElementPrintVisitor);       // (1)      
    car.accept(carElementPrintVisitor);          // (2)

    std::cout << '\n';

    CarElementDoVisitor carElementDoVisitor;

    engine.accept(carElementDoVisitor);          // (3)
    car.accept(carElementDoVisitor);             // (4)

    std::cout << '\n';

}

Zu Beginn der main-Funktion werden alle Bestandteile des Autos erstellt. Danach nehmen die engine und das car die carElementPrintVisitor an (1 und 2). In den Zeilen (3) und (4) werden beide Objekte von carElementDoVisitor angenommen. CarElement (5) und CarElementVisitor (6) sind die abstrakten Basisklassen der Objekthierarchie und der Operationshierarchie. Entsprechend der Abbildung werden die konkreten Autoelemente und Besucher erstellt. Das Auto ist die interessanteste Komponente, denn es hält seine Komponenten in einem std::vector<Element*> (7).

Hier ist die Ausgabe des Programms:

Verwandte Muster

Das Besuchermuster ist wohl das Entwurfsmuster mit der höchsten Musterdichte aus dem Buch "Design Patterns: Elements of Reusable Object-Oriented Software".

Vorteile

  • Eine neue Operation (Besucher) kann einfach zur Operationshierarchie hinzugefügt werden,
  • eine Operation ist in einem Besucher gekapselt und
  • es lässt sich ein Zustand aufbauen, während die Objekthierarchie traversiert wird.

Nachteile

  • Die Änderung der Objekthierarchie mit einem neuen VisitedObject ist aufwändig,
  • das besuchtes Objekt VisitedObject in der Objekthierarchie muss entfernt oder hinzugefügt werden und
  • die Schnittstelle des Besuchers muss erweitert und die Operation visit(VisitObject)Mitgliedsfunktion zu jedem konkreten Besucher hinzufügt oder entfernt werden.

Der Grund für die Kompliziertheit des Besuchermusters ist vor allem der Double Dispatch.

Bevor ich auf Double Dispatch eingehe, möchte ich mich mit Single Dispatch oder virtuellen Funktionsaufrufen beschäftigen.

Single Dispatch

Beim Single Dispatch entscheidet das Objekt, welche Mitgliedsfunktion aufgerufen wird. Um Virtualität in C++ zu erreichen, braucht man zwei Zutaten: eine Indirektion wie ein Zeiger oder eine Referenz und eine virtuelle Mitgliedsfunktion.

// singleDispatch.cpp

#include <iostream>

class Ball {
 public:
    virtual std::string getName() const = 0;
    virtual ~Ball() = default;
};

class HandBall: public Ball {
    std::string getName() const override {
        return "HandBall";
    }
};

int main() {

    std::cout << '\n';

    HandBall hBall;

    Ball* ballPointer = &hBall;                         // (1)
    std::cout << "ballPointer->getName(): " 
      << ballPointer->getName() << '\n';

    Ball& ballReference = hBall;                        // (2)
    std::cout << "ballReference.getName(): " 
      << ballReference.getName() << '\n';

    std::cout << '\n';

}

Der Ausdruck Ball* ballPointer = &hBall (1) hat zwei Typen. Den statischen Typ (Ball*) und den dynamischen Typ (Handball*), der durch die Adresse des Operators & zurückgegeben wird. Aufgrund der Virtualität der Mitgliedsfunktion getName wird der Aufruf der Mitgliedsfunktion zur Laufzeit bestimmt. Folglich findet ein dynamischer Aufruf statt und die Mitgliedsfunktion getName wird aufgerufen. Eine ähnliche Argumentation gilt für die in (2) verwendete Referenz.

Dies ist die Ausgabe des Programms:

Analysieren wir nun den Double Dispatch, der im Besuchermuster verwendet wird.

Beim Double Dispatch hängt es von zwei Objekten ab, welche Operation ausgeführt wird.

Das bedeutet im konkreten Fall des Programms visitor.cpp, dass der Besucher und das besuchte Objekt es gemeinsam bestimmen, welche Mitgliedsfunktion aufgerufen wird.

Um es konkret zu machen: Welche Aktionen werden durch den Aufruf car.accept(carElementDoVisitor) (4) gestartet? Der Einfachheit halber ist hier die Mitgliedsfunktion accept.

void accept(CarElementVisitor& visitor) const override {
    for (auto elem : elements) {
        elem->accept(visitor);
    }
    visitor.visit(*this);
}

Die Mitgliedsfunktion accept von car durchläuft alle Elemente und ruft elem->accept(visitor) für diesen auf: elem ist ein Zeiger und accept eine virtuelle Funktion => dynamischer Dispatch

Schließlich ruft der Besucher visit auf sich selbst auf, wobei er das besuchte Element als Argument verwendet: visitor.visit(*this).Folglich wird die entsprechende Überladung des Besuchers aufgerufen => static Dispatch

Double Dispatch ist im Fall des Visitor ein Ping-Pong-Spiel zwischen dem Element (Auto-Element) und dem Visitor. Das Auto-Element wendet einen dynamischen Dispatch (override) an und der Visitor einen statischen Dispatch (overload).

Die Template-Methode ist ein verhaltensbasiertes Entwurfsmuster, das ein Template für einen Algorithmus definiert. In C++ verwenden wir gerne eine spezielle Variante: Non-Virtual Interface (NVI). Ich werde die Template-Methode in meinem nächsten Artikel genauer vorstellen. (rme)