C++Architecture

Modern C++ Class Design: Constructors, Rule of Five, Member Init & Special Members (C++23)

TT
TopicTrick Team
Modern C++ Class Design: Constructors, Rule of Five, Member Init & Special Members (C++23)

Modern C++ Class Design: Constructors, Rule of Five, Member Init & Special Members (C++23)


Table of Contents


The Complete C++ Object Lifecycle


Member Initializer Lists: Order Matters

Never assign in the constructor body when you can initialize in the member initializer list. Initialization is always faster (no default-construct + assign), and for const members and references it's mandatory:

cpp
class Connection {
    const std::string host_;   // const — must be initialized, not assigned
    int               port_;
    std::vector<char> buffer_;
    int               timeout_ms_;
    
public:
    // BAD: assigns inside body (buffer_ default-constructed, then assigned)
    Connection(std::string h, int p) {
        host_       = std::move(h); // Error! host_ is const
        port_       = p;
        buffer_     = std::vector<char>(1024); // Extra default construction
        timeout_ms_ = 5000;
    }
    
    // GOOD: member initializer list — constructs directly
    Connection(std::string h, int p)
        : host_(std::move(h))    // Directly move-constructed — zero waste
        , port_(p)               // Direct initialization
        , buffer_(1024)          // Constructed with size=1024
        , timeout_ms_(5000)      // Direct initialization
    {
        // Body is empty — all setup done in the init list
    }
};

[!IMPORTANT] Members are always initialized in declaration order (the order they appear in the class body), regardless of the order in the initializer list. A mismatch causes subtle bugs — most compilers warn about this with -Wreorder.


Constructor Types: A Complete Reference

cpp
class Player {
    std::string name_;
    int         health_;
    int         level_;
    
public:
    // === 1. Default Constructor ===
    Player() : name_("Unknown"), health_(100), level_(1) {}
    
    // === 2. Parameterized Constructor ===
    Player(std::string name, int health, int level)
        : name_(std::move(name)), health_(health), level_(level) {}
    
    // === 3. Delegating Constructor (C++11) — avoid code duplication ===
    Player(std::string name) : Player(std::move(name), 100, 1) {}
    // Delegates to the main 3-arg constructor above
    
    // === 4. Converting Constructor (implicit) ===
    Player(int level)  // Creates player from just a level
        : name_("Player"), health_(100), level_(level) {}
    // NOTE: this enables implicit: Player p = 5;  (may be surprising)
    
    // === 5. explicit Constructor — prevent implicit conversion ===
    // (see next section)
};

The explicit Keyword: Preventing Silent Conversions

Without explicit, single-argument constructors enable implicit type conversion — a common source of bugs:

cpp
class Radius {
    double value_;
public:
    // Without explicit — DANGEROUS:
    Radius(double v) : value_(v) {}
};

void draw_circle(Radius r) { /* ... */ }

// These all compile silently — was this intended?
draw_circle(5.0);    // OK: double → Radius (implicit)
draw_circle(5);      // OK: int → double → Radius (two conversions!)
draw_circle(true);   // OK: bool → int → double → Radius (three!)

// With explicit — SAFE:
class SafeRadius {
    double value_;
public:
    explicit SafeRadius(double v) : value_(v) {}
};

draw_safe_circle(5.0);                // ERROR: no implicit conversion
draw_safe_circle(SafeRadius{5.0});    // OK: explicit construction
draw_safe_circle(SafeRadius(5.0));    // OK: explicit construction

Rule: Mark all single-argument constructors explicit unless you specifically intend implicit conversion (e.g., std::string from const char* is deliberately implicit).


=default and =delete: Explicit Compiler Control

= default tells the compiler to generate the optimal default implementation. = delete removes the operation entirely, turning misuse into a compile error:

cpp
class Timer {
    uint64_t start_ns_;
    uint64_t end_ns_;
    
public:
    Timer() : start_ns_(get_ns()), end_ns_(0) {}
    
    // Explicitly request compiler-generated destructor
    ~Timer() = default;
    
    // Timer must not be copied (copying a timer doesn't make sense)
    Timer(const Timer&)            = delete;
    Timer& operator=(const Timer&) = delete;
    
    // But it CAN be moved (transfer ownership of the timing)
    Timer(Timer&&)            noexcept = default;
    Timer& operator=(Timer&&) noexcept = default;
    
    uint64_t elapsed_ns() const { return end_ns_ - start_ns_; }
    void stop() { end_ns_ = get_ns(); }
};

Timer t;
// Timer t2 = t;          // COMPILE ERROR: copy deleted — good!
Timer t3 = std::move(t);  // OK: move allowed

The Rule of Zero: Prefer Composition

If your class only contains RAII members (smart pointers, containers, string), the compiler generates correct copy/move/destroy automatically — don't write any special member functions:

cpp
// === RULE OF ZERO — ideal modern C++ ===
class NetworkClient {
    std::unique_ptr<Socket>     socket_;   // Owns socket — unique ownership
    std::string                 host_;     // RAII string
    std::vector<uint8_t>        buffer_;   // RAII vector
    std::shared_ptr<TLSContext> tls_;      // Shared ownership
    
public:
    NetworkClient(std::string host, uint16_t port)
        : socket_(std::make_unique<Socket>(host, port))
        , host_(std::move(host))
        , buffer_(4096)
        , tls_(std::make_shared<TLSContext>())
    {}
    
    // NOTHING ELSE NEEDED:
    // ~NetworkClient()  — correctly destroys all members (unique_ptr → deletes socket)
    // Copy ctor/assign — deleted (unique_ptr is non-copyable → propagates up)
    // Move ctor/assign — correctly moves all members
    // ALL generated by the compiler from member types!
};

The Rule of Five: Raw Resource Management

When you must own a raw resource (legacy C API, custom allocator, hardware register), implement all five:

cpp
class Buffer {
    char*  data_;
    size_t size_;
    
public:
    explicit Buffer(size_t n) : data_(new char[n]), size_(n) {}
    
    // Rule of Five:
    
    // 1. Destructor
    ~Buffer() { delete[] data_; }
    
    // 2. Copy Constructor — deep copy
    Buffer(const Buffer& other)
        : data_(new char[other.size_]), size_(other.size_) {
        std::memcpy(data_, other.data_, size_);
    }
    
    // 3. Copy Assignment — deep copy with self-assignment check
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;      // Self-assignment guard
        char* new_data = new char[other.size_]; // Allocate FIRST (exception safety)
        std::memcpy(new_data, other.data_, other.size_);
        delete[] data_;                         // Free old data
        data_ = new_data;
        size_ = other.size_;
        return *this;
    }
    
    // 4. Move Constructor — steal resources (O(1), no allocation)
    Buffer(Buffer&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;  // Leave source in valid but empty state
        other.size_ = 0;
    }
    
    // 5. Move Assignment — steal resources, free old
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data_;         // Free current resources
        data_ = other.data_;    // Steal source data
        size_ = other.size_;
        other.data_ = nullptr;  // Nullify source
        other.size_ = 0;
        return *this;
    }
    
    char*  data() const noexcept { return data_; }
    size_t size() const noexcept { return size_; }
};

Destructor Order and Virtual Destructors

C++ destroys objects in the reverse order of construction:

cpp
// Destruction order:
// 1. Destructor body runs
// 2. Non-static members destroyed in REVERSE declaration order
// 3. Base class destructor runs

class Base {
public:
    virtual ~Base() { /* runs LAST */ }  // MUST be virtual for polymorphic delete!
};

class Derived : public Base {
    std::string name_;  // Destroyed 2nd
    int         id_;    // Destroyed 1st (reverse declaration order)
public:
    ~Derived() override { /* runs FIRST */ }
};

// Without virtual destructor:
Base* b = new Derived("test", 42);
delete b;  // Without virtual ~Base(), only Base::~Base() runs!
           // Derived::~Derived() and name_ are NEVER destroyed → LEAK

C++20/23 Improvements: Designated Initializers

cpp
// C++20 Designated Initializers — init struct members by name
struct ServerConfig {
    std::string host    = "localhost";
    uint16_t    port    = 8080;
    int         timeout = 30;
    bool        tls     = false;
};

// Old (error-prone positional):
ServerConfig cfg1{"example.com", 443, 60, true};

// C++20 designated (self-documenting, order-independent):
ServerConfig cfg2{
    .host    = "example.com",
    .port    = 443,
    .timeout = 60,
    .tls     = true
};

// Missing fields take their default values:
ServerConfig cfg3{
    .host = "api.example.com",
    .tls  = true  // port/timeout use defaults (8080, 30)
};

Frequently Asked Questions

When must I write my own destructor? Only when your class directly owns a raw resource that isn't wrapped in RAII (raw new/delete, C API handles like FILE*, OS resources like file descriptors). If all your members are RAII types, let the compiler generate the destructor via the Rule of Zero.

Why must move constructors be noexcept? std::vector::push_back may reallocate. During reallocation, it must move all existing elements to the new memory. If a move constructor can throw, the vector cannot safely use it (the move might partially succeed, leaving both old and new storage in inconsistent states). With noexcept, the vector uses moves. Without it, it falls back to copies — potentially 10-100× slower for large element types.

What is the difference between initialization and assignment? Initialization creates a new object (int x = 5 or int x(5) or int x{5}). Assignment modifies an existing object (x = 5). In class terms: MyClass a = b calls the copy constructor (initialization). a = b where a already exists calls copy assignment operator. The member initializer list performs initialization — always prefer it over body assignment.


Key Takeaway

C++ class design is deterministic lifecycle management. Every resource acquisition must have a corresponding release path that runs even when exceptions occur. The Rule of Zero says: compose from RAII types and let the compiler handle everything. The Rule of Five says: when you must touch raw resources, implement all five special members explicitly, correctly, and with noexcept on move operations. There is no middle ground — a class that defines the destructor but not the copy/move operations silently uses the wrong behavior.

Read next: OOP vs Composition: Interfaces & Polymorphism →


Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.