Überladen des Zuweisungsoperators
Für Klassen lassen sich fast alle Operatoren so umdefinieren, dass sie im Zusammenhang mit Objekten eine beliebig definierbare Aktion ausführen. Zum Einstieg in das Überladen von Operatoren sehen wir uns das Überladen des Zuweisungsoperators '=' an.
Syntax
Um den Zuweisungsoperator '=' für eine Klasse zu überladen, ist folgende Methode zu implementieren:
CAny& operator = (const DTYP& param);
CAny ist die Klasse, für die der Zuweisungsoperator überladen wird.
Nach dem Returntyp folgt das Schlüsselwort operator und dann der zu überladende Operator, hier '='. Innerhalb der Parameterklammer steht der rechte Operand des Operators. Somit ist folgende Zuweisung definiert:
CAnyObject = DTYP(ausdruck);
Im nachfolgenden Beispiel wird der Zuweisungsoperator für die Klasse Window definiert, um Objekte dieser Klasse einander zuzuweisen.
#include <print>
#include <cstring>
class Window
{
char* pTitle = nullptr;
public:
Window() = default;
// ctor mit Parameter
Window(const char* const _pTitle)
{
auto len = strlen(_pTitle)+1;
pTitle = new char[len];
strcpy_s(pTitle,len,_pTitle);
}
// dtor
~Window()
{
delete [] pTitle;
}
// Fenster 'zeichnen'
void Print() const
{
if (pTitle != nullptr)
std::println("Fenstertitel: {}",pTitle);
else
std::println("Fenstertitel: default");
}
Window& operator = (this Window& self, const Window& src)
{
delete [] self.pTitle; // bisherigen Speicherplatz freigeben!
auto len = strlen(src.pTitle)+1;
self.pTitle = new char[len];
strcpy_s(self.pTitle,len,src.pTitle);
return self;
}
};
int main()
{
// Ein Fenster definieren und zeichnen
Window win1{"Ein Fenster"};
win1.Print();
// Zwei weitere Fenster definieren
Window win2,win3;
// Nun win1 den Fenstern win2 und win3 zuweisen
win2 = win3 = win1;
// Beide Fenster zeichnen
win2.Print();
win3.Print();
}
Fenstertitel: Ein Fenster
Fenstertitel: Ein Fenster
Fenstertitel: Ein Fenster
Der überladene Zuweisungsoperator '=' muss eine Referenz auf das aktuelle Objekt zurückliefern. Sehen Sie dazu die Anweisung in Zeile 47 an. Dieser Ausdruck wird vom Compiler in zwei Teilausdrücke zerlegt. Zuerst wird der Teilausdruck win3 = win1; berechnet. Dies führt zum Aufruf des überladenen Zuweisungsoperators für das Objekt win3 (linker Operand), wobei win1 (rechter Operand) als Parameter an die Methode übergeben wird. Das Ergebnis dieses Ausdrucks wird nun als neuer rechter Operand für den zweiten Teilausdruck win2 = Ergebnis aus (win3=win1) eingesetzt. Und dieses 'Ergebnis' ist der Inhalt des Objekts win3 nach der Auswertung des ersten Teilausdrucks.
Alternativ kann anstelle des explizten this auch der 'versteckte' this-Zeiger weiterhin verwendet werden. Der überladene Zuweisungsoperator würde damit wie folgt aussehen:
Window& operator = (const Window& src)
{
delete [] pTitle; // bisherigen Speicherplatz freigeben!
auto len = strlen(src.pTitle)+1;
pTitle = new char[len];
strcpy_s(pTitle,len,src.pTitle);
return *this;
}
Sie müssen im Regelfall immer den Zuweisungsoperator überladen, wenn eine Klasse dynamische Eigenschaften enthält. Tun Sie dies nicht und es wird eine Zuweisung eines Objekts an ein anderes durchgeführt, enthalten beide Objekte danach Verweise auf den gleichen Speicherbereich.
Regel der großen 5
Im Zusammenhang mit dem Überladen des Operators = soll die sogenannte "Regel der großen 5" nicht unerwähnt bleiben.
Ist eine der Methoden
- Kopierkonstruktor
- Kopier-Zuweisungsoperator
- Move-Konstruktor
- Move-Zuweisungsoperator
- Destruktor
notwendig, sind in der Regel ebenfalls die anderen Methoden erforderlich!
Sehen wir uns dazu das nachfolgende vereinfachte Beispiel an, ohne die Move-Methoden. Die dort definierte Klasse Window enthält die dynamische Eigenschaft pTitle zum Abspeichern eines Fenstertitels. Der für den Fenstertitel benötigte Platz wird im Konstruktor reserviert. Damit wird automatisch der Destruktor notwendig, der den reservierten Speicherplatz wieder freigibt. Nach der obigen Regel sind nun auch der Kopierkonstruktor sowie der überladene (Kopier-)Zuweisungsoperator notwendig!
#include <print>
#include <cstring>
class Window
{
char* pTitle = nullptr;
public:
// Default ctor fuer Window Felder
Window() = default;
// ctor mit Parameter
Window(const char* const _pTitle)
{
auto len = strlen(_pTitle)+1;
pTitle = new char[len];
strcpy_s(pTitle,len,_pTitle);
}
// copy-ctor
Window(const Window& src):
Window(src.pTitle)
{}
// dtor
~Window()
{
delete [] pTitle;
}
// Zuweisungsoperator
Window& operator = (this Window& self, const Window& src)
{
if (&self == &src)
return self;
delete [] self.pTitle; // bisherigen Speicherplatz freigeben!
auto len = strlen(src.pTitle)+1;
self.pTitle = new char[len];
strcpy_s(self.pTitle,len,src.pTitle);
return self;
}
// Fenster 'zeichnen'
void Print() const
{
if (pTitle != nullptr)
std::println("Fenstertitel: {}",pTitle);
else
std::println("Fenstertitel: default");
}
};
int main()
{
Window win1{"Ein Fenster"}; // ctor mit Parameter
Window win2{win1}; // copy-ctor
Window win3;
win3 = win1; // Zuweisungsoperator
win2.Print();
win3.Print();
}
Fenstertitel: Ein Fenster
Fenstertitel: Ein Fenster
Fenstertitel: Ein Fenster
Enthält die Klasse, für die der Operator = überladen wird, dynamische Daten, so sollte eine Zuweisung auf sich selbst immer abgefangen werden. Wird eine solche Zuweisung nicht abgefangen, kann dies unter Umständen zum fehlerhaften Verhalten des Operators führen. Überlegen Sie einmal was passiert, wenn im obigen Beispiel die Abfrage nicht vorhanden wäre (Zeile 29+30: self=src).
Move-Zuweisungsoperator (Move-Semantik, Teil 2)
Im vorherigen Kapitel haben wir den Move-Konstruktor kennengelernt, der es erlaubt, den Besitz einer Ressource beim Erstellen eines Objekts vom Quell-Objekt auf das neue Objekt zu übertragen.
Und unter gewissen Bedingungen, die nicht so selten sind wie wir gleichen sehen werden, kann es auch bei einer Zuweisung sinnvoll sein die Ressourcen vom Quell-Objekt an das Ziel-Objekt zu übertragen. Sehen wir uns einmal folgende Anweisungen an:
// Klassendefinition
class CData
{
int *pData = nullptr; // dynamische Eigenschaft!
public:
...
// Überladener Zuweisungsoperator
CData& operator= (const CData& src)
{...}
};
int main()
{
// Erstelle 3 Objekte
CData obj1{...};
CData obj2, obj3;
// Objekt zuweisen
obj2 = obj1; // 1. Zuweisung
obj3 = obj1 + obj2; // 2. Zuweisung
}
Unter der Annahme, dass dem Compiler bekannt ist wie zwei CData-Objekte zu addieren sind, passiert hier folgendes: In der ersten Zuweisung wird der definierte Zuweisungsoperator im Kontext von obj2 aufgerufen, der als Parameter obj1 erhält. Doch was erhält der Zuweisungsoperator bei der zweiten Zuweisung als Parameter übergeben? Bevor die Zuweisung erfolgen kann, ist zunächst die Summe aus obj1 + obj2 zu berechnen. Und diese Summe wird in einem temporären CData-Objekt abgelegt, welches dann als Parameter an den Zuweisungsoperator von obj3 übergeben wird. Doch wird dieses temporäre CData-Objekt nach der Zuweisung noch benötigt? Eher nicht, da darauf nicht zugegriffen werden kann. Und in einem solchen Fall hilft der Move-Zuweisungsoperator den Ressourcenbedarf und auch die Laufzeit zu optimieren, denn er überträgt den Besitz der Ressource aus diesem temporären Objekt (hier der Summe aus obj1+obj2) an das Zielobjekt obj3.
Wie sieht nun der Move-Zuweisungsoperator aus? Er erhält als Parameter eine rvalue-Referenz (gekennzeichnet durch && nach dem Datentyp). Innerhalb des Operators werden dann die Ressourcen vom Quell-Objekt auf das Ziel-Objekt übertragen. Und zum Schluss ist das Quell-Objekt in einen gültigen Zustand zu versetzen, sodass bei dessen Löschung nicht versucht wird, die an das Ziel-Objekt übertragene Ressource freizugeben. Für unsere Klasse CData aus dem obigen Beispiel sieht der Move-Zuweisungsoperator damit wie folgt aus:
// Klassendefinition
class CData
{
int *pData = nullptr; // dynamische Eigenschaft!
public:
...
// Überladener Zuweisungsoperator
CData& operator= (const CData& src)
{...}
// Move-Operator =
CData& operator=(CData&& src)
{
// Zuweisung auf sich selbst abprüfen
if (this == &src)
return *this;
pData = src.pData; // Datenbesitz transferieren
// Quell-Objekt besitzt keine Daten mehr!
src.pData = nullptr;
return *this;
}
};
int main()
{
// Erstelle 3 Objekte Window
CData obj1{...};
CData obj2, obj33;
// Objekt zuweisen
obj2 = obj1 // 1. Zuweisung
obj3 = obj1 + obj2; // 2. Zuweisung
}
Bei der ersten Zuweisung wird der 'normale' Zuweisungsoperator aufgerufen und bei der zweiten Zuweisung automatisch durch den Compiler der Move-Zuweisungsoperator. Die komplette Klasse CData finden Sie übrigens im Anhang Q: CData Klasse.
Ebenso kann der Move-Zuweisungsoperator zur Steigerung der Effizienz eingesetzt werden. Erweitern wir die Klasse CData aus dem Anhang Q um eine Methode, um zwei CData-Objekte zu vertauschen.
class CData
{
...
};
void CData::Swap(this CData& self, CData& obj2)
{
CData tmp{ std::move(self) }; // move-ctor
self = std::move(obj2); // move-Zuweisung
obj2 = std::move(tmp); // move-Zuweisung
}
Die Swap() Methode überträgt in Zeile 7 die Daten aus dem aktuellen Objekt zunächst in das temporäre Objekt tmp mithilfe des Move-Konstruktors. Die Zeile 8 überträgt dann die Daten aus dem Objekt obj2 ins aktuelle Objekt durch Aufruf des Move-Zuweisungsoperators. Und zum Schluss werden die Daten aus dem tmp-Objekt ins Objekt obj2 übertragen. D.h., der gesamte Tauschvorgang verläuft ohne zusätzliches Allokieren von Speicher für den Datentransfer.
Aufruf des überladenen Operators =
Der überladene Zuweisungsoperator kann entweder direkt, d.h., durch Angabe des Namens der Methode operator=, oder indirekt durch einfaches Anwenden des Zuweisungsoperators aufgerufen werden. In der Regel wird der letzte Fall verwendet, da er eingängiger ist.
import CData;
int main()
{
CData data1{ 5 }, data2, data3;
data2.operator = (data1); // direkter Aufruf
data3 = data2; // indirekter Aufruf
}
Mehrfaches Überladen
Da das Überladen des Zuweisungsoperators durch eine Methode erfolgt, kann der Operator auch mehrfach überladen werden. Dies ermöglicht Zuweisungen mit unterschiedlichen Datentypen (rechter Operand des Operators =).
Ausgangsbasis für das Beispiel ist wieder die Klasse CData. Diese Klasse soll nun um die Möglichkeit erweitert werden, alle die in ihr abgelegten Daten mit einem bestimmten Wert zu überschreiben. Eine Lösung wäre, eine neue Methode fill() zu erstellen.
Aber es geht auch anders, über eine Zuweisung. Dazu wird der Zuweisungsoperator so überladen, dass er als 2. Operand den entsprechenden int-Wert erhält.
class CData
{
...
}
// Ueberschreibt alle Daten mit data
CData& operator = (this CData& self, int data)
{
for (size_t index = 0; index < self.dSize; index++)
self.pData[index] = data;
return self;
}
Nun können mit einer Zuweisung alle Daten überschrieben werden.
import CData;
int main()
{
CData data1{ 5 };
// ...weitere Anweisung
data1 = 99; // Alle Daten auf 99 setzen
}
Verhindern von Zuweisungen bei Objekten
Standardmäßig kann einem Objekt ein anderes Objekt der gleichen Klasse zugewiesen werden. Wie bekannt, definiert der Compiler unter bestimmten Bedinungen automatisch die Methode operator =, welche die Eigenschaften einfach kopiert. Diese automatische Generierung der Operator-Methode kann unterbunden werden, indem bei der Deklaration des Operators nach der Parameterklammer = delete angegeben wird.
Außerdem kann mittels = delete eine genaue Kontrolle darüber erfolgen, für welche Datentypen eine Zuweisung erlaubt sein soll und für welche nicht.
class CData
{
...
CData& operator = (int data);
CData& operator = (float data) = delete;
};
Durch diese Anweisungen können zwar weiterhin die Daten mit einem Integer-Wert überschrieben werden, aber nicht mit einem Gleitkommawert.
Übungen
oassign_01:
Für die Verwaltung von Prüffällen ist eine Klasse CTestCase zu definieren. Ein Prüffall soll außer einer Beschreibung eine eindeutige Prüffallnummer, eine Priorität und einen Status enthalten. Die Priorität eines Prüffalls kann low, medium, high oder critical sein und der Status defined, failed oder ok (enums!). Die Prüffallnummer ist automatisch zu vergeben.
Bei der Definition eines Prüffalls ist dessen Beschreibung sowie die Priorität anzugeben. Der Status des Prüffalls ist initial auf 'defined' zu setzen.
Die Priorität und der Status eines Prüffalls sollen durch Zuweisungen geändert werden können.
Für die Ausgabe der Prüffalldaten ist eine Methode zu erstellen. Geben Sie den Status im 'Klartext' aus und nicht als enum-Wert!
In der main() Funktion ist zunächst ein Prüffall zu definieren und auszugeben. Anschließend ist dessen Priorität und Status zu verändern und der Prüffall erneut auszugeben.
Zum Testen der Prüffall-Nummerierung ist ein weiterer Prüffall zu definieren und dessen Status auf 'failed' zu setzen. Geben Sie dessen Prüffalldaten ebenfalls aus.
Prueffall: Testen der Programmfunktionen
Nummer: 1, Prio: low, Status: defined
Prueffall: Testen der Programmfunktionen
Nummer: 1, Prio: high, Status: ok
Prueffall: Stresstest
Nummer: 2, Prio: critical, Status: failed
oassign_02:
Fügen Sie der Klasse CString aus der vorherigen Übung octor_02 zwei Zuweisungsoperatoren hinzu. Zum einen soll es möglich sein, zwei CString-Objekte einander zuzuweisen und zum anderen ein C-String einem CString-Objekt.
Alle anderen Methoden bleiben vorerst unverändert.
Erstellen Sie in main() zwei CString-Objekte, denen Sie bei ihrer Definition einen beliebigen Text zuweisen. Geben Sie die CString-Objekte zur Kontrolle aus.
Weisen Sie dann dem ersten CString-Objekt einen beliebigen C-String zu. Anschließend ist das erste CString-Objekt dem zweiten CString-Objekt zuzuweisen. Geben Sie beide CString-Objekte erneut aus.
Ausgangs-Strings:
Das ist der erste String
und das der zweite
Nach der Zuweisung:
Dieser Text wird dupliziert
Dieser Text wird dupliziert