Member im Konstruktor per Move oder Copy initialisieren?



  • @KK27 sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    Vermeidest du persönlich const Referenzen als Parameter in Konstruktoren(vom Copy Konstruktor abgesehen)?

    Wenn das Objekt eine eigene Kopie von dem Parameter z.B. in einer Member-Variablen bekommen soll, mache ich es wie deine zweite Variante. By Value und dann move. Ansonsten ist bei mir auch erstmal const& Standard.

    By Value + Move ist erstens ein sehr simples Pattern und hat zweitens den Vorteil, dass der Anwender entscheiden kann, ob er moven will oder nicht. Wenn man als const& entgegennimmt, wird dem Anwender nämlich die Kopie aufgezwungen. Alternativ kann man auch mit std::string&&-Parametern rumhantieren, das macht es aber nur unnötig kompliziert und damit lässt sich bestenfalls ein ohnehin recht billiges Move einsparen.

    Und natürlich geht das auch analog zu std::string mit einem std::vector und allen anderen Typen, die sich sinnvoll "moven" lassen.



  • @KK27
    Ich bin bei grossen, komplexen Objekten dazu übergegangen eher auf move zu verzichten -- ausser die Parameter sind wirklich teuer zu kopieren. Beispiel:

    Foo::Foo(std::shared_ptr<Logger> logger, int a, int b, int c, int d) :
        m_logger(std::move(logger)),
        m_component1(a),
        m_component2(m_logger, b),
        m_component3(c, d),
        ...
        ... {
    ...
    

    Kann man so machen. Bringt aber nicht viel, weil die Initialisierung hier vermutlich schon so teuer ist, dass die 2 atomic Instructions die man sich bei der std::shared_ptr Kopie spart relativ gesehen nicht viel ausmachen. Umgekehrt kann man schnell den Fehler machen m_component2(logger, b) statt m_component2(m_logger, b) zu schreiben. Bzw. wenn die Reihenfolge der Membervariablen nicht passt gibt's auch ein Problem. Daher verwende ich in solchen Fällen eher const& - weil ich da Lesbarkeit und Wartbarkeit für wichtiger halte als 20 oder 30 eingesparte CPU Zyklen.

    In deinem Beispiel wo nur ein std::string und ein int übergeben werden finde ich "by value + move" aber eine gute Lösung. Aus den von @Finnegan beschriebenen Gründen.

    Wobei es immer darauf ankommt wie performance-kritisch etwas ist. Wenn das die zentrale Klasse in deiner Anwendung ist was Performance angeht, dann könnte es Sinn machen einen Konstruktor mit std::string&& und einen mit std::string const& zu machen. Wobei der std::string&& Konstruktor dann vermutlich auch inline sein sollte. In dem Fall wäre es aber auch wichtig einen Performance-Vergleich zu machen um zu sehen was wirklich schneller ist. Denn da kann man sich auch schnell mal verschätzen 🙂



  • @hustbaer sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    [...] dann könnte es Sinn machen einen Konstruktor mit std::string&& und einen mit std::string const& zu machen. [...]

    Mit dem "und einen" lugt da breits ein kombinatorischer Alptraum über den Horizont, wenn man es mal mit mehr als einem Parameter zu tun hat, den man gerne moven möchte ... auch ein Argument für By-Value+Move 😁

    Selbst wenn man nicht auf bereits existierende Move-Konstruktoren wie bei std::string zurückgreifen kann und die selbst implementieren muss, ist das am Ende immer noch nur eine Funktion pro Klasse. Mit den Rvalue-Referenz-Parametern spart man hingegen nur einen Move im Vergleich zu By-Value+Move (den, mit dem der Parameter initialisiert wird oder der Move in die Member-Variable, wenn kopiert werden soll).

    Für wirklich "heisse" Funktionen kann man das sicher rechtfertigen, für alle anderen ist By-Value+Move allerdings meiner Meinung nach so simpel, dass es man schon fast ein gutes Argument braucht es nicht zu verwenden. Aber eben nur, wenn man auch eine eigene Kopie des Parameters haben will, sonst const&.



  • @Finnegan sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    Mit dem "und einen" lugt da breits ein kombinatorischer Alptraum über den Horizont, wenn man es mal mit mehr als einem Parameter zu tun hat, den man gerne moven möchte ... auch ein Argument für By-Value+Move

    Ich teile deine Meinung. Das Thema wäre für mich dann abgeschlossen. Vielen Dank für eure beiden Antworten und Meinungen.



  • @Finnegan sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    Für wirklich "heisse" Funktionen kann man das sicher rechtfertigen, für alle anderen ist By-Value+Move allerdings meiner Meinung nach so simpel, dass es man schon fast ein gutes Argument braucht es nicht zu verwenden.

    Sehe ich ähnlich.

    Wobei mir gerade ein Detail einfällt - etwas was viele vermutlich gar nicht wissen: "by value" (mit oder ohne move) erfordert u.U. (je nach Calling Convention) die Generierung eines dtor Aufrufs pro Funktionsaufruf. Was auch wieder etwas Overhead und vor allem Code-Bloat erzeugt. Das ist in den meisten Fällen vermutlich auch egal - aber es gibt Spezialfälle wo es hilfreich sein kann wenn man das weiss. (Wir haben z.B. an einigen Stellen in unseren Programmen Funktionen die hundert- bis tausendfach kleine Hilfsfunktionen aufrufen um eine Datenstruktur aufzubauen. Da kann sich das dann schnell negativ auf die Code-Grösse auswirken.)



  • @hustbaer sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    @Finnegan sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    Für wirklich "heisse" Funktionen kann man das sicher rechtfertigen, für alle anderen ist By-Value+Move allerdings meiner Meinung nach so simpel, dass es man schon fast ein gutes Argument braucht es nicht zu verwenden.

    Sehe ich ähnlich.

    Wobei mir gerade ein Detail einfällt - etwas was viele vermutlich gar nicht wissen: "by value" (mit oder ohne move) erfordert u.U. (je nach Calling Convention) die Generierung eines dtor Aufrufs pro Funktionsaufruf. Was auch wieder etwas Overhead und vor allem Code-Bloat erzeugt. Das ist in den meisten Fällen vermutlich auch egal - aber es gibt Spezialfälle wo es hilfreich sein kann wenn man das weiss. (Wir haben z.B. an einigen Stellen in unseren Programmen Funktionen die hundert- bis tausendfach kleine Hilfsfunktionen aufrufen um eine Datenstruktur aufzubauen. Da kann sich das dann schnell negativ auf die Code-Grösse auswirken.)

    Ja, da hast du recht. Real-existierende Software kann echt ne Bitch sein - warum wundert es mich nicht, dass es auch hier wieder einige Sonderfälle geben kann, die einem eventuell einen Strich durch die Rechnung machen? 😁

    Ich habe ja eigentlich die Hoffnung, dass der Destruktor eines Objekts, von dem "gemoved" wurde in den meisten Fällen einen leeren Branch nimmt, der Optimizer das auch erkennen kann und letztendlich diesen leeren Branch in reines Wohlgefallen "inlined". Aber man weiss nie, bevor man sich nicht den generierten Code angesehen hat. Danke für den Hinweis. Es ist völlig klar, dass der Destruktor aufgerufen werden muss (zumindest "as-if"), aber ich hatte das hierbei tatsächlich nicht auf dem Schirm.



  • @Finnegan sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    Es ist völlig klar, dass der Destruktor aufgerufen werden muss (zumindest "as-if"), aber ich hatte das hierbei tatsächlich nicht auf dem Schirm.

    Naja, es ist abhängig von der Calling-Convention wer den Dtor aufruft. Bei MSVC macht es die aufgerufene Funktion, bei GCC macht es der Aufrufer. (Was GCC übrigens auch dazu zwingt das Objekt im Speicher zu übergeben - auch wenn es klein genug dafür wäre in Registern übergeben zu werden.)

    C++

    struct Foo {
        Foo(int f) : f{f} {}
        ~Foo();
        int f;
    };
    
    void fun(Foo f);
    
    void test() {
        fun(42);
    }
    

    MSVC

    void test(void) PROC ; test, COMDAT
      mov ecx, 42 ; 0000002aH
      jmp void fun(Foo) ; fun
    void test(void) ENDP ; test
    

    GCC

    test():
      push rbx
      sub rsp, 16
      lea rdi, [rsp+12]
      mov DWORD PTR [rsp+12], 42
      call fun(Foo)
      lea rdi, [rsp+12]
      call Foo::~Foo() [complete object destructor]
      add rsp, 16
      pop rbx
      ret
      mov rbx, rax
      jmp .L2
    test() [clone .cold]:
    .L2:
      lea rdi, [rsp+12]
      call Foo::~Foo() [complete object destructor]
      mov rdi, rbx
      call _Unwind_Resume
    


  • @hustbaer sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    @Finnegan sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    Es ist völlig klar, dass der Destruktor aufgerufen werden muss (zumindest "as-if"), aber ich hatte das hierbei tatsächlich nicht auf dem Schirm.

    Naja, es ist abhängig von der Calling-Convention wer den Dtor aufruft. Bei MSVC macht es die aufgerufene Funktion, bei GCC macht es der Aufrufer. (Was GCC übrigens auch dazu zwingt das Objekt im Speicher zu übergeben - auch wenn es klein genug dafür wäre in Registern übergeben zu werden.)

    DAS war mir tatsächlich völlig unbekannt. Meine Kenntnis von Calling Conventions beschränkt sich nur darauf wie Argumente übergeben werden und wer den Stack aufräumt. Destruktoren oder gar Dinge wie Exception Handling gehören nicht dazu. Danke für den Hinweis 🙂



  • @Finnegan sagte in Member im Konstruktor per Move oder Copy initialisieren?:

    Meine Kenntnis von Calling Conventions beschränkt sich nur darauf wie Argumente übergeben werden

    Was ja auch davon beeinflusst wird. Bei GCC, wenn so ein kleines Objekt keinen Dtor hat, dann wird es in Registern übergeben. Wenn es allerdings einen Dtor hat, dann muss es im Speicher übergeben werden. Weil der Aufrufer ja die Änderungen im Objekt sehen muss, damit er es korrekt zerstören kann.

    Und was Optimierungen angeht: MSVC kann dadurch auch oft besser optimieren. Wenn ein Objekt immer "konsumiert" wird und der Dtor daher nichts tun würde, dann kann MSVC den manchmal wirklich wegoptimieren. z.B.:

    #include <string>
    
    struct Foo {
        Foo(std::string s);
        std::string s;
    };
    
    Foo::Foo(std::string s) : s(std::move(s)) {}
    

    =>

    s$GSCopy$ = 0
    this$GSCopy$ = 0
    this$ = 32
    s$ = 40
    Foo::Foo(std::basic_string<char,std::char_traits<char>,std::allocator<char> >) PROC ; Foo::Foo, COMDAT
    $LN60:
      sub rsp, 24
      xor eax, eax
      mov QWORD PTR this$GSCopy$[rsp], rcx
      xorps xmm0, xmm0
      mov QWORD PTR s$GSCopy$[rsp], rdx
      movups XMMWORD PTR [rcx], xmm0
      mov QWORD PTR [rcx+16], rax
      mov QWORD PTR [rcx+24], rax
      movups xmm0, XMMWORD PTR [rdx]
      movups XMMWORD PTR [rcx], xmm0
      movups xmm1, XMMWORD PTR [rdx+16]
      movups XMMWORD PTR [rcx+16], xmm1
      mov QWORD PTR [rdx+24], 15
      mov QWORD PTR [rdx+16], rax
      mov BYTE PTR [rdx], al
      mov rax, rcx
      mov QWORD PTR [rdx+24], 15
      add rsp, 24
      ret 0
    Foo::Foo(std::basic_string<char,std::char_traits<char>,std::allocator<char> >) ENDP ; Foo::Foo
    

    Ist jetzt nicht super übersichtlich, aber man kann denke ich gut sehen dass hier keine Operation zum Freigeben von Speicher vorhanden ist - und auch kein "non-inline" Funktionsaufruf der das indirekt machen könnte. D.h. der std::string Dtor wurde erfolgreich wegoptimiert. Das coole dabei ist, dass dazu kein Inlining von Foo::Foo selbst erforderlich ist - es muss nur der Code der von Foo::Foo aufgerufen wird inlined werden.

    Bei GCC und Clang wäre dagegen erforderlich dass Foo::Foo selbst inline in den Aufrufer erweitert wird. Allerdings habe ich mit GCC/Clang noch kein nicht-triviales Beispiel zusammengebracht wo sie da auch wirklich den Dtor wegoptimieren würden. Selbst bei dem einfachen Beispiel mit einem einzigen std::string generieren GCC und Clang Code für den Aufruf des Dtor.



  • @hustbaer Puh, das ist ja nicht so toll in diesen Fällen. Gerade wenn es bei kleinen Objekten relavant ist, die irgendwelche Ressourcen wie Speicher halten (sowas wie Länge+Pointer). Gerade bei denen sagte mir meine Intuition bisher, dass die sich besonders effizient moven lassen (was für die Move-Operation selbst ja auch stimmt). Ein struct { int data [100]; } wird das Destruktor-Problem nicht haben, da es ohnehin im Speicher übergeben werden muss. Dafür ist bei dem ein Move aber auch herzlich sinnlos (und de facto immer eine Kopie).

    Ich frage mich ob diese Konvention von GCC an anderer Stelle irgendwelche Vorteile hat oder ob die einfach nur historisch gewachsen ist und man jetzt mit der Philosophie des über lange Zeit stabilen ABI festgenagelt ist. Mir persönlich wäre das zumindest nicht wichtig - ich gehe ohnehin immer davon aus, dass ich gerade für C++ auch alle Bibliotheken mit dem selben Compiler und den selben Flags kompilieren muss. Meiner Erfahrung nach ändert sich da gerade mit MSVC öfter mal was. Wenn ich Kompatibiliät will, baue ich ein C-Interface dazu (z.B. "Hourglass-Style": C++ Lib <-> C API <-> C++ Header). Aber ich ich entwickle auch meist an in sich abgeschlossenen Projekten außerhalb der Linux-Paketmanager, wo alle Libs mit dabei sind.

    Danke nochmal, wieder was gelernt 🙂


Anmelden zum Antworten