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
{
circle
,
square
}
;
class
Shape
{
protected
:
explicit
Shape
(
ShapeType
type
)
:
type_
(
type
)
{
}
public
:
virtual
~
Shape
(
)
=
default
;
ShapeType
getType
(
)
const
{
return
type_
;
}
private
:
ShapeType
type_
;
}
;
Zunächst führen wir die Aufzählung ShapeType
ein, die derzeit die beiden Aufzählungszeichen circle
und square
(). Offenbar haben wir es zunächst nur mit Kreisen und Quadraten zu tun. Zweitens stellen wir die Klasse Shape
(). Angesichts des geschützten Konstruktors und des virtuellen Destruktors () 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
(). Dieses Datenelement wird über den Konstruktor initialisiert () initialisiert und kann über die Memberfunktion getType()
abgefragt werden () 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
{
public
:
explicit
Circle
(
double
radius
)
:
Shape
(
circle
)
,
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
() und muss aus diesem Grund und wegen des Fehlens eines Standardkonstruktors in Shape
die Basisklasse initialisieren (Da 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
{
public
:
explicit
Square
(
double
side
)
:
Shape
(
square
)
,
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 (). Der Hauptunterschied besteht darin, dass die Square
ihre Basisklasse mit demsquare
Enumerator ().
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
)
;
//---- <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
(
)
)
{
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>
(). 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 () 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
():
enum
ShapeType
{
circle
,
square
,
triangle
}
;
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 ():
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
:
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 ():
//---- <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
;
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
;
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
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.
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 ShapeOperation
ein 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.
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
;
virtual
void
visit
(
Square
const
&
,
/*...*/
)
const
=
0
;
// Possibly more visit() functions, one for each concrete shape
}
;
In diesem Beispiel gibt es eine visit()
Funktion für Circle
() und eine für Square
(). 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 Draw
Klasse 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 ():9
class
Shape
{
public
:
virtual
~
Shape
(
)
=
default
;
virtual
void
accept
(
ShapeVisitor
const
&
v
)
=
0
;
// ...
}
;
Die Funktion accept()
wird als rein virtuelle Funktion in der Basisklasse eingeführt und muss daher in jeder abgeleiteten Klasse implementiert werden ( und):
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
)
;
}
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
)
;
}
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 Shape
Hierarchie 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 ShapeVisitor
eine 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).
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
)
)
;
shapes
.
emplace_back
(
std
:
:
make_unique
<
Square
>
(
1.2
)
)
;
shapes
.
emplace_back
(
std
:
:
make_unique
<
Circle
>
(
4.1
)
)
;
drawAllShapes
(
shapes
)
;
// ...
return
EXIT_SUCCESS
;
}
In dieser main()
Funktion erfolgen alle Zuweisungen über std::make_unique()
(,, undDiese 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
{
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
{
}
;
v
=
42
;
// Assigns the 'int' 42 to the variant
v
=
3.14
;
// Assigns the 'double' 3.14 to the variant
v
=
2.71F
;
// Assigns a 'float', which is promoted to 'double'
v
=
"
Bjarne
"
;
// Assigns the string literal 'Bjarne' to the variant
v
=
43
;
// Assigns the 'int' 43 to the variant
int
const
i
=
std
:
:
get
<
int
>
(
v
)
;
// Direct access to the value
int
*
const
pi
=
std
:
:
get_if
<
int
>
(
&
v
)
;
// Direct access to the value
std
:
:
visit
(
{
}
,
v
)
;
// Applying the Print visitor
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::string
enthalten (). 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 (). Wenn wir anschließend double
den Wert 3,14 zuweisen, speichert die Variante eine double
mit dem Wert 3,14 (Wenn 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
().
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 (Wenn du deine Meinung änderst und die ganze Zahl 43 zuweist (), 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()
(). 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()
(). 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()
().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 (), 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
{
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
>
;
//---- <Shapes.h> ----------------
#
include
<vector>
#
include
<Shape.h>
using
Shapes
=
std
:
:
vector
<
Shape
>
;
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
(Anstelle 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 ().
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 Draw
Besucher:
//---- <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.
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
>
;
//---- <SomeHeader.h> ----------------
#
include
<Circle.h>
#
include
<Ellipse.h>
#
include
<variant>
using
RoundShapes
=
std
:
:
variant
<
Circle
,
Ellipse
>
;
//---- <SomeOtherHeader.h> ----------------
#
include
<Square.h>
#
include
<Rectangle.h>
#
include
<variant>
using
AngularShapes
=
std
:
:
variant
<
Square
,
Rectangle
>
;
Zusätzlich zu der Shape
Abstraktion, die wir bereits erstellt haben (), kannst du die std::variant
für alle runden Formen erstellen (), und du kannst ein std::variant
für alle eckigen Formen erstellen (), 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.
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 |
|
1.1992 s |
1.2279 s |
|
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 CircleVisitor
und 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 CircleVisitor
und implementiert die Funktion visit()
für Circle
. Wenn er keine Kreise unterstützen will, erbt er einfach nicht von CircleVisitor
.
Der folgende Codeschnipsel zeigt eine mögliche Implementierung der Visitor
Basisklassen:
//---- <AbstractVisitor.h> ----------------
class
AbstractVisitor
{
public
:
virtual
~
AbstractVisitor
(
)
=
default
;
}
;
//---- <Visitor.h> ----------------
template
<
typename
T
>
class
Visitor
{
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 (). 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 (Die 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.
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
{
if
(
auto
const
*
cv
=
dynamic_cast
<
Visitor
<
Circle
>
const
*
>
(
&
v
)
)
{
cv
-
>
visit
(
*
this
)
;
}
}
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
(). 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>
(). 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()
().
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.
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 |
|
1.1992 s |
1.2279 s |
|
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.