Referenzen



  • Hallo Leute,

    in Zusammenhang mit Streams, Operatoren aber auch normalen Memberfunktion sind mir des öfteren Referenzen als Rückgabewert für Funktionen über den Weg gelaufen:

    ostream& class::write(ostream& os) const
    {
       os.write((char*)&data1, sizeof(data1));
       os.write((char*)&data2, sizeof(data2));
       return os;
    }
    

    Ich weis, dass diese gerne für Iteratoren & Operatoren eingesetzt werden:

    
    class
    {
       private:
          data usw.
       public:
         ...
         Konstruktor
         ...
         class& operator+= (const class& obj)
         {
           data +=obj.data;
           return *this;
         }
    

    und der Funktionsaufruf einer Funktion mit einer Referenz als Rückgabewert ein Objekt darstellt und auch wie ein
    Objekt behandelt werden kann:

    int main()
    {
       double x1 = 1.1, x2 = x1+0.5;
       refMin(x1, x2) = 10.1 
       cout <<x1 <<endl; // x1 =10.1 da x1 das Minimum ist
       cout <<x2 <<endl; // x2=1.6
    
    }
    
    double& refMin(double& a, double& b)
    {
       return a <=b ? a:b;
    }
    

    Jedoch ist mir immer noch nicht klar, wozu das ganze sinnvoll ist. Wird bei der Rückgabe als Referenz wie auch bei der Parameterübergabe als Referenz/Parameterübergabe mittels Zeiger der Kopieraufwand eingespart, was die Laufzeit verbessert? Ab welchen Datengrößen empfiehlt sich eigentlich die Parameterübergabe/(Rückgabe) als Referenz? Reden wir da von Objekten oder reichen gewöhnliche Datentypen wie strings, integer oder char aus, bei denen das Laufzeitverhalten positiv beeinflusst werden kann? Vielen Dank!



  • @C-Sepp sagte in Referenzen:

    Wird bei der Rückgabe als Referenz wie auch bei der Parameterübergabe als Referenz/Parameterübergabe mittels Zeiger der Kopieraufwand eingespart, was die Laufzeit verbessert?

    Ja.

    @C-Sepp sagte in Referenzen:

    Ab welchen Datengrößen empfiehlt sich eigentlich die Parameterübergabe/(Rückgabe) als Referenz?

    Eine Referenz / Pointer hat auf deinem System wohl eine Größe von 8 Bytes, ungefähr so groß, wie die meisten Builtin-Typen (int, float, etc.). Bei Builtin-Typen macht es wohl keinen Unterschied, sie per Kopie oder Referenz zurückzugeben. Ein char ist standardgemäß ein Byte groß, wenn du dieses als Referenz zurückgibst, hast du wieder nichts gewonnen, hier also besser eine Kopie zurück liefern. Es sei denn du willst sie natürlich von außen hin verändern, dann macht eine Referenz wohl Sinn.

    Objekte (std::string, std::vector<>, etc.) solltest du jedenfalls immer per Referenz (oder auch const reference) zurückgeben, insofern du keine Kopie benötigst. Gibt auch Objekte, die nicht kopierbar sind, sie also zwangsweise per Referenz zurückgeben musst, wenn du sie als Rückgabewert einer Funktion benutzen willst.

    Seit den neusten C++ Standards werden Objekte, die du per Kopie zurückgibst auch gerne gemoved, wenn ein Move-Konstruktor definiert ist, d.h. eine Kopie wird dir dadurch erspart.



  • @C-Sepp sagte in Referenzen:

    Jedoch ist mir immer noch nicht klar, wozu das ganze sinnvoll ist.
    ...
    Ab welchen Datengrößen empfiehlt sich eigentlich die Parameterübergabe/(Rückgabe) als Referenz?

    Es geht dabei meist nicht darum irgendwas zu optimieren. Z.B. wird üblicherweise bei Stream-Insertion Operatoren eine Referenz auf den Stream zurückgegeben:

    ostream& operator <<(ostream& os, Foo foo) {
        ...
        return os;
    }
    

    Das wird gemacht damit man mehrere Aufrufe von u.U. verschiedenen Stream-Insertion Operatoren aneinanderhängen kann, ala:

    std::cout << "Mein Foo: " << foo << std::endl;
    

    Eine Kopie zurückzugeben wäre hier weder sinnvoll noch ist es überhaupt möglich. Streams sind i.A. nicht kopierbar. Und wären sie es, dann würde es kaum Sinn machen dass die nachfolgenden << Aufrufe in der Kopie landen - man will ja alles in den einen Stream ausgeben. Mal ganz vom "slicing" Problem abgesehen.


    Ab welchen Datengrößen empfiehlt sich eigentlich die Parameterübergabe/(Rückgabe) als Referenz?

    Das kann man nicht allgemein beantworten. Erstmal kommt es darauf an ob neben dem Kopieren der Bytes noch weitere Dinge nötig sind. Ein std::string z.B. braucht ab einer bestimmten Länge eine dynamische Speicheranforderung. Nehmen wir an die Grenze wäre 30 Byte. Die 30 Byte zu kopieren geht noch relativ schnell. Die dynamische Speicheranforderung (+das Freigeben später) werden da deutlich länger dauern. Daher macht es Sinn Strings als Referenz zu übergeben. Oder wo sinnvoll & möglich auf std::string_view auszuweichen.

    Aber selbst wenn wir davon ausgehen dass nur triviale Datentypen (char, int, Zeiger, ...) kopiert werden müssen kann man es nicht so einfach sagen.

    Es kommt z.B. auch auf dein Programm an. Vorausgesetzt das ganze Läuft auf aktuellen CPUs, dann kann es in bestimmten Fällen gut sein dass es besser (schneller) ist selbst Objekte mit ein paar hundert Byte noch "by value" zu übergeben. Wobei das eher bei der Parameterübergabe relevant ist, bei der Rückgabe sind Nachteile bei "return by reference" vermutlich eher selten. Grund ist dass der Compiler dann besser optimieren kann. Er weiss dann dass die aufgerufene Funktion das Objekt nicht ändern kann.

    Also z.B.

    class Foo {
    public:
        explicit Foo(int val) : m_value(val) {}
        int getValue() const { return m_value; }
        void setValue(int newval) { m_value = newval; }
    private:
        int m_value;
    };
    
    // Irgendwo anders implementiert, und "whole program optimization" is nicht aktiv.
    void takeFooByValue(Foo foo);
    void takeFooByRef(Foo const& foo);
    
    int testByValue() {
        Foo f{42};
        takeFooByValue(f);
        return f.getValue();
    }
    
    int testByRef() {
        Foo f{43};
        takeFooByRef(f);
        return f.getValue();
    }
    

    In testByValue kann hier

    • Das Objekt direkt in einem Register übergeben werden
    • Das return f.getValue(); in ein return 42; verwandelt werden

    In testByRef dagegen muss

    • Das Objekt in den Speicher geschrieben werden
    • Der Wert nach dem Aufruf von takeFooByRef wieder aus dem Speicher geladen werden, denn takeFooByRef könnte den Wert ja ändern.
      (Falls du denkst dass das const in der Signatur von takeFooByRef das verhindern/verbieten würde: weder noch. Es ist in C++ unter bestimmten - hier zutreffenden - Bedingungen völlig legal const wegzucasten um ein Objekt zu modifizieren.)

    Speicherzugriffe sind langsamer als die Verwendung von Registern, d.h. hier wird 2x Strafe bezahlt für das "pass by reference".

    Und um das ganze noch komplizierter zu machen: sämtliche potentiellen Nachteile von "pass by reference" werden meist komplett ausgelöscht wenn Funktionen inlined werden.

    Ich würde als Faustregel empfehlen: alles was

    • maximal die Grösse von zwei Zeigern hat und
    • nur aus trivialen Datentypen besteht
    • die auch trivial kopiert werden

    sollte man eher "by value" übergeben.

    Wobei das Limit 2 * Zeigergrösse eher konservativ ist. In vielen Fällen wir man auch bei grösseren Objekten noch einen Vorteil durch "pass by value" sehen.

    Umgekehrt ist es schwierig. Also ich würde auf keinen Fall sagen dass man immer alles was grösser als 2 Zeiger ist "by reference" übergeben sollte.



  • @Luks-Nuke sagte in Referenzen:

    Objekte (std::string, std::vector<>, etc.) solltest du jedenfalls immer per Referenz (oder auch const reference) zurückgeben, insofern du keine Kopie benötigst.

    Komische Definition von "Objekt". In C++ ist selbst ein char ein Objekt. Ein pair<int, int> auch. Oder ne string_view.

    Seit den neusten C++ Standards werden Objekte, die du per Kopie zurückgibst auch gerne gemoved, wenn ein Move-Konstruktor definiert ist, d.h. eine Kopie wird dir dadurch erspart.

    In den meisten Fällen greift eigentlich die (N)RVO. In neueren Standards ist die vorgeschrieben, aber erlaubt ist die IIRC schon in C++98. Und dabei wird nichts gemoved. Gemoved wird bloss wenn (N)RVO nicht möglich ist.



  • @hustbaer sagte in Referenzen:

    Komische Definition von "Objekt". In C++ ist selbst ein char ein Objekt. Ein pair<int, int> auch. Oder ne string_view.

    Ich war mir unsicher ob man Builtin-Typen auch als Objekt bezeichnen darf. Dürfte wohl klar sein was gemeint ist.



  • @Luks-Nuke Nein, ist nicht klar. Ist std::string_view für dich ein Objekt? Wenn ja, dann: bitte, bitte, bitte nicht per Referenz übergeben.



  • @hustbaer ich unterscheide zwischen Builtins und Objekten. Wenn ein Klassenobjekt kann natürlich auch so groß sein wie ein Pointer, beispielsweise wenn sie nur einen Pointer als Member hält. Generell sind sie aber mehr als das. Gibt natürlich Ausnahmen, auch Iteratoren. Habe ich wohl zu sehr verallgemeinert.


  • Mod

    @Luks-Nuke sagte in Referenzen:

    @hustbaer ich unterscheide zwischen Builtins und Objekten.

    Solltest du nicht so machen, sonst verstehen dich andere Leute falsch. Hier wäre besser von primitiven ("fundamental") und zusammengesetzten ("compound") Datentypen zu reden. So trifft man dann auch Arrays, die ja auch zusammengesetzt sind aus vielen Teilen, obwohl nirgends eine Klasse vorkommt.

    Als Objekttypen versteht man in C++ alles, was keine Referenz oder Funktion ist.



  • Ok.



  • @Luks-Nuke
    Wenn du "class oder struct" meinst, dann wäre die passende Formulierung die man häufiger liest "object of class type". Das schliesst "struct" mit ein, "class" und "struct" sind ja quasi austauschbar (es ist z.B. eine forward-declaration mit "class" zu machen und die Definition dann mit "struct" - oder umgekehrt).

    @SeppJ
    Laut C++ Standard ist alles was kein "fundamental type" ist, ein "compound type". Und "fundamental type" sind nur:

    • Arithmetische Typen
    • void
    • nullptr_t

    D.h. Zeiger, Referenzen und Enums (inklusive std::byte) sind alles "compound types".

    Das ist ne schön einfache und klare Definition. Aber IMO auch eine die bei vielen für Überraschung sorgen wird.



  • @Luks-Nuke sagte in Referenzen:

    @hustbaer ich unterscheide zwischen Builtins und Objekten.

    Wie klassifizierst du dann Zeiger, Referenzen, Arrays und Enums? Und macht es bei den ersten drei für dich einen Unterschied ob der "underlying type" auch ein "builtin" ist oder z.B. ne struct?



  • @hustbaer sagte in Referenzen:

    Wie klassifizierst du dann Zeiger, Referenzen, Arrays und Enums?

    Ich nehme das nicht so streng. Ich nenne sie bei ihrem Namen.



  • Du verstehst nicht was ich mein.
    Ich meine: wenn du wo "builtin" geschrieben hast, sind dann Zeiger, Referenzen, Arrays und Enums mit gemeint?



  • @hustbaer sagte in Referenzen:

    Ich meine: wenn du wo "builtin" geschrieben hast, sind dann Zeiger, Referenzen, Arrays und Enums mit gemeint?

    Wie gesagt ich habe das nie so genau genommen. Wenn ich von Builtins rede meine ich eigentlich die blau eingefärbten Datentypen.



  • @Luks-Nuke sagte in Referenzen:

    Wenn ich von Builtins rede meine ich eigentlich die blau eingefärbten Datentypen.

    Die was? Meine Musiklehrerin hat auch immer in Noten irgendwelche Farben gesehen - das habe ich auch nie verstanden. Ich verbinde höchtens Emotionen mit Datentypen. Meistens Hass, wenn sich der Typ nicht so verhält, wie ich mir das wünsche 😉



  • Dass es sich um fundamentale Typen handelt wurde ja hier mehr ausführlich gemacht. Dass man diese auch als ein Objekt bezeichnen kann war mir tatsächlich noch nicht bewusst.


  • Mod

    @hustbaer sagte in Referenzen:

    @SeppJ
    Laut C++ Standard ist alles was kein "fundamental type" ist, ein "compound type". Und "fundamental type" sind nur:

    • Arithmetische Typen
    • void
    • nullptr_t

    D.h. Zeiger, Referenzen und Enums (inklusive std::byte) sind alles "compound types".

    Ist das nicht auch das, was Luks-Nuke meint? Tja, das kommt eben von ungwöhnlicher Wortwahl.

    Aber ohne weiter Haare zu spalten, geht die Klassifizierung der Entitäten im Standard sowieso an der Referenzeffizienzfrage vorbei. Allerhöchstens noch insofern, als dass es im Standard Klassifikationen bezüglich der Kopierbarkeit gibt, aber Kopierbarkeit an sich sagt ja nichts über Effizienz aus, und der Standard interessiert sich nicht für Effizienz. Von daher ist die ganze Diskussion über Wortwahl sowieso müßig. Die wahre Antwort liegt irgendwo bei a) es muss kopierbar sein, vorzugsweise trivial (hier kann man noch den Standard konsultieren, was das bedeutet); b) die Menge der zu kopierenden Daten sollte N Pointergrößen nicht überschreiten, wobei N irgendeine Zahl im Bereich 1-20 ist und unvorhersehbar von der Anzahl und Art der Zugriffe abhängt; und c) wenn das alles eine einzige Compiliereinheit ist, dann optimiert dir der Compiler das höchtswahrscheinlich sowieso, egal was man schreibt.

    Aber das wurde alles schon gesagt, die Diskussion über die exakte Klassifikation von Sprachelementen, die danach aufgekommen ist, bringt die Antwort nicht weiter, da die Einordnungen nur als ganz grobe Richtschnur mit der Effizienzantwort zu tun haben.



  • @SeppJ sagte in Referenzen:

    … b) die Menge der zu kopierenden Daten sollte N Pointergrößen nicht überschreiten, wobei N irgendeine Zahl im Bereich 1-20 ist und unvorhersehbar von der Anzahl und Art der Zugriffe abhängt;

    Die Datenmenge sollte kleiner als eine Cache Line sein, denn eine Cache Line wird immer gelesen. Bei aktuellen Intel CPUs ist eine Cache Line 64 Bytes groß.


  • Mod

    @john-0 sagte in Referenzen:

    @SeppJ sagte in Referenzen:

    … b) die Menge der zu kopierenden Daten sollte N Pointergrößen nicht überschreiten, wobei N irgendeine Zahl im Bereich 1-20 ist und unvorhersehbar von der Anzahl und Art der Zugriffe abhängt;

    Die Datenmenge sollte kleiner als eine Cache Line sein, denn eine Cache Line wird immer gelesen. Bei aktuellen Intel CPUs ist eine Cache Line 64 Bytes groß.

    Und wieso ist das nicht das Argument dafür, dass es mindestens 64 Bytes sein müssen, bevor man überhaupt über Referenzen nachdenkt?



  • @SeppJ sagte in Referenzen:

    Und wieso ist das nicht das Argument dafür, dass es mindestens 64 Bytes sein müssen, bevor man überhaupt über Referenzen nachdenkt?

    Weil das Aligment und der Kopieraufwand da noch eine Rolle spielt. Wenn man einen integralen Datentyp nutzt, dann liegt ein Wert immer innerhalb einer Cache Line. Bei zwei oder mehr Werten ist das schon nicht mehr garantiert. Je näher man an die 64 Byte Grenze kommt, desto wahrscheinlicher werden Cache Misses für mehr als einen Wert. Das kann man nur umgehen, in dem man die Datenstruktur explizit so alloziert, dass sie innerhalb einer Cache Line liegt. Dafür wurde ja in C++17 aligned_alloc eingeführt.

    Der Kopieraufwand spielt sicherlich auch eine Rolle, aber nur dann wenn man nicht auf Cache Misses warten muss. Der L1 Cache ist noch sehr schnell, aber selbst der L2 Cache ist schon so langsam, dass das Kopieren nicht mehr ins Gewicht fallen sollte.

    Wenn man stattdessen Zeiger verwendet, ist der Zeiger schnell kopiert. Nur muss man sich immer vor Augen halten, dass die eigentliche Datenstruktur noch geladen werden muss, und die liegt wahrscheinlich in einer anderen Cache Line.


Anmelden zum Antworten