Constructors, Destructors & Rule of 0/3/5
Master the full lifecycle of C++ objects — from construction through destruction — and learn when to write (or not write) special member functions.
Object Lifecycle in C++
In C++, you have direct control over when and how objects are created and destroyed. The compiler can generate up to six special member functions automatically: default constructor, copy constructor, copy assignment operator, move constructor, move assignment operator, and destructor. Understanding when to write your own — and when to let the compiler do it — is one of the most important skills in C++.
Constructor Varieties
Constructors initialize an object. C++ supports several kinds: default (no arguments), parameterized, converting (single argument, implicit conversion), and delegating (one constructor calls another).
#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;
}The explicit Keyword
Single-argument constructors enable implicit conversions, which can cause subtle bugs. Use explicit to prevent unintended conversions — this is almost always what you want.
#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 Initializer Lists & Initialization Order
The member initializer list is the preferred way to initialize members. It is required for const members, reference members, and members without a default constructor. Members are initialized in declaration order, not the order they appear in the initializer list.
#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;
}Destructors & Rule of Zero / Three / Five
A destructor (~ClassName()) runs when an object is destroyed — it releases resources and must not throw exceptions. For most classes, the compiler-generated destructor is sufficient.
The Rule of Zero: prefer RAII wrappers (std::string, std::vector, std::unique_ptr) so you never need to write any special member functions. If you must manage a raw resource, the Rule of Three says you need a destructor, copy constructor, and copy assignment operator. The Rule of Five extends this to include the move constructor and move assignment operator.
#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 and =delete
You can explicitly request the compiler-generated version of a special member with =default, or explicitly suppress it with =delete. This makes your intent clear and documents the class's copying/moving semantics.
#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;
}Members are initialized in the order they are declared in the class, not the order they appear in the initializer list. If one member depends on another, make sure the dependency is declared first. Compilers warn about this with -Wreorder — never ignore that warning. A classic bug: initializing size_ from a parameter but then using size_ to allocate data_, with data_ declared before size_.
- Use
expliciton single-argument constructors to prevent unintended implicit conversions - The member initializer list is required for
constand reference members, and more efficient for all members - Members initialize in declaration order, not initializer-list order
- Aim for the Rule of Zero — use RAII types so the compiler-generated special members are correct
- If you write any of destructor/copy-ctor/copy-assign, write all five (Rule of Five)
=defaultdocuments intent;=deleteexplicitly forbids an operation
Quiz — Test Your Knowledge
(20 XP)1. What does the `explicit` keyword on a constructor prevent?
2. In what order are class members initialized?
3. What is the Rule of Zero?