Der Besucher
Varianten der Implementierung des Besucher-Musters in C++
Das Besucher Muster - engl. Visitor Pattern und im folgenden Text einfach Visitor genannt - wurde in dem Buch „Design Patterns. Elements of Reusable Objectoriented Software.“ von E. Gamma et. al. [GoF95] mit diesem Namen belegt. Dieses Muster gehört zu den trickreicheren des Musterkatalogs in der genannten Publikation und kann in unterschiedlichen Varianten verschiedene Anforderungen realisieren helfen. Zunächst aber zu der grundsätzlichen Funktionsweise des Visitors:
Abbildung 1: Visitor aus [GoF95]
Das Prinzip
Das Muster definiert eine Art Infrastruktur für den Zugriff auf komplexe dynamische Daten- und Objektstrukturen. Dabei wird der dynamischen Struktur eine Methodenschnittstelle gegeben, die ein sogenanntes Visitorobjekt entgegennimmt und über alle Daten der Struktur leitet. Das Visitorobjekt läuft also an allen Elementen der Struktur vorbei und wird von den Elementen über eine Methode „akzeptiert“. Dabei ruft das Strukturelement eine Methode im Visitorobjekt auf, die speziell für den Typ des Strukturelements geschrieben wurde und eine Referenz oder einen Zeiger auf das Strukturelement in ihrer Parameterliste entgegennimmt. Damit hat der Visitor wiederum die Chance eine Operation auf das Strukturelement auszuführen und dabei auf dessen Typ zu reagieren.
Der Sinn des Visitors besteht darin, die eigentliche Operation auf die Daten von der Traversierung über die Verwaltungsstruktur zu trennen. Aus der Entkopplung dieser beiden Aspekte entstehen Freiheitsgrade für Variationen derselben. Die Traversierung wird durch die genannte Infrastruktur aus Methoden übernommen. Die durch den Visitor transportierte Operation wird in einer Kindklasse der Visitorschnittstelle implementiert. Damit lassen sich beliebige Operationen definieren und durch den allgemeinen Traversierungsmechanismus in die Objektstruktur tragen. Mit dem Namen des Visitors wird das Verhalten des Musters gewürdigt. Das Visitorobjekt geht die Objekte in der Objektstruktur „besuchen“.
Visitor1.h:
#include <list> class Component { public: virtual ~Component(); }; class Composite : public Component { public: ~Composite(); void add( Component * ); void remove( Component * ); private: std::list<Component*> container; }; class Leaf1 : public Component {}; class Leaf2 : public Component {};
Visitor1.cpp:
#include "Visitor1.h" Component::~Component() {} Composite::~Composite() { for( auto ptr : container) { delete ptr; } } void Composite::add( Component *c ) { container.push_back( c ); } void Composite::remove( Component *c ) { container.remove( c ); }
Listing 1: Eine schematische Kompositimplementierung
Die Funktionsweise des Visitors beruht auf einer standardisierten Zusammenarbeit zwischen den
Objekten der traversierten Datenstruktur mit dem Visitorobjekt. Diese Zusammenarbeit wird durch die
Infrastrukturmethoden der Daten implementiert, die üblicherweise accept()
oder
ähnlich benannt werden. Diese Methoden bekommen einen Parameter einen Zeiger oder eine Referenz
auf eine Visitorschnittstelle. Die Visitorschnittstelle stellt wiederum eigene Methoden zur
Verfügung. Die visit()
-Methoden: für jeden Typ in der Datenstruktur eine.
Diese visit()
-Methoden werden aus den accept()
-Methoden der Daten
aufgerufen. Dabei werden zwei V-Table-Aufrufe durchgeführt, um die Operation den Daten
zuzuorden. Daher nennt man dieses Verfahren auch das Double Dispatch Verfahren.
Durch die Anwendung des Visitors erspart man sich die wiederholte Implementierung der Traversierung. Die Berücksichtigung der Typen der Daten durch den Visitor liefert eine Vorlage zur bequemen Definition von sehr unterschiedlichen Operationen. Die Daten können bei der Implementierung der Operation unabhängig vor Ihrer Ordnungsstruktur betrachtet werden. Die Ordnungsstruktur muss nur dann mitbetrachtet werden, wenn eine spezielle Operation diese auch berücksichtigen muss.
Im Folgenden sollen schematische Implementierungen des Visitors gezeigt und diskutiert werden. Dabei sollen die Vor- und Nachteile des Visitors wie auch die Stärken und die Schwächen spezifischer Implementierungen in C++ beleuchtet werden. Dazu wird anhand einer schematischen Implementierung eines Kompositums eine Objektstruktur vorgegeben, die durch einen Visitor bearbeitet wird. Auch der Visitor wird schematisch implementiert, was die Diskussion am Muster ohne die Berücksichtigung externer fachlicher Aspekte erleichtert. Es wird dabei dem Leser überlassen, die Transferleistung in eine reale Projektumgebung durch die eigene Vorstellungskraft zu leisten. Das sollte aber nicht allzu schwer fallen, denn die schematischen Implementierungen lassen sich mit nur wenigen Änderungen in realer Software nutzbar machen.
Eine praktische Demonstration
Die im Listing 1 vorgestellte schematische Kompositimplementierung steht für eine beliebige dynamische
Objekt- oder Datenstruktur. Sie wurde gewählt, um zu zeigen, dass sich der Visitor nicht nur auf
lineare oder flache Strukturen anwenden lässt. Er ist in der Lage beliebige Strukturen zu durchlaufen.
Das Kompositum stellt eine komplexe Baumform dar, die mit dem Visitor sehr gut bearbeitet werden kann.
Bevor auf besondere Vor- und Nachteile des Visitors eingegangen wird, soll er in einer ersten Variante
an dem Kompositum gezeigt werden.
Dazu wird eine Schnittstellenklasse für die Visitorhierarchie definiert. Diese Schnittstelle soll
hier der Einfachheit halber Visitor
genannt werden. Diese Schnittstelle braucht für alle
Typen des Kompositums eine Methode visit()
.
class Visitor {
public:
virtual ~Visitor();
virtual void visit( class Composite & ) = 0;
virtual void visit( class Leaf1 & ) = 0;
virtual void visit( class Leaf2 & ) = 0;
};
Listing 2: Die Visitor-Schnittstelle
Da es sich bei der Visitorschnittstelle um eine Klasse handelt die abgeleitet werden soll, braucht sie auch einen virtuellen Destruktor. Ausserdem müssen die Parametertypen vordeklariert werden, denn sie können bei der Definition der Visitorschnittstelle noch nicht bekannt sein. Im Listing 2 wird diese Vorwärtsdeklaration innerhalb der Parameterlisten angebracht. Später sollen von dieser Schnittstelle die Klassen abgeleitet werden, die die Operationen implementieren.
Zuerst aber muss die Datenstruktur die accept()
-Methode
bekommen, um die Infrastruktur des Visitormusters zu komplettieren. Die Basisklasse der Kompositstruktur
Component
bekommt also eine abstrakte Methode accept()
die als Parameter
eine Visitorreferenz bekommt und in den spezialisierten Klassen implementiert werden muss.
Diese Methode ist ein Standardelement des Musters und kümmert sich nur um die Weiterleitung des Visitors.
Sie enthält niemals irgendwelche Implementierungsdetails außerhalb des Prinzips des Musters.
Visitor1.h:
#include <list> class Visitor { public: virtual ~Visitor(); virtual void visit( class Composite & ) = 0; virtual void visit( class Leaf1 & ) = 0; virtual void visit( class Leaf2 & ) = 0; }; class Component { public: virtual ~Component(); virtual void accept( Visitor & ) = 0; }; class Composite : public Component { public: ~Composite(); void add( Component * ); void remove( Component * ); void accept( Visitor & ); private: std::list<Component*> container; }; class Leaf1 : public Component { public: void accept( Visitor & ); }; class Leaf2 : public Component { public: void accept( Visitor & ); };
Visitor1.cpp:
#include "Visitor1.h" Visitor::~Visitor() {} Component::~Component() {} Composite::~Composite() { for( auto ptr : container) { delete ptr; } } void Composite::add( Component *c ) { container.push_back( c ); } void Composite::remove( Component *c ) { container.remove( c ); } void Composite::accept( Visitor &v ) { v.visit(*this); for( auto ptr : container) { ptr->accept(v); } } void Leaf1::accept( Visitor &v ) { v.visit(*this); } void Leaf2::accept( Visitor &v ) { v.visit(*this); }
Listing 3: Eine schematische Visitorimplementierung
Die Implementierung der accept()
-Methoden ist sehr einfach. Sie müssen eigentlich nur
die visit()
-Methode des Visitors aufrufen. Nur die Implementierung der accept()
-Methode
der Kompositumklasse ist etwas aufwendiger: sie muss an alle enthaltenen Objekte den accept()
-Aufruf
weiterleiten. Diese Implementierung ist also im Fall unseres Kompositums die eigentliche Schaltstelle
der Traversierung für alle Operationen auf die Datenstruktur. Mit diesen accept()
-Methoden
ist die Infrastruktur für das Visitormuster komplett und kann verwendet werden.
Nun kann man über die Visitorschnittstelle Operationen definieren. Dazu überlädt man
die visit()
-Methoden und implementiert die Operation. Das spezialisierte Visitorobjekt
kann vor dem Durchlauf durch die Datenstruktur Attribute auf den Weg bekommen. Außerdem kann
es während der Traversion Zustandsinformationen über die Datenstruktur sammeln.
Das erste Beispiel der Anwendung soll genau solche Zustandsinformationen über die Objektstruktur der Komposite sammeln und verfügbar machen. Der Testcode in Listing 4 soll einen ersten Baum aufbauen, der für einen Test dienen kann.
... Composite root; root.add( new Leaf1() ); root.add( new Leaf1() ); root.add( new Leaf2() ); Composite *p = new Composite(); p->add( new Leaf2() ); p->add( new Leaf1() ); root.add( p ); ...
Listing 4: Baumförmige Testdaten
Der Testcode kann natürlich beliebig variiert werden. Die erste Implementierung einer Operation über die Visitorschnittstelle soll zeigen, dass der Entwickler einer solchen Operation die tatsächliche Struktur nicht mehr berücksichtigen muss.
Um eine Operation zu schreiben muss der Entwickler von der Schittstellenklasse Visitor
ableiten und alle ihre Methoden implementieren. Das erste Experiment in Listing 5 mit einer Operation soll einfach
statistische Informationen über den Baum sammeln. Die Operation soll also einfach die Anzahl der
Objekte zählen. In einer Variation sollen dann jeweils die Anzahlen der Kompositobjekte,
der Leaf1
- und der Leaf2
-Objekte bestimmt werden.
#include "Visitor1.h" #include <iostream> class Zaehler : public Visitor { public: Zaehler (): n(0) {} void visit( Composite & ) { n++; } void visit( Leaf1 & ) { n++; } void visit( Leaf2 & ) { n++; } unsigned int anzahl() const { return n; } private: unsigned int n; }; int main() { Composite root; root.add( new Leaf1() ); root.add( new Leaf1() ); root.add( new Leaf2() ); Composite *p = new Composite(); p->add( new Leaf2() ); p->add( new Leaf1() ); root.add( p ); Zaehler z; root.accept( z ); std::cout << z.anzahl() << std::endl; return 0; }
Listing 5: Der Zähler und seine Anwendung
Die Zählerklasse aus Listing 5 enthält das Attribut n
, das für
den Zählvorgang bestimmt ist. Dieses Attribut muss im Konstruktor der Klasse initialisiert werden.
Außerdem enthält die Klasse noch die Methode anzahl()
, um den Wert des
Attributs n
nach außen liefern zu können. Alles weitere machen die
visit()
-Methoden. Sie führen die eigentliche Operation an den Objekten aus. Im Falle
der Zählerklasse ist diese natürlich einfach.
An den in Listing 4 aufgeführten Testdaten lässt sich der Zähler nun einfach Anwenden: man muss
nur ein Objekt von ihm anlegen und mit der accept()
-Methode dem root
-Objekt
übergeben − Listing 5.
Dieser Aufruf von accept()
mit der Übergabe eines Besucherobjektes führt dazu,
dass der gesamte Objektbaum durchlaufen wird. An allen Knoten und Blättern des Baumes werden die
visit()
-Methoden des Besuchers aufgerufen. Das wiederum lässt in unserem Fall den
Zähler zählen. Nach dem accept()
-Aufruf enthält das Zählerobjekt den
Zustand, den es während des Durchlaufes durch den Objektbaum angenommen hat. Das Attribut n
enthält die Anzahl der Objekte des Baumes und kann durch die Methode anzahl()
abgefragt
werden.
Dieses einfache Beispiel soll die grundsätzliche Funktionsweise des Visitors demonstrieren. Dabei wurde auf den Aspekt, dass der Visitor auf die unterschiedlichen Typen des Objektbaums reagieren kann noch gar nicht eingegangen. Eine einfache Variante der Zählerklasse zeigt diese Möglichkeit: in Listing 6 wurde die Zählerklasse so abgewandelt, dass sie die Anzahlen der Objekte unterschiedlicher Typen separat zählt.
Die Berücksichtigung der Objekttypen durch den Visitor
class Zaehler : public Visitor { public: Zaehler() : nc(0), n1(0), n2(0) {} void visit( Composite & ) { nc++; } void visit( Leaf1 & ) { n1++; } void visit( Leaf2 & ) { n2++; } unsigned int anzahlObjekte() const { return nc+n1+n2; } unsigned int anzahlComposite() const { return nc; } unsigned int anzahlLeaf1() const { return n1; } unsigned int anzahlLeaf2() const { return n2; } private: unsigned int nc; unsigned int n1; unsigned int n2; };
Listing 6: Der erweiterte Zähler
Durch die minimalen Änderungen aus Listing 6 reagiert der Zähler bereits auf die unterschiedlichen Typen im Objektbaum. Damit spielt man mit dem Besucher seine eigentliche Stärke aus: die Flexibilität, auf unterschiedliche Typen zu reagieren. Damit unterschiedet sich dieses Muster auch von einem anderen Muster der GoF, dem Iterator. Dieser kann zwar komplexe Datencontainer traversieren, es fehlt ihm jedoch an der Fähigkeit, unterschiedliche Objekttypen in den Containern unterschieden zu können.
In der Implementierung aus Listing 6 hat der Vistitor einfach drei neue Attribute bekommen, die in den
jeweiligen visit()
-Methoden inkrementiert werden. Die Methoden anzahl..()
geben einfach die entsprechenden Attribute nach außen.
Bis jetzt wurden zwei Operationen demonstriert, die einen rein lesenden Zugriff auf die Objektstruktur
ausführen. Dabei wird noch nicht einmal irgend ein interner Objektstatus abgefragt, sondern nur
das Vorhandensein verschiedener Objekte vermerkt.
Natürlich muss bei entsprechenden Operationen auch der genannte Zugriff auf den inneren Zustand
der Objekte möglich sein; sowohl lesend als auch schreibend. Dazu bekommen die visit()
-Methoden
die Parameter, die in unseren Zählerbeispielen noch nicht benannt werden. Führt man jedoch
Bezeichner ein, ist der Zugriff auf die Objekte möglich. Ob man für den Zugriff durch den Visitor
eine extra Schnittstelle einführt, oder ob ein solcher Zugriff auf öffentliche Attribute
erfolgt, ist für das Prinzip des Visitors nicht wesentlich. Es enthüllt jedoch eine
strukturelle Schwäche des Musters, über die noch zu sprechen sein wird.
Weitere Varianten in der Implementierung
Nach diesem theoretischen Absatz soll nun wieder etwas praktischer Code folgen. In unserem Zähler ist es möglich, auf unterschiedliche Objekttypen zu reagieren. Was der unsere Beispielimplementierung des Besuchers bis jetzt nicht kann, ist auf die Verschachtelungstiefe des Objektbaumes reagieren. Das zugrunde liegende Problem ist, dass die bisher implementierte Infrastruktur nur auf Objekttypen und nicht auf Eigenheiten der Organisationsform sensibel ist. Beispielhaft an den Kompositdaten soll nun dem Visitor eine solche Sensibilität gegeben werden.
class Visitor { public: virtual ~Visitor(); virtual void visit( class Composite & ) = 0; virtual void leave( class Composite & ) = 0; virtual void visit( class Leaf1 & ) = 0; virtual void visit( class Leaf2 & ) = 0; }; ... void Composite::accept( Visitor &v ) { v.visit(*this); for( auto ptr : container ) { ptr->accept(v); } v.leave(*this); }
Listing 7: Eine erweiterte Visitor-Schnittstelle
Neben den visit()
-Methoden braucht die Visitorschnittstelle eine weitere Methode, die das
Verlassen eines Kompositobjekts anzeigt. In der Beispielimplementierung soll die Methode einfach leave()
heißen. Diese leave()
-Methode muss nun auch durch die entsprechede accept()
-Methode
in der Objektstruktur aufgerufen werden: also in accept()
der Klasse Composite
.
− siehe Listung 7.
Wenn sich nun eine Operation für die Verschachtelungstiefe des Objektbaums „interessiert“,
muss sie nur dementsprechend die leave()
-Methode implementieren. In dieser Methode muss
nur ein Attribut, das die Verschachtelungstiefe repräsentiert dekrementiert werden, während in
der dazugehörigen visit()
-Methode inkrementiert wird.
Natürlich sehen solche Varianten, die die Organisationsform der Objektstruktur berücksichtigen sehr unterschiedlich aus, denn sie müssen spezifisch an die Struktur angepasst werden.
Das Problem der Leerimplementierungen
Wenn in der Objektstruktur viele Typen vertreten sind, kann sich für die Implementierung der Operationen das Problem ergeben, dass viele Methoden wiederholt leer implementiert werden müssen. Das kann sehr unangenehm sein und im Extremfall kann der formale Aufwand die Vorteile des Visitors überwiegen. Zudem sind viele leer implementierte Methoden nich unbedingt das, was man effizient nennen kann. Die Codegröße steigt mit jeder unnötigen Funktion.
class VisitorEmptyImpl : public Visitor { public: void visit( Composite & ); void visit( Leaf1 & ); void visit( Leaf2 & ); }; ... void VisitorEmptyImpl::visit( Composite & ) {} void VisitorEmptyImpl::visit( Leaf1 & ) {} void VisitorEmptyImpl::visit( Leaf2 & ) {}
Listing 8: Eine leer implementierter Visitor
Da bei einer Schnittstellenimplementierung der Compiler die Implementierung aller abstrakten − rein virtuellen − Methoden verlangt, müssen Implementierungen angegeben werden. In Java-Bibliotheken wird diesem Problem oft mit einer Klasse begegnet, die die entsprechenden Methoden alle leer implementiert. Man gibt bei der Verwendung einer solchen Klasse allerdings die Überprüfung der vollständigen Implementierung durch den Compiler auf. Dafür müssen nur die Schnittstellenmethoden überschrieben werden, die man auch wirklich für den entsprechenden Fall benötigt. Wenn eine Operation definiert wird, die auf die Gesamtheit der Objekttypen angewendet werden soll, ist es weiterhin ratsam von der Schnittstelle abzuleiten. Die leer implementierende Klasse sollte nur für solche Operationen verwendet werden, die auf eine Untermenge der Objekttypen definiert ist. Auch in C++ lässt sich diese Technik anwenden. Allerdings verlangt die Anwendung dieser Technik, dass man die Hintergründe versteht und die genannten Vorüberlegungen bezüglich der Art der zu definierenden Operation anstellt. Auch der Leser des Codes muss mit diesen Überlegungen vertraut sein, um ihn interpretieren zu können.
Die Verletzung des Kapselungsprinzips auf Klassenebene
Eine der Errungenschaften der Objektorientierung ist es, die Operationen auf die Daten dort zu definieren,
wo sich auch die zugehörigen Daten befinden − in den Klassen der Objekte. Dieses Systematisierungsprinzip
erleichtert den Überblick über komplexe Software dadurch, dass man eine Reduktion der zu
berücksichtigenden Einzelheiten erreicht. Man muss nur solche Attribute und Operationen berücksichtigen,
die zur gestellten Frage an den Quellcode geören. Die Klasse ist eine solche „Datenkapsel“.
Das in der Objektorientierung formulierte Delegationsprinzip geht davon aus, dass jedes Objekt
seine Attribute selbst verändert und auswertet. In der Schnittstelle der Klasse ist im Allgemeinen
kein Hinweis auf die interne Attributrepräsentation enthalten. Die Technik dieser systematischen
Abgrenzung wird durch das Sichtbarkeitskonzept der Programmiersprache geliefert. Die Schlüsselwörter
public
, protected
und private
sorgen in C++ für ein
entsprechendes Instrumentarium.
Der Visitor ist ein Muster, das ein Aufbrechen der Kapselung erzwingt. Das Visitorobjekt bearbeitet Attribute in einer dynamischen Objektstruktur. Die Operationen werden also von außen an die Objekte herangetragen. Dazu ist es notwendig, die Attribute die bearbeitet werden sollen entweder öffentlich zu machen oder mit einer speziellen für den Visitor vorbereiteten Schnittstelle zu exportieren.
Die Kosten
Es stellt sich also die Frage nach den nachteiligen Auswirkungen dieser Kapselungsverletzung und damit nach den Kosten den das Visitorprinzip mit sich bringt.
Bedenkt man, dass das Besuchermuster eine Art Infrastruktur für eine beliebige Anzahl von Operationen
auf die Objektstruktur definiert, dann richten sich alle speziellen Operationen auch nach dieser Infrastruktur.
Diese Infrastruktur enthält als wesentlichen bestandteil eine Schnittstellenklasse Visitor
,
die ihrerseits visit()
-Methoden für jeden speziellen Typ der Objektstruktur besitzt.
Würde ein Typ zur Objektstruktur hinzugefügt oder aus ihr entfernt werden, zöge das
auch eine Änderung der Visitorschnittstelle nach sich. Eine Änderung an der Schnittstelle
beeinflusst auch alle schon implementierten Operationen. Die Operationen auf einen sich in der genannten
Weise ändernden Objektbaum müssten sich also auch alle ändern. Es hängt nun davon ab,
wieviele Operationen anzupassen wären, ob man sich eine solche Änderung leisten kann.
Die Verletzung der Kapselung hat also immer dann schwer zu beherrschende Konsequenzen, wenn die Typen der Datenseite im Projekt nicht hinreichend definiert bzw. nicht stabil sind.
Die Reduktion der Abhängigkeiten durch den azyklischen Besucher
Die von Alexandrescu in [AA01] beschriebene Variante des Visitors beruht im Wesentlichen darauf, dass
die Visitorschnittstelle in viele Einzelschnittstellen aufgespalten wird, die jeweils einem zu besuchenden
Typ der Datenseite assoziiert ist. Dieser Typ überprüft in seiner accept()
-Methode,
ob der erhaltene Besucher die assoziierte Schnittstelle implementiert. Diese Überprüfung wird mit
dem dynamic_cast<>()
-Operator zur Laufzeit durchgeführt.
class BaseVisitor { public: virtual ~BaseVisitor() {} }; template <class T> class Visitor : public virtual BaseVisitor { public: virtual void visit( T & ) = 0; }; class Visitable { public: virtual ~Visitable() {} virtual void accept( BaseVisitor &v ) = 0; protected: template <class T> static void acceptImpl( T& visited, BaseVisitor &guest ) { if( Visitor<T> *p = dynamic_cast<Visitor<T>*>(&guest) ) p->visit( visited ); } };
Listing 9: Der azyklische Besucher
Der Vorteil dieser Variante liegt darin, dass man bei neu hinzugefügten Typen der Datenseite keine Anpassung der bereits definierten Operationen durchführen muss, da deren Schnittstellen stabil bleiben. Die zyklische Abhängigkeit des Codes der Datenstruktur und der Visitorschnittstelle ist aufgehoben. Daher auch die Bezeichnung azyklischer Besucher. Man etabliert die Abhängigkeit erst zwischen den Operationen, die mit durch eine Besucherklasse realisiert werden sollen und den konkreten besuchten Typen.
Betrachten wir jedoch zunächst die accept()
-Methoden etwas näher: in deren Parameterliste
muss ein allgemeiner Typ angegeben werden. Dieser Typ könnte eine Visitorklasse ohne
visit()
-Methoden sein. Um einen konkreten Visitor zu definieren, der eine Operation
transportiert, muss dieser von der allgemeinen leeren Visitorklasse ableiten und zusätzlich von
solchen Klassen, die die entsprechenden visit()
-Methoden vordefinieren. Diese zusätzlichen
Klassen sind spezielle Visitorschnittstellenklassen für die zu durchlaufenden Datentypen. Diese
speziellen Visitorklassen sind abstrakt und haben jeweils nur eine rein virtuelle visit()
-Methode
mit dem Typ in der Parameterliste, für den sie definiert sind. Man kann diese spezielle
auch von der allgemeinen Visitorklasse ableiten, um später bei der Definition der Operationen nicht
die Ableitung der allgemeinen zu erzwingen. Zwingend nötig ist dieses Vorgehen nicht. Wird es aber
gemacht, muss eine virtuelle Vererbung durchgeführt werden, denn die speziellen Visitorschnittstellen
sollen später in Kombination eingesetzt werden können. Würde man die Vererbung nicht
virtuell durchführen, könnte man nicht mit einem Zeiger oder einer Referenz der Basisklasse
BaseVisitor
auf das spezielle Besucherobjekt verweisen, da mehrere Basisklassenanteile in
seinem Layout vorhanden wären.
Die Kosten des azyklischen Besuchers
Natürlich hat auch der azyklische Besucher seine Kosten. Zum Beispiel verursacht er einen höheren Laufzeitaufwand durch den dynamischen Cast. Ein gewichtigeres Thema ist das folgende: Durch die Entkopplung der Besucherklasse von der zu besuchenden Datenklasse entfällt die Kontrolle durch den Compiler, ob überhaupt eine Operation für die letzere definiert worden ist. Der oben erwähnte Vorteil dieser Lösung ist also gleichzeitig auch ein Nachteil. Aber was ist schon perfekt in diesem Leben?
#include <variant> #include <vector> #include <iostream> class A { public: void ma() const { std::cout << "A::ma()" << std::endl; } }; class B { public: void mb() const { std::cout << "A::mb()" << std::endl; } }; using AB = std::variant<A,B> std::vector<AB> createObjects() { std::vector<AB> v; v.push_back( A() ); // Sowohl A.. v.push_back( B() ); // ..als auch B v.push_back( B() ); v.push_back( A() ); return v; } struct Visitor { void operator()(const A &obj) { std::cout << "It's an A -> "; obj.ma(); } void operator()(const B &obj) { std::cout << "It's a B -> "; obj.mb(); } }; void useObjects(const std::vector<AB> &v) { for( const auto &e : v ) { std::visit( Visitor{}, e ); } } int main() { auto v = createObjects(); useObjects(v); return 0; }
Listing 10: | Der Besucher im Zusammenhang mit dem Typ std::variant<T...> |
Ein Besucher in C++17
Die C++ Standardbibliothek hat mit C++17 eine interessante Erweiterung bekommen: das Klassentemplate
std::variant<T...>
. Damit lassen sich spezifische variante Datentypen erzeugen.
Allerdings sind diese auf die Inhaltstypen festgelegt, die in der Templateparameterliste übergeben wurden.
Interessant wird es nun, wenn man die Benutzung dieser varianten Typen betrachtet.
Will man eine Operation auf ein Objekt eines varianten Typs ausführen, muss man auf den
enthaltenen Datentyp reagieren können. Und genau da kommt das Besuchermuster ins Spiel.
Es kann auf unterschiedliche Typen der besuchten Objekte reagieren.
Der Besucher ermöglicht damit im Zusammenhang mit dem Template std::variant<...>
eine ungewöhnliche neue Facette des C++ Designs in C++17:
eine Art leichtgewichtiger Laufzeitpolymorphismus, ohne dafür Vererbung einsetzen zu müssen.
Sehen wir uns dafür den Code in Listing 10 an. Es gibt die beiden Klassen A und B, die keine
gemeinsame formale Schnittstelle haben. Die Namen der Methoden unterscheiden sich, weshalb
auch nicht von einer generisch identischen Schnittstelle gesprochen werden kann.
Der variante Datentyp AB macht den Inhaltstyp austauschbar. AB kann sowohl A als
auch B aufnehmen. Die funktionale Vereinheitlichung der beiden Typen A und B
erfolgt über die Visitorklasse, die Funktionsoperatoren für beide Typen bereit hält.
Anders als in den vorangehenden Beispielen bis zum Listing 9 hat dieser Visitor keine
Methode visit()
. Die Aufgabe diser Methode wird durch die beiden genannten
Operatoren übernommen. Die Verteilung der Aufrufe auf den jeweils richtigen Operator übernimmt
ein Element der Standardbibliothek mit dem Namen std::visit()
. Diese Templatemethode
verbindet ein Element des Typs AB mit dem Visitor und sorgt dafür, dass der passende
Operator des Visitors aufgerufen wird. Die Operatoren des Visitors sind nicht virtuell.
Genauso wenig wie die Methoden der Klassen A und B.
Der Visitor ist in diesem Zusammenhang also ein reines Funktionsobjekt, das noch nicht einmal
eine V-Table für seine Funktionen benötigt. Da aber eine Laufzeitentscheidung anhand der Inhaltstypen
A oder B gefällt werden muss, wird die Funktion der V-Table durch die Templatemethode
std::visit()
übernommen.
Statt herkömmliche Vererbung zu verwenden, werden verschiedene Typen
über das Template std::variant<...>
geklammert.
Damit kann eine Interfaceklasse entfallen. Die virtuellen Methoden
einer Vererbung werden durch den Visitor ersetzt der Methoden von außen
an die Objekte heranträgt. Damit wird – wie generell bei der Anwendung dieses
Musters – das Delegationsprinzip in der Objektorientierten Programmierung
verletzt.
Typen können aber leicht und niederschwellig in
unterschiedliche Abstraktionszusammenhänge gebracht werden, ohne dafür
jedes Mal ein Interface definieren zu müssen. Ein dazu gehöriger Besuchertyp
ersetzt die virtuellen Methoden, die im Vererbungsfall innerhalb der
Klassenhierarchie definiert werden müssten. Eine variante Klammer zwischen
zwei Klassen zu definieren ist sehr einfach und durch eine einfache
using
-Direktive zu bewerkstelligen. Der dazu gehörige
Besucher ist ebenso leichtgewichtig zu implementieren.
Das macht das Design in C++17 wesentlich flexibler, da nun neben der Vererbung
eine weitere und dazu leichtgewichtige Möglichkeit der Abstraktion geschaffen wurde.
Literatur
[GoF95] | Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides: Design Patterns. Elements of Reusable Objectoriented Software. 1995. |
[AA01] | Andrei Alexandrescu: Modern C++ Design. Generic Programming and Design Patterns Applied. 2001. |
Link zum älteren Artikel zum Besuchermuster in C++ von 2016.
Zuletzt geändert am 28.10.2023