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
- Constructor Types: A Complete Reference
- The explicit Keyword: Preventing Silent Conversions
- =default and =delete: Explicit Compiler Control
- The Rule of Zero: Prefer Composition
- The Rule of Five: Raw Resource Management
- Writing a Complete Buffer Class
- Destructor Order and Virtual Destructors
- C++20/23 Improvements: Designated Initializers & Aggregates
- Frequently Asked Questions
- Key Takeaway
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:
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
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:
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 constructionRule: 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:
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 allowedThe 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:
// === 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:
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:
// 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 → LEAKC++20/23 Improvements: Designated Initializers
// 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.
