Zum Inhalt springen

Kopiersemantik: tief vs. flach

Lerne den Unterschied zwischen flachen und tiefen Kopien kennen, wie du korrekte Kopieroperationen für ressourcenbesitzende Klassen implementierst und was das Copy-and-Swap-Idiom leistet.

Warum Kopiersemantik wichtig ist

Wenn du in C++ ein Objekt kopierst — was genau wird dann eigentlich kopiert? Bei einfachen Typen ist die Antwort klar: Alle Member werden Bit für Bit kopiert. Besitzt ein Objekt jedoch eine Ressource über einen Zeiger, erzeugt der voreingestellte (vom Compiler generierte) Kopierkonstruktor eine flache Kopie: Er kopiert den Zeigerwert, nicht aber die Daten, auf die er zeigt. Damit teilen sich zwei Objekte dieselbe Ressource, was zu Double-Free-Fehlern und Datenkorruption führt.

Kopiersemantik zu verstehen ist unverzichtbar, weil Kopien an vielen Stellen implizit entstehen: bei der Übergabe per Wert, beim Zurückgeben aus Funktionen, beim Einfügen in Container und bei Zuweisungen.

Das Problem der flachen Kopie

Der vom Compiler erzeugte Kopierkonstruktor kopiert jeden Member einzeln. Bei Zeigern kopiert er die Adresse — nicht die Daten, auf die gezeigt wird. Das ist eine flache Kopie und für ressourcenbesitzende Klassen gefährlich.

shallow_copy_bug.cpp
#include <iostream>
#include <cstring>

class BrokenString {
    char* data_;
    std::size_t len_;

public:
    BrokenString(const char* s)
        : len_{std::strlen(s)}
        , data_{new char[std::strlen(s) + 1]}
    {
        std::strcpy(data_, s);
    }

    ~BrokenString() {
        delete[] data_;  // frees the buffer
    }

    // No copy constructor defined!
    // Compiler generates shallow copy: copies the pointer

    void print() const {
        std::cout << data_ << '\n';
    }
};

int main() {
    BrokenString a{"Hello"};
    {
        BrokenString b = a;  // shallow copy: b.data_ == a.data_
        b.print();           // "Hello" — works for now
    }  // b destroyed: delete[] b.data_ (same pointer as a.data_!)

    // a.data_ is now a dangling pointer — UNDEFINED BEHAVIOR
    a.print();  // may crash, print garbage, or appear to work
    return 0;
}

Eine tiefe Kopie implementieren

Eine tiefe Kopie allokiert neuen Speicher und kopiert die eigentlichen Daten. So besitzt jedes Objekt seine eigene, unabhängige Kopie der Ressource.

deep_copy.cpp
#include <iostream>
#include <cstring>
#include <algorithm>

class SafeString {
    char* data_;
    std::size_t len_;

public:
    explicit SafeString(const char* s)
        : len_{std::strlen(s)}
        , data_{new char[std::strlen(s) + 1]}
    {
        std::strcpy(data_, s);
    }

    // Deep copy constructor
    SafeString(const SafeString& other)
        : len_{other.len_}
        , data_{new char[other.len_ + 1]}
    {
        std::strcpy(data_, other.data_);
    }

    // Deep copy assignment with self-assignment check
    SafeString& operator=(const SafeString& other) {
        if (this == &other) return *this;  // self-assignment guard

        // Allocate new resource BEFORE releasing old one
        char* new_data = new char[other.len_ + 1];
        std::strcpy(new_data, other.data_);

        // Release old resource
        delete[] data_;

        // Adopt new resource
        data_ = new_data;
        len_ = other.len_;
        return *this;
    }

    ~SafeString() { delete[] data_; }

    void print() const {
        std::cout << data_ << " (len=" << len_ << ")\n";
    }
};

int main() {
    SafeString a{"Hello"};
    SafeString b = a;     // deep copy: b gets its own buffer
    SafeString c{"World"};
    c = a;                // deep copy assignment
    a.print();  // Hello (len=5)
    b.print();  // Hello (len=5)
    c.print();  // Hello (len=5)
    return 0;
}

Das Copy-and-Swap-Idiom

Das Copy-and-Swap-Idiom liefert eine elegante, exception-sichere Implementierung des Kopier-Zuweisungsoperators. Es nutzt den Kopierkonstruktor und eine swap-Funktion wieder und bietet starke Exception-Sicherheit bei minimaler Code-Duplizierung.

copy_and_swap.cpp
#include <iostream>
#include <cstring>
#include <utility>

class Buffer {
    char* data_;
    std::size_t size_;

public:
    explicit Buffer(std::size_t size)
        : size_{size}, data_{new char[size]{}} {}

    Buffer(const Buffer& other)
        : size_{other.size_}, data_{new char[other.size_]}
    {
        std::memcpy(data_, other.data_, size_);
    }

    ~Buffer() { delete[] data_; }

    // Copy-and-swap: take parameter by VALUE (invokes copy ctor)
    Buffer& operator=(Buffer other) {   // copy happens here
        swap(*this, other);             // swap resources
        return *this;
    }   // 'other' destroyed here, freeing our old resource

    // Non-throwing swap — essential for strong exception safety
    friend void swap(Buffer& a, Buffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }

    char& operator[](std::size_t i) { return data_[i]; }
    std::size_t size() const { return size_; }
};

int main() {
    Buffer a{5};
    a[0] = 'H'; a[1] = 'i';

    Buffer b{10};
    b = a;  // copy-and-swap: exception-safe, self-assignment-safe

    std::cout << b[0] << b[1] << " (size " << b.size() << ")\n";
    return 0;
}

Kopieren verbieten

Manche Typen sollten nicht kopierbar sein — zum Beispiel Typen, die einzigartige Ressourcen repräsentieren, etwa Dateihandles, Netzwerkverbindungen oder Threads. Verwende =delete, um das klar zu kommunizieren und zu erzwingen:

class UniqueConnection {
public:
    UniqueConnection(const UniqueConnection&) = delete;
    UniqueConnection& operator=(const UniqueConnection&) = delete;
    // Move-Operationen können weiterhin aktiviert bleiben
};

Vor C++11 erreichte man das, indem man die Kopieroperationen private deklarierte, ohne sie zu definieren — ein verwirrender Trick, den =delete elegant ersetzt.

Stolperfalle

Eine naive Kopier-Zuweisung, die die alte Ressource freigibt, bevor sie die neue allokiert, ist in zweierlei Hinsicht kaputt: (1) Bei einer Selbstzuweisung (a = a) löscht sie die Daten und versucht dann, aus ihnen zu kopieren, und (2) wenn new nach dem delete wirft, bleibt das Objekt in einem ungültigen Zustand zurück. Das Copy-and-Swap-Idiom löst beide Probleme: Die Kopie entsteht vor jeder Änderung, und swap ist noexcept.

Best Practice

Der beste Weg, Kopierfehler zu vermeiden, ist, rohe Ressourcen gar nicht erst selbst zu verwalten. Nutze std::string statt char*, std::vector statt roher Arrays und std::unique_ptr statt besitzender roher Zeiger. Wenn alle deine Member eine korrekte Kopiersemantik haben, sind die vom Compiler erzeugten Kopieroperationen automatisch korrekt. Das ist die Regel von null in der Praxis.

Wichtigste Erkenntnisse
  • Die vom Compiler erzeugte Kopie ist flach — sie kopiert Zeigerwerte, nicht die Daten, auf die gezeigt wird
  • Ressourcenbesitzende Klassen brauchen einen Kopierkonstruktor und einen Zuweisungsoperator mit tiefer Kopie
  • Das Copy-and-Swap-Idiom liefert Exception-Sicherheit und Sicherheit bei Selbstzuweisung gratis dazu
  • Verwende =delete, um das Kopieren von Typen mit einzigartigen Ressourcen ausdrücklich zu verbieten
  • Die beste Strategie ist die Regel von null: Nutze RAII-Wrapper, damit du nie eigene Kopierlogik brauchst

Quiz — Teste dein Wissen

(15 XP)

1. Was passiert, wenn du ein Objekt flach kopierst, das Heap-Speicher über einen rohen Zeiger besitzt?

2. Warum nimmt das Copy-and-Swap-Idiom seinen Parameter per Wert?

3. Welcher Ansatz wird empfohlen, um eigene Kopieroperationen von vornherein überflüssig zu machen?