perfekt forwarding richtig verstehen



  • hallo,
    ich habe perfect forwarding noch nicht richtig verstanden:
    oder zumindest, wie man funktionen schreibt, die mit typen hantieren, die viel zu kopieren sind.

    Dazu habe ich mir ein kleines mwe überlegt, das keinen wirklichen sinn ergibt sondern nur eine wrapper funktion implementiert, mit der anschließend perfect forwarding gemacht werden kann.

    vor c++11 wurden doch die funktionen so geschrieben, dass constrefs übergeben wurden, also etwa sowas:

    #include <vector>
    #include <initializer_list>
    #include <iostream>
    
    class cls {
        public:
            cls() = delete;
            cls(int intval, std::initializer_list<int> vec) 
                : _intval(intval), _vec(vec){
                    std::cout << "called Parametrized-Construct" << std::endl;
                }
            cls(const cls &inst) 
                : _intval(inst._intval), _vec(inst._vec){
                    std::cout << "called Copy-Construct" << std::endl;
                }
            cls(cls &&inst) 
                : _intval(std::move(inst._intval)), _vec(std::move(inst._vec)){
                    std::cout << "called Move-Construct" << std::endl;
                }   
            ~cls() {std::cout << "called Destruct" << std::endl;}
            int _intval;
            std::vector<int> _vec;
    };
    
    template <typename T>
    void func_old(const T &arg) {
        std::cout << "int:" << arg._intval << ", vec: ";
        for(int i  : arg._vec) {
            std::cout << i << ", ";
        }   
        std::cout << std::endl;
    }
    
    template <typename T>
    void wrapper_old(const T &arg) {
        func_old(arg);
    }
    
    int main() {
        {
        cls a(1, {1,2,3,4,5});
        cls b(3, {4,5,3,4,5});
        wrapper_old(a);
        wrapper_old(cls(2, {2,3,4,5,6}));
        wrapper_old(cls(a));
        wrapper_old(std::move(a));
        wrapper_old(cls(std::move(b)));
        }
        return 0;
    }
    

    Mit C++11 wurde nun perfect forwarding eingeführt und es sind nun auch universal-references möglich:
    Also etwa sowas:

    template <typename T>
    void func_new(T &&arg) {
        std::cout << "int:" << arg._intval << ", vec: ";
        for(int i  : arg._vec) {
            std::cout << i << ", ";
        }
        std::cout << std::endl;
    }
    
    template <typename T>
    void wrapper_new(T &&arg) {
        func_new(std::forward<decltype(arg)>(arg));
    }
    
    int main() {
        {
        cls a(1, {1,2,3,4,5});
        cls b(3, {4,5,3,4,5});
        wrapper_new(a);
        wrapper_new(cls(2, {2,3,4,5,6}));
        wrapper_new(cls(a));
        wrapper_new(std::move(a));
        wrapper_new(cls(std::move(b)));
        }
        return 0;
    }
    

    Ist diese neue Form nun besser oder schlechter?
    Wie sollte man nun funktionen schreiben? mit der alten methode, mit der neuen oder gibt es eine noch bessere alternative?

    danke



  • warewin schrieb:

    Ist diese neue Form nun besser oder schlechter?
    Wie sollte man nun funktionen schreiben? mit der alten methode, mit der neuen oder gibt es eine noch bessere alternative?

    Wenn die Referenz des Objekts nicht irgendwohin weitergereicht ("forwarded") wird, wo du von einem "move" des Objekts (im Gegensatz zum kopieren) profitierst, macht es in meinen Augen ein "perfect forwarding" im Allgemeinen keinen Sinn. Besonders, wenn du wie in deinem Beispiel lediglich Daten ausgibst.

    std::forward macht beispielsweise dann Sinn, wenn du innerhalb von func_new den Parameter arg in einen Container einfügen würdest:

    template <typename T>
    void wrapper_new(T &&arg) {
        func_new(std::forward<T>(arg));
    }
    
    template <typename T>
    void func_new(T &&arg) {
        std::vector<T> v;
        // Muss natürlich std::forward<T>(arg) statt 
        // std::forward(arg) heissen, Dank an Arcoth
        // für den aufmerksamen Code-Review!
        v.push_back(std::forward<T>(arg));
    }
    
    ...
    
    // Alle in den 4 Funktionsaufrufen übergebenen
    // Argumente sind rvalues (temporäre, namenlose
    // Objekte oder mit std::move explizit in eine
    // rvalue-Referenz gecastet).
    wrapper_new(cls(2, {2,3,4,5,6}));
    wrapper_new(cls(a));
    wrapper_new(std::move(a));
    wrapper_new(cls(std::move(b)));
    

    Hier sorgen die std::forward dafür dass in allen 4 Fällen die "value"-Kategorie des ursprünglichen Arguments (rvalue) erhalten bleibt, und die Methode std::vector<T>::push_back(T&&) aufgerufen wird, die ihrerseits wieder dafür sorgt (wahrscheinlich ebenfalls durch ein std::forward ), dass die eingefügten Objekte mittels des Move-Konstruktors von cls erzeugt werden.

    Oder vielleicht etwas simpler:

    template <typename T>
    void func_new(T arg) {
    }
    
    template <typename T>
    void wrapper_new(T &&arg) {
        func_new(std::forward<T>(arg));
    }
    
    ...
    
    wrapper_new(a);
    wrapper_new(cls(a));
    wrapper_new(std::move(a));
    

    ... solte ausgeben:

    called Copy-Construct
    called Move-Construct
    called Move-Construct
    

    Wenn am Ende der Kette das "geforwardete" Objekt jedoch nicht "gemoved" wird, erschließt sich mir auf Anhieb kein Grund, weshalb man ein solches "Forwarding" einsetzten sollte (auch wenn es sicher gute, jedoch eher exotische Gründe geben mag).

    Um deine Frage also konkret zu beantworten: So wie die Funktionen bei dir definiert sind, sind func_old und wrapper_old völlig ausreichend und perfect forwarding bringt dir keinen Vorteil.

    Gruss,
    Finnegan

    P.S.: Habe das std::forward<decltype(arg)> in wrapper_new() durch ein std::forward<U> ersetzt. Letzteres ist auf jeden Fall nicht falsch, während ich mir bei ersterem unsicher bin, ob es äquivalent ist oder nicht eventuell zu einem std::forward<U&&> oder einem std::forward<U&> expandiert wird, was soweit ich informiert bin nicht korrekt ist.


  • Mod

    Ja, du solltest auf jeden Fall forwarden. Schon allein wegen der Möglichkeit dass die Funktion die am Ende einer forwarding-Kette steht, e.g. func_new , künftig in einer Weise modifiziert wird die forwarding benötigt/von forwarding profitiert.


  • Mod

    P.S.: Habe das std::forward<decltype(arg)> in wrapper_new() durch ein std::forward<U> ersetzt. Letzteres ist auf jeden Fall nicht falsch, während ich mir bei ersterem unsicher bin, ob es äquivalent ist oder nicht eventuell zu einem std::forward<U&&> oder einem std::forward<U&> expandiert wird, was soweit ich informiert bin nicht korrekt ist.

    Es ist äquivalent. Dabei muss vor allem reference collapsing sowohl im Rückgabetyp von forward als auch im Parameter von wrapper_new berücksichtigt werden.

    template< class T >
    T&& forward( typename std::remove_reference<T>::type& t );
    
    template< class T >
    T&& forward( typename std::remove_reference<T>::type&& t );
    
    template <typename U> 
    void wrapper_new(U &&arg)
    { 
        forward<decltype(arg)>(arg) 
    }
    

    Wenn U eine Referenz ist, ist decltype(arg) == U (aufgrund von reference collapsing).
    Wenn U keine Referenz ist, ist decltype(arg) == U&& . Allerdings gibt es dann durch T&& in forward wiederum reference collapsing, sodass der Rückgabetyp U&& wird, was er auch sein soll (da das Argument ein rvalue war und als solches weitergeleitet werden soll).

    Allerdings ist

    v.push_back(std::forward(arg));
    

    Schwachsinn und sollte nicht kompilieren.



  • Arcoth schrieb:

    Ja, du solltest auf jeden Fall forwarden. Schon allein wegen der Möglichkeit dass die Funktion die am Ende einer forwarding-Kette steht, e.g. func_new , künftig in einer Weise modifiziert wird die forwarding benötigt/von forwarding profitiert.

    Ich glaube es schadet der Lesbar- und Wartbarkeit des Codes ganz ordentlich, wenn man beginnen würde, aus jeder Funktion bei der die Argumente irgendwann mal irgendwohin gemoved werden können eine Template-Funktion zu machen, bei der alle betreffenden Parameter-Typen deduzierbar sind.

    Bei Funktionen wo es deutlich absehbar ist, dass man profitiert, gebe ich dir recht, bei allen anderen möchte ich jedoch nicht erst das "root of all evil" bemühen müssen 😃

    Was natürlich stimmt ist, dass wenn ich schon ein

    template <typename T>
    void wrapper_new(T &&arg)
    

    habe, ein std::forward so ziemlich die einzig sinnvolle Wahl ist: Ein std::move kann ich hier nicht machen, da die Funktion auch lvalues "frisst", und wenn ich nicht ausnutze, dass arg auch eine rvalue-Referenz sein kann, kann ich mir das && -Geraffel auch gleich sparen 😉

    Finnegan


  • Mod

    Ich glaube es schadet der Lesbar- und Wartbarkeit des Codes ganz ordentlich, wenn man beginnen würde, aus jeder Funktion bei der die Argumente irgendwann mal irgendwohin gemoved werden können eine Template-Funktion zu machen, bei der alle betreffenden Parameter-Typen deduzierbar sind.

    Wenn ich Argumente an etwas forwarde was diese momentan kopiert (nicht lediglich auf die Daten zugreift o.ä.), sollte man schon forwarden. Den Kopien sollten an allen Ecken vermieden werden, darum geht es bei perfect forwarding. (Und um overload resolution.)



  • Arcoth schrieb:

    Wenn ich Argumente an etwas forwarde was diese momentan kopiert (nicht lediglich auf die Daten zugreift o.ä.), sollte man schon forwarden. Den Kopien sollten an allen Ecken vermieden werden, darum geht es bei perfect forwarding. (Und um overload resolution.)

    Nichts anderes habe ich behauptet, allerdings war die Frage ob die wrapper/func _new oder _old -Funktionen besser/schlechter sind, und für den gegebenen Code sind die _new -Varianten schlechter weil sie dasselbe Resultat mit umständlicherem Code erzielen.

    Arcoth schrieb:

    Allerdings ist

    v.push_back(std::forward(arg));
    

    Schwachsinn und sollte nicht kompilieren.

    Danke für den diskteten Hinweis auf den Tippfehler, ich wusste gar nicht, dass du auf die Diplomatenschule gegangen bist 😃

    Finngean


Log in to reply