Bewegungssemantik in C++

Abdul Mateen 12 Oktober 2023
  1. Vorbereitungen zur Erstellung von C++-Objekten
  2. Bewegungssemantik in C++
Bewegungssemantik in C++

In diesem Tutorial werden wir die Bewegungssemantik in C++ besprechen:

  • Wir werden verwandte Konzepte von Deep Copy & Shallow Copy diskutieren.
  • Wir werden schnell die Idee von lvalue und rvalue besprechen.
  • Wir werden versuchen, die Bewegungssemantik anhand von Beispielen zu verstehen.

Hinweis: Wenn Sie mit Konzepten für flache und tiefe Kopien vertraut sind, können Sie direkt zum Abschnitt Semantik verschieben springen.

Vorbereitungen zur Erstellung von C++-Objekten

Lassen Sie uns anhand des folgenden trivialen Beispiels schnell den Mechanismus zum Erstellen einer Objektkopie verstehen:

class T {
  int x;

 public:
  T(int x = 1) { this->x = x; }
  int getX() const { return x; }
  void setX(int x) { this->x = x; }
};
int main() {
  T o1;
  T o2(o1);
  cout << o1.getX() << '\t' << o2.getX() << '\n';
  o2.setX(5);
  cout << o1.getX() << '\t' << o2.getX() << '\n';
  return 0;
}

In diesem einfachen Beispiel hat die Klasse T nur ein Datenelement: x. Wir haben einen parametrisierten Konstruktor mit einem Standardwert und zwei Factory-Methoden für x geschrieben.

In main() haben wir das Objekt o1 mit dem verfügbaren Konstruktor erstellt. In der zweiten Zeile haben wir ein weiteres Objekt erstellt, o2, eine Kopie von o1.

Dieses Objekt wird durch einen Kopierkonstruktor erstellt, der standardmäßig in jeder Klasse vorhanden ist. Dieser Standardkonstruktor erstellt eine Member-weise Kopie der Datenmember.

In Zeile 11 drucken wir Datenelemente beider Objekte, während Zeile 4 den Wert des Datenelements x von o2 ändert. Abschließend zeigen wir wieder Datenelemente an, um die Auswirkung der Änderung auf beide Objekte zu sehen.

Hier ist die Ausgabe dieses Codes:

1	1
1	5

In der ersten Ausgabezeile haben beide Datenmember den Wert 1, weil das erste Objekt mit dem parametrisierten Konstruktor mit dem Standardwert 1 erstellt wird. Da das zweite Objekt eine Kopie von o1 ist, sind beide Werte gleich.

Später ruft o2 jedoch die Setter-Funktion auf, um den Wert ihres Datenelements zu ändern. Die zweite Ausgabezeile zeigt, dass die Änderung des Werts der Datenelemente von o2 den Wert der Datenelemente von o1 nicht beeinflusst.

Flache Kopie

Leider funktioniert der obige Kopiermechanismus nicht, falls die Datenelemente Zeiger sind, die auf dynamischen Speicher zeigen (auf dem Heap erstellt). In diesem Szenario zeigt das Kopierobjekt auf dieselbe dynamische Speicherstelle, die durch das vorherige Objekt erstellt wurde; daher wird gesagt, dass das Kopierobjekt eine flache Kopie eines anderen Objekts hat.

Die Lösung funktioniert gut, wenn Objekte oder Datenelemente schreibgeschützt sind; Andernfalls kann es zu ernsthaften Problemen kommen. Lassen Sie uns zunächst das Konzept der flachen Kopie anhand des folgenden Codebeispiels verstehen:

class T {
  int *x, size;

 public:
  T(int s = 5) {
    size = s;
    x = new int[size];
    x[0] = x[1] = x[2] = x[3] = x[4] = 1;
  }
  void set(int index, int val) { this->x[index] = val; }
  void show() const {
    for (int i = 0; i < size; i++) cout << x[i] << ' ';
    cout << '\n';
  }
};
int main() {
  T o1;
  T o2(o1);
  o1.show();
  o2.show();
  o2.set(2, 5);
  o1.show();
  o2.show();
  return 0;
}

Die Klasse T hat in diesem Beispiel zwei Datenelemente: Zeiger x und Größe. Auch hier haben wir einen parametrisierten Konstruktor mit einem Standardwert.

Wir haben dem Datenelement Größe im Konstruktorkörper den Wert Standardparameter zugewiesen.

Zeile 7 und 8 im Konstruktor deklarieren ein dynamisches Array der Länge size und weisen allen Array-Elementen 1 zu. Die Methode set platziert den Wert von val an der Position index des dynamischen Arrays.

In main() haben wir o1 und o2 erstellt, wobei o2 das Kopierobjekt ist. Als nächstes drucken wir beide Objekte.

Auch hier ändern wir den Wert des dritten Elements des Arrays in o2 und drucken Objekte.

Hier ist die Ausgabe dieses Codes:

1 1 1 1 1
1 1 1 1 1
1 1 5 1 1
1 1 5 1 1

Jetzt entspricht die Ausgabe möglicherweise nicht Ihren Erwartungen. Die ersten beiden Zeilen zeigen eine exakte Kopie; In der dritten und vierten Zeile haben wir jedoch die gleichen Elemente in beiden Objekten, während wir nur eines geändert haben.

Dies liegt daran, dass der standardmäßige Kopierkonstruktor eine flache Kopie erstellt hat, indem er den Wert des Zeigers im ersten Objekt (das eine Adresse auf das dynamisch zugewiesene Array ist) dem Zeiger des zweiten Objekts zuweist.

Auch diese flache Kopie ist in Ordnung, wenn wir schreibgeschützte Werte haben; andernfalls zeigt die Ausgabe deutlich die Update-Hazards.

Tiefe Kopie

Im Gegensatz zur flachen Kopie weisen wir in der tiefen Kopie jedem Objekt einen separaten dynamischen Speicher zu. Dann erstellen wir eine Member-weise Kopie für jedes Element innerhalb des Heaps.

Dies wird im Wesentlichen durch Überladen des Kopierkonstruktors und des Zuweisungsoperators erreicht. Sehen Sie sich das Codierungsbeispiel an, in dem wir den Kopierkonstruktor überladen:

T(const T &t) {
  size = t.size;
  x = new int[size];
  for (int i = 0; i < size; i++) x[i] = t.x[i];
}

Bitte beachten Sie, dass in der zweiten Zeile die Adresse der neuen dynamischen Zuordnung dem x zugewiesen wird. In den Zeilen 3 und 4 haben wir alle dynamischen Array-Elemente kopiert, um eine tiefe Kopie zu erstellen.

Hinweis: Das obige Beispiel enthält nur Code für das Überladen des Kopierkonstruktors. Der Rest des Codes ist derselbe wie im vorherigen Beispiel.

Hier ist die Ausgabe des im vorherigen Beispiel geschriebenen Hauptcodes nach dem Hinzufügen des Kopierkonstruktors:

1 1 1 1 1
1 1 1 1 1
1 1 1 1 1
1 1 5 1 1

Die Zeilen 3 und 4 zeigen keinen Modifikationseffekt im zweiten Objekt auf das erste Objekt. Das bedeutet, dass die Änderung nur dem zweiten Objekt inhärent ist, weil nun 2 eine tiefe Kopie von o1 ist.

Lassen Sie uns nun schnell das Konzept von lvalue & rvalue besprechen.

LWerte und RWerte

Der lvalue oder linke Wert bezieht sich auf den Operanden links vom Zuweisungsoperator, der den Wert erhält. Im Gegensatz dazu bedeutet rvalue den Operanden auf der rechten Seite des Zuweisungsoperators, der den Wert liefert.

Daher können die einfachen Variablen sowohl als lvalue als auch als rvalue verwendet werden. Konstanten oder variable Konstanten können nur als rvalue verwendet werden.

Die Ausdrücke sind rvalue. Zum Beispiel können wir a = b + c schreiben, aber wir können nicht b + c = a schreiben oder wir können nicht 2 = a schreiben.

Bewegungssemantik in C++

Beginnen wir abschließend mit der Diskussion über die Bewegungssemantik. Hier werden wir dieses Konzept in Bezug auf die Kopierkonstruktoren diskutieren.

Sehen Sie sich zunächst die folgenden Aufrufe des Kopierkonstruktors für die Klasse T an:

T o2(o1);
T o3(o1 - o2);

Wenn wir den obigen Code gründlich analysieren, erfordert nur die erste Zeile eine tiefe Kopie, in der wir später o1 inspizieren können. Darüber hinaus können wir eine Anweisung im Kopierkonstruktor haben, um den Inhalt von o1 zu ändern (z. B. eine Anweisung wie t.set(2,5)).

Deshalb sagen wir, o1 sei ein L-Wert.

Der Ausdruck o1-o2 ist jedoch kein L-Wert; Stattdessen ist es ein Rvalue, da es sich um ein einheitliches Objekt (ohne Namen) handelt und wir nicht erneut auf o1-o2 zugreifen müssen. Daher können wir sagen, dass rvalues temporäre Objekte darstellen, die kurz nach der Ausführung der betrachteten Anweisung zerstört werden.

C++0x hat einen neuen Ansatz namens RValue Reference eingeführt, um auf den Typ rvalue zu verweisen. Dieser neue Ansatz ermöglicht das Abgleichen der rvalue-Argumente beim Überladen von Funktionen.

Dazu müssen wir einen weiteren Konstruktor mit einem rvalue-Referenzparameter schreiben.

T(T&& t) {
  x = t.data;
  t.data = nullptr;
}

Beachten Sie zunächst den neuen Konstruktor (Move-Konstruktor statt Copy-Konstruktor genannt), bei dem wir ein weiteres &-Zeichen hinzugefügt haben, das eine Referenz für einen Rvalue ist.

In Zeile 2 haben wir, anstatt eine tiefe Kopie des dynamischen Arrays zu erstellen, dem neuen Objekt einfach die Adresse des vorhandenen Objekts zugewiesen. Zur weiteren Sicherheit haben wir dem Pointer des bestehenden Objekts nullptr zugewiesen.

Der Grund dafür ist, dass wir nicht auf das temporäre Quellobjekt zugreifen müssen. Daher verschieben wir im Move-Konstruktor die Ressource (d. h. das dynamische Array) vom vorhandenen (temporären) Objekt zum neuen aufrufenden Objekt.

Um es besser zu verstehen, schauen wir uns einen anderen Code an, der den Zuweisungsoperator überlädt:

T& operator=(T t) {
  size = t.size;
  for (int i = 0; i < size; i++) x[i] = t.x[i];
  return *this;
}

Wir haben die rvalue-Referenz hier nicht verwendet. In C++0x prüft der Compiler, ob der Parameter rvalue oder lvalue ist, und entsprechend wird entweder der move- oder der copy-Konstruktor aufgerufen.

Wenn wir also den Zuweisungsoperator o1=o2 nennen, initialisiert der Kopierkonstruktor t. Rufen wir jedoch den Zuweisungsoperator o3 = o2-o1 auf, initialisiert der Move-Konstruktor t.

Der Grund ist, dass o2-o1 ein rvalue statt ein lvalue ist.

Abschließend schließen wir, dass der Kopierkonstruktor verwendet werden kann, um eine tiefe Kopie zu erstellen, um die Option zu sparen, das Quellobjekt für weiteren Zugriff sicher aufzubewahren. Der Move-Konstruktor weist dem Zeiger des aufrufenden Objekts den dynamischen Speicher des vorhandenen Objekts zu, da später kein Zugriff auf das rvalue-Objekt erforderlich ist.