Zum Inhalt springen

Konstruktoren, Destruktoren & Regel von 0/3/5

Beherrsche den vollständigen Lebenszyklus von C++-Objekten — von der Konstruktion bis zur Zerstörung — und lerne, wann du spezielle Memberfunktionen schreiben solltest und wann besser nicht.

Der Objekt-Lebenszyklus in C++

In C++ hast du die direkte Kontrolle darüber, wann und wie Objekte erzeugt und zerstört werden. Der Compiler kann bis zu sechs spezielle Memberfunktionen automatisch erzeugen: Standardkonstruktor, Kopierkonstruktor, Kopier-Zuweisungsoperator, Move-Konstruktor, Move-Zuweisungsoperator und Destruktor. Zu verstehen, wann du sie selbst schreibst — und wann du das dem Compiler überlässt —, ist eine der wichtigsten Fähigkeiten in C++.

Arten von Konstruktoren

Konstruktoren initialisieren ein Objekt. C++ kennt mehrere Arten: Standardkonstruktoren (ohne Argumente), parametrisierte, konvertierende (ein einziges Argument, implizite Konvertierung) und delegierende (ein Konstruktor ruft einen anderen auf).

constructors.cpp
#include <iostream>
#include <string>

class Temperature {
    double celsius_;
    std::string label_;

public:
    // Default constructor
    Temperature() : celsius_{0.0}, label_{"unknown"} {}

    // Parameterized constructor
    Temperature(double c, const std::string& label)
        : celsius_{c}, label_{label} {}

    // Converting constructor — allows implicit conversion from double
    Temperature(double c) : celsius_{c}, label_{"sensor"} {}

    // Delegating constructor — reuses another constructor
    Temperature(const std::string& label)
        : Temperature{0.0, label} {}  // delegates to parameterized

    void print() const {
        std::cout << label_ << ": " << celsius_ << "°C\n";
    }
};

int main() {
    Temperature t1;                   // default
    Temperature t2{36.6, "body"};     // parameterized
    Temperature t3 = 100.0;           // converting (implicit)
    Temperature t4{"outdoor"};        // delegating

    t1.print();  // unknown: 0°C
    t2.print();  // body: 36.6°C
    t3.print();  // sensor: 100°C
    t4.print();  // outdoor: 0°C
    return 0;
}

Das Schlüsselwort explicit

Konstruktoren mit einem einzigen Argument ermöglichen implizite Konvertierungen, die subtile Fehler verursachen können. Verwende explicit, um ungewollte Konvertierungen zu verhindern — das ist fast immer das, was du willst.

explicit.cpp
#include <iostream>
#include <string>

class UserId {
    int id_;
public:
    explicit UserId(int id) : id_{id} {}
    int get() const { return id_; }
};

void process(UserId uid) {
    std::cout << "Processing user #" << uid.get() << '\n';
}

int main() {
    UserId u1{42};       // OK: direct initialization
    // UserId u2 = 42;   // ERROR: implicit conversion blocked by explicit
    // process(42);      // ERROR: would require implicit conversion
    process(UserId{42}); // OK: explicit construction
    return 0;
}

Member-Initialisierungslisten & Initialisierungsreihenfolge

Die Member-Initialisierungsliste ist der bevorzugte Weg, Member zu initialisieren. Für const-Member, Referenz-Member und Member ohne Standardkonstruktor ist sie sogar zwingend erforderlich. Member werden in der Deklarationsreihenfolge initialisiert, nicht in der Reihenfolge, in der sie in der Initialisierungsliste stehen.

init_list.cpp
#include <iostream>
#include <string>

class Connection {
    const int max_retries_;      // const: MUST use init list
    std::string& log_target_;   // reference: MUST use init list
    std::string host_;
    int port_;

public:
    Connection(std::string& log, const std::string& host, int port)
        : max_retries_{3}          // const member
        , log_target_{log}         // reference member
        , host_{host}              // initialized in declaration order
        , port_{port}
    {
        // Constructor body runs AFTER all members are initialized
        log_target_ += "[Connection to " + host_ + " created]\n";
    }

    void print() const {
        std::cout << host_ << ':' << port_
                  << " (max retries: " << max_retries_ << ")\n";
    }
};

int main() {
    std::string log;
    Connection conn{log, "db.example.com", 5432};
    conn.print();
    std::cout << log;
    return 0;
}

Destruktoren & die Regel von null / drei / fünf

Ein Destruktor (~ClassName()) läuft, wenn ein Objekt zerstört wird — er gibt Ressourcen frei und darf keine Exceptions werfen. Für die meisten Klassen genügt der vom Compiler erzeugte Destruktor völlig.

Die Regel von null: Bevorzuge RAII-Wrapper (std::string, std::vector, std::unique_ptr), damit du überhaupt keine speziellen Memberfunktionen schreiben musst. Musst du doch eine rohe Ressource verwalten, sagt die Regel von drei, dass du einen Destruktor, einen Kopierkonstruktor und einen Kopier-Zuweisungsoperator brauchst. Die Regel von fünf erweitert das um den Move-Konstruktor und den Move-Zuweisungsoperator.

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

// === Rule of Zero: the GOAL ===
// No special members needed — std::string handles everything
class Person {
    std::string name_;
    int age_;
public:
    Person(const std::string& name, int age)
        : name_{name}, age_{age} {}
    // Compiler generates correct copy/move/destructor automatically!
};

// === Rule of Five: when managing a raw resource ===
class CharBuffer {
    char* data_;
    std::size_t size_;

public:
    explicit CharBuffer(const char* str)
        : size_{std::strlen(str)}
        , data_{new char[std::strlen(str) + 1]}
    {
        std::strcpy(data_, str);
    }

    // Destructor
    ~CharBuffer() { delete[] data_; }

    // Copy constructor (deep copy)
    CharBuffer(const CharBuffer& other)
        : size_{other.size_}
        , data_{new char[other.size_ + 1]}
    {
        std::strcpy(data_, other.data_);
    }

    // Copy assignment (copy-and-swap idiom)
    CharBuffer& operator=(CharBuffer other) {  // note: by value
        swap(*this, other);
        return *this;
    }

    // Move constructor
    CharBuffer(CharBuffer&& other) noexcept
        : data_{other.data_}, size_{other.size_}
    {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    // Move assignment
    CharBuffer& operator=(CharBuffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    friend void swap(CharBuffer& a, CharBuffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }

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

int main() {
    CharBuffer a{"Hello"};
    CharBuffer b = a;           // copy constructor
    CharBuffer c{"World"};
    c = a;                      // copy assignment
    CharBuffer d = std::move(a); // move constructor
    d.print();  // Hello
    a.print();  // (null) — moved-from state
    return 0;
}

=default und =delete

Du kannst die vom Compiler erzeugte Version einer speziellen Memberfunktion mit =default ausdrücklich anfordern oder sie mit =delete ausdrücklich unterdrücken. Das macht deine Absicht klar und dokumentiert die Kopier- und Move-Semantik der Klasse.

default_delete.cpp
#include <memory>
#include <string>

// Non-copyable, movable resource handle
class FileHandle {
    std::string path_;
    int fd_;

public:
    FileHandle(const std::string& path, int fd)
        : path_{path}, fd_{fd} {}

    // Explicitly default the destructor
    ~FileHandle() = default;

    // Delete copy operations — file handles should not be duplicated
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;

    // Default move operations
    FileHandle(FileHandle&&) = default;
    FileHandle& operator=(FileHandle&&) = default;
};

int main() {
    FileHandle fh{"data.txt", 3};
    // FileHandle copy = fh;         // ERROR: copy deleted
    FileHandle moved = std::move(fh); // OK: move is allowed
    return 0;
}
Stolperfalle

Member werden in der Reihenfolge initialisiert, in der sie in der Klasse deklariert sind, nicht in der Reihenfolge der Initialisierungsliste. Hängt ein Member von einem anderen ab, achte darauf, dass die Abhängigkeit zuerst deklariert wird. Compiler warnen davor mit -Wreorder — ignoriere diese Warnung niemals. Ein klassischer Fehler: size_ aus einem Parameter zu initialisieren und dann size_ zu nutzen, um data_ zu allokieren, während data_ vor size_ deklariert ist.

Wichtigste Erkenntnisse
  • Verwende explicit bei Konstruktoren mit einem einzigen Argument, um ungewollte implizite Konvertierungen zu verhindern
  • Die Member-Initialisierungsliste ist für const- und Referenz-Member zwingend und für alle Member effizienter
  • Member werden in Deklarationsreihenfolge initialisiert, nicht in der Reihenfolge der Initialisierungsliste
  • Strebe die Regel von null an — nutze RAII-Typen, damit die vom Compiler erzeugten speziellen Member korrekt sind
  • Schreibst du eines von Destruktor/Kopierkonstruktor/Kopier-Zuweisung, schreibe alle fünf (Regel von fünf)
  • =default dokumentiert die Absicht; =delete verbietet eine Operation ausdrücklich

Quiz — Teste dein Wissen

(20 XP)

1. Was verhindert das Schlüsselwort `explicit` an einem Konstruktor?

2. In welcher Reihenfolge werden Klassenmember initialisiert?

3. Was besagt die Regel von null?