Constructors and Destructors: Object Lifetime in C++
Understand how C++ constructors initialise objects and destructors clean them up — member initializer lists, constructor types, initialization order, and why destructors are the cornerstone of RAII.
By the end of this page you will write constructors with member initializer lists, understand the four main constructor types, know the exact order in which members are initialized, and write destructors that release resources reliably.
What and Why
Every C++ object has a lifetime: it is created, used, and destroyed. Constructors run at creation; destructors run at destruction. These two functions together give you a guarantee that no other major language provides: any resource you acquire in a constructor will be released when the object is destroyed — automatically, even when exceptions occur.
This guarantee is the foundation of RAII, and it is why C++ code that uses constructors and destructors correctly is more resource-safe than code that manages resources manually.
Step by Step
The default constructor
A constructor with no parameters (or all-default parameters) is the default constructor. The compiler generates one automatically if you declare no constructors at all:
struct Point {
int x;
int y;
};
Point p; // default-constructed; x and y have indeterminate values for local varsIf you declare any constructor yourself, the compiler stops generating the default constructor. You can ask for it back explicitly:
class Widget {
public:
Widget() = default; // ask the compiler to generate it anyway
Widget(int id) : id_(id) {}
private:
int id_ = 0; // default member initializer (C++11)
};Member initializer list
The body of a constructor (the { } part) runs after all members are already initialized. If you assign in the body, you are re-assigning, not initializing — which costs an extra operation for non-trivial types.
The member initializer list — the : member(value) part after the parameter list — is where members are actually initialized:
class Connection {
std::string host_;
int port_;
bool connected_;
public:
Connection(std::string host, int port)
: host_(std::move(host)) // initialized directly — no copy
, port_(port)
, connected_(false)
{
// body runs after all members are initialized
open();
}
};For const members and reference members, a member initializer is not optional — it is mandatory:
class Config {
const int max_connections_; // const: cannot be assigned after init
int& ref_; // reference: must be bound at construction
public:
Config(int max, int& target)
: max_connections_(max) // must be here
, ref_(target) // must be here
{}
};Initialization order
The order members are initialized is always the order they are declared in the class — not the order they appear in the initializer list. Mismatches cause subtle bugs:
class Danger {
int b_;
int a_; // declared after b_
public:
// Initializer list says a_ first, then b_ — but b_ is initialized FIRST
// because b_ is declared first. b_ = 0 (uninitialized a_) — undefined behaviour!
Danger() : a_(5), b_(a_) {}
};
class Safe {
int a_;
int b_; // declared after a_
public:
Safe() : a_(5), b_(a_) {} // a_ initialized first — then b_ = a_ = 5
};The compiler will warn about this mismatch with -Wreorder.
Constructor types
Parameterised constructor — takes arguments to set initial state:
class Rectangle {
double width_, height_;
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
double area() const { return width_ * height_; }
};
Rectangle r(3.0, 4.0);Copy constructor — creates a new object as a copy of an existing one:
class Buffer {
char* data_;
std::size_t size_;
public:
Buffer(const Buffer& other) // copy constructor
: size_(other.size_)
, data_(new char[size_])
{
std::memcpy(data_, other.data_, size_);
}
};
Buffer a(1024);
Buffer b = a; // invokes copy constructorMove constructor (C++11) — transfers resources from a temporary instead of copying them:
class Buffer {
char* data_;
std::size_t size_;
public:
Buffer(Buffer&& other) noexcept // move constructor
: data_(other.data_)
, size_(other.size_)
{
other.data_ = nullptr; // leave source in valid but empty state
other.size_ = 0;
}
};
Buffer make_buffer();
Buffer b = make_buffer(); // move constructor — no heap copyDelegating constructor (C++11) — one constructor calls another to avoid repeating initialization logic:
class Server {
std::string host_;
int port_;
int timeout_ms_;
public:
Server(std::string host, int port, int timeout_ms)
: host_(std::move(host)), port_(port), timeout_ms_(timeout_ms) {}
Server(std::string host, int port)
: Server(std::move(host), port, 5000) {} // delegates, sets default timeout
Server() : Server("localhost", 8080) {} // delegates further
};Destructors
The destructor runs when an object's lifetime ends — when it goes out of scope, when a container destroys it, when delete is called on a pointer. It has no parameters and no return type:
class FileHandle {
FILE* fp_;
public:
explicit FileHandle(const char* path)
: fp_(std::fopen(path, "r"))
{
if (!fp_) throw std::runtime_error("Cannot open file");
}
~FileHandle() { // destructor
if (fp_) std::fclose(fp_); // always runs, even if an exception is thrown
}
};
void read_config() {
FileHandle f("config.txt"); // constructor opens file
// ... read from f
} // destructor closes file here — no explicit close neededThe destructor runs in the reverse order of construction: members are destroyed in reverse declaration order, then the base class destructors run.
Virtual destructors are required in base classes when objects are deleted through a base pointer:
class Base {
public:
virtual ~Base() = default; // virtual! Without this, deleting a Derived* via Base* is UB
};
class Derived : public Base {
std::string data_;
public:
~Derived() override = default; // compiler generates cleanup of data_
};
Base* obj = new Derived();
delete obj; // calls ~Derived(), then ~Base() — correct because ~Base() is virtualCommon Patterns
Resource acquisition in constructor, release in destructor
class MutexLock {
std::mutex& mu_;
public:
explicit MutexLock(std::mutex& mu) : mu_(mu) { mu_.lock(); }
~MutexLock() { mu_.unlock(); }
// Prevent copying — a lock cannot be duplicated
MutexLock(const MutexLock&) = delete;
MutexLock& operator=(const MutexLock&) = delete;
};
void critical_section(std::mutex& mu) {
MutexLock lock(mu); // mutex locked here
// ... safe to access shared data
} // lock destroyed here — mutex released automaticallyRule of Three / Five
If you write a destructor that frees a resource, you almost certainly also need a copy constructor and copy assignment operator — or you should delete them. See Rule of Five.
What Can Go Wrong
Initialization order surprise
class Widget {
int size_;
std::vector<int> data_; // declared after size_
public:
Widget(int n) : data_(n), size_(data_.size()) {}
// BUG: size_ is initialized BEFORE data_ (declaration order).
// size_ = data_.size() where data_ is not yet constructed — UB.
};Fix: reorder declarations, or derive size_ from n directly.
Forgetting noexcept on move constructors
Without noexcept, std::vector cannot use the move constructor during reallocation — it falls back to copying. Always mark move constructors (and move assignment) noexcept when they genuinely cannot throw.
Throwing in a destructor
An exception escaping a destructor during stack unwinding (another exception already in flight) calls std::terminate. Destructors should never throw:
~Connection() {
try {
socket_.close(); // might throw — catch it!
} catch (...) {
// log the error, but do not propagate
}
}Quick Reference
| What | Syntax |
|---|---|
| Default constructor | Widget() = default; |
| Parameterised constructor | Widget(int x) : x_(x) {} |
| Member initializer list | : member_(val), other_(val2) |
| Copy constructor | Widget(const Widget&) |
| Move constructor | Widget(Widget&&) noexcept |
| Delegating constructor | Widget() : Widget(0) {} (C++11) |
| Destructor | ~Widget() { /* cleanup */ } |
| Virtual destructor | virtual ~Base() = default; |
| Suppress default | Widget() = delete; |
Destructor Order Guarantee
Destruction always happens in the exact reverse order of construction. This means members are destroyed in reverse declaration order, and in an inheritance hierarchy the most-derived class destructor runs first, working back up to the base class destructor last. The symmetry is intentional: whatever you acquired first, you release last.
#include <iostream>
struct Base {
Base() { std::cout << "Base constructed\n"; }
~Base() { std::cout << "Base destroyed\n"; }
};
struct Member {
std::string name;
Member(std::string n) : name(n) { std::cout << name << " constructed\n"; }
~Member() { std::cout << name << " destroyed\n"; }
};
struct Derived : Base {
Member first_{"first"};
Member second_{"second"};
Derived() { std::cout << "Derived constructed\n"; }
~Derived() { std::cout << "Derived destroyed\n"; }
};
int main() {
Derived d;
}
// Output:
// Base constructed
// first constructed
// second constructed
// Derived constructed
// Derived destroyed
// second destroyed <- reverse of construction
// first destroyed
// Base destroyed <- base lastThis guarantee makes RAII composable: a class holding two RAII members does not need to do anything special — their destructors fire in the right order automatically.
Virtual Destructors
If a class has any virtual method, its destructor must also be virtual. When you delete a derived object through a base-class pointer and the destructor is not virtual, the compiler only calls the base destructor — the derived destructor never runs, leaking whatever the derived class owned.
// WRONG — base destructor is not virtual
class Animal {
public:
virtual void speak() = 0;
~Animal() { std::cout << "Animal destroyed\n"; } // non-virtual!
};
class Dog : public Animal {
std::string name_;
public:
Dog(std::string n) : name_(std::move(n)) {}
void speak() override { std::cout << "Woof\n"; }
~Dog() { std::cout << "Dog " << name_ << " destroyed\n"; }
};
Animal* a = new Dog("Rex");
delete a; // UNDEFINED BEHAVIOUR — only ~Animal() runs, ~Dog() is skipped// CORRECT — add virtual to the base destructor
class Animal {
public:
virtual void speak() = 0;
virtual ~Animal() = default; // one keyword fixes everything
};
class Dog : public Animal {
std::string name_;
public:
Dog(std::string n) : name_(std::move(n)) {}
void speak() override { std::cout << "Woof\n"; }
~Dog() override = default; // 'override' is optional but documents intent
};
Animal* a = new Dog("Rex");
delete a; // correct: ~Dog() runs first, then ~Animal()A useful rule of thumb: if a class is meant to be inherited from (has any virtual method), declare virtual ~Base() = default; immediately. If a class is not meant to be polymorphic, mark it final or leave the destructor non-virtual.
noexcept on Destructors
Destructors are implicitly noexcept in C++11 and later — the compiler adds it for you. This is intentional: an exception escaping a destructor during stack unwinding (while another exception is already propagating) calls std::terminate immediately. There is no safe way to recover.
class Resource {
int* data_;
public:
explicit Resource(int n) : data_(new int[n]) {}
// Implicitly noexcept — the compiler infers this
~Resource() {
delete[] data_;
}
};Because noexcept is implicit, the only time you need to write it yourself is when the compiler cannot infer it — typically when the destructor calls a function whose noexcept status is unknown. In that case, declare it explicitly:
~MyClass() noexcept {
try {
flush_cache(); // might throw internally
} catch (...) {
// swallow — never let exceptions escape
}
}The general rule is: catch all exceptions inside the destructor body and handle or log them. Never let them escape.
Rule of Five in Practice
If you manage a raw resource in a class, you need all five special member functions: destructor, copy constructor, copy assignment, move constructor, and move assignment. The "Rule of Five" codifies this. Here is a complete, correct RAII class that wraps a heap-allocated array:
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
#include <utility> // std::exchange
class IntArray {
int* data_;
std::size_t size_;
public:
// 1. Constructor
explicit IntArray(std::size_t n)
: data_(n ? new int[n]() : nullptr), size_(n) {}
// 2. Destructor
~IntArray() {
delete[] data_;
}
// 3. Copy constructor — deep copy
IntArray(const IntArray& other)
: data_(other.size_ ? new int[other.size_] : nullptr)
, size_(other.size_)
{
std::copy(other.data_, other.data_ + size_, data_);
}
// 4. Copy assignment — copy-and-swap idiom
IntArray& operator=(IntArray other) noexcept {
swap(other); // other takes our old resources, cleans up in its dtor
return *this;
}
// 5. Move constructor — steal resources, leave source empty
IntArray(IntArray&& other) noexcept
: data_(std::exchange(other.data_, nullptr))
, size_(std::exchange(other.size_, 0))
{}
// Move assignment is handled by the copy-assignment operator above
// (because it accepts by value, move-constructing the argument when called with an rvalue)
void swap(IntArray& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
std::size_t size() const noexcept { return size_; }
int& operator[](std::size_t i) { return data_[i]; }
const int& operator[](std::size_t i) const { return data_[i]; }
};Notice that std::exchange is a clean way to move a value out and replace it with a safe sentinel in one expression. After a move, the source object must be in a valid but unspecified state — nullptr/0 satisfies that requirement for a raw pointer array.
Aggregate Initialization vs. Constructor
When a type has no user-provided constructors, no private or protected non-static data members, and no virtual functions, it is an aggregate — and brace initialization bypasses constructors entirely. Understanding the distinction prevents surprises.
// Aggregate — no constructor declared
struct Point {
int x;
int y;
};
Point p1{3, 4}; // aggregate initialization — directly sets x=3, y=4
Point p2 = {5, 6}; // also aggregate initialization// NOT an aggregate — has a user-provided constructor
class Pixel {
int x_, y_;
int color_;
public:
Pixel(int x, int y) : x_(x), y_(y), color_(0) {}
};
Pixel px{10, 20}; // calls Pixel(int, int) constructor — not aggregate initIn C++20, a class with a user-declared constructor (even = default) is no longer an aggregate. This tightened the rules to reduce surprising behavior:
struct C20Example {
int value;
C20Example() = default; // user-declared, not user-provided
};
// C++17: aggregate initialization still works — C20Example{42} is valid
// C++20: NOT an aggregate — C20Example{42} is an error (no matching constructor)When in doubt: if you want aggregate-style brace initialization, avoid declaring any constructor. If you need invariants, write a constructor and document that the type is not an aggregate.
Explicit Destructor Calls and Placement New
You can construct an object into memory you manage yourself using placement new, and you must then call the destructor explicitly when you are done. This is an advanced pattern used inside allocators, containers, and std::optional-like types — beginners rarely need it, but knowing it exists removes the mystery from library internals.
#include <new> // std::launder (C++17)
#include <cstdlib> // std::aligned_alloc / std::free
// Allocate raw, aligned storage
void* raw = std::aligned_alloc(alignof(std::string), sizeof(std::string));
// Construct a std::string in that storage using placement new
std::string* s = new (raw) std::string("hello");
// Use the object normally
(*s) += " world";
// Explicit destructor call — required because no delete[] will run here
s->~basic_string(); // or: s->~std::string();
// Release the raw memory (do NOT use 'delete s' — that would double-free)
std::free(raw);The explicit destructor call s->~basic_string() is the only correct way to end the object's lifetime without freeing the memory underneath it. Omitting it leaks whatever the object owns. Using delete s instead would also attempt to free raw a second time — undefined behaviour.
In everyday code, prefer std::optional, std::variant, or a custom allocator-aware container rather than managing placement new yourself.
Common Mistakes
Two mistakes recur so often in C++ codebases that they deserve a dedicated checklist.
Non-virtual destructor in a polymorphic base class
Inheriting from a class without a virtual destructor and then deleting through a base pointer is undefined behaviour. Static analysis tools (clang-tidy check cppcoreguidelines-virtual-class-destructor) catch this automatically. The fix is one keyword:
// Before — silent UB when deleting via base pointer
class Shape {
public:
virtual double area() = 0;
~Shape() {} // BUG: non-virtual
};
// After — correct
class Shape {
public:
virtual double area() = 0;
virtual ~Shape() = default; // FIXED
};Exception escaping a destructor
An exception that escapes a destructor while another exception is already unwinding the stack causes std::terminate. The pattern to avoid it is always the same — wrap everything in try/catch(...):
class NetworkConnection {
Socket socket_;
public:
~NetworkConnection() {
try {
socket_.shutdown(); // can throw if already disconnected
socket_.close();
} catch (const std::exception& e) {
log_error("shutdown failed: ", e.what());
} catch (...) {
log_error("unknown error during shutdown");
}
// execution reaches here cleanly — destructor exits normally
}
};Logging is fine inside a catch block in a destructor; re-throwing is never fine.
What's Next
- RAII Introduction — how constructors and destructors eliminate manual resource management entirely.
- Classes Basics — the full picture of writing C++ classes.
- Rule of Five — when to write (or delete) all five special member functions.