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.
![Handschlag](https://heise.cloudimg.io/width/610/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/6/5/3/5/6/7/shaking-hands-3091906_1920-def676aba67f1068.jpeg)
(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
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.
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)