Überblick
Warum brauchen wir die Copy-and-Swap-Sprache?
Jede Klasse, die eine Ressource verwaltet (ein Wrapper wie ein intelligenter Zeiger), muss The Big Three implementieren . Während die Ziele und die Implementierung des Kopierkonstruktors und des Destruktors unkompliziert sind, ist der Kopierzuweisungsoperator wohl der nuancierteste und schwierigste. Wie soll es gemacht werden? Welche Fallstricke müssen vermieden werden?
Das Copy-and-Swap-Idiom ist die Lösung und unterstützt den Zuweisungsoperator elegant dabei, zwei Dinge zu erreichen: Vermeidung von Codeduplizierungen und Bereitstellung einer starken Ausnahmegarantie .
Wie funktioniert es?
Konzeptionell wird die Funktionalität des Kopierkonstruktors verwendet, um eine lokale Kopie der Daten zu erstellen. Anschließend werden die kopierten Daten mit einer swap
Funktion verwendet, wobei die alten Daten gegen die neuen Daten ausgetauscht werden. Die temporäre Kopie wird dann zerstört und nimmt die alten Daten mit. Wir erhalten eine Kopie der neuen Daten.
Um das Copy-and-Swap-Idiom verwenden zu können, benötigen wir drei Dinge: einen funktionierenden Copy-Konstruktor, einen funktionierenden Destruktor (beide sind die Basis eines Wrappers und sollten daher sowieso vollständig sein) und eine swap
Funktion.
Eine Swap-Funktion ist eine nicht werfende Funktion, die zwei Objekte einer Klasse Mitglied für Mitglied austauscht. Wir könnten versucht sein, zu verwenden, std::swap
anstatt unsere eigenen bereitzustellen, aber dies wäre unmöglich; std::swap
verwendet den Kopierkonstruktor und den Kopierzuweisungsoperator in seiner Implementierung, und wir würden letztendlich versuchen, den Zuweisungsoperator in Bezug auf sich selbst zu definieren!
(Nicht nur das, sondern auch unqualifizierte Anrufe an swap
verwenden unseren benutzerdefinierten Swap-Operator und überspringen die unnötige Konstruktion und Zerstörung unserer Klasse, die std::swap
dies mit sich bringen würde.)
Eine ausführliche Erklärung
Das Ziel
Betrachten wir einen konkreten Fall. Wir wollen in einer ansonsten nutzlosen Klasse ein dynamisches Array verwalten. Wir beginnen mit einem funktionierenden Konstruktor, Kopierkonstruktor und Destruktor:
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
class dumb_array
{
public:
// (default) constructor
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}
// copy-constructor
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr),
{
// note that this is non-throwing, because of the data
// types being used; more attention to detail with regards
// to exceptions must be given in a more general case, however
std::copy(other.mArray, other.mArray + mSize, mArray);
}
// destructor
~dumb_array()
{
delete [] mArray;
}
private:
std::size_t mSize;
int* mArray;
};
Diese Klasse verwaltet das Array fast erfolgreich, muss jedoch operator=
ordnungsgemäß funktionieren.
Eine fehlgeschlagene Lösung
So könnte eine naive Implementierung aussehen:
// the hard part
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get rid of the old data...
delete [] mArray; // (2)
mArray = nullptr; // (2) *(see footnote for rationale)
// ...and put in the new
mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}
return *this;
}
Und wir sagen, wir sind fertig; Dies verwaltet jetzt ein Array ohne Lecks. Es gibt jedoch drei Probleme, die im Code nacheinander als gekennzeichnet sind (n)
.
Der erste ist der Selbstzuordnungstest. Diese Überprüfung dient zwei Zwecken: Sie verhindert auf einfache Weise, dass bei der Selbstzuweisung unnötiger Code ausgeführt wird, und schützt uns vor subtilen Fehlern (z. B. Löschen des Arrays, nur um zu versuchen, es zu kopieren). In allen anderen Fällen dient es lediglich dazu, das Programm zu verlangsamen und als Rauschen im Code zu wirken. Selbstzuweisung tritt selten auf, daher ist diese Prüfung meistens eine Verschwendung. Es wäre besser, wenn der Bediener ohne sie richtig arbeiten könnte.
Das zweite ist, dass es nur eine grundlegende Ausnahmegarantie bietet. Wenn dies new int[mSize]
fehlschlägt, wurde *this
es geändert. (Die Größe ist nämlich falsch und die Daten sind weg!) Für eine starke Ausnahmegarantie müsste es sich um Folgendes handeln:
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get the new data ready before we replace the old
std::size_t newSize = other.mSize;
int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
// replace the old data (all are non-throwing)
delete [] mArray;
mSize = newSize;
mArray = newArray;
}
return *this;
}
Der Code wurde erweitert! Was uns zum dritten Problem führt: Codeduplizierung. Unser Zuweisungsoperator dupliziert effektiv den gesamten Code, den wir bereits an anderer Stelle geschrieben haben, und das ist eine schreckliche Sache.
In unserem Fall besteht der Kern nur aus zwei Zeilen (der Zuordnung und der Kopie), aber bei komplexeren Ressourcen kann dieses Aufblähen des Codes ein ziemlicher Aufwand sein. Wir sollten uns bemühen, uns niemals zu wiederholen.
(Man könnte sich fragen: Wenn so viel Code benötigt wird, um eine Ressource korrekt zu verwalten, was ist, wenn meine Klasse mehr als eine verwaltet? Dies scheint zwar ein berechtigtes Problem zu sein, erfordert jedoch nicht triviale try
/ catch
Klauseln, ist dies jedoch nicht -ausgabe. Das liegt daran, dass eine Klasse nur eine Ressource verwalten sollte !)
Eine erfolgreiche Lösung
Wie bereits erwähnt, behebt das Copy-and-Swap-Idiom alle diese Probleme. Aber im Moment haben wir alle Anforderungen außer einer: eine swap
Funktion. Während die Dreierregel erfolgreich die Existenz unseres Kopierkonstruktors, Zuweisungsoperators und Destruktors beinhaltet, sollte sie eigentlich "Die großen Dreieinhalb" heißen: Jedes Mal, wenn Ihre Klasse eine Ressource verwaltet, ist es auch sinnvoll, eine swap
Funktion bereitzustellen .
Wir müssen unserer Klasse Swap-Funktionen hinzufügen, und das tun wir wie folgt: †:
class dumb_array
{
public:
// ...
friend void swap(dumb_array& first, dumb_array& second) // nothrow
{
// enable ADL (not necessary in our case, but good practice)
using std::swap;
// by swapping the members of two objects,
// the two objects are effectively swapped
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}
// ...
};
( Hier ist die Erklärung warum public friend swap
.) Jetzt können wir nicht nur unsere tauschen dumb_array
, sondern Swaps im Allgemeinen können effizienter sein; Es werden lediglich Zeiger und Größen ausgetauscht, anstatt ganze Arrays zuzuweisen und zu kopieren. Abgesehen von diesem Bonus an Funktionalität und Effizienz sind wir jetzt bereit, die Copy-and-Swap-Sprache zu implementieren.
Unser Auftragsoperator ist ohne weiteres:
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
Und das ist es! Mit einem Schlag werden alle drei Probleme auf einmal elegant angegangen.
Warum funktioniert es?
Wir bemerken zuerst eine wichtige Wahl: Das Parameterargument wird als Wert genommen . Man könnte zwar genauso gut Folgendes tun (und tatsächlich tun es viele naive Implementierungen der Redewendung):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
Wir verlieren eine wichtige Optimierungsmöglichkeit . Darüber hinaus ist diese Auswahl in C ++ 11 von entscheidender Bedeutung, das später erläutert wird. (Im Allgemeinen lautet eine bemerkenswert nützliche Richtlinie wie folgt: Wenn Sie eine Kopie von etwas in einer Funktion erstellen möchten, lassen Sie den Compiler dies in der Parameterliste tun. ‡)
In beiden Fällen ist diese Methode zum Abrufen unserer Ressource der Schlüssel zur Vermeidung von Codeduplizierungen: Wir können den Code aus dem Kopierkonstruktor verwenden, um die Kopie zu erstellen, und müssen kein bisschen davon wiederholen. Nachdem die Kopie erstellt wurde, können wir sie austauschen.
Beachten Sie, dass beim Aufrufen der Funktion alle neuen Daten bereits zugewiesen, kopiert und zur Verwendung bereit sind. Dies gibt uns eine starke kostenlose Ausnahmegarantie: Wir werden die Funktion nicht einmal aufrufen, wenn die Erstellung der Kopie fehlschlägt, und es ist daher nicht möglich, den Status von zu ändern *this
. (Was wir zuvor für eine starke Ausnahmegarantie manuell gemacht haben, macht der Compiler jetzt für uns; wie nett.)
Zu diesem Zeitpunkt sind wir frei zu Hause, weil wir swap
nicht werfen. Wir tauschen unsere aktuellen Daten gegen die kopierten Daten aus, um unseren Status sicher zu ändern, und die alten Daten werden temporär gespeichert. Die alten Daten werden dann freigegeben, wenn die Funktion zurückkehrt. (Wobei der Gültigkeitsbereich des Parameters endet und sein Destruktor aufgerufen wird.)
Da die Redewendung keinen Code wiederholt, können wir keine Fehler im Operator einführen. Beachten Sie, dass dies bedeutet, dass wir keine Selbstzuweisungsprüfung mehr benötigen, um eine einheitliche Implementierung von zu ermöglichen operator=
. (Außerdem haben wir keine Leistungseinbußen mehr bei Nicht-Selbstzuweisungen.)
Und das ist die Copy-and-Swap-Sprache.
Was ist mit C ++ 11?
Die nächste Version von C ++, C ++ 11, enthält eine sehr wichtige Änderung bei der Verwaltung von Ressourcen: Die Dreierregel ist jetzt die Viererregel (anderthalb). Warum? Weil wir nicht nur in der Lage sein müssen, unsere Ressource zu kopieren und zu konstruieren, sondern sie auch verschieben und konstruieren müssen .
Zum Glück ist das ganz einfach:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
Was ist denn hier los? Erinnern Sie sich an das Ziel der Bewegungskonstruktion: die Ressourcen einer anderen Instanz der Klasse zu entnehmen und sie in einem Zustand zu belassen, der garantiert zuweisbar und zerstörbar ist.
Was wir also getan haben, ist einfach: Initialisieren Sie über den Standardkonstruktor (eine C ++ 11-Funktion) und tauschen Sie dann mit aus other
. Wir wissen, dass eine standardmäßig erstellte Instanz unserer Klasse sicher zugewiesen und zerstört werden kann, sodass wir wissen, dass wir other
nach dem Austausch dasselbe tun können.
(Beachten Sie, dass einige Compiler die Konstruktordelegierung nicht unterstützen. In diesem Fall müssen wir die Klasse standardmäßig manuell erstellen. Dies ist eine unglückliche, aber glücklicherweise triviale Aufgabe.)
Warum funktioniert das?
Das ist die einzige Änderung, die wir an unserer Klasse vornehmen müssen. Warum funktioniert das? Denken Sie an die immer wichtige Entscheidung, den Parameter zu einem Wert und nicht zu einer Referenz zu machen:
dumb_array& operator=(dumb_array other); // (1)
Wenn other
jetzt mit einem r-Wert initialisiert wird, wird er verschiebungskonstruiert . Perfekt. Auf die gleiche Weise, wie wir in C ++ 03 unsere Kopierkonstruktorfunktionalität wiederverwenden können, indem wir das Argument als Wert verwenden, wählt C ++ 11 bei Bedarf auch automatisch den Verschiebungskonstruktor aus. (Und natürlich kann, wie in dem zuvor verlinkten Artikel erwähnt, das Kopieren / Verschieben des Werts einfach ganz weggelassen werden.)
Und so schließt die Copy-and-Swap-Sprache.
Fußnoten
* Warum setzen wir mArray
auf null? Denn wenn ein weiterer Code im Operator ausgelöst wird, wird dumb_array
möglicherweise der Destruktor von aufgerufen. und wenn dies geschieht, ohne es auf null zu setzen, versuchen wir, bereits gelöschten Speicher zu löschen! Wir vermeiden dies, indem wir es auf null setzen, da das Löschen von null keine Operation ist.
† Es gibt andere Behauptungen, dass wir uns auf std::swap
unseren Typ spezialisieren, eine Klasse swap
neben einer freien Funktion bereitstellen swap
sollten usw. Dies ist jedoch alles unnötig: Jede ordnungsgemäße Verwendung swap
erfolgt durch einen unqualifizierten Anruf, und unsere Funktion wird es sein gefunden durch ADL . Eine Funktion reicht aus.
‡ Der Grund ist einfach: Sobald Sie die Ressource für sich haben, können Sie sie austauschen und / oder verschieben (C ++ 11), wo immer sie sein muss. Durch Erstellen der Kopie in der Parameterliste maximieren Sie die Optimierung.
†† Der Verschiebungskonstruktor sollte im Allgemeinen sein noexcept
, andernfalls std::vector
wird der Kopierkonstruktor von Code (z. B. Größenänderungslogik) verwendet, auch wenn eine Verschiebung sinnvoll wäre. Markieren Sie es natürlich nur dann als nicht, wenn der darin enthaltene Code keine Ausnahmen auslöst.