Motivation und Einsatz von Move-Konstruktoren in C ++

17

Ich habe kürzlich über Move-Konstruktoren in C ++ gelesen (siehe z. B. hier ) und ich versuche zu verstehen, wie sie funktionieren und wann ich sie verwenden sollte.

Soweit ich weiß, wird ein Verschiebungskonstruktor verwendet, um die Leistungsprobleme zu verringern, die durch das Kopieren großer Objekte verursacht werden. Auf der Wikipedia-Seite heißt es: "Ein chronisches Leistungsproblem mit C ++ 03 sind die kostspieligen und unnötigen tiefen Kopien, die implizit auftreten können, wenn Objekte als Wert übergeben werden."

Normalerweise spreche ich solche Situationen an

  • durch Weitergabe der Objekte als Referenz oder
  • durch die Verwendung von intelligenten Zeigern (z. B. boost :: shared_ptr), um das Objekt zu umgehen (die intelligenten Zeiger werden anstelle des Objekts kopiert).

In welchen Situationen sind die beiden oben genannten Techniken nicht ausreichend und die Verwendung eines Verschiebungskonstruktors ist praktischer?

Giorgio
quelle
1
Abgesehen von der Tatsache, dass die Bewegungssemantik viel mehr bewirken kann (wie in den Antworten angegeben), sollten Sie nicht fragen, in welchen Situationen das Übergeben von Referenzen oder intelligenten Zeigern nicht ausreicht, sondern ob diese Techniken wirklich die beste und sauberste Methode sind dies zu tun (Vorsicht, shared_ptrnur um schnelles Kopieren zu ermöglichen) und wenn die Verschiebungssemantik das Gleiche ohne Beeinträchtigung von Codierung, Semantik und Sauberkeit erreichen kann.
Chris sagt Reinstate Monica

Antworten:

16

Mit der Verschiebungssemantik wird C ++ um eine ganze Dimension erweitert. Sie können damit nicht nur günstig Werte zurückgeben.

Zum Beispiel, ohne Bewegung-Semantik std::unique_ptrfunktioniert nicht - schauen Sie std::auto_ptr, was mit der Einführung der Bewegung-Semantik veraltet und in C ++ 17 entfernt wurde. Das Verschieben einer Ressource unterscheidet sich erheblich vom Kopieren. Es ermöglicht die Übertragung des Eigentums an einem einzigartigen Gegenstand.

Schauen wir uns das zum Beispiel nicht an std::unique_ptr, da es ziemlich gut diskutiert wird. Sehen wir uns beispielsweise ein Vertex Buffer-Objekt in OpenGL an. Ein Vertex-Puffer repräsentiert den Speicher auf der GPU. Er muss mithilfe spezieller Funktionen zugewiesen und freigegeben werden, wobei möglicherweise die Lebensdauer stark eingeschränkt ist. Es ist auch wichtig, dass nur ein Besitzer es benutzt.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Dies könnte nun mit a geschehen, std::shared_ptraber diese Ressource darf nicht gemeinsam genutzt werden. Dies macht es verwirrend, einen gemeinsam genutzten Zeiger zu verwenden. Sie könnten verwenden std::unique_ptr, aber das erfordert immer noch eine Verschiebungssemantik.

Natürlich habe ich keinen Verschiebungskonstruktor implementiert, aber Sie haben die Idee.

Das Relevante dabei ist, dass einige Ressourcen nicht kopierbar sind . Sie können Zeiger weitergeben, anstatt sie zu verschieben. Wenn Sie jedoch unique_ptr verwenden, liegt das Problem des Eigentums vor. Es lohnt sich, so klar wie möglich zu sein, was die Absicht des Codes ist, daher ist ein Move-Konstruktor wahrscheinlich der beste Ansatz.

Max
quelle
Danke für die Antwort. Was würde passieren, wenn man hier einen gemeinsamen Zeiger verwenden würde?
Giorgio
Ich versuche selbst zu antworten: Die Verwendung eines gemeinsamen Zeigers würde es nicht ermöglichen, die Lebensdauer des Objekts zu steuern, während es eine Voraussetzung ist, dass das Objekt nur für eine bestimmte Zeitdauer leben kann.
Giorgio
3
@ Giorgio Sie könnten einen gemeinsam genutzten Zeiger verwenden, der jedoch semantisch falsch wäre. Es ist nicht möglich, einen Puffer gemeinsam zu nutzen. Dies würde im Wesentlichen dazu führen, dass Sie einen Zeiger an einen Zeiger übergeben (da vbo im Grunde ein eindeutiger Zeiger auf den GPU-Speicher ist). Jemand, der Ihren Code später ansieht, könnte sich fragen: "Warum gibt es hier einen gemeinsamen Zeiger?" Ist es eine gemeinsam genutzte Ressource? Das könnte ein Fehler sein! '. Es ist besser, so klar wie möglich darüber zu sein, was die ursprüngliche Absicht war.
Max
@Giorgio Ja, das ist auch Teil der Anforderung. Wenn der 'Renderer' in diesem Fall eine Ressource freigeben möchte (möglicherweise nicht genügend Arbeitsspeicher für neue Objekte auf der GPU), darf es kein anderes Handle für den Arbeitsspeicher geben. Die Verwendung eines shared_ptr, der außerhalb des Gültigkeitsbereichs liegt, funktioniert, wenn Sie ihn nicht an einem anderen Ort aufbewahren.
Max
@ Giorgio Siehe meine Bearbeitung für einen weiteren Versuch zur Klärung.
Max
5

Die Bewegungssemantik ist nicht unbedingt eine große Verbesserung, wenn Sie einen Wert zurückgeben - und wenn Sie einen shared_ptr(oder einen ähnlichen) verwenden, pessimieren Sie wahrscheinlich vorzeitig. In Wirklichkeit machen fast alle einigermaßen modernen Compiler das, was als Return Value Optimization (RVO) und Named Return Value Optimization (NRVO) bezeichnet wird. Dies bedeutet , dass wenn Sie einen Wert sind Rückkehr, statt Kopieren tatsächlich den Wert überhauptSie übergeben einfach einen versteckten Zeiger / Verweis darauf, wo der Wert nach der Rückgabe zugewiesen wird, und die Funktion verwendet diesen, um den Wert dort zu erstellen, wo er enden wird. Der C ++ - Standard enthält spezielle Vorkehrungen, um dies zu ermöglichen. Selbst wenn (zum Beispiel) Ihr Kopierkonstruktor sichtbare Nebenwirkungen hat, ist es nicht erforderlich, den Kopierkonstruktor zu verwenden, um den Wert zurückzugeben. Beispielsweise:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

Die Grundidee hier ist ziemlich einfach: Erstellen Sie eine Klasse mit genügend Inhalten, die Sie nach Möglichkeit lieber nicht kopieren möchten std::vector möchten wir mit 32767 zufälligen Ints füllen). Wir haben eine explizite Kopie, die uns anzeigt, wann / ob sie kopiert wird. Wir haben auch etwas mehr Code, um etwas mit den Zufallswerten im Objekt zu tun, so dass das Optimierungsprogramm (zumindest leicht) nicht alles an der Klasse eliminiert, nur weil es nichts tut.

Wir haben dann Code, um eines dieser Objekte von einer Funktion zurückzugeben, und verwenden dann die Summierung, um sicherzustellen, dass das Objekt wirklich erstellt und nicht nur vollständig ignoriert wird. Wenn wir es zumindest mit den neuesten / modernsten Compilern ausführen, stellen wir fest, dass der von uns geschriebene Kopierkonstruktor überhaupt nicht ausgeführt wird - und ja, ich bin mir ziemlich sicher, dass sogar eine schnelle Kopie mit einemshared_ptr noch langsamer ist als das Kopieren überhaupt.

Durch Umzug können Sie eine ganze Reihe von Dingen erledigen, die Sie (direkt) ohne sie nicht erledigen könnten. Betrachten Sie den Zusammenführungsteil einer externen Zusammenführungssorte - Sie haben beispielsweise 8 Dateien, die Sie zusammenführen möchten. Idealerweise möchten Sie alle 8 dieser Dateien in ein vector- einfügen, aber da vector(ab C ++ 03) Elemente ifstreamkopiert werden müssen und s nicht kopiert werden können, stecken Sie mit einigen unique_ptr/ fest shared_ptr. oder etwas in dieser Reihenfolge, um sie in einen Vektor setzen zu können. Beachten Sie, dass der Compiler das auch dann nicht weiß, wenn wir (zum Beispiel) ein reserveLeerzeichen in setzen, vectorso dass wir sicher sind, dass unser ifstreams nie wirklich kopiert wird, so dass der Code nicht kompiliert wird, obwohl wir wissen, dass der Copy-Konstruktor es niemals sein wird trotzdem benutzt.

Auch wenn es immer noch nicht kopiert werden, in C ++ 11 ein ifstream kann bewegt werden. In diesem Fall wendet sich die wahrscheinlich nicht wird immer bewegt werden, aber die Tatsache , dass sie hält notwendig sein könnte , wenn der Compiler glücklich, so dass wir unsere setzen können ifstreamObjekte in einvector direkt, ohne Smart - Pointer - Hacks.

Ein Vektor, der es tut expandiert, ist ein ziemlich gutes Beispiel für eine Zeit, in der Bewegungssemantik wirklich nützlich sein kann / ist. In diesem Fall hilft RVO / NRVO nicht, da es sich nicht um den Rückgabewert einer Funktion (oder etwas sehr Ähnliches) handelt. Wir haben einen Vektor, der einige Objekte enthält, und wir möchten diese Objekte in einen neuen, größeren Speicherbereich verschieben.

In C ++ 03 wurden dazu Kopien der Objekte im neuen Speicher erstellt und anschließend die alten Objekte im alten Speicher gelöscht. Es war jedoch Zeitverschwendung, all diese Kopien nur zum Wegwerfen der alten Kopien anzufertigen. In C ++ 11 können Sie erwarten, dass sie stattdessen verschoben werden. Auf diese Weise können wir im Wesentlichen eine flache Kopie anstelle einer (im Allgemeinen viel langsameren) tiefen Kopie erstellen. Mit anderen Worten, mit einer Zeichenfolge oder einem Vektor (für nur einige Beispiele) kopieren wir nur die Zeiger in die Objekte, anstatt Kopien aller Daten zu erstellen, auf die sich diese Zeiger beziehen.

Jerry Sarg
quelle
Danke für die sehr ausführliche Erklärung. Wenn ich das richtig verstehe, könnten alle Situationen, in denen das Bewegen ins Spiel kommt, von normalen Zeigern behandelt werden, aber es wäre unsicher (komplex und fehleranfällig), jedes Mal die gesamte Zeiger-Jonglage zu programmieren. Stattdessen befindet sich also ein unique_ptr (oder ein ähnlicher Mechanismus) unter der Haube, und die Bewegungssemantik stellt sicher, dass am Ende des Tages nur ein Teil des Zeigers kopiert wird und kein Objekt kopiert wird.
Giorgio
@ Giorgio: Ja, das stimmt so ziemlich. Die Sprache fügt keine Bewegungssemantik hinzu. Es werden rWert-Referenzen hinzugefügt. Eine rvalue-Referenz kann (offensichtlich genug) an einen rvalue gebunden sein. In diesem Fall wissen Sie, dass es sicher ist, die interne Darstellung der Daten zu "stehlen" und nur die Zeiger zu kopieren, anstatt eine tiefe Kopie zu erstellen.
Jerry Coffin
4

Erwägen:

vector<string> v;

Wenn Sie Zeichenfolgen zu v hinzufügen, wird diese nach Bedarf erweitert, und bei jeder Neuzuweisung müssen die Zeichenfolgen kopiert werden. Bei Move-Konstruktoren ist dies im Grunde kein Problem.

Natürlich können Sie auch Folgendes tun:

vector<unique_ptr<string>> v;

Das funktioniert aber nur gut, weil der std::unique_ptrmove-Konstruktor implementiert ist.

Die Verwendung std::shared_ptrist nur in (seltenen) Situationen sinnvoll, in denen Sie tatsächlich das gemeinsame Eigentum haben.

Nemanja Trifunovic
quelle
aber was ist, wenn stattdessen stringeine Instanz Foomit 30 Datenelementen vorhanden ist? Die unique_ptrVersion wäre nicht effizienter?
Vassilis
2

Rückgabewerte sind die Stellen, an denen ich am häufigsten den Wert anstelle einer Referenz übergeben möchte. In der Lage zu sein, ein Objekt schnell "auf dem Stapel" zurückzugeben, ohne einen massiven Leistungsnachteil zu erleiden, wäre schön. Auf der anderen Seite ist es nicht besonders schwierig, dies zu umgehen (gemeinsame Zeiger sind einfach zu bedienen ...), daher bin ich mir nicht sicher, ob es sich wirklich lohnt, zusätzliche Arbeit an meinen Objekten zu leisten, nur um dies tun zu können.

Michael Kohne
quelle
Normalerweise verwende ich auch intelligente Zeiger zum Umbrechen von Objekten, die von einer Funktion / Methode zurückgegeben werden.
Giorgio
1
@ Giorgio: Das ist definitiv sowohl trügerisch als auch langsam.
DeadMG
Moderne Compiler sollten eine automatische Verschiebung durchführen, wenn Sie ein einfaches On-the-Stack-Objekt zurückgeben, sodass keine gemeinsamen ptrs usw. erforderlich sind
Christian Severin