Featured image of post Copy Elision in C++: Effiziente Objekterstellung ohne unnötige Kopien

Copy Elision in C++: Effiziente Objekterstellung ohne unnötige Kopien

Lerne, wie Copy Elision in C++ funktioniert und warum sie seit C++17 verpflichtend ist. Entdecke die Unterschiede zwischen RVO und NRVO, typische Fehlerquellen und die Auswirkungen auf Embedded-Systeme. Mit praxisnahen Code-Beispielen und Optimierungstipps.

Copy Elision ist eine der leistungsfähigsten Optimierungen in modernen C++-Compilern. Sie ermöglicht das Vermeiden unnötiger Kopien und Moves, indem Objekte direkt an ihrem endgültigen Speicherort erzeugt werden. Dies kann sowohl die Laufzeit verbessern als auch den Speicherverbrauch minimieren. In diesem Beitrag untersuchen wir, wie Copy Elision funktioniert, welche Arten es gibt und wie sie gezielt gefördert werden kann.

Grundlagen der Copy Elision

Copy Elision bezeichnet eine Compiler-Optimierung, die das explizite Kopieren oder Verschieben eines Objekts verhindert, indem es direkt am Zielort konstruiert wird. Seit C++17 ist Copy Elision in bestimmten Fällen verpflichtend, wodurch sich die Effizienz vieler Programme verbessert hat.

Beispiel ohne Copy Elision:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Example {
public:
    Example() { std::cout << "Konstruktor aufgerufen" << std::endl; }
    Example(const Example&) { std::cout << "Kopierkonstruktor aufgerufen" << std::endl; }
};

Example createExample() {
    Example e;
    return e;
}

int main() {
    Example ex = createExample(); // Kopierkonstruktor wird aufgerufen
}

Ohne Copy Elision wird hier der Kopierkonstruktor ausgeführt, was unnötige Performance-Kosten verursacht. Besonders auf Embedded-Systemen kann dies gravierende Auswirkungen haben: Zusätzliche Kopien erhöhen nicht nur die CPU-Last, sondern verbrauchen auch wertvolle Speicherressourcen, die oft stark begrenzt sind. In extremen Fällen kann dies zu unerwarteten Speicherengpässen oder erhöhtem Energieverbrauch führen, was die Laufzeit eines batteriebetriebenen Geräts negativ beeinflusst.

Arten von Copy Elision

Copy Elision kann auf verschiedene Arten auftreten, je nachdem, wie ein Objekt zurückgegeben oder innerhalb einer Funktion verwaltet wird. Die beiden bekanntesten Mechanismen sind Return Value Optimization (RVO) und Named Return Value Optimization (NRVO). Diese Optimierungen reduzieren unnötige Kopien und steigern die Effizienz von C++-Programmen erheblich. Nachfolgend werden die wichtigsten Arten von Copy Elision im Detail erläutert.

Return Value Optimization (RVO)

RVO tritt auf, wenn eine Funktion ein lokales Objekt zurückgibt und dieses direkt an der Zieladresse im aufrufenden Code erzeugt wird.

1
2
3
Example createExample() {
    return Example(); // Kein Kopierkonstruktor durch RVO
}

Hier erzeugt der Compiler das zurückgegebene Objekt direkt in ex im main()-Scope, wodurch der Kopierkonstruktor vermieden wird.

Named Return Value Optimization (NRVO)

NRVO funktioniert ähnlich wie RVO, jedoch mit benannten Variablen:

1
2
3
4
Example createExample() {
    Example e;
    return e; // NRVO optimiert den Kopiervorgang
}

Während RVO verpflichtend ist, bleibt NRVO eine optionale Optimierung. Das bedeutet:

  • Manche Compiler wenden NRVO an und vermeiden eine Kopie.
  • Andere rufen den Kopier- oder Move-Konstruktor auf.
  • NRVO ist also nicht garantiert, sondern vom Compiler abhängig.

Folge: Falls der Compiler NRVO nicht unterstützt, wird das Objekt in createExample() normal erzeugt und anschließend mit dem Kopier- oder Move-Konstruktor nach ex übertragen. Dadurch entsteht eine zusätzliche Performance-Kostenstelle, die vermieden werden kann, wenn NRVO unterstützt wird.

Copy Elision in C++17 und später

Vor C++17 konnte Copy Elision vom Compiler angewendet werden, war aber nicht garantiert. Seit C++17 ist sie für RVO-Szenarien verpflichtend:

1
2
3
Example createExample() {
    return Example(); // Copy Elision garantiert seit C++17
}

Damit wird das Objekt direkt im Speicher von ex in main() erzeugt, ohne einen Kopier- oder Move-Konstruktor aufzurufen.

Wann Copy Elision nicht greift

Copy Elision ist eine leistungsfähige Optimierung, aber sie wird nicht immer angewendet. In bestimmten Situationen ist der Compiler gezwungen, den Kopier- oder Move-Konstruktor aufzurufen, was zu unerwarteten Performance-Einbußen führen kann. Dies ist besonders in Embedded-Systemen kritisch, da zusätzliche Kopien unnötige Speicher- und CPU-Ressourcen verbrauchen und den Energieverbrauch erhöhen. Nachfolgend werden häufige Szenarien erläutert, in denen Copy Elision nicht greift.

Move auf eine Rückgabe

1
2
3
4
Example createExample() {
    Example e;
    return std::move(e); // Verhindert Copy Elision
}

Hier verwandelt std::move(e) das Objekt explizit in ein Rvalue, wodurch der Compiler gezwungen wird, den Move-Konstruktor aufzurufen. Das bedeutet, dass eine zusätzliche Speicheroperation durchgeführt wird, anstatt das Objekt direkt am Zielort zu erzeugen. In Embedded-Systemen kann dies zu unnötigen Speicherfragmentierungen und erhöhter CPU-Last führen.

Empfohlene Lösung: Vermeide std::move() in Rückgabeausdrücken, es sei denn, es ist absolut notwendig.

Kopier- oder Move-Konstruktor entfernt

1
2
3
4
5
class NoCopy {
public:
    NoCopy() = default;
    NoCopy(const NoCopy&) = delete; // Copy Elision greift nicht, wenn Kopie verboten ist
};

Falls der Kopier- oder Move-Konstruktor gelöscht ist (= delete), kann der Compiler keine Kopie oder Move durchführen. Wenn Copy Elision nicht möglich ist, führt dies zu einem Compiler-Fehler. Dies kann problematisch sein, wenn Code stark auf Copy Elision angewiesen ist und dadurch unerwartet nicht mehr kompiliert.

Empfohlene Lösung: Falls ein Typ = delete für den Kopier- und Move-Konstruktor definiert, stelle sicher, dass Copy Elision nicht essenziell für die Funktionalität des Codes ist oder dass alternative Wege zur Objekterstellung genutzt werden.

Bedingter Rückgabewert

1
2
3
4
5
6
7
8
Example createExample(bool flag) {
    if (flag) {
        return Example(); // Copy Elision möglich
    } else {
        Example e;
        return e; // Keine garantierte Copy Elision
    }
}

Hier existieren zwei verschiedene Rückgabepfade, sodass der Compiler nicht sicherstellen kann, dass das zurückgegebene Objekt immer direkt im Zielort konstruiert werden kann. Das bedeutet, dass in einigen Fällen eine Kopie oder ein Move statt Copy Elision auftritt.

Empfohlene Lösung: Vermeide mehrere Rückgabepfade für dasselbe Objekt, wenn Copy Elision genutzt werden soll. Eine alternative Lösung wäre:

1
2
3
4
5
6
7
Example createExample(bool flag) {
    Example e;
    if (flag) {
        // evtl. zusätzliche Logik
    }
    return e; // Höhere Wahrscheinlichkeit für NRVO
}

Objekt besitzt Polymorphismus-Merkmal

1
2
3
4
5
6
7
8
9
class Base {
public:
    virtual ~Base() {}
};
class Derived : public Base {
};
Base createBase() {
    return Derived(); // Copy Elision wird nicht angewendet
}

Bei der Rückgabe eines polymorphen Objekts kann Copy Elision nicht sicher angewendet werden, da das exakte Layout des Objekts zur Laufzeit ermittelt werden muss. Das bedeutet, dass das Objekt zuerst als Derived erzeugt und dann in ein Base-Objekt umgewandelt werden muss, was zu zusätzlichen Kopien oder Moves führt.

Empfohlene Lösung: Nutze in solchen Fällen Smart Pointer (std::unique_ptr<Base>) oder Referenzen (Base&), um teure Kopier- und Move-Operationen zu vermeiden.

1
2
3
std::unique_ptr<Base> createBase() {
    return std::make_unique<Derived>();
}

Type-safe unions und Copy Elision

1
2
3
std::variant<int, Example> createVariant() {
    return Example(); // Copy Elision kann verhindert werden
}

std::variant und std::any müssen intern Speicher für verschiedene Typen verwalten. Dies bedeutet, dass Objekte nicht immer direkt an ihrem endgültigen Speicherort konstruiert werden können. Stattdessen wird das Objekt zunächst erstellt und dann in den std::variant-Speicher kopiert oder bewegt.

Empfohlene Lösung: Falls möglich, nutze dedizierte Klassen oder std::optional<T> anstelle von std::variant, wenn nur eine begrenzte Anzahl von Typen verwaltet werden muss.

1
2
3
std::optional<Example> createOptional() {
    return Example(); // Copy Elision funktioniert zuverlässiger
}

Auswirkungen auf Embedded-Systeme

In Embedded-Systemen haben die oben genannten Einschränkungen von Copy Elision erhebliche Konsequenzen:

  • Erhöhter Speicherverbrauch: Zusätzliche Kopien und Moves belegen wertvollen RAM oder Flash-Speicher.
  • Höherer Energieverbrauch: Mehr CPU-Zyklen für Kopier- oder Move-Operationen bedeuten eine kürzere Akkulaufzeit.
  • Unvorhersehbare Laufzeitkosten: Insbesondere in Echtzeitsystemen kann es kritisch sein, wenn sich die Laufzeit eines Codes unvorhersehbar ändert.

Empfohlene Best Practices:

  • Vermeide std::move() in Rückgabewerten, es sei denn, es ist absolut notwendig.
  • Nutze explizite Return-Optimierung durch direkte Konstruktion in der Rückgabe.
  • Vermeide polymorphe Rückgaben, wenn Performance entscheidend ist.
  • Teste mit -fno-elide-constructors, um herauszufinden, wann Copy Elision wirklich angewendet wird.
  1. Wenn std::move() auf eine Rückgabe angewendet wird:

    1
    2
    3
    4
    
    Example createExample() {
        Example e;
        return std::move(e); // Verhindert Copy Elision
    }
    

    std::move() verwandelt das Objekt explizit in ein Rvalue, wodurch der Compiler gezwungen wird, die Move-Semantik zu verwenden.

  2. Wenn der Kopier- oder Move-Konstruktor = delete ist:

    1
    2
    3
    4
    5
    
    class NoCopy {
    public:
        NoCopy() = default;
        NoCopy(const NoCopy&) = delete; // Copy Elision greift nicht, wenn Kopie verboten ist
    };
    

FAQ – Häufig gestellte Fragen zu Copy Elision

Was ist Copy Elision in C++?

Copy Elision ist eine Compiler-Optimierung, die unnötige Kopier- und Move-Operationen vermeidet, indem Objekte direkt an ihrem endgültigen Speicherort erstellt werden.

Was ist der Unterschied zwischen RVO und NRVO?

  • RVO (Return Value Optimization) tritt auf, wenn eine Funktion ein temporäres Objekt zurückgibt und dieses direkt im Ziel konstruiert wird. Seit C++17 ist RVO verpflichtend.
  • NRVO (Named Return Value Optimization) funktioniert ähnlich, jedoch mit benannten Variablen. NRVO ist optional und vom Compiler abhängig.

Warum sollte man std::move() nicht in Rückgabewerten verwenden?

Wenn std::move() auf eine Rückgabe angewendet wird, verhindert es Copy Elision, da das Objekt explizit als Rvalue behandelt wird, wodurch der Move-Konstruktor aufgerufen werden muss.

Wie kann man testen, ob Copy Elision aktiv ist?

Mit der Compiler-Option -fno-elide-constructors kann man überprüfen, ob der Compiler Copy Elision anwendet:

1
g++ -std=c++17 -fno-elide-constructors main.cpp

Welche Auswirkungen hat Copy Elision auf Embedded-Systeme?

  • Weniger Speicherverbrauch durch Vermeidung unnötiger Kopien.
  • Niedrigerer Energieverbrauch, da weniger CPU-Zyklen für Kopieroperationen erforderlich sind.
  • Deterministisches Verhalten, da weniger unerwartete Laufzeitkosten auftreten.

Wann greift Copy Elision nicht?

  • Wenn std::move() in einer Rückgabe verwendet wird.
  • Wenn der Kopier- oder Move-Konstruktor = delete ist.
  • Wenn sich die Rückgabe in einer Bedingung oder Schleife befindet.
  • Bei polymorphen Objekten oder Nutzung von std::variant und std::any.

Best Practices zur Förderung von Copy Elision

  • Vermeide std::move() in Rückgabeausdrücken, da dies den Move-Konstruktor erzwingt.
  • Nutze RVO-konforme Muster, indem du Objekte direkt in der Rückgabe erzeugst.
  • Halte Konstruktoren einfach und effizient, um dem Compiler eine Optimierung zu erleichtern.
  • Verwende -fno-elide-constructors, um zu testen, ob Copy Elision aktiv ist.
    1
    
    g++ -std=c++17 -fno-elide-constructors main.cpp
    

Fazit

Copy Elision ist eine der nützlichsten Optimierungen in C++, da sie unnötige Kopien eliminiert und die Performance steigert. Während RVO seit C++17 verpflichtend ist, bleibt NRVO eine optionale Optimierung. Compiler können entscheiden, ob sie NRVO nutzen oder nicht, was bedeutet, dass in einigen Fällen unnötige Kopien entstehen können. Durch das Vermeiden von std::move() in Rückgabewerten und die bewusste Nutzung von RVO-geeigneten Mustern kann die Effizienz von C++-Programmen erheblich verbessert werden.

Erstellt mit Hugo
Theme Stack gestaltet von Jimmy