Kapitel 4. Das Besucherentwurfsmuster

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

In diesem Kapitel geht es ausschließlich um das Visitor-Muster. Wenn du bereits vom Visitor-Muster gehört oder es sogar in deinen eigenen Entwürfen verwendet hast, fragst du dich vielleicht, warum ich Visitor als erstes Muster ausgewählt habe, das ich im Detail erkläre. Ja, Visitor gehört sicher nicht zu den glamourösesten Entwurfsmustern, aber es ist ein großartiges Beispiel, um zu zeigen, wie viele Möglichkeiten du bei der Implementierung eines Entwurfsmusters hast und wie unterschiedlich diese Implementierungen sein können. Außerdem ist es ein gutes Beispiel, um die Vorteile von modernem C++ zu verdeutlichen.

In "Leitfaden 15: Design für das Hinzufügen vonTypen oder Operationen"sprechen wir zunächst über die grundlegende Design-Entscheidung, die du treffen musst, wenn du dich im Bereich der dynamischen Polymorphie bewegst: konzentriere dich entweder auf Typen oder auf Operationen. In diesem Leitfaden sprechen wir auch über die Stärken und Schwächen der einzelnen Programmierparadigmen.

In "Leitfaden 16: Visitor zur Erweiterung von Operationen verwenden" stelle ich dir das Visitor-Entwurfsmuster vor. Ich erkläre, warum es dazu dient, Operationen anstelle von Typen zu erweitern, und zeige dir sowohl die Vorteile als auch die Nachteile des klassischen Visitor-Musters.

In "Leitfaden 17: Berücksichtige std::variant für dieImplementierung von Visitor"lernst du die moderne Implementierung des Visitor-Designmusters kennen. Ich stelle dir std::variant vor und erkläre die vielen Vorteile dieser speziellen Implementierung.

In "Leitfaden 18: Vorsicht vor der Leistung des azyklischen Besuchers" stelle ich dir den azyklischen Besucher vor. Auf den ersten Blick scheint dieser Ansatz einige grundlegende Probleme des Visitor-Musters zu lösen, aber bei näherer Betrachtung werden wir feststellen, dass der Laufzeit-Overhead diese Implementierung disqualifizieren kann.

Leitlinie 15: Entwurf für die Hinzufügung vonArten oder Vorgängen

Für mag der Begriff dynamische Polymorphie nach viel Freiheit klingen. Es mag sich ähnlich anfühlen wie damals, als du noch ein Kind warst: endlose Möglichkeiten, keine Einschränkungen! Nun, du bist älter geworden und hast dich der Realität gestellt: Du kannst nicht alles haben, und es gibt immer eine Entscheidung zu treffen. Leider ist es mit der dynamischen Polymorphie ähnlich. Auch wenn es sich nach völliger Freiheit anhört, gibt es eine Einschränkung: Willst du Typen oder Operationen erweitern?

Um zu sehen, was ich meine, kehren wir zu dem Szenario aus Kapitel 3 zurück: Wir wollen eine bestimmte Form zeichnen.1 Wir halten uns an den dynamischen Polymorphismus und setzen dieses Problem zunächst mit der guten alten prozeduralen Programmierung um.

Eine verfahrenstechnische Lösung

Die erste Header-Datei Point.h bietet eine recht einfache Klasse Point. Sie dient vor allem dazu, den Code zu vervollständigen, aber sie zeigt uns auch, dass wir es mit 2D-Formen zu tun haben:

//---- <Point.h> ----------------

struct Point
{
   double x;
   double y;
};

Die zweite konzeptionelle Kopfdatei Shape.h erweist sich als viel interessanter:

//---- <Shape.h> ----------------

enum ShapeType  1
{
   circle,
   square
};

class Shape  2
{
 protected:
   explicit Shape( ShapeType type )
      : type_( type )  5
   {}

 public:
   virtual ~Shape() = default;  3

   ShapeType getType() const { return type_; }  6

 private:
   ShapeType type_;  4
};

Zunächst führen wir die Aufzählung ShapeType ein, die derzeit die beiden Aufzählungszeichen circle und square(1). Offenbar haben wir es zunächst nur mit Kreisen und Quadraten zu tun. Zweitens stellen wir die Klasse Shape(2). Angesichts des geschützten Konstruktors und des virtuellen Destruktors (3) kannst du erahnen, dass Shape als Basisklasse funktionieren soll. Aber das ist nicht das überraschende Detail an Shape: Shape hat ein Datenmitglied vom Typ ShapeType(4). Dieses Datenelement wird über den Konstruktor initialisiert (5) initialisiert und kann über die Memberfunktion getType() abgefragt werden (6) abgefragt werden. Offenbar speichert Shape seinen Typ in Form der Aufzählung ShapeType.

Ein Beispiel für die Verwendung der Basisklasse Shape ist die Klasse Circle:

//---- <Circle.h> ----------------

#include <Point.h>
#include <Shape.h>

class Circle : public Shape  7
{
 public:
   explicit Circle( double radius )
      : Shape( circle )  8
      , radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   double radius() const { return radius_; }
   Point  center() const { return center_; }

 private:
   double radius_;
   Point center_{};
};

Circle erbt öffentlich von Shape(7) und muss aus diesem Grund und wegen des Fehlens eines Standardkonstruktors in Shape die Basisklasse initialisieren (8Da es sich um einen Kreis handelt, verwendet er den circle Enumerator als Argument für den Konstruktor der Basisklasse.

Wie bereits erwähnt, wollen wir Formen zeichnen. Deshalb führen wir die Funktion draw() für Kreise ein. Da wir uns nicht zu sehr an die Implementierungsdetails des Zeichnens binden wollen, wird die Funktion draw() in der konzeptionellen Header-Datei deklariertDrawCircle.h deklariert und in der entsprechenden Quelldatei definiert:

//---- <DrawCircle.h> ----------------

class Circle;

void draw( Circle const& );


//---- <DrawCircle.cpp> ----------------

#include <DrawCircle.h>
#include <Circle.h>
#include /* some graphics library */

void draw( Circle const& c )
{
   // ... Implementing the logic for drawing a circle
}

Natürlich gibt es nicht nur Kreise. Wie aus dem square enumerator hervorgeht, gibt es auch eine Square Klasse:

//---- <Square.h> ----------------

#include <Point.h>
#include <Shape.h>

class Square : public Shape  9
{
 public:
   explicit Square( double side )
      : Shape( square )  10
      , side_( side )
   {
      /* Checking that the given side length is valid */
   }

   double side  () const { return side_; }
   Point  center() const { return center_; }

 private:
   double side_;
   Point center_{};  // Or any corner, if you prefer
};


//---- <DrawSquare.h> ----------------

class Square;

void draw( Square const& );


//---- <DrawSquare.cpp> ----------------

#include <DrawSquare.h>
#include <Square.h>
#include /* some graphics library */

void draw( Square const& s )
{
   // ... Implementing the logic for drawing a square
}

Die Klasse Square sieht der Klasse Circle sehr ähnlich (9). Der Hauptunterschied besteht darin, dass die Square ihre Basisklasse mit demsquare Enumerator (10).

Da wir sowohl Kreise als auch Quadrate zur Verfügung haben, wollen wir nun einen ganzen Vektor mit verschiedenen Formen zeichnen. Aus diesem Grund führen wir die Funktion drawAllShapes() ein:

//---- <DrawAllShapes.h> ----------------

#include <memory>
#include <vector>
class Shape;

void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes );  11


//---- <DrawAllShapes.cpp> ----------------

#include <DrawAllShapes.h>
#include <Circle.h>
#include <Square.h>

void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
   for( auto const& shape : shapes )
   {
      switch( shape->getType() )  12
      {
         case circle:
            draw( static_cast<Circle const&>( *shape ) );
            break;
         case square:
            draw( static_cast<Square const&>( *shape ) );
            break;
      }
   }
}

drawAllShapes() nimmt einen Vektor von Formen in Form von std::unique_ptr<Shape>(11). Der Zeiger auf die Basisklasse ist notwendig, um verschiedene Arten von konkreten Formen zu speichern, und die std::unique_ptr insbesondere, um die Formen automatisch über das RAII-Idiom zu verwalten. Innerhalb der Funktion beginnen wir damit, den Vektor zu durchlaufen, um jede Form zu zeichnen. Leider haben wir zu diesem Zeitpunkt nur die Zeiger von Shape. Deshalb müssen wir jede Form mit Hilfe der Funktion getType()nett fragen (12) fragen: Was für eine Form bist du? Wenn die Form mit circle antwortet, wissen wir, dass wir sie als Circle zeichnen und die entsprechende static_cast ausführen müssen. Wenn die Form mit square antwortet, zeichnen wir sie als Square.

Ich kann spüren, dass du mit dieser Lösung nicht besonders glücklich bist. Aber bevor wir über die Unzulänglichkeiten sprechen, lass uns die Funktion main() betrachten:

//---- <Main.cpp> ----------------

#include <Circle.h>
#include <Square.h>
#include <DrawAllShapes.h>
#include <memory>
#include <vector>

int main()
{
   using Shapes = std::vector<std::unique_ptr<Shape>>;

   // Creating some shapes
   Shapes shapes;
   shapes.emplace_back( std::make_unique<Circle>( 2.3 ) );
   shapes.emplace_back( std::make_unique<Square>( 1.2 ) );
   shapes.emplace_back( std::make_unique<Circle>( 4.1 ) );

   // Drawing all shapes
   drawAllShapes( shapes );

   return EXIT_SUCCESS;
}

Es funktioniert! Mit dieser main() Funktion wird der Code kompiliert und zeichnet drei Formen (zwei Kreise und ein Quadrat). Ist das nicht toll? Ja, aber das wird dich nicht davon abhalten, dich zu beschweren: "Was für eine primitive Lösung! Nicht nur, dass switch eine schlechte Wahl ist, um zwischen verschiedenen Arten von Formen zu unterscheiden, es hat auch keinen Standardfall! Und wer kam auf die verrückte Idee, den Typ der Formen mit einer unscoped enumeration zu kodieren?"2 Du schaust verdächtig in meine Richtung...

Nun, ich kann deine Reaktion verstehen. Aber lass uns das Problem ein bisschen genauer analysieren. Lass mich raten: Du erinnerst dich an "Leitlinie 5: Design for Extension". Und du stellst dir jetzt vor, was du tun müsstest, um eine dritte Art von Form hinzuzufügen. Zuerst müsstest du die Aufzählung erweitern. Wir müssten zum Beispiel den neuen Enumerator triangle(13):

enum ShapeType
{
   circle,
   square,
   triangle  13
};

Beachte, dass dieser Zusatz nicht nur Auswirkungen auf die switch -Anweisung in der FunktiondrawAllShapes() hat (sie ist jetzt wirklich unvollständig), sondern auch auf alle vonShape abgeleiteten Klassen (Circle und Square). Diese Klassen sind von der Aufzählung abhängig, da sie von der Basisklasse Shape abhängen und die Aufzählung auch direkt verwenden. Daher würde eine Änderung der Aufzählung dazu führen, dass alle deine Quelldateien neu kompiliert werden müssen.

Das sollte dir als ein ernstes Problem erscheinen. Und das ist es auch. Der Kern des Problems ist die direkte Abhängigkeit aller Shape-Klassen und -Funktionen von der Aufzählung. Jede Änderung an der Aufzählung hat einen Ripple-Effekt zur Folge, der eine Neukompilierung der abhängigen Dateien erfordert. Das verstößt natürlich direkt gegen das Open-Closed-Prinzip (OCP) (siehe"Leitfaden 5: Design for Extension"). Das scheint nicht richtig zu sein: Das Hinzufügen einer Triangle sollte nicht zu einer Neukompilierung der Klassen Circle und Square führen.

Es gibt aber noch mehr. Du musst nicht nur eine Triangle Klasse schreiben (das überlasse ich deiner Fantasie), sondern auch die switch Anweisung aktualisieren, um Dreiecke zu behandeln (14):

void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
   for( auto const& shape : shapes )
   {
      switch( shape->getType() )
      {
         case circle:
            draw( static_cast<Circle const&>( *shape ) );
            break;
         case square:
            draw( static_cast<Square const&>( *shape ) );
            break;
         case triangle:  14
            draw( static_cast<Triangle const&>( *shape ) );
            break;
      }
   }
}

Ich kann mir deinen Aufschrei vorstellen: "Copy-and-Paste! Duplikation!" Ja, in dieser Situation ist es sehr wahrscheinlich, dass ein Entwickler Copy-and-Paste verwendet, um die neue Logik zu implementieren. Das ist einfach so praktisch, weil der neue Fall den beiden vorherigen so ähnlich ist. Und das ist in der Tat ein Hinweis darauf, dass das Design verbessert werden könnte. Ich sehe jedoch einen viel schwerwiegenderen Fehler: Ich würde davon ausgehen, dass dies in einer größeren Codebasis nicht die einzige switch Anweisung ist. Im Gegenteil, es wird noch andere geben, die ebenfalls aktualisiert werden müssen. Wie viele sind es? Ein Dutzend? Fünfzig? Über hundert? Und wie findest du sie alle? OK, du argumentierst also, dass der Compiler dir bei dieser Aufgabe helfen würde. Vielleicht mit den Schaltern, ja, aber was ist, wenn es auch if-else-if-Kaskaden gibt? Und wenn du nach diesem Aktualisierungsmarathon glaubst, fertig zu sein, wie kannst du dann garantieren, dass du wirklich alle notwendigen Abschnitte aktualisiert hast?

Ja, ich kann deine Reaktion verstehen und auch, warum du diese Art von Code lieber nicht haben möchtest: Diese explizite Handhabung von Typen ist ein Wartungsalptraum. Um Scott Meyers zu zitieren:3

Diese Art der typbasierten Programmierung hat in C eine lange Geschichte und wir wissen unter anderem, dass sie zu Programmen führt, die im Grunde genommen nicht wartbar sind.

Eine objektorientierte Lösung

Lass mich also fragen: Was hättest du getan? Wie hättest du das Zeichnen von Formen implementiert? Nun, ich kann mir vorstellen, dass du einen objektorientierten Ansatz verwendet hättest. Das heißt, du hättest die Aufzählung gestrichen und eine rein virtuelle draw() Funktion zur BasisklasseShape hinzugefügt. Auf diese Weise muss sich Shape seinen Typ nicht mehr merken:

//---- <Shape.h> ----------------

class Shape
{
 public:
   Shape() = default;

   virtual ~Shape() = default;

   virtual void draw() const = 0;
};

Ausgehend von dieser Basisklasse müssen abgeleitete Klassen nur noch die Memberfunktion draw() implementieren (15):

//---- <Circle.h> ----------------

#include <Point.h>
#include <Shape.h>

class Circle : public Shape
{
 public:
   explicit Circle( double radius )
      : radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   double radius() const { return radius_; }
   Point  center() const { return center_; }

   void draw() const override;  15

 private:
   double radius_;
   Point center_{};
};


//---- <Circle.cpp> ----------------

#include <Circle.h>
#include /* some graphics library */

void Circle::draw() const
{
   // ... Implementing the logic for drawing a circle
}


//---- <Square.h> ----------------

#include <Point.h>
#include <Shape.h>

class Square : public Shape
{
 public:
   explicit Square( double side )
      : side_( side )
   {
      /* Checking that the given side length is valid */
   }

   double side  () const { return side_; }
   Point  center() const { return center_; }

   void draw() const override;  15

 private:
   double side_;
   Point center_{};
};


//---- <Square.cpp> ----------------

#include <Square.h>
#include /* some graphics library */

void Square::draw() const
{
   // ... Implementing the logic for drawing a square
}

Sobald die virtuelle Funktion draw() vorhanden ist und von allen abgeleiteten Klassen implementiert wird, kann sie zur Umstrukturierung der Funktion drawAllShapes() verwendet werden:

//---- <DrawAllShapes.h> ----------------

#include <memory>
#include <vector>
class Shape;

void drawAllShapes( std::vector< std::unique_ptr<Shape> > const& shapes );


//---- <DrawAllShapes.cpp> ----------------

#include <DrawAllShapes.h>
#include <Shape.h>

void drawAllShapes( std::vector< std::unique_ptr<Shape> > const& shapes )
{
   for( auto const& shape : shapes )
   {
      shape->draw();
   }
}

Ich kann sehen, wie du dich entspannst und wieder zu lächeln beginnst. Das ist so viel schöner, so viel sauberer. Obwohl ich verstehe, dass du diese Lösung bevorzugst und gerne noch ein bisschen länger in dieser Komfortzone bleiben würdest, muss ich dich leider auf einen Makel hinweisen. Ja, diese Lösung kann auch einen Nachteil haben.

Wie in der Einleitung zu diesem Abschnitt erwähnt, können wir mit einem objektorientierten Ansatz nun sehr einfach neue Typen hinzufügen. Alles, was wir tun müssen, ist, eine neue abgeleitete Klasse zu schreiben. Wir müssen keinen bestehenden Code ändern oder neu kompilieren (mit Ausnahme der Funktion main()). Das erfüllt die OCP perfekt. Hast du jedoch bemerkt, dass wir nicht mehr in der Lage sind, einfach Operationen hinzuzufügen? Nehmen wir zum Beispiel an, wir brauchen eine virtuelle serialize() Funktion, um eine Shape in Bytes umzuwandeln. Wie können wir diese Funktion hinzufügen, ohne den bestehenden Code zu ändern? Wie kann jemand diese Funktion einfach hinzufügen, ohne die Basisklasse Shape zu verändern?

Leider ist das nicht mehr möglich. Wir haben es jetzt mit einer geschlossenen Menge von Operationen zu tun, was bedeutet, dass wir die OCP in Bezug auf die Additionsoperationen verletzen. Um eine virtuelle Funktion hinzuzufügen, muss die Basisklasse geändert werden, und alle abgeleiteten Klassen (Kreise, Quadrate usw.) müssen die neue Funktion implementieren, auch wenn die Funktion vielleicht nie aufgerufen wird. Zusammenfassend lässt sich sagen, dass die objektorientierte Lösung die OCP in Bezug auf das Hinzufügen von Typen erfüllt, aber in Bezug auf die Operationen gegen sie verstößt.

Ich weiß, dass du dachtest, wir hätten die prozedurale Lösung für immer hinter uns gelassen, aber lass uns einen zweiten Blick darauf werfen. Bei der prozeduralen Lösung war das Hinzufügen einer neuen Operation eigentlich sehr einfach. Neue Operationen konnten z. B. in Form von freien Funktionen oder separaten Klassen hinzugefügt werden. Es war nicht nötig, die Basisklasse Shape oder eine der abgeleiteten Klassen zu ändern. Mit der prozeduralen Lösung haben wir also die OCP in Bezug auf das Hinzufügen von Operationen erfüllt. Aber wie wir gesehen haben, verstößt die prozedurale Lösung gegen die OCP in Bezug auf das Hinzufügen von Typen. Sie scheint also eine Umkehrung der objektorientierten Lösung zu sein, die genau andersherum ist.

Achte auf die Designwahl bei dynamischer Polymorphie

Die Erkenntnis von aus diesem Beispiel ist, dass man bei der Verwendung von dynamischer Polymorphie die Wahl hat: Entweder kann man Typen einfach hinzufügen, indem man die Anzahl der Operationen festlegt, oder man kann Operationen einfach hinzufügen, indem man die Anzahl der Typen festlegt. Die OCP hat also zwei Dimensionen: Wenn du Software entwickelst, musst du bewusst entscheiden, welche Art der Erweiterung du erwartest.

Die Stärke der objektorientierten Programmierung ist das einfache Hinzufügen neuer Typen, aber ihre Schwäche ist, dass das Hinzufügen von Operationen viel schwieriger wird. Die Stärke der prozeduralen Programmierung ist das einfache Hinzufügen von Operationen, aber das Hinzufügen von Typen ist eine echte Qual(Tabelle 4-1). Es hängt von deinem Projekt ab: Wenn du davon ausgehst, dass häufig neue Typen hinzugefügt werden, solltest du eine OOP-Lösung anstreben, die Operationen als geschlossene Menge und Typen als offene Menge behandelt. Wenn du davon ausgehst, dass Operationen hinzugefügt werden, solltest du eine prozedurale Lösung anstreben, die Typen als geschlossene Menge und Operationen als offene Menge behandelt. Wenn du die richtige Wahl triffst, sparst du deine Zeit und die Zeit deiner Kollegen, und Erweiterungen werden sich natürlich und einfach anfühlen.4

Tabelle 4-1. Stärken und Schwächen der verschiedenen Programmierparadigmen
Programmierparadigma Stärke Schwäche

Prozedurale Programmierung

Addition der Operationen

Hinzufügen von (polymorphen) Typen

Objektorientierte Programmierung

Hinzufügen von (polymorphen) Typen

Addition der Operationen

Sei dir dieser Stärken bewusst: Wähle auf der Grundlage deiner Erwartung, wie sich eine Codebasis entwickeln wird, den richtigen Ansatz für das Design von Erweiterungen. Ignoriere die Schwächen nicht, und bringe dich nicht in eine unglückliche Wartungshölle.

Ich nehme an, du fragst dich jetzt, ob es möglich ist, zwei offene Sets zu haben. Soweit ich weiß, ist das nicht unmöglich, aber in der Regel unpraktisch. Als Beispiel zeige ich dir in"Leitfaden 18: Vorsicht vor der Leistung von azyklischen Besuchern", dass die Leistung erheblich darunter leiden kann.

Da du vielleicht ein Fan der Template-basierten Programmierung und ähnlicher Bemühungen zur Kompilierzeit bist, sollte ich auch ausdrücklich darauf hinweisen, dass der statische Polymorphismus nicht dieselben Einschränkungen hat. Während beim dynamischen Polymorphismus eine der Entwurfsachsen (Typen und Operationen) festgelegt werden muss, sind beim statischen Polymorphismus beide Informationen zur Kompilierzeit verfügbar. Daher können beide Aspekte leicht erweitert werden (wenn du es richtig machst).5

Leitlinie 16: Nutze den Besucher, um den Betrieb zu erweitern

Unter hast du im vorherigen Abschnitt gesehen, dass die Stärke der objektorientierten Programmierung (OOP) das Hinzufügen von Typen und ihre Schwäche das Hinzufügen von Operationen ist. Natürlich hat die OOP eine Antwort auf diese Schwäche: das Visitor Design Pattern.

Das Visitor Design Pattern ist eines der klassischen Design Patterns, die von der Gang of Four (GoF) beschrieben wurden. Sein Schwerpunkt liegt darauf, dass du häufig Operationen anstelle von Typen hinzufügen kannst. Erlaube mir, das Visitor Design Pattern anhand des vorherigen Beispiels zu erklären: das Zeichnen von Formen.

In Abbildung 4-1 siehst du die Hierarchie von Shape. Die Klasse Shape ist wiederum die Basisklasse für eine bestimmte Anzahl von konkreten Formen. In diesem Beispiel gibt es nur die beiden KlassenCircle und Square, aber es ist natürlich möglich, mehr Formen zu haben. Außerdem kannst du dir die Klassen Triangle, Rectangle oder Ellipse vorstellen.

Abbildung 4-1. Die UML-Darstellung einer Shape-Hierarchie mit zwei abgeleiteten Klassen (Circle und Square)

Die Analyse der Designaspekte

Nehmen wir an, du bist dir sicher, dass du bereits alle Formen hast, die du jemals brauchen wirst. Das heißt, du betrachtest die Menge der Formen als eine geschlossene Menge. Was dir jedoch fehlt, sind zusätzliche Operationen. Dir fehlt zum Beispiel eine Operation zum Drehen der Shapes. Außerdem möchtest du Shapes serialisieren, d.h. du möchtest die Instanz eines Shapes in Bytes umwandeln. Und natürlich möchtest du Shapes zeichnen. Außerdem möchtest du es jedem ermöglichen, neue Operationen hinzuzufügen. Deshalb erwartest du einen offenen Satz vonOperationen.6

Jede neue Operation erfordert nun, dass du eine neue virtuelle Funktion in die Basisklasse einfügst. Leider kann das auf verschiedene Weise problematisch sein. Am offensichtlichsten ist, dass nicht jeder in der Lage ist, eine virtuelle Funktion in die Basisklasse Shape einzufügen. Ich zum Beispiel kann nicht einfach loslegen und deinen Code ändern. Daher würde dieser Ansatz nicht die Erwartung erfüllen, dass jeder Operationen hinzufügen kann. Obwohl du das schon als endgültiges negatives Urteil sehen kannst, wollen wir das Problem der virtuellen Funktionen noch genauer analysieren.

Wenn du dich entscheidest, eine rein virtuelle Funktion zu verwenden, musst du die Funktion in jeder abgeleiteten Klasse implementieren. Für deine eigenen abgeleiteten Typen könntest du das als kleinen Mehraufwand abtun. Aber du könntest auch zusätzlichen Aufwand für andere Leute verursachen, die eine Form durch Vererbung von der Basisklasse Shape erstellt haben.7 Und das ist durchaus zu erwarten, denn das ist die Stärke von OOP: Jeder kann leicht neue Typen hinzufügen. Da das zu erwarten ist, kann das ein Grund sein, keine rein virtuelle Funktion zu verwenden.

Als Alternative könntest du eine reguläre virtuelle Funktion einführen, d. h. eine virtuelle Funktion mit einer Standardimplementierung. Während ein Standardverhalten für eine rotate() Funktion eine sehr vernünftige Idee ist, klingt eine Standardimplementierung für eine serialize() Funktion gar nicht so einfach. Ich gebe zu, dass ich lange darüber nachdenken müsste, wie ich eine solche Funktion implementieren könnte. Du könntest jetzt vorschlagen, einfach eine Ausnahme als Standard zu werfen. Das bedeutet jedoch, dass abgeleitete Klassen das fehlende Verhalten erneut implementieren müssen, und es wäre eine rein virtuelle Funktion in Verkleidung oder ein klarer Verstoß gegen das Liskovsche Substitutionsprinzip (siehe "Richtlinie 6: Das erwartete Verhalten von Abstraktionen einhalten").

So oder so ist das Hinzufügen einer neuen Operation in die Basisklasse Shape schwierig oder gar nicht möglich. Der Grund dafür ist, dass das Hinzufügen von virtuellen Funktionen gegen die OCP verstößt. Wenn du wirklich häufig neue Operationen hinzufügen musst, dann solltest du so entwerfen, dass die Erweiterung von Operationen einfach ist. Das ist es, was das Visitor Design Pattern zu erreichen versucht.

Das Visitor Design Pattern erklärt

Das Ziel des Visitor-Designmusters ist es, das Hinzufügen von Operationen zu ermöglichen.

Das Besucherentwurfsmuster

Intention: "Repräsentiert eine Operation, die auf den Elementen einer Objektstruktur ausgeführt werden soll. Mit Visitor kannst du eine neue Operation definieren, ohne die Klassen der Elemente zu ändern, auf die sie wirkt."8

Zusätzlich zu der Shape Hierarchie stelle ich nun die ShapeVisitor Hierarchie auf der linken Seite von Abbildung 4-2 vor. Die Basisklasse ShapeVisitor stellt eineAbstraktion der Formoperationen dar. Aus diesem Grund könnte man argumentieren, dass ShapeOperationein besserer Name für diese Klasse wäre. Es ist jedoch von Vorteil, die"Richtlinie 14: Verwende den Namen eines Entwurfsmusters, um die Absicht zu kommunizieren" anzuwenden. Der Name Visitor hilft anderen, das Design zu verstehen.

Abbildung 4-2. Die UML-Darstellung des Entwurfsmusters "Visitor

Die Basisklasse ShapeVisitor verfügt über eine rein virtuelle Funktion visit() für jede konkrete Form in der Hierarchie Shape:

class ShapeVisitor
{
 public:
   virtual ~ShapeVisitor() = default;

   virtual void visit( Circle const&, /*...*/ ) const = 0;  1
   virtual void visit( Square const&, /*...*/ ) const = 0;  2
   // Possibly more visit() functions, one for each concrete shape
};

In diesem Beispiel gibt es eine visit() Funktion für Circle(1) und eine für Square(2). Natürlich könnte es noch mehr visit() Funktionen geben - zum Beispiel eine fürTriangle, eine für Rectangle und eine für Ellipse- vorausgesetzt, es handelt sich dabei ebenfalls um Klassen, die von der Basisklasse Shape abgeleitet sind.

Mit der Basisklasse ShapeVisitor kannst du jetzt ganz einfach neue Operationen hinzufügen. Alles, was du tun musst, um eine Operation hinzuzufügen, ist eine neue abgeleitete Klasse einzuführen. Um zum Beispiel das Drehen von Formen zu ermöglichen, kannst du die Klasse Rotate einführen und alle Funktionen von visit()implementieren. Um das Zeichnen von Formen zu ermöglichen, musst du nur eine DrawKlasse einführen:

class Draw : public ShapeVisitor
{
 public:
   void visit( Circle const& c, /*...*/ ) const override;
   void visit( Square const& s, /*...*/ ) const override;
   // Possibly more visit() functions, one for each concrete shape
};

Und du kannst darüber nachdenken, mehrere Draw Klassen einzuführen, eine für jede Grafikbibliothek, die du unterstützen musst. Das ist ganz einfach, denn du musst keinenbestehenden Code ändern. Es ist nur notwendig, die ShapeVisitor Hierarchie zu erweitern, indem duneuen Code hinzufügst. Daher erfüllt dieser Entwurf die OCP in Bezug auf das Hinzufügen vonOperationen.

Um die Software-Designmerkmale von Visitor vollständig zu verstehen, ist es wichtig zu wissen, warum das Visitor-Designmuster die OCP erfüllen kann. Das ursprüngliche Problem war, dass jede neue Operation eine Änderung der Basisklasse Shape erforderte. Visitor identifiziert das Hinzufügen von Operationen als Variationspunkt. Indem du diesen Variationspunkt extrahierst, d. h. zu einer eigenen Klasse machst, folgst du dem Single-Responsibility-Prinzip (SRP): Shape muss nicht für jede neue Operation geändert werden. Das vermeidet häufige Änderungen der Shape Hierarchie und ermöglicht das einfache Hinzufügen neuer Vorgänge. Das SRP fungiert also als Enabler für die OCP.

Um Besucher (Klassen, die von der Basisklasse ShapeVisitor abgeleitet sind) auf Shapes zu verwenden, musst du jetzt noch eine letzte Funktion zur Shape Hierarchie hinzufügen: dieaccept() Funktion (3):9

class Shape
{
 public:
   virtual ~Shape() = default;
   virtual void accept( ShapeVisitor const& v ) = 0;  3
   // ...
};

Die Funktion accept() wird als rein virtuelle Funktion in der Basisklasse eingeführt und muss daher in jeder abgeleiteten Klasse implementiert werden (4 und5):

class Circle : public Shape
{
 public:
   explicit Circle( double radius )
      : radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   void accept( ShapeVisitor const& v ) override { v.visit( *this ); }  4

   double radius() const { return radius_; }

 private:
   double radius_;
};


class Square : public Shape
{
 public:
   explicit Square( double side )
      : side_( side )
   {
      /* Checking that the given side length is valid */
   }

   void accept( ShapeVisitor const& v ) override { v.visit( *this ); }  5

   double side() const { return side_; }

 private:
   double side_;
};

Die Implementierung von accept() ist einfach; sie muss lediglich die entsprechendevisit() Funktion auf dem gegebenen Besucher basierend auf dem Typ des konkreten Shape aufrufen. Dies wird erreicht, indem der this Zeiger als Argument an visit() übergeben wird. Somit ist die Implementierung von accept() in jeder abgeleiteten Klasse gleich, aber aufgrund eines unterschiedlichen Typs des this Zeigers wird sie eine andere Überladung der visit()Funktion in dem gegebenen Besucher auslösen. Daher kann die Basisklasse Shape keine Standardimplementierung anbieten.

Die Funktion accept() kann jetzt überall dort eingesetzt werden, wo du eine Operation durchführen musst. drawAllShapes() verwendet zum Beispiel accept(), um alle Formen in einem bestimmten Vektor von Formen zu zeichnen:

void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
   for( auto const& shape : shapes )
   {
      shape->accept( Draw{} );
   }
}

Mit der Funktion accept() kannst du deine ShapeHierarchie jetzt ganz einfach um Operationen erweitern. Du hast jetzt einen offenen Satz von Operationen entworfen. Erstaunlich! Es gibt jedoch kein Patentrezept und kein Design, das immer funktioniert. Jeder Entwurf hat seine Vorteile, aber auch seine Nachteile. Bevor du also anfängst zu feiern, sollte ich dich über die Nachteile des Visitor-Designmusters aufklären, damit du ein vollständiges Bild bekommst.

Analyse der Unzulänglichkeiten des Visitor Design Patterns

Das Designmuster Visitor ist leider alles andere als perfekt. Das war zu erwarten, denn Visitor ist ein Workaround für eine intrinsische OOP-Schwäche, anstatt auf den Stärken der OOP aufzubauen.

Der erste Nachteil ist die geringe Flexibilität bei der Implementierung. Das wird deutlich, wenn du dir die Implementierung eines Translate Besuchers ansiehst. DerTranslate Besucher muss den Mittelpunkt jeder Form um einen bestimmten Abstand verschieben. Dafür muss Translate für jede konkrete Shape eine visit() Funktion implementieren. Besonders für Translate kannst du dir vorstellen, dass die Implementierung dieser visit()Funktionen sehr ähnlich, wenn nicht sogar identisch wäre: Es gibt keinen Unterschied zwischen der Übersetzung einer Circle und einer Square. Trotzdem musst du allevisit() Funktionen schreiben. Natürlich könntest du die Logik aus den visit()Funktionen extrahieren und diese in einer dritten, separaten Funktion implementieren, um nach dem DRY-Prinzip Doppelarbeit zu vermeiden.10 Aber leider geben dir die strengen Anforderungen der Basisklasse nicht die Freiheit, diese visit() Funktionen als eine einzige zu implementieren. Das Ergebnis ist eine Art Kesselsteincode:

class Translate : public ShapeVisitor
{
 public:
   // Where is the difference between translating a circle and translating
   // a square? Still you have to implement all virtual functions...
   void visit( Circle const& c, /*...*/ ) const override;
   void visit( Square const& s, /*...*/ ) const override;
   // Possibly more visit() functions, one for each concrete shape
};

Eine ähnliche Unflexibilität bei der Implementierung ist der Rückgabetyp der visit() Funktionen. Die Entscheidung, was die Funktion zurückgibt, wird in der Basisklasse ShapeVisitor getroffen. Abgeleitete Klassen können das nicht ändern. Der übliche Ansatz ist, das Ergebnis im Visitor zu speichern und später darauf zuzugreifen.

Der zweite Nachteil ist, dass es mit dem Designmuster "Besucher" schwierig wird, neue Typen hinzuzufügen. Bisher sind wir davon ausgegangen, dass du sicher bist, dass du alle Formen hast, die du jemals brauchen wirst. Das Hinzufügen eines neuen Shapes in der Hierarchie von Shape würde bedeuten, dass die gesamte Hierarchie von ShapeVisitor aktualisiert werden müsste: Du müsstest der Basisklasse ShapeVisitoreine neue rein virtuelle Funktion hinzufügen, die dann von allen abgeleiteten Klassen implementiert werden müsste. Das bringt natürlich alle Nachteile mit sich, die wir bereits besprochen haben. Vor allem würdest du andere Entwickler dazu zwingen, ihre Operationen zu aktualisieren.11 Das Visitor Design Pattern erfordert also eine geschlossene Menge von Typen und bietet im Gegenzug eine offene Menge von Operationen.

Der Grund für diese Einschränkung ist, dass es eine zyklische Abhängigkeit zwischen der Basisklasse ShapeVisitor, den konkreten Formen (Circle, Square, etc.) und der Basisklasse Shape gibt (siehe Abbildung 4-3).

Abbildung 4-3. Abhängigkeitsdiagramm für das Entwurfsmuster "Visitor

Die Basisklasse ShapeVisitor hängt von den konkreten Formen ab, da sie für jede dieser Formen eine Funktion visit() bereitstellt. Die konkreten Formen hängen von der Basisklasse Shape ab, da sie alle Erwartungen und Anforderungen der Basisklasse erfüllen müssen. Und die Basisklasse Shape hängt aufgrund der Funktion accept() von der Basisklasse ShapeVisitor ab. Aufgrund dieser zyklischen Abhängigkeit können wir nun problemlos neue Operationen hinzufügen (auf einer niedrigeren Ebene unserer Architektur aufgrund einer Abhängigkeitsumkehrung), aber wir können nicht mehr problemlos Typen hinzufügen (denn das müsste auf der hohen Ebene unserer Architektur geschehen). Aus diesem Grund nennen wir das klassische Visitor-Designmuster Cyclic Visitor.

Der dritte Nachteil ist der aufdringliche Charakter eines Besuchers. Um einen Besucher zu einer bestehenden Hierarchie hinzuzufügen, musst du die virtuelle accept() zur Basisklasse dieser Hierarchie hinzufügen. Das ist zwar oft möglich, leidet aber immer noch unter dem üblichen Problem, wenn du eine rein virtuelle Funktion zu einer bestehenden Hierarchie hinzufügst (siehe"Leitfaden 15: Design für das Hinzufügen vonTypen oder Operationen"). Wenn es jedoch nicht möglich ist, die Funktion accept() hinzuzufügen, ist diese Form des Besuchers keine Option. In diesem Fall musst du dir keine Sorgen machen: In"Leitfaden 17: Berücksichtige std::variant für dieImplementierung von Visitor" werden wir eine andere, nicht aufdringliche Form des Visitor-Entwurfsmusters sehen.

Ein vierter, wenn auch zugegebenermaßen unklarer, Nachteil ist, dass die Funktion accept() an abgeleitete Klassen vererbt wird. Wenn jemand später eine weitere Ebene von abgeleiteten Klassen hinzufügt (und dieser jemand könntest du sein) und vergisst, die Funktionaccept() zu überschreiben, wird der Besucher auf den falschen Typ angewendet. Und leider würdest du in diesem Fall keine Warnung erhalten. Das ist nur ein weiterer Beweis dafür, dass das Hinzufügen neuer Typen schwieriger geworden ist. Eine mögliche Lösung für dieses Problem wäre, die KlassenCircle und Square als final zu deklarieren, was jedoch zukünftige Erweiterungen einschränken würde.

"Wow, das sind eine Menge Nachteile. Gibt es noch mehr?" Ja, leider gibt es noch zwei weitere. Der fünfte Nachteil liegt auf der Hand, wenn wir bedenken, dass wir jetzt für jede Operation zwei virtuelle Funktionen aufrufen müssen. Zu Beginn kennen wir weder die Art der Operation noch die Art der Form. Die erste virtuelle Funktion ist die accept() Funktion, der eine abstrakte ShapeVisitor übergeben wird. Die Funktion accept()löst nun den konkreten Typ der Form auf. Die zweite virtuelle Funktion ist dievisit() Funktion, der ein konkreter Typ von Shape übergeben wird. Die visit() Funktion löst nun den konkreten Typ der Operation auf. Dieser sogenannte doppelte Versand ist leider nicht kostenlos. Im Gegenteil, von der Leistung her solltest du das Visitor Design Pattern als eher langsam betrachten. Ich werde im nächsten Leitfaden einige Leistungszahlen nennen.

Wenn wir über Leistung sprechen, sollte ich auch zwei andere Aspekte erwähnen, die sich negativ auf die Leistung auswirken. Erstens weisen wir normalerweise jede Form und jeden Besucher einzeln zu. Betrachte die folgende main() Funktion:

int main()
{
   using Shapes = std::vector< std::unique_ptr<Shape> >;

   Shapes shapes;

   shapes.emplace_back( std::make_unique<Circle>( 2.3 ) );  6
   shapes.emplace_back( std::make_unique<Square>( 1.2 ) );  7
   shapes.emplace_back( std::make_unique<Circle>( 4.1 ) );  8

   drawAllShapes( shapes );

   // ...

   return EXIT_SUCCESS;
}

In dieser main() Funktion erfolgen alle Zuweisungen über std::make_unique()(6,7, und8Diese vielen kleinen Zuweisungen kosten allein Laufzeit und führen auf Dauer zu einer Fragmentierung des Speichers.12 Außerdem kann der Speicher in einer ungünstigen, cache-unfreundlichen Weise angeordnet sein. Aus diesem Grund verwenden wir in der Regel Zeiger, um mit den resultierenden Formen und Besuchern zu arbeiten. Die daraus resultierenden Indirektionen erschweren es einem Compiler, irgendeine Art von Optimierung durchzuführen, und machen sich in Leistungsbenchmarks bemerkbar. Um ehrlich zu sein, ist dies jedoch kein Visitor-spezifisches Problem, sondern diese beiden Aspekte sind in der OOP im Allgemeinen recht verbreitet.

Der letzte Nachteil des Visitor-Entwurfsmusters ist, dass es erfahrungsgemäß ziemlich schwer ist, dieses Entwurfsmuster vollständig zu verstehen und zu pflegen. Das ist ein eher subjektiver Nachteil, aber die Komplexität des komplizierten Zusammenspiels der beiden Hierarchien fühlt sich oft eher wie eine Last als eine echte Lösung an.

Zusammenfassend lässt sich sagen, dass das Visitor Design Pattern eine OOP-Lösung ist, die eine einfache Erweiterung von Operationen anstelle von Typen ermöglicht. Dies wird durch die Einführung einer Abstraktion in Form der Basisklasse ShapeVisitor erreicht, die es dir ermöglicht, Operationen auf eine andere Gruppe von Typen anzuwenden. Das ist zwar eine einzigartige Stärke von Visitor, aber leider auch mit einigen Mängeln behaftet: Unflexibilitäten bei der Implementierung in beiden Vererbungshierarchien aufgrund einer starken Kopplung an die Anforderungen der Basisklassen, eine eher schlechte Leistung und die inhärente Komplexität von Visitor machen es zu einem eher unbeliebten Entwurfsmuster.

Wenn du noch unentschlossen bist, ob du einen klassischen Visitor verwenden sollst oder nicht, nimm dir die Zeit, den nächsten Abschnitt zu lesen. Ich zeige dir einen anderen Weg, einen Besucher zu implementieren - eine Lösung, mit der du wahrscheinlich viel eher zufrieden sein wirst.

Leitlinie 17: Berücksichtige std::variant für dieImplementierung von Visitor

In "Leitfaden 16: Visitor zum Erweitern von Operationen verwenden" habe ich dir das Visitor-Entwurfsmuster vorgestellt. Ich kann mir vorstellen, dass du dich nicht sofort verliebt hast: Visitor hat zwar einige einzigartige Eigenschaften, aber es ist auch ein ziemlich komplexes Entwurfsmuster mit starker interner Kopplung und Leistungsmängeln. Nein, definitiv keine Liebe! Aber keine Sorge, die klassische Form ist nicht die einzige Möglichkeit, wie du das Visitor-Entwurfsmuster umsetzen kannst. In diesem Abschnitt möchte ich dir eine andere Art der Umsetzung von Visitor vorstellen. Und ich bin mir sicher, dass dir dieser Ansatz viel besser gefallen wird.

Einführung in std::variant

Unter haben wir zu Beginn dieses Kapitels über die Stärken und Schwächen der verschiedenen Paradigmen (OOP versus prozedurale Programmierung) gesprochen. Insbesondere haben wir darüber gesprochen, dass die prozedurale Programmierung besonders gut darin ist, neue Operationen zu einem bestehenden Satz von Typen hinzuzufügen. Wie wäre es also, wenn wir die Stärken der prozeduralen Programmierung ausnutzen würden, anstatt nach Umgehungsmöglichkeiten in der OOP zu suchen? Nein, keine Sorge, ich schlage natürlich keine Rückkehr zu unserer ursprünglichen Lösung vor. Dieser Ansatz war einfach zu fehleranfällig. Stattdessen spreche ich über std::variant:

#include <cstdlib>
#include <iostream>
#include <string>
#include <variant>

struct Print  10
{
   void operator()( int value ) const
      { std::cout << "int: " << value << '\n'; }
   void operator()( double value ) const
      { std::cout << "double: " << value << '\n'; }
   void operator()( std::string const& value ) const
      { std::cout << "string: " << value << '\n'; }
};

int main()
{
   // Creates a default variant that contains an 'int' initialized to 0
   std::variant<int,double,std::string> v{};  1

   v = 42;        // Assigns the 'int' 42 to the variant  2
   v = 3.14;      // Assigns the 'double' 3.14 to the variant  3
   v = 2.71F;     // Assigns a 'float', which is promoted to 'double'  4
   v = "Bjarne";  // Assigns the string literal 'Bjarne' to the variant  5
   v = 43;        // Assigns the 'int' 43 to the variant  6

   int const i = std::get<int>(v);  // Direct access to the value  7

   int* const pi = std::get_if<int>(&v);  // Direct access to the value  8

   std::visit( Print{}, v );  // Applying the Print visitor  9

   return EXIT_SUCCESS;
}

Da du vielleicht noch nicht das Vergnügen hattest, die C++17std::variant kennenzulernen, möchte ich dir eine kurze Einführung geben, nur für den Fall. Eine Variante steht für eine von mehreren Alternativen. Die Variante am Anfang der Funktion main()im Codebeispiel kann eine int, eine double oder eine std::stringenthalten (1). Beachte, dass ich oder gesagt habe: Eine Variante kann nur eine dieser drei Alternativen enthalten. Sie ist nie mehrere von ihnen, und unter normalen Umständen sollte sie auch nie nichts enthalten. Aus diesem Grund nennen wir eine Variante einen Summentyp: Die Menge der möglichen Zustände ist die Summe der möglichen Zustände der Alternativen.

Eine Standardvariante ist auch nicht leer. Sie wird mit dem Standardwert der ersten Alternative initialisiert. Im Beispiel enthält eine Standardvariante eine Ganzzahl mit dem Wert 0. Das Ändern des Wertes einer Variante ist einfach: Du kannst ihr einfach neue Werte zuweisen. Wir können zum Beispiel den Wert 42 zuweisen, was bedeutet, dass die Variante jetzt eine ganze Zahl mit dem Wert 42 speichert (2). Wenn wir anschließend double den Wert 3,14 zuweisen, speichert die Variante eine double mit dem Wert 3,14 (3Wenn du einen Wert eines Typs zuweisen möchtest, der nicht zu den möglichen Alternativen gehört, gelten die üblichen Umwandlungsregeln. Wenn du z. B. einenfloat zuweisen möchtest, würde er nach den normalen Umrechnungsregeln zu einem double(4).

Um die Alternativen zu speichern, stellt die Variante gerade genug internen Puffer zur Verfügung, um die größte der Alternativen aufzunehmen. In unserem Fall ist die größte Alternative diestd::string, die in der Regel zwischen 24 und 32 Byte groß ist (abhängig von der verwendeten Implementierung der Standardbibliothek). Wenn du also das String-Literal"Bjarne" zuweist, räumt die Variante zuerst den vorherigen Wert auf (da gibt es nicht viel zu tun; es ist nur ein double) und konstruiert dann, da es die einzige Alternative ist, die funktioniert, die std::string an Ort und Stelle in ihrem eigenen Puffer (5Wenn du deine Meinung änderst und die ganze Zahl 43 zuweist (6), wird die Variante die std::string mit Hilfe ihres Destruktors ordnungsgemäß zerstören und den internen Puffer für die Ganzzahl wiederverwenden. Wunderbar, nicht wahr? Die Variante ist typsicher und immer richtig initialisiert. Was kann man sich noch wünschen?

Natürlich willst du etwas mit den Werten innerhalb der Variante machen. Es würde nichts nützen, wenn wir den Wert einfach nur speichern würden. Leider kannst du eine Variante nicht einfach einem anderen Wert zuweisen, z. B. einem int, um deinen Wert zurückzubekommen. Nein, der Zugriff auf den Wert ist ein bisschen komplizierter. Es gibt mehrere Möglichkeiten, auf die gespeicherten Werte zuzugreifen. Die direkteste Methode ist std::get()(7). Mit std::get() kannst du nach einem Wert eines bestimmten Typs fragen. Wenn die Variante einen Wert dieses Typs enthält, gibt sie einen Verweis darauf zurück. Ist dies nicht der Fall, wird die std::bad_variant_exception geworfen. Das scheint eine ziemlich unhöfliche Antwort zu sein, wenn man bedenkt, dass du so nett gefragt hast. Aber wir sollten wahrscheinlich froh sein, dass die Variante nicht vorgibt, einen Wert zu enthalten, wenn sie es tatsächlich nicht tut. Wenigstens ist sie ehrlich. Es gibt einen schöneren Weg in Form von std::get_if()(8). std::get_if() gibt im Gegensatz zu std::get() keine Referenz, sondern einen Zeiger zurück. Wenn du einen Typ anforderst, den std::variant gerade nicht enthält, wirft es keine Ausnahme, sondern gibt stattdessen nullptr zurück. Es gibt jedoch noch eine dritte Möglichkeit, die für unsere Zwecke besonders interessant ist: std::visit() (9).std::visit() ermöglicht es dir, eine beliebige Operation mit dem gespeicherten Wert durchzuführen. Genauer gesagt, kannst du einen benutzerdefinierten Besucher übergeben, der eine beliebige Operation mit dem gespeicherten Wert einer geschlossenen Gruppe von Typen durchführt. Kommt dir das bekannt vor?

Der Print Besucher (10), den wir als erstes Argument übergeben, muss für jede mögliche Alternative einen Funktionsaufrufoperator (operator()) bereitstellen. In diesem Beispiel wird dies durch die Bereitstellung von dreioperator()s erfüllt: eine für int, eine für double und eine für std::string. Besonders erwähnenswert ist, dass Print von keiner Basisklasse erben muss und keine virtuellen Funktionen besitzt. Daher gibt es keine starke Kopplung an irgendwelche Anforderungen. Wenn wir wollten, könnten wir auch die Funktionsaufrufoperatoren für int und double zu einem zusammenfassen, da ein int in ein double umgewandelt werden kann:

struct Print
{
   void operator()( double value ) const
      { std::cout << "int or double: " << value << '\n'; }
   void operator()( std::string const& value ) const
      { std::cout << "string: " << value << '\n'; }
};

Während die Frage, welche Version wir bevorzugen sollten, für uns im Moment nicht von besonderem Interesse ist, wirst du feststellen, dass wir bei der Umsetzung sehr flexibel sind. Es gibt nur eine sehr lose Kopplung, die auf der Konvention beruht, dass es für jede Alternative eine operator() geben muss, unabhängig von der genauen Form. Wir haben keineVisitor Basisklasse, die uns zwingt, Dinge auf eine ganz bestimmte Weise zu tun. Wir haben auch keine Basisklasse für die Alternativen: Es steht uns frei, grundlegende Typen wie int und double zu verwenden, aber auch beliebige Klassentypen wie std::string. Und was vielleicht am wichtigsten ist: Jeder kann ganz einfach neue Operationen hinzufügen. Es muss kein bestehender Code geändert werden. Damit können wir argumentieren, dass es sich um eine prozedurale Lösung handelt, die nur viel eleganter ist als der ursprüngliche enumbasierte Ansatz, bei dem eine Basisklasse als Unterscheidungsmerkmal verwendet wurde.

Refaktorierung des Zeichnens von Formen als wertbasierte,nicht-intrusive Lösung

Mit und diesen Eigenschaften ist std::variant perfekt für unser Zeichenbeispiel geeignet. Lass uns das Zeichnen von Formen mit std::variant neu implementieren. Zuerst überarbeiten wir die KlassenCircle und Square:

//---- <Circle.h> ----------------

#include <Point.h>

class Circle
{
 public:
   explicit Circle( double radius )
      : radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   double radius() const { return radius_; }
   Point  center() const { return center_; }

 private:
   double radius_;
   Point center_{};
};


//---- <Square.h> ----------------

#include <Point.h>

class Square
{
 public:
   explicit Square( double side )
      : side_( side )
   {
      /* Checking that the given side length is valid */
   }

   double side  () const { return side_; }
   Point  center() const { return center_; }

 private:
   double side_;
   Point center_{};
};

Sowohl Circle als auch Square sind deutlich vereinfacht: keine Basisklasse Shape mehr, keine Notwendigkeit, virtuelle Funktionen zu implementieren - insbesondere nicht die Funktion accept(). Dieser Visitor-Ansatz ist also nicht aufdringlich: Diese Form von Visitor kann einfach zu bestehenden Typen hinzugefügt werden! Und es ist nicht nötig, diese Klassen für kommende Operationen vorzubereiten. Wir können uns ganz darauf konzentrieren, diese beiden Klassen als das zu implementieren, was sie sind: geometrische Primitive.

Der schönste Teil des Refactorings ist jedoch die tatsächliche Verwendung vonstd::variant:

//---- <Shape.h> ----------------

#include <variant>
#include <Circle.h>
#include <Square.h>

using Shape = std::variant<Circle,Square>;  11


//---- <Shapes.h> ----------------

#include <vector>
#include <Shape.h>

using Shapes = std::vector<Shape>;  12

Da unsere geschlossene Menge von Typen eine Menge von Formen ist, enthält die Variante nun entwederCircle oder Square. Und was ist ein guter Name für eine Abstraktion einer Menge von Typen, die Formen darstellen? Nun...Shape(11Anstelle einer Basisklasse, die vom eigentlichen Typ der Form abstrahiert, übernimmt std::variant diese Aufgabe. Wenn du das zum ersten Mal siehst, bist du wahrscheinlich völlig verblüfft. Aber warte, es gibt noch mehr: Das bedeutet auch, dass wir jetzt std::unique_ptr den Rücken kehren können. Erinnere dich: Der einzige Grund, warum wir (intelligente) Zeiger verwendet haben, war, dass wir verschiedene Arten von Formen im selben Vektor speichern konnten. Aber jetzt, wo std::variant uns dasselbe ermöglicht, können wir einfach verschiedene Objekte in einem einzigen Vektor speichern (12).

Mit dieser Funktionalität können wir benutzerdefinierte Operationen für Formen schreiben. Wir sind immer noch daran interessiert, Formen zu zeichnen. Zu diesem Zweck implementieren wir jetzt den DrawBesucher:

//---- <Draw.h> ----------------

#include <Shape.h>
#include /* some graphics library */

struct Draw
{
   void operator()( Circle const& c ) const
      { /* ... Implementing the logic for drawing a circle ... */ }
   void operator()( Square const& s ) const
      { /* ... Implementing the logic for drawing a square ... */ }
};

Auch hier folgen wir der Erwartung, eine operator() für jede Alternative zu implementieren: eine für Circle und eine für Square. Aber dieses Mal haben wir die Wahl: Wir müssen keine Basisklasse implementieren und deshalb auch keine virtuelle Funktion überschreiben. Deshalb ist es auch nicht nötig, genau eine operator()für jede Alternative zu implementieren. Obwohl es in diesem Beispiel sinnvoll erscheint, zwei Funktionen zu haben, haben wir die Möglichkeit, die beiden operator()s zu einer Funktion zusammenzufassen. Wir haben auch die Wahl, was den Rückgabetyp der Operation betrifft. Wir können lokal entscheiden, was wir zurückgeben sollen, und es ist keine Basisklasse, die unabhängig von der spezifischen Operation eine globale Entscheidung trifft. Flexibilität bei der Implementierung. Lose Kopplung. Erstaunlich!

Das letzte Teil des Puzzles ist die Funktion drawAllShapes():

//---- <DrawAllShapes.h> ----------------

#include <Shapes.h>

void drawAllShapes( Shapes const& shapes );


//---- <DrawAllShapes.cpp> ----------------

#include <DrawAllShapes.h>

void drawAllShapes( Shapes const& shapes )
{
   for( auto const& shape : shapes )
   {
      std::visit( Draw{}, shape );
   }
}

Die Funktion drawAllShapes() wird überarbeitet, um std::visit() zu nutzen. In dieser Funktion wenden wir nun den Besucher Draw auf alle Varianten an, die in einem Vektor gespeichert sind.

Die Aufgabe von std::visit() ist es, die notwendige Typenzuweisung für dich durchzuführen. Wenn die angegebene std::variant eine Circle enthält, ruft sie die Draw::operator() für Kreise auf. Ansonsten ruft sie die Draw::operator() für Quadrate auf. Wenn du wolltest, könntest du die gleiche Abfertigung manuell mit std::get_if() durchführen:

void drawAllShapes( Shapes const& shapes )
{
   for( auto const& shape : shapes )
   {
      if( Circle* circle = std::get_if<Circle>(&shape) ) {
         // ... Drawing a circle
      }
      else if( Square* square = std::get_if<Square>(&shape) ) {
         // ... Drawing a square
      }
   }
}

Ich weiß, was du jetzt denkst: "Blödsinn! Warum sollte ich das jemals tun wollen? Das würde zu demselben Wartungsalptraum führen wie eine enumbasierte Lösung." Ich stimme dir vollkommen zu: Aus Sicht des Softwaredesigns wäre das eine schreckliche Idee. Dennoch, und ich muss sagen, dass es schwer ist, das im Kontext dieses Buches zuzugeben, kann es einen guten Grund geben, das (manchmal) zu tun: Leistung. Ich weiß, jetzt habe ich dein Interesse geweckt, aber da wir ohnehin gleich über Leistung sprechen werden, möchte ich diese Diskussion für ein paar Absätze verschieben. Ich werde darauf zurückkommen, versprochen!

Mit all diesen Details können wir endlich die Funktion main() umgestalten. Aber es gibt nicht viel zu tun: Anstatt Kreise und Quadrate mit Hilfe vonstd::make_unique() zu erzeugen, erstellen wir einfach Kreise und Quadrate direkt und fügen sie dem Vektor hinzu. Das funktioniert dank des nicht expliziten Konstruktors von variant, der die implizite Umwandlung jeder der Alternativen ermöglicht:

//---- <Main.cpp> ----------------

#include <Circle.h>
#include <Square.h>
#include <Shapes.h>
#include <DrawAllShapes.h>

int main()
{
   Shapes shapes;

   shapes.emplace_back( Circle{ 2.3 } );
   shapes.emplace_back( Square{ 1.2 } );
   shapes.emplace_back( Circle{ 4.1 } );

   drawAllShapes( shapes );

   return EXIT_SUCCESS;
}

Das Endergebnis dieser wertebasierten Lösung ist verblüffend faszinierend: Es gibt keine Basisklassen. Keine virtuellen Funktionen. Keine Zeiger. Keine manuellen Speicherzuweisungen. Die Dinge sind so einfach, wie sie nur sein können, und es gibt nur sehr wenig Boilerplate-Code. Und obwohl der Code ganz anders aussieht als bei den vorherigenLösungen, sind die architektonischen Eigenschaften identisch: Jeder kann neue Operationen hinzufügen, ohne den bestehenden Code ändern zu müssen (siehe Abbildung 4-4). Daher erfüllen wir immer noch die OCP in Bezug auf das Hinzufügen von Operationen.

Abbildung 4-4. Abhängigkeitsdiagramm für die Lösung std::variant

Wie bereits erwähnt, ist dieser Visitor-Ansatz nicht aufdringlich. Aus architektonischer Sicht bietet dies einen weiteren, bedeutenden Vorteil gegenüber dem klassischen Visitor. Wenn du den Abhängigkeitsgraphen des klassischen Visitor (siehe Abbildung 4-3) mit dem Abhängigkeitsgraphen der Lösungstd::variant (siehe Abbildung 4-4) vergleichst, wirst du sehen, dass der Abhängigkeitsgraph für die Lösung std::variant eine zweite architektonische Grenze hat. Das bedeutet, dass es keine zyklischen Abhängigkeiten zwischen std::variant und seinen Alternativen gibt. Ich sollte das wiederholen, um seine Bedeutung zu unterstreichen: Es gibt keine zyklische Abhängigkeit zwischen std::variant und seinen Alternativen! Was wie ein kleines Detail aussehen mag, ist in Wirklichkeit ein riesiger architektonischer Vorteil. RIESIG! Du könntest z.B. eine Abstraktion auf der Basis von std::variant im Handumdrehen erstellen:

//---- <Shape.h> ----------------

#include <variant>
#include <Circle.h>
#include <Square.h>

using Shape = std::variant<Circle,Square>;  13


//---- <SomeHeader.h> ----------------

#include <Circle.h>
#include <Ellipse.h>
#include <variant>

using RoundShapes = std::variant<Circle,Ellipse>;  14


//---- <SomeOtherHeader.h> ----------------

#include <Square.h>
#include <Rectangle.h>
#include <variant>

using AngularShapes = std::variant<Square,Rectangle>;  15

Zusätzlich zu der Shape Abstraktion, die wir bereits erstellt haben (13), kannst du die std::variant für alle runden Formen erstellen (14), und du kannst ein std::variant für alle eckigen Formen erstellen (15), die beide möglicherweise weit von der Shape Abstraktion entfernt sind. Das geht ganz einfach, weil es nicht nötig ist, von mehreren Visitor-Basisklassen abzuleiten. Im Gegenteil, die Shape-Klassen würden davon unberührt bleiben. Daher ist die Tatsache, dass die std::variant Lösung nicht aufdringlich ist, von höchstem architektonischen Wert!

Leistungsmaßstäbe

Ich weiß, wie du dich jetzt gerade fühlst. Ja, so ist das mit der Liebe auf den ersten Blick. Aber ob du es glaubst oder nicht, da ist noch mehr. Es gibt ein Thema, das wir noch nicht besprochen haben, ein Thema, das jedem C++-Entwickler am Herzen liegt, und das ist natürlich die Leistung. Auch wenn es in diesem Buch nicht wirklich um Leistung geht, ist es dennoch erwähnenswert, dass du dir keine Sorgen um die Leistung von std::variant machen musst. Ich kann dir schon jetzt versprechen, dass es schnell ist.

Bevor ich dir die Benchmark-Ergebnisse zeige, erlaube mir jedoch ein paar Anmerkungen zu den Benchmarks. Leistung - seufz. Leider ist die Leistung immer ein schwieriges Thema. Es gibt immer jemanden, der sich über die Leistung beschwert. Aus diesem Grund würde ich dieses Thema am liebsten ganz auslassen. Aber dann gibt es andere Leute, die sich über die fehlenden Leistungszahlen beschweren. Seufz. Nun, da es anscheinend immer einige Beschwerden geben wird und die Ergebnisse einfach zu gut sind, um sie zu verpassen, werde ich euch ein paar Benchmark-Ergebnisse zeigen. Aber es gibt zwei Bedingungen: Erstens wirst du sie nicht als quantitative Werte betrachten, die die absolute Wahrheit darstellen, sondern nur als qualitative Werte, die in die richtige Richtung weisen. Und zweitens wirst du keinen Protest vor meinem Haus anzetteln, weil ich nicht deinen Lieblingscompiler, dein Lieblingscompilerflag oder deine Lieblings IDE verwendet habe. Versprochen?

Du: nickst und gelobst, dich nicht mehr über Kleinigkeiten zu beschweren!

OK, toll, dann findest du in Tabelle 4-2 die Benchmark-Ergebnisse.

Tabelle 4-2. Benchmark-Ergebnisse für verschiedene Visitor-Implementierungen
Implementierung für Besucher GCC 11.1 Clang 11.1

Klassisches Designmuster für Besucher

1.6161 s

1.8015 s

Objektorientierte Lösung

1.5205 s

1.1480 s

Enum-Lösung

1.2179 s

1.1200 s

std::variant (mit std::visit())

1.1992 s

1.2279 s

std::variant (mit std::get_if())

1.0252 s

0.6998 s

Damit diese Zahlen einen Sinn ergeben, sollte ich dir ein wenig mehr Hintergrundwissen vermitteln. Um das Szenario etwas realistischer zu gestalten, habe ich nicht nur Kreise und Quadrate, sondern auch Rechtecke und Ellipsen verwendet. Dann habe ich 25.000 Operationen an 10.000 zufällig erstellten Formen durchgeführt. Anstatt diese Formen zu zeichnen, habe ich den Mittelpunkt durch zufällige Vektoren aktualisiert.13 Der Grund dafür ist, dass diese Übersetzungsoperation sehr billig ist und es mir ermöglicht, den intrinsischen Overhead all dieser Lösungen (wie z. B. die Umwege und den Overhead der virtuellen Funktionsaufrufe) besser zu zeigen. Eine teure Operation wie draw() würde diese Details verschleiern und könnte den Eindruck erwecken, dass alle Ansätze ziemlich ähnlich sind. Ich habe sowohl GCC 11.1 als auch Clang 11.1 verwendet und bei beiden Compilern nur die Flags-O3 und -DNDEBUG hinzugefügt. Als Plattform habe ich macOS Big Sur (Version 11.4) auf einem 8-Core Intel Core i7 mit 3,8 GHz und 64 GB Hauptspeicher verwendet.

Die offensichtlichste Erkenntnis aus den Benchmark-Ergebnissen ist, dass die Variantenlösung viel effizienter ist als die klassische Visitor-Lösung. Das sollte nicht überraschen: Aufgrund der doppelten Abfertigung enthält die klassische Visitor-Implementierung eine Menge Indirektionen und ist daher ebenfalls schwer zu optimieren. Außerdem ist das Speicherlayout der Shape-Objekte perfekt: Im Vergleich zu allen anderen Lösungen, einschließlich der Enum-basierten Lösung, werden alle Shapes zusammenhängend im Speicher gespeichert, was das cachefreundlichste Layout ist, das man wählen kann. Die zweite Erkenntnis ist, dass std::variant tatsächlich ziemlich effizient ist, wenn auch nicht überraschend effizient. Es ist jedoch überraschend, dass die Effizienz stark davon abhängt, ob wirstd::get_if() oder std::visit() verwenden (ich habe versprochen, darauf zurückzukommen). Sowohl GCC als auch Clang produzieren viel langsameren Code, wenn sie std::visit() verwenden. Ich nehme an, dass std::visit() zu diesem Zeitpunkt noch nicht perfekt implementiert und optimiert ist. Aber wie ich schon sagte, ist Leistung immer schwierig, und ich versuche nicht, diesem Geheimnis noch weiter auf den Grund zu gehen.14

Das Wichtigste ist, dass die Schönheit von std::variant nicht durch schlechte Leistungszahlen getrübt wird. Im Gegenteil: Die Leistungsergebnisse tragen dazu bei, deine neu entdeckte Beziehung zustd::variant zu intensivieren.

Analyse der Unzulänglichkeiten der std::variant-Lösung

Während ich diese Beziehung nicht gefährden möchte, sehe ich es als meine Pflicht an, auch auf ein paar Nachteile hinzuweisen, mit denen du zu kämpfen hast, wenn du die Lösung auf std::variant verwendest.

Zunächst sollte ich noch einmal auf das Offensichtliche hinweisen: Als eine Lösung, die dem Visitor Design Pattern ähnelt und auf prozeduraler Programmierung basiert, konzentriert sich std::variant ebenfalls auf die Bereitstellung eineroffenen Menge von Operationen. Der Nachteil ist, dass du mit einemgeschlossenen Satz von Typen umgehen musst. Wenn du neue Typen hinzufügst, entstehen ähnliche Probleme wie bei der Enum-basierten Lösung in"Leitfaden 15: Design für das Hinzufügen vonTypen oder Operationen". Zunächst müsstest du die Variante selbst aktualisieren, was zu einer Neukompilierung des gesamten Codes führen könnte, der den Variantentyp verwendet (erinnerst du dich an die Aktualisierung der Aufzählung?). Außerdem müsstest du alle Operationen aktualisieren und die möglicherweise fehlende operator() für die neue(n) Variante(n) hinzufügen. Das Gute daran ist, dass der Compiler sich beschweren würde, wenn einer dieser Operatoren fehlt. Der Nachteil ist, dass der Compiler keine schöne, lesbare Fehlermeldung ausgibt, sondern etwas, das der Mutter aller Templating-Fehlermeldungen ähnelt. Alles in allem fühlt es sich ziemlich ähnlich an wie unsere bisherigen Erfahrungen mit der enum-basierten Lösung.

Ein zweites potenzielles Problem, das du im Auge behalten solltest, ist, dass du vermeiden solltest, Typen mit sehr unterschiedlichen Größen in einer Variante unterzubringen. Wenn mindestens eine der Alternativen viel größer ist als die anderen, verschwendest du möglicherweise viel Speicherplatz für viele kleine Alternativen. Das würde sich negativ auf die Leistung auswirken. Eine Lösung wäre, große Alternativen nicht direkt zu speichern, sondern sie hinter Zeigern, über Proxy-Objekte oder mit Hilfe des Bridge-Designmusters zu speichern.15 Natürlich würde dies eine Umleitung einführen, die ebenfalls Leistung kostet. Ob dies ein Leistungsnachteil im Vergleich zur Speicherung von Werten unterschiedlicher Größe ist, musst du selbst herausfinden.

Zu guter Letzt solltest du dir immer darüber im Klaren sein, dass eine Variante eine Menge Informationen preisgeben kann. Sie stellt zwar eine Laufzeitabstraktion dar, aber die enthaltenen Typen sind dennoch deutlich sichtbar. Das kann zu physischen Abhängigkeiten von der Variante führen, d. h., wenn du einen der alternativen Typen änderst, musst du eventuell den abhängigen Code neu kompilieren. Die Lösung wäre wiederum, stattdessen Zeiger oder Proxy-Objekte zu speichern, die Implementierungsdetails verbergen. Leider würde sich das auch auf die Leistung auswirken, da ein Großteil der Leistungssteigerung darauf zurückzuführen ist, dass der Compiler die Details kennt und sie entsprechend optimiert. Es gibt also immer einen Kompromiss zwischen Leistung und Verkapselung.

Trotz dieser Unzulänglichkeiten erweist sich std::variant als ein wunderbarer Ersatz für das OOP-basierte Visitor-Designmuster. Es vereinfacht den Code erheblich, entfernt fast den gesamten Boilerplate-Code, kapselt die hässlichen und wartungsintensiven Teile und bietet eine hervorragende Leistung. Darüber hinaus ist std::variant ein weiteres großartiges Beispiel dafür, dass es bei einem Entwurfsmuster um die Absicht geht und nicht um Implementierungsdetails.

Leitlinie 18: Achte auf die Leistung des azyklischen Besuchers

Wie du in "Leitfaden 15: Entwurf für das Hinzufügen vonTypen oder Operationen" gesehen hast , musst du bei der Verwendung von dynamischem Polymorphismus eine Entscheidung treffen: Du kannst eine offene Menge von Typen oder eine offene Menge von Operationen unterstützen. Du kannst nicht beides haben. Ich habe ausdrücklich gesagt, dass meines Wissens nach beides nichtunmöglich, aber in der Regel unpraktisch ist. Um das zu verdeutlichen, möchte ich dir eine weitere Variante des Visitor-Designmusters vorstellen: den azyklischen Visitor.16

In "Leitfaden 16: Verwendung von Visitor zur Erweiterung von Operationen" hast du gesehen, dass es eine zyklische Abhängigkeit zwischen den Hauptakteuren des Visitor-Designmusters gibt: DieVisitor Basisklasse hängt von den konkreten Formen ab (Circle, Square, usw.), die konkreten Formen hängen von der Shape Basisklasse ab, und die Shape Basisklasse hängt von derVisitor Basisklasse ab. Aufgrund dieser zyklischen Abhängigkeit, die alle wichtigen Akteure auf einer Ebene in der Architektur festhält, ist es schwierig, neue Typen zu einem Visitor hinzuzufügen. Die Idee des azyklischen Besuchers ist es, diese Abhängigkeit zu durchbrechen.

Abbildung 4-5 zeigt ein UML-Diagramm für den azyklischen Visitor. Im Vergleich zum GoF-Visitor gibt es auf der rechten Seite des Bildes nur kleine Unterschiede, während sich auf der linken Seite einige grundlegende Änderungen ergeben. Am wichtigsten ist, dass dieVisitor Basisklasse in mehrere Basisklassen aufgeteilt wurde: die Basisklasse AbstractVisitor und eine Basisklasse für jeden konkreten Formtyp (in diesem Beispiel Circle​Visi⁠torund SquareVisitor). Alle Besucher müssen von der Basisklasse AbstractVisitor erben, haben jetzt aber auch die Möglichkeit, von den formenspezifischen Besucher-Basisklassen zu erben. Wenn ein Betrieb Kreise unterstützen will, erbt er von der Basisklasse Circle​Visi⁠tor und implementiert die Funktion visit() für Circle. Wenn er keine Kreise unterstützen will, erbt er einfach nicht von CircleVisitor.

Abbildung 4-5. Die UML-Darstellung eines azyklischen Besuchers

Der folgende Codeschnipsel zeigt eine mögliche Implementierung der Visitor Basisklassen:

//---- <AbstractVisitor.h> ----------------

class AbstractVisitor  1
{
 public:
   virtual ~AbstractVisitor() = default;
};


//---- <Visitor.h> ----------------

template< typename T >
class Visitor  2
{
 protected:
   ~Visitor() = default;

 public:
   virtual void visit( T const& ) const = 0;
};

Die Basisklasse AbstractVisitor ist nichts anderes als eine leere Basisklasse mit einem virtuellen Destruktor (1). Eine andere Funktion ist nicht notwendig. Wie du sehen wirst, dient AbstractVisitor nur als allgemeines Tag zur Identifizierung von Besuchern und muss selbst keine Funktion bereitstellen. In C++ neigen wir dazu, die formspezifischen Basisklassen der Besucher in Form einer Klassenvorlage zu implementieren (2Die Klassenvorlage Visitor ist auf einen bestimmten Formtyp parametrisiert und führt die rein virtuelle visit() für diese bestimmte Form ein.

In der Implementierung unseres Draw Besuchers würden wir nun von drei Basisklassen erben: von AbstractVisitor, von Visitor<Circle> und Visitor<Square>, da wir sowohl Circle als auch Square unterstützen wollen:

class Draw : public AbstractVisitor
           , public Visitor<Circle>
           , public Visitor<Square>
{
 public:
   void visit( Circle const& c ) const override
      { /* ... Implementing the logic for drawing a circle ... */ }
   void visit( Square const& s ) const override
      { /* ... Implementing the logic for drawing a square ... */ }
};

Durch diese Wahl der Implementierung wird die zyklische Abhängigkeit durchbrochen. WieAbbildung 4-6 zeigt, hängt die hohe Ebene der Architektur nicht mehr von den konkreten Formtypen ab. Sowohl die Formen (Circle und Square) als auch die Operationen befinden sich jetzt auf der unteren Ebene der Architektur. Wir können nun sowohl Typen als auch Operationen hinzufügen.

An diesem Punkt schaust du sehr misstrauisch, fast anklagend, in meine Richtung. Habe ich nicht gesagt, dass es nicht möglich ist, beides zu haben? Natürlich ist es möglich, oder? Nun, noch einmal, ich habe nicht behauptet, dass es unmöglich ist. Ich habe vielmehr gesagt, dass es unpraktisch sein könnte. Nachdem du nun die Vorteile eines azyklischen Besuchers gesehen hast, möchte ich dir die Nachteile dieses Ansatzes zeigen.

Abbildung 4-6. Abhängigkeitsdiagramm für den azyklischen Besucher

Werfen wir zunächst einen Blick auf die Implementierung der Funktion accept() in Circle:

//---- <Circle.h> ----------------

class Circle : public Shape
{
 public:
   explicit Circle( double radius )
      : radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   void accept( AbstractVisitor const& v ) override {  3
      if( auto const* cv = dynamic_cast<Visitor<Circle> const*>(&v) ) {  4
         cv->visit( *this );  5
      }
   }

   double radius() const { return radius_; }
   Point  center() const { return center_; }

 private:
   double radius_;
   Point center_{};
};

Vielleicht ist dir die kleine Änderung in der Shape Hierarchie aufgefallen: Die virtuelle Funktion accept() akzeptiert jetzt ein AbstractVisitor(3). Du erinnerst dich auch daran, dass dieAbstractVisitor keine eigene Operation implementiert. Anstatt eine visit() Funktion auf AbstractVisitor aufzurufen, stellt Circle daher fest, ob der angegebene Besucher Kreise unterstützt, indem er eine dynamic_cast nachVisitor<Circle> (4). Beachte, dass er eine Zeigerumwandlung durchführt, was bedeutet, dass der dynamic_cast entweder einen gültigen Zeiger auf einen Visitor<Circle> oder einen nullptr zurückgibt. Wenn er einen gültigen Zeiger auf einen Visitor<Circle> zurückgibt, ruft er die entsprechende Funktion visit() (5).

Obwohl dieser Ansatz sicherlich funktioniert und dazu beiträgt, die zyklische Abhängigkeit des Visitor-Designmusters zu durchbrechen, hinterlässt dynamic_cast immer ein ungutes Gefühl. Eindynamic_cast sollte immer ein mulmiges Gefühl hinterlassen, denn wenn es schlecht genutzt wird, kann es eine Architektur zerstören. Das würde passieren, wenn wir einen Cast von der hohen Ebene der Architektur auf etwas durchführen, das sich auf der niedrigen Ebene der Architektur befindet.17 In unserem Fall ist es in Ordnung, ihn zu verwenden, da er auf der niedrigen Ebene unserer Architektur ausgeführt wird. Wir brechen also nicht die Architektur, indem wir Wissen über eine niedrigere Ebene in die hohe Ebene einfügen.

Das eigentliche Manko liegt in der Laufzeitstrafe. Wenn du den gleichen Benchmark wie in "Leitfaden 17: Berücksichtige std::variant für dieImplementierung von Visitor" für einen azyklischen Visitor durchführst, stellst du fest, dass die Laufzeit fast eine Größenordnung über der Laufzeit eines zyklischen Visitors liegt (siehe Tabelle 4-3). Der Grund dafür ist, dass dynamic_cast langsam ist. Sehr langsam. Und er ist für diese Anwendung besonders langsam. Was hier macht, ist ein Cross-Cast. Wir casten nicht einfach auf eine bestimmte abgeleitete Klasse, sondern wir casten in einen anderen Zweig der Vererbungshierarchie. Dieser Cross-Cast, gefolgt von einem Aufruf einer virtuellen Funktion, ist wesentlich aufwändiger als ein einfacher Downcast.

Tabelle 4-3. Leistungsergebnisse für verschiedene Visitor-Implementierungen
Implementierung für Besucher GCC 11.1 Clang 11.1

Azyklischer Besucher

14.3423 s

7.3445 s

Zyklischer Besucher

1.6161 s

1.8015 s

Objektorientierte Lösung

1.5205 s

1.1480 s

Enum-Lösung

1.2179 s

1.1200 s

std::variant (mit std::visit())

1.1992 s

1.2279 s

std::variant (mit std::get_if())

1.0252 s

0.6998 s

Obwohl ein Acylic Visitor architektonisch eine sehr interessante Alternative ist, könnten diese Leistungsergebnisse sie aus praktischer Sicht disqualifizieren. Das heißt nicht, dass du ihn nicht verwenden solltest, aber sei dir zumindest bewusst, dass die schlechte Leistung ein starkes Argument für eine andere Lösung sein könnte.

1 Ich kann sehen, wie du mit den Augen rollst! "Oh, schon wieder dieses langweilige Beispiel!" Aber denk an die Leser, die Kapitel 3 übersprungen haben. Sie sind jetzt froh, dass sie diesen Abschnitt ohne eine langwierige Erklärung des Szenarios lesen können.

2 Seit C++11 haben wir scoped enumerations, manchmal auch class enumerations genannt, wegen der Syntax enum class zur Verfügung. Damit kann der Compiler z. B. besser vor unvollständigen switch Anweisungen warnen. Wenn du diesen Fehler entdeckst, hast du dir einen Bonuspunkt verdient!

3 Scott Meyers, More Effective C++: 35 New Ways to Improve Your Programs and Designs, Punkt 31 (Addison-Wesley, 1995).

4 Beachte, dass der mathematische Begriff der offenen und geschlossenen Mengen etwas völlig anderes ist.

5 Als Beispiel für das Design mit statischem Polymorphismus kannst du dir die Algorithmen der Standard Template Library (STL) ansehen. Du kannst leicht neue Operationen, d. h. Algorithmen, aber auch neue Typen hinzufügen, die kopiert, sortiert usw. werden können.

6 Es ist immer schwer, Vorhersagen zu treffen. Aber normalerweise haben wir eine ziemlich gute Vorstellung davon, wie sich unsere Codebasis entwickeln wird. Wenn du keine Ahnung hast, wie sich die Dinge entwickeln werden, solltest du die erste Änderung oder Erweiterung abwarten, daraus lernen und dann eine fundiertere Entscheidung treffen. Diese Philosophie ist Teil des allgemein bekannten YAGNI-Prinzips, das dich vor Over-Engineering warnt; siehe auch "Leitlinie 2: Design for Change".

7 Ich wäre nicht glücklich darüber - vielleicht wäre ich sogar sehr unglücklich - aber ich würde wahrscheinlich nicht wütend werden. Aber deine anderen Kollegen? Im schlimmsten Fall würdest du vom nächsten Team-Barbecue ausgeschlossen werden.

8 Erich Gamma et al., Design Patterns: Elements of Reusable Object-Oriented Software.

9 accept() ist der Name, der im GoF-Buch verwendet wird. Es ist der traditionelle Name im Zusammenhang mit dem Design Pattern Visitor. Es steht dir natürlich frei, einen anderen Namen zu verwenden, z. B. apply(). Aber bevor du ihn umbenennst, solltest du den Ratschlag aus "Leitfaden 14: Verwende den Namen eines Entwurfsmusters, um die Absicht zu kommunizieren" beachten .

10 Es ist wirklich ratsam, die Logik in eine einzige Funktion zu packen. Der Grund dafür ist die Änderung: Wenn du die Implementierung später aktualisieren musst, willst du die Änderung nicht mehrfach durchführen. Das ist die Idee des DRY-Prinzips (Don't Repeat Yourself). Also erinnere dich bitte an "Leitlinie 2: Design for Change".

11 Bedenke das Risiko: Das könnte dich für immer von Team-Grillpartys ausschließen!

12 Eine Speicherfragmentierung ist viel wahrscheinlicher, wenn du std::make_unique() verwendest, das einen Aufruf von new kapselt, anstatt einige spezielle Zuordnungsschemata zu verwenden.

13 Ich verwende tatsächlich Zufallsvektoren, die mit Hilfe von std::mt19937 und std::uniform_real_distribution erzeugt werden, aber erst nachdem ich mir selbst bewiesen habe, dass sich die Leistung für GCC 11.1 nicht und für Clang 11.1 nur geringfügig ändert. Offenbar ist die Erstellung von Zufallszahlen an sich nicht besonders aufwendig (zumindest auf meinem Rechner). Da du versprochen hast, diese Ergebnisse als qualitativ zu betrachten, sollten wir gut sein.

14 Es gibt weitere alternative Implementierungen von variant. Die Boost-Bibliothek bietet zwei Implementierungen: Abseil bietet eine abweichende Implementierung, und es lohnt sich, einen Blick auf die Implementierung von Michael Park zu werfen.

15 Das Proxy-Muster ist ein weiteres GoF-Entwurfsmuster, das ich in diesem Buch aus Platzgründen leider nicht behandeln kann. Ich werde jedoch ausführlich auf das Bridge-Entwurfsmuster eingehen; siehe "Leitfaden 28: Brücken bauen, umphysische Abhängigkeitenzu beseitigen".

16 Weitere Informationen über das Acyclic Visitor-Pattern von seinem Erfinder findest du in Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (Pearson).

17 Siehe "Leitlinie 9: Achte auf die Eigentümerschaft von Abstraktionen" für eine Definition der Begriffe High Level und Low Level.

Get C++ Software Design now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.