CodeGym /Kurse /C++ SELF /Rule of Five: besser als Rule of Zero

Rule of Five: besser als Rule of Zero

C++ SELF
Level 45, Lektion 3
Verfügbar

1. Destruktor hinzugefügt — und das Kopieren ist kaputt

An diesem Punkt erleben viele Studierende zum ersten Mal den „Dominoeffekt“: Sie haben dem Typ einen Destruktor hinzugefügt (um die Ressource freizugeben) — und plötzlich stellt sich heraus, dass simples Kopieren des Objekts das Programm kaputtmacht. Die Ursache ist banal und fast ärgerlich: die Standardkopie kopiert die Felder, und wenn unter den Feldern ein „Handle“ auf eine Ressource ist (z. B. T*), dann wird nicht die Ressource, sondern die Adresse kopiert.

Spielen wir eine typische Situation durch. Sie entscheiden sich, ein dynamisches Array zu speichern (warum genau ist egal), geben es im Destruktor sauber frei — und denken, alles sei gut.


#include <cstddef>

struct IntBuffer {
    std::size_t size{};
    int* data{nullptr};

    explicit IntBuffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~IntBuffer() { delete[] data; }
};

Und jetzt — eine Zeile, die völlig harmlos aussieht:

int main() {
    IntBuffer a{3};
    IntBuffer b = a; // Standardkopie: size wird kopiert, data wird kopiert (Adresse!)
}

Was passiert semantisch? Beide Objekte denken, sie seien Eigentümer desselben Arrays. Wenn das Programm aus main herausläuft, wird zuerst b zerstört und führt delete[] aus, danach wird a zerstört und versucht, delete[] ein zweites Mal aufzurufen. Das nennt man double-free, und das führt mindestens zu einem Absturz, schlimmstenfalls zu seltsamen „Geisterbugs“, die nicht sofort auftauchen.

Die Moral dieses Abschnitts ist einfach und unangenehm: sobald ein Typ Ressourceneigentum übernimmt, ist die Standardkopie meistens semantisch falsch.

2. Rule of Five: was das ist und warum sie auftaucht

Rule of Five klingt wie der Titel eines B‑Actionfilms, aber eigentlich ist es eine Disziplin: Wenn Ihr Typ manuell eine Ressource besitzt, müssen Sie fast immer kohärent ein Set aus fünf speziellen Memberfunktionen definieren (oder verbieten). Wir tun das nicht der „Code‑Ästhetik“ wegen — wir versuchen einfach, unser Programm nicht durch doppelte Freigaben oder Dangling‑Pointer zu sprengen.

Die fünf „Spezialfunktionen“ sind diese Truppe:

Spezialfunktion Was sie semantisch macht Warum sie für die Ressource wichtig ist
Destruktor ~T() gibt die Ressource frei ohne ihn gäbe es Leaks
Kopierkonstruktor T(const T&) erzeugt eine Kopie des Objekts für die Ressource braucht man eine tiefe Kopie oder ein Verbot
Kopierzuweisung T& operator=(const T&) ersetzt den Inhalt eines Objekts durch eine Kopie man muss korrekt die alte Ressource freigeben und die neue kopieren
Move‑Konstruktor T(T&&) verschiebt die Ressource aus der Quelle so kann man „umziehen“, ohne teure Kopien
Move‑Zuweisung T& operator=(T&&) ersetzt die Ressource durch Verschieben man muss die alte Ressource freigeben und die neue übernehmen

Wichtig: Sie sind diesen Funktionen wahrscheinlich schon begegnet, aber heute verbinden wir das mit einer praktischen Ursache‑Wirkungs‑Beziehung. Wenn Sie eine Ressource „mit bloßen Händen“ besitzen, kann der Compiler Ihre Absicht nicht erraten. Er kopiert ehrlich die Felder — und zerstört damit das Eigentum.

Anzeichen für „manuelle Ressource“ im Typ

Rule of Five ist nicht nötig, weil „das so in C++ üblich ist“, sondern weil der Typ eine Verantwortung trägt: Jemand muss die korrekte Freigabe der Ressource und das richtige Verhalten bei Kopieren/Bewegen garantieren. Die einfachste Regel zur Einschätzung, ob Sie Rule of Five brauchen: Hat der Typ eine Ressource, die sich nicht einfach bitweise kopieren lässt?

Lassen Sie uns typische „manuelle Ressourcen“ aufzählen, ohne zu sehr ins Detail zu gehen.

Ressource Wie das „Handle“ aussieht Womit freigegeben Was bei Standardkopie kaputtgeht
Dynamischer Speicher T* + new/new[] delete/delete[] double-free / use-after-free
Datei (C‑Level) FILE* fclose() zwei Objekte versuchen, dieselbe Datei zu schließen
System‑Handle (Konzept) Zahl/Pointer‑Identifier API‑„close“-Funktion analog: zwei Eigentümer desselben Handles

Wir gehen hier bewusst nicht auf komplexere Ressourcen oder Architekturen ein. Aber das reicht: wenn im Typ ein „Handle“ steckt, das Sie manuell schließen/freigeben müssen, dann hat der Typ klares Eigentum.

Und hier entsteht die Verzweigung: Entweder implementieren Sie eine tiefe Kopie (damit die Kopie ihre eigene Ressource bekommt), oder Sie verbieten Kopieren und erlauben nur Bewegung, oder Sie sorgen dafür, dass die Ressource in einem RAII‑Feld lebt und alles durch die Rule of Zero gelöst wird.

Warum „nur den Destruktor schreiben“ fast immer schlechter ist, als es scheint

Wenn Anfänger den ersten Destruktor schreiben, sind sie oft stolz: „Ich habe den Speicher gezähmt“. Das Problem ist, dass ein einziger Destruktor selten „eine kleine Änderung“ ist. Er verändert die Semantik des Typs: Jetzt hat Ihr Typ Eigentum, und Eigentum stellt automatisch die Frage „was bedeutet Kopie?“.

Selbst wenn Sie logisch gar nicht vorhatten, das Objekt zu kopieren, kann Kopieren unterwegs passieren: Sie geben ein Objekt aus einer Funktion zurück, stecken es in einen Container, übergeben es per Wert, nutzen versehentlich auto x = y;. Und plötzlich haben Sie zwei Objekte, die auf dieselbe Ressource zeigen.

Noch unangenehmer: manche automatische Optimierungen und Objektverschiebungen hängen davon ab, welche Spezialfunktionen vorhanden sind und welche Verträge sie einhalten (z. B. mag die Standardbibliothek es, wenn Move‑Operationen keine Exceptions werfen; unique_ptr hält das strikt ein).

Daher der Kern: ein Destruktor ist nicht „nur eine weitere Funktion“, er ist eine Erklärung, dass Sie für die Ressource verantwortlich sind. Und wenn Sie verantwortlich sind — müssen Sie auch Fragen zu Kopien und Moves beantworten.

Wie man eine Wahl trifft — ohne Mystik

Studierende versuchen oft, sich die Rule of Five als „magische Formel“ zu merken, aber besser ist ein einfaches Entscheidungsdiagramm. Ihr Ziel sollte nicht sein, heldenhaft Spezialfunktionen zu implementieren, sondern rechtzeitig zu erkennen, wann man sie nicht schreiben sollte.

Unten die Entscheidungslogik in Form eines Flussdiagramms.

flowchart TD
    A[Sie entwerfen Ihren Typ] --> B{Gibt es ein manuelles Handle? new/delete, FILE*, handle}
    B -- nein --> C["Rule of Zero: RAII‑Felder (string, vector, unique_ptr) — keine Spezialfunktionen schreiben"]
    B -- ja --> D{Braucht man semantisch eine Kopie?}
    D -- nein --> E["Kopieren verbieten: =delete (und später über Moves nachdenken)"]
    D -- ja --> F[Rule of Five: tiefe Kopie und Moves durchdenken]

Beachten Sie die Semantik: Rule of Five taucht nicht auf, weil Sie das Wort „Five“ in einem Buch gesehen haben, sondern weil Sie ehrlich mit „ja“ auf die Frage antworten: „Habe ich ein manuelles Handle?“

Wenn Sie erreichen können, dass kein „manuelles Handle“ existiert (oder es in einem RAII‑Feld gekapselt ist), dann macht die Rule of Zero Ihren Code automatisch einfacher, sicherer und meist auch angenehmer in der Entwicklung.

3. Drei Strategien: Rule of Zero, Rule of Five oder Kopierverbot

Im realen Code haben Sie fast immer eine Wahl. Guter Stil in modernem C++ bedeutet nicht, überall Rule of Five anzuwenden, sondern im Gegenteil: Rule of Five sollte die seltene Ausnahme sein. Betrachten wir drei Strategien ohne Fanatismus.

Rule of Zero: Standardbibliothek soll besitzen

Rule of Zero heißt, dass Sie die Ressource in einem RAII‑Feld speichern: std::string, std::vector, std::unique_ptr, Datei‑Streams usw. Dann kann der Compiler sinnvolle Spezialfunktionen generieren, weil jedes Feld selbst weiß, wie es korrekt kopiert/bewegt wird.

Beispiel: wir brauchen einen „Zahlenpuffer“. Statt new[] verwenden wir std::vector<int>.

#include <vector>
#include <cstddef>

struct IntBuffer {
    std::vector<int> data;              // RAII: verwaltet den Speicher selbst
    explicit IntBuffer(std::size_t n) : data(n, 0) {}
};

Hier sind weder Destruktor noch Kopierkonstruktor noch Move‑Operationen nötig: std::vector übernimmt alles.

Rule of Five: ja, ich besitze manuell die Ressource

Das ist die Situation, in der Sie aus irgendeinem Grund nicht oder nicht wollen, die Ressource in einem Standardbesitzer zu speichern. Für Lernzwecke machen wir das, um die Mechanik zu verstehen. In der Praxis sollte das selten sein und eine gute Begründung erfordern.

Wichtig: in dieser Vorlesung implementieren wir nicht alle Operationen vollständig (das folgt in späteren Lektionen), aber wir lernen, den Trigger zu erkennen: wenn Sie manuell besitzen, müssen Sie die fünf Funktionen bedenken.

#include <cstddef>

struct RawBuffer {
    std::size_t size{};
    int* data{nullptr};

    explicit RawBuffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~RawBuffer() { delete[] data; }

    RawBuffer(const RawBuffer& other);                // wird benötigt
    RawBuffer& operator=(const RawBuffer& other);     // wird benötigt
    RawBuffer(RawBuffer&& other) noexcept;            // wird benötigt
    RawBuffer& operator=(RawBuffer&& other) noexcept; // wird benötigt
};

Der wichtigste Punkt: sobald Sie ~RawBuffer() schreiben und darin delete[] aufrufen, sind Sie fast zwangsläufig bei der „Fünfergruppe“ — entweder als Implementationen oder als Verbote.

Kopierverbot: einzigartiges Eigentum, keine Kopien

Manchmal ist Kopieren semantisch unnötig oder sogar schädlich. Wenn Sie möchten, dass ein Objekt der einzige Eigentümer einer Ressource ist, ist es ehrlicher, das dem Compiler direkt mitzuteilen.

#include <cstddef>

struct RawBuffer {
    std::size_t size{};
    int* data{nullptr};

    explicit RawBuffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~RawBuffer() { delete[] data; }

    RawBuffer(const RawBuffer&) = delete;
    RawBuffer& operator=(const RawBuffer&) = delete;
};

Das ist keine „faule Lösung“. Das ist ein legitimer Designvertrag: „das Eigentum ist einzigartig“. Im Geiste ähnelt das std::unique_ptr: auch er ist nicht kopierbar.

4. Beispiel aus einer Konsolenanwendung: TaskBox und Befehlshistorie

Damit das Thema nicht abstrakt bleibt, binden wir es an eine einfache Lern‑Konsolenanwendung. Nennen wir sie „TaskBox“: wir lesen Befehle, fügen Aufgaben hinzu, geben die Liste aus. Bisher haben wir uns auf std::string und std::vector gestützt — das war richtig, weil sie RAII sind.

Beginnen wir mit dem Anwendungszustand: Aufgabenliste und Historie der zuletzt eingegebenen Befehle.

#include <string>
#include <vector>

struct AppState {
    std::vector<std::string> tasks;
    std::vector<std::string> history; // Rule of Zero: wird korrekt kopiert
};

So ein Zustand kann problemlos kopiert werden (auch wenn das nicht immer nötig ist): std::vector<std::string> macht die tiefe Kopie selbst.

Stellen Sie sich jetzt vor, jemand „optimiert“ die Historie: „Warum viele Strings speichern? Kleben wir alles in einen großen char* zusammen.“ Genau hier wird Rule of Five relevant.

#include <cstddef>
#include <cstring>

struct HistoryBlob {
    std::size_t size{};
    char* data{nullptr};

    explicit HistoryBlob(const char* text) {
        size = std::strlen(text);
        data = new char[size + 1]{};
        std::strcpy(data, text);
    }

    ~HistoryBlob() { delete[] data; }
};

Sieht „funktional“ aus. Fügen wir das jetzt in den Zustand ein:

#include <vector>
#include <string>

struct AppState {
    std::vector<std::string> tasks;
    HistoryBlob history{"init"}; // besitzt Speicher manuell
};

Und hier sind wir an der zentralen Stelle der Vorlesung: nun ist AppState gefährlich kopierbar, weil es ein HistoryBlob mit „nacktem“ Eigentum enthält. Der Compiler generiert eine Kopie von AppState, die history.data als Adresse kopiert. Der weitere Verlauf ist bekannt: zwei Destruktoren, ein Pointer.

Die Wahl der Strategie ist also keine Abstraktion — sie beantwortet buchstäblich die Frage: „Kann ich sicher schreiben AppState b = a;?“

Wenn Ihr Feld std::vector<std::string> ist, dann ist Kopieren sicher. Wenn das Feld ein char* ist und Sie im Destruktor delete[] aufrufen, dann ist die Standardkopie eine Zeitbombe.

5. Typische Fehler bei der Wahl zwischen Rule of Zero und Rule of Five

Fehler Nr. 1: „Ich habe einen Destruktor geschrieben, aber sonst nichts angefasst — es kompiliert ja.“
Das ist der heimtückischste Fehler, weil der Compiler den Code oft lautlos kompiliert, und das Problem erst später auftritt: beim Kopieren des Objekts, beim Rückgeben aus einer Funktion, bei Verschiebungen innerhalb eines Containers. Wenn Ihr Destruktor delete/delete[] enthält, müssen Sie fast sicher entweder Kopieren verbieten, eine tiefe Kopie implementieren oder die Felder auf RAII‑Typen umstellen.

Fehler Nr. 2: Besitz in einem „rohen“ Pointer halten, obwohl std::vector/std::string möglich wäre.
Anfänger schreiben oft char*, weil es „näher an der Hardware“ ist, obwohl die Aufgabe einfaches Speichern von Text ist. Am Ende führen Sie Rule of Five dort ein, wo Rule of Zero möglich gewesen wäre. Modernes C++ ist wertvoll, weil in Anwendungscode manuelles Speicherhandling meist unnötig ist.

Fehler Nr. 3: „Objekt kopieren“ mit „Adresse kopieren“ verwechseln.
Das Kopieren eines T* kopiert nicht die Daten, sondern die Koordinaten, wo die Daten liegen. Wenn zwei Eigentümer dieselbe Adresse haben, sind sie keine „eingespielte Mannschaft“ — sie konkurrieren darum, wer zuerst delete aufruft.

Fehler Nr. 4: punktuelle „Flickschusterei“ an der Oberflächenkopie anstatt systematisch zu denken.
Manche versuchen: „Ich passe an einer Stelle auf, dass ich nicht kopiere“. Das ist fragil: der Code wächst, neue Funktionen, Container, Rückgaben kommen hinzu. Zuverlässiger ist ein Typ, der selbst die Korrektheit garantiert: entweder korrekt kopierbar (tiefe Kopie), oder bewusst nicht kopierbar (=delete), oder überhaupt kein manuelles Eigentum (Rule of Zero).

Fehler Nr. 5: Rule of Five als „immer anzuwendende Regel“ betrachten.
Das ist fast das Gegenstück zur vorherigen Extremposition. Rule of Five ist ein Werkzeugkasten, den man braucht, wenn man wirklich manuell Ressourcen besitzt. In normalen Anwendungsfällen sollte Ihre erste Frage sein: „Kann ich das so gestalten, dass ein std::vector/std::string/unique_ptr das Eigentum übernimmt?“ Wenn ja — tun Sie das, und Sie erhalten automatisch korrekte Kopien/Bewegungen ohne unnötigen Aufwand.

1
Aufgabe
C++ SELF, Level 45, Lektion 3
Gesperrt
Puffer ohne Aufwand
Puffer ohne Aufwand
1
Aufgabe
C++ SELF, Level 45, Lektion 3
Gesperrt
Einzigartiges Array
Einzigartiges Array
1
Aufgabe
C++ SELF, Level 45, Lektion 3
Gesperrt
Tiefe Kopie
Tiefe Kopie
1
Aufgabe
C++ SELF, Level 45, Lektion 3
Gesperrt
Session ohne Schmerzen
Session ohne Schmerzen
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION