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 unterschied­lichen Varianten verschiedene Anforderungen realisieren helfen. Zunächst aber zu der grundsätzlichen Funktionsweise des Visitors:

<<Abbildung: UML-Diagramm des Besucher-Musters>>

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 Methoden­schnittstelle 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 Struktur­element 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 Traver­sierung über die Verwaltungs­struktur zu trennen. Aus der Entkopplung dieser beiden Aspekte entstehen Freiheitsgrade für Variationen derselben. Die Traver­sierung wird durch die genannte Infra­struktur aus Methoden übernommen. Die durch den Visitor transpor­tierte Operation wird in einer Kindklasse der Visitor­schnitt­stelle implementiert. Damit lassen sich beliebige Operationen definieren und durch den allgemeinen Traversierungs­mechanismus in die Objekt­struktur tragen. Mit dem Namen des Visitors wird das Verhalten des Musters gewürdigt. Das Visitor­objekt geht die Objekte in der Objekt­struktur „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 Funktions­weise des Visitors beruht auf einer standardi­sierten Zusammen­arbeit zwischen den Objekten der traver­sierten Daten­struktur mit dem Visitorobjekt. Diese Zusammen­arbeit wird durch die Infra­struktur­methoden der Daten implementiert, die üblicherweise accept() oder ähnlich benannt werden. Diese Methoden bekommen einen Para­meter einen Zeiger oder eine Referenz auf eine Visitor­schnitt­stelle. Die Visitor­schnitt­stelle stellt wiederum eigene Methoden zur Verfügung. Die visit()-Methoden: für jeden Typ in der Daten­struktur 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 Implemen­tierung der Traversierung. Die Berück­sichtigung 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 Ordnungs­struktur betrachtet werden. Die Ordnungs­struktur muss nur dann mitbetrachtet werden, wenn eine spezielle Operation diese auch berücksichtigen muss.

Im Folgenden sollen schematische Implemen­tierungen des Visitors gezeigt und diskutiert werden. Dabei sollen die Vor- und Nach­teile des Visitors wie auch die Stärken und die Schwächen spezifischer Implemen­tierungen in C++ beleuchtet werden. Dazu wird anhand einer schematischen Implemen­tierung eines Kompositums eine Objekt­struktur vorgegeben, die durch einen Visitor bearbeitet wird. Auch der Visitor wird schema­tisch implemen­tiert, was die Diskussion am Muster ohne die Berück­sichtigung externer fachlicher Aspekte erleichtert. Es wird dabei dem Leser überlassen, die Transfer­leistung in eine reale Projekt­umgebung durch die eigene Vorstellung­skraft zu leisten. Das sollte aber nicht allzu schwer fallen, denn die schema­tischen Implemen­tierungen lassen sich mit nur wenigen Änderungen in realer Software nutzbar machen.

Eine praktische Demonstration

Die im Listing 1 vorgestellte schematische Komposit­implemen­tierung steht für eine beliebige dynamische Objekt- oder Daten­struktur. 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 Schnittstellen­klasse für die Visitor­hierarchie definiert. Diese Schnitt­stelle soll hier der Einfachheit halber Visitor genannt werden. Diese Schnitt­stelle 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 Visitor­schnitt­stelle um eine Klasse handelt die ab­geleitet wer­den soll, braucht sie auch einen virtuellen De­struktor. Ausser­dem müssen die Para­meter­typen vor­dekla­riert werden, denn sie können bei der Defini­tion der Visitor­schnitt­stelle noch nicht bekannt sein. Im Listing 2 wird diese Vorwärts­deklara­tion innerhalb der Para­meter­listen ange­bracht. Später sollen von dieser Schnitt­stelle die Klassen ab­geleitet werden, die die Opera­tionen implemen­tieren.

Zuerst aber muss die Daten­struktur die accept()-Methode bekommen, um die Infra­struktur des Visitormusters zu komplettieren. Die Basis­klasse der Komposit­struktur Component bekommt also eine abstrakte Methode accept() die als Parameter eine Visitor­referenz bekommt und in den speziali­sierten Klassen implemen­tiert werden muss. Diese Methode ist ein Standardelement des Musters und kümmert sich nur um die Weiterleitung des Visitors. Sie enthält niemals irgendwelche Implementierungs­details 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 Implemen­tierung der accept()-Methode der Kompositum­klasse ist etwas aufwendiger: sie muss an alle enthaltenen Objekte den accept()-Aufruf weiterleiten. Diese Implemen­tierung ist also im Fall unseres Kompositums die eigentliche Schalt­stelle der Traver­sierung für alle Operationen auf die Daten­struktur. Mit diesen accept()-Methoden ist die Infrastruktur für das Visitormuster komplett und kann verwendet werden.

Nun kann man über die Visitor­schnitt­stelle Opera­tionen definieren. Dazu überlädt man die visit()-Methoden und implementiert die Operation. Das speziali­sierte Visitor­objekt kann vor dem Durch­lauf durch die Daten­struktur Attribute auf den Weg bekommen. Außer­dem kann es während der Traver­sion Zustands­infor­mationen über die Daten­struktur sammeln.

Das erste Beispiel der Anwendung soll genau solche Zustands­informationen über die Objekt­struktur 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 Imple­mentierung einer Operation über die Visitor­schnittstelle soll zeigen, dass der Entwickler einer solchen Operation die tatsächliche Struktur nicht mehr berück­sichtigen muss.

Um eine Operation zu schreiben muss der Entwickler von der Schitt­stellenklasse Visitor ableiten und alle ihre Methoden implementieren. Das erste Experiment in Listing 5 mit einer Operation soll einfach statis­tische 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 eigent­liche 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 An­wenden: 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 Über­gabe eines Besucher­objektes 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 grund­sätz­liche Funktions­weise des Visitors demonstrieren. Dabei wurde auf den Aspekt, dass der Visitor auf die unter­schied­lichen 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 Änder­ungen aus Listing 6 reagiert der Zähler bereits auf die unter­schied­lichen 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 Objekt­struktur 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ähler­beispielen 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 Beispiel­implementierung des Besuchers bis jetzt nicht kann, ist auf die Verschachtelungstiefe des Objektbaumes reagieren. Das zugrunde liegende Problem ist, dass die bisher implementierte Infra­struktur nur auf Objekttypen und nicht auf Eigenheiten der Organisations­form sensibel ist. Beispielhaft an den Komposit­daten 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 Visitor­schnitt­stelle eine weitere Methode, die das Verlassen eines Kompositobjekts anzeigt. In der Beispiel­implementierung 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 Verschachtelungs­tiefe des Objektbaums „interessiert“, muss sie nur dement­sprechend die leave()-Methode implemen­tieren. In dieser Methode muss nur ein Attribut, das die Ver­schachtelungs­tiefe reprä­sentiert dekrementiert werden, während in der dazugehörigen visit()-Methode inkrementiert wird.

Natürlich sehen solche Varianten, die die Organisations­form der Objekt­struktur berück­sichtigen sehr unter­schiedlich 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 Schnitt­stellen­implemen­tierung der Compiler die Implemen­tierung aller abstrakten − rein virtuellen − Methoden verlangt, müssen Imple­mentierungen ange­geben werden. In Java-Biblio­theken wird diesem Problem oft mit einer Klasse begegnet, die die entsprech­enden Methoden alle leer implementiert. Man gibt bei der Verwendung einer solchen Klasse allerdings die Über­prüfung der voll­ständigen Implemen­tierung durch den Compiler auf. Dafür müssen nur die Schnitt­stellen­methoden über­schrieben 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 Schnitt­stelle 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­über­legungen 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 Systemati­sierungs­prinzip 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ück­sichtigen, 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 Kapselungs­verletzung 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 Schnitt­stellen­klasse 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 Visitor­schnittstelle in viele Einzel­schnittstellen 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 Visitor­schnittstelle 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 Parameter­liste muss ein allgemeiner Typ angegeben werden. Dieser Typ könnte eine Visitor­klasse 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 Visitor­schnittstellen­klassen für die zu durchlaufenden Datentypen. Diese speziellen Visitor­klassen 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 Visitor­schnittstellen 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 Basisklassen­anteile 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 Klassen­template std::variant<T...>. Damit lassen sich spezifische variante Daten­typen erzeugen. Allerdings sind diese auf die Inhaltstypen festgelegt, die in der Template­parameter­liste ü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 leicht­gewichtiger Laufzeit­polymorphismus, 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 Verein­heitlichung der beiden Typen A und B erfolgt über die Visitorklasse, die Funktions­operatoren 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 Standard­bibliothek mit dem Namen std::visit(). Diese Template­methode 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 Laufzeit­entscheidung anhand der Inhalts­typen A oder B gefällt werden muss, wird die Funktion der V-Table durch die Template­methode std::visit() übernommen.

Statt herkömmliche Vererbung zu verwenden, werden verschiedene Typen über das Template std::variant<...> geklammert. Damit kann eine Interface­klasse 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 Delegations­prinzip in der Objektorientierten Programmierung verletzt.

Typen können aber leicht und niederschwellig in unterschiedliche Abstraktions­zusammenhä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 bewerk­stelligen. 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 leicht­gewichtige 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