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.
(Bild: gemeinfrei)
- Rainer Grimm
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.
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.
Besuchermuster
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.
(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.
Beispiel
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".
- Die stabile Objekthierarchie wendet typischerweise das Kompositum-Muster an.
- Das Iterator-Muster wird häufig verwendet, um die Objekthierarchie zu durchlaufen.
- Die Erstellung neuer Elemente kann mit einem Erstellungsmuster wie Fabrikmethode oder Prototype erfolgen.
Vor- und Nachteile
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
VisitedObjectist aufwändig, - das besuchtes Objekt
VisitedObjectin 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.
Single Dispatch und 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.
Double Dispatch
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).
Wie geht's weiter?
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)