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.
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 [1]" 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 [2].
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 [3]auf Wikipedia stellt die Objekthierarchie und die Operationshierarchie gut dar.
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 [5]".
- Die stabile Objekthierarchie wendet typischerweise das Kompositum-Muster [6] an.
- Das Iterator-Muster [7] wird hÀufig verwendet, um die Objekthierarchie zu durchlaufen.
- Die Erstellung neuer Elemente kann mit einem Erstellungsmuster wie Fabrikmethode [8]oder Prototype [9]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 [10] ist ein verhaltensbasiertes Entwurfsmuster, das ein Template fĂŒr einen Algorithmus definiert. In C++ verwenden wir gerne eine spezielle Variante: Non-Virtual Interface (NVI) [11]. Ich werde die Template-Methode in meinem nĂ€chsten Artikel genauer vorstellen. (rme [12])
URL dieses Artikels:
https://www.heise.de/-7357177
Links in diesem Artikel:
[1] https://en.wikipedia.org/wiki/Design_Patterns
[2] https://en.wikipedia.org/wiki/Eiffel_(programming_language)
[3] https://en.wikipedia.org/wiki/Visitor_pattern
[4] https://commons.wikimedia.org/w/index.php?curid=122709059
[5] https://en.wikipedia.org/wiki/Design_Patterns
[6] https://www.heise.de/blog/Patterns-in-der-Softwareentwicklung-Das-Kompositum-Muster-7323502.html
[7] https://en.wikipedia.org/wiki/Iterator_pattern
[8] https://www.heise.de/blog/Softwareentwicklung-Das-Design-Pattern-Fabrikmethode-zum-Erzeugen-von-Objekten-7252845.html
[9] https://en.wikipedia.org/wiki/Prototype_pattern
[10] https://en.wikipedia.org/wiki/Template_method_pattern
[11] https://en.wikipedia.org/wiki/Non-virtual_interface_pattern
[12] mailto:rme@ix.de
Copyright © 2022 Heise Medien