Adding an empty destructor, ~Logged() {}, to a class that held a ten-million-element std::vector changed std::move into a full copy. The move went from well under a microsecond to about 45 milliseconds, the “moved-from” object still held all its data, and neither GCC nor Clang said anything under -Wall -Wextra. Constructors and destructors look like the simplest part of a C++ class. They are also where the language quietly decides what your class can do, based on what you did and did not write.
This guide covers what constructors and destructors are, the exact order in which C++ builds and tears down an object, initializer lists and the member-order trap, delegating constructors, explicit, = default and = delete, the special member functions and the rule of zero, three and five, virtual destructors, and which destructors run when a program ends. Every program was compiled and run for this article on Ubuntu 24.04 with g++ 13.3 and clang++ 18.1.3 under -std=c++17 and -std=c++20 with -Wall -Wextra -pedantic. The working examples produce no warnings and run clean under AddressSanitizer and UndefinedBehaviorSanitizer; the deliberately broken ones are shown with the diagnostics they actually produced, or with none where the compilers stayed silent. All output is captured verbatim.
What Are Constructors and Destructors in C++?
A constructor is a special member function that runs when an object is created. It has the same name as the class and no return type, and its job is to put the object into a valid state. A destructor, named with a tilde (~Widget), runs automatically when the object’s lifetime ends and releases whatever the object owns. Together they tie a resource’s lifetime to an object’s lifetime, a technique known as RAII.
| Kind | Declared as | Runs when |
|---|---|---|
| Default constructor | Widget() | An object is created with no arguments |
| Parameterized constructor | Widget(int n) | An object is created with matching arguments |
| Copy constructor | Widget(const Widget&) | A new object is initialized from an existing one |
| Move constructor | Widget(Widget&&) noexcept | A new object is initialized from a temporary or std::move |
| Destructor | ~Widget() | Scope exit, delete, the end of the program for statics |
If a class declares no constructor, the compiler provides a default constructor. If a class declares no destructor, the compiler provides one that destroys each member. The basics of declaring them are covered in our guide to classes in C++; this guide concentrates on the rules that decide whether they behave as you expect. The formal rules are on cppreference’s constructors page.
The Order of Construction and Destruction
C++ builds an object from its parts: base classes first, then members in the order they are declared, then the body of the class’s own constructor. Destruction runs the same steps backwards:
// lifecycle.cpp - the order in which C++ builds and tears down an object
#include <iostream>
struct Tracer {
const char* name;
explicit Tracer(const char* n) : name(n) { std::cout << " construct " << name << '\n'; }
~Tracer() { std::cout << " destroy " << name << '\n'; }
};
struct Base {
Tracer b{"Base member"};
Base() { std::cout << " Base constructor body\n"; }
~Base() { std::cout << " Base destructor body\n"; }
};
struct Widget : Base {
Tracer first{"first member"};
Tracer second{"second member"};
Widget() { std::cout << " Widget constructor body\n"; }
~Widget() { std::cout << " Widget destructor body\n"; }
};
int main()
{
std::cout << "create:\n";
{
Widget w;
std::cout << "use\n";
} // w's lifetime ends here
std::cout << "done\n";
}
Output:
create:
construct Base member
Base constructor body
construct first member
construct second member
Widget constructor body
use
Widget destructor body
destroy second member
destroy first member
Base destructor body
destroy Base member
done
The rule behind the order is that every part an object depends on already exists when its constructor body runs, and still exists when its destructor body runs. Widget‘s constructor body can use first, second and everything in Base, and so can its destructor, because none of them is destroyed until the destructor body has finished.
Initializer Lists and the Member-Order Trap
Members should be initialized in the constructor’s initializer list, not assigned in its body. The list initializes each member directly; assignment in the body first default-constructs the member and then overwrites it. But the list has a rule that is easy to miss: members are initialized in the order they are declared in the class, whatever order the initializer list is written in.
// reorder.cpp - members are initialized in declaration order, not list order
#include <cstddef>
#include <iostream>
#include <vector>
class Grid {
public:
Grid(std::size_t rows, std::size_t cols)
: rows_(rows), cols_(cols), cells_(rows_ * cols_) {} // reads as if rows_ and cols_ come first
std::size_t size() const { return cells_.size(); }
private:
std::vector<int> cells_; // declared first, so initialized first
std::size_t rows_;
std::size_t cols_;
};
int main()
{
Grid g(3, 4);
std::cout << "expected 12 cells, got " << g.size() << '\n';
}
The initializer list reads as if rows_ and cols_ are set before cells_ uses them. They are not: cells_ is declared first, so it is constructed first, from two members that have not been initialized yet. Four builds gave three different results:
| Build | Output |
|---|---|
g++ 13.3 -O0 | terminate called after throwing an instance of 'std::length_error' / what(): cannot create std::vector larger than max_size() |
g++ 13.3 -O2 | expected 12 cells, got 0 |
clang++ 18.1.3 -O0 | expected 12 cells, got 0 |
clang++ 18.1.3 -O2 | expected 12 cells, got 12 |
Each result repeated across three runs. The last row is the dangerous one: the optimized Clang build printed the right answer from reading two uninitialized values, so a test run with that build would pass. Clang’s MemorySanitizer reports use-of-uninitialized-value in Grid::Grid. The compilers do warn here, which is a good reason to build with -Wall:
warning: 'Grid::cols_' will be initialized after [-Wreorder] (g++)
warning: field 'cols_' will be initialized after field 'cells_' [-Wreorder-ctor] (clang++)
warning: field 'rows_' is uninitialized when used here [-Wuninitialized] (clang++)
The fix is to declare members in the order they depend on each other, write the initializer list in the same order, and not initialize one member from another unless the order is guaranteed.
Delegating Constructors and Default Member Initializers
Overloaded constructors often share most of their work. Before C++11, a common attempt to reuse one constructor from another looked like this, and it does not do what it appears to:
// notdelegating.cpp - calling a constructor inside a constructor's body
#include <iostream>
class Brick {
public:
Brick(double length, double height, double width)
: length_(length), height_(height), width_(width) {}
explicit Brick(double length) {
Brick(length, 5.25, 4.55); // builds a temporary Brick and discards it
}
void show() const {
std::cout << length_ << " x " << height_ << " x " << width_ << '\n';
}
private:
double length_, height_, width_;
};
int main()
{
Brick b(5.55);
b.show();
}
Brick(length, 5.25, 4.55); inside the body does not initialize *this. It creates a separate, unnamed Brick, which is destroyed at the semicolon, and b‘s members are never set. What b.show() printed (three runs of each build):
| Build | Output (one run) |
|---|---|
g++ -O0 | 6.95302e-310 x 6.92933e-310 x 0 |
g++ -O2 | 6.95311e-310 x 6.95066e-310 x 0 |
clang++ -O0 | 6.93619e-310 x 0 x 6.95269e-310 |
clang++ -O2 | 0 x 6.95311e-310 x 6.95311e-310 |
The values changed from run to run, as expected of memory that was never written. Neither compiler produced a warning, even under -Wall -Wextra -pedantic -Werror. MemorySanitizer caught it (use-of-uninitialized-value).
C++11 added two features that remove the need for the trick. A delegating constructor names another constructor of the same class in its initializer list, and default member initializers give each member a value where it is declared:
// delegating.cpp - C++11 delegating constructors and default member initializers
#include <iostream>
class Brick {
public:
Brick(double length, double height, double width)
: length_(length), height_(height), width_(width) {}
explicit Brick(double length) : Brick(length, 5.25, 4.55) {} // delegates
Brick() = default; // uses the default member initializers below
void show() const {
std::cout << length_ << " x " << height_ << " x " << width_ << '\n';
}
private:
double length_ = 4.15; // default member initializers (C++11)
double height_ = 3.55;
double width_ = 3.75;
};
int main()
{
Brick standard;
Brick long_one(5.55);
Brick custom(3.25, 3.05, 3.25);
standard.show();
long_one.show();
custom.show();
}
Output:
4.15 x 3.55 x 3.75
5.55 x 5.25 x 4.55
3.25 x 3.05 x 3.25
Brick(double) hands all its work to the three-argument constructor. Brick() = default uses the values written next to each member, so the defaults live in one place rather than being repeated in several constructor bodies. A constructor that delegates may not also initialize members in its list; the target constructor does all of it.
explicit: Stopping Accidental Conversions
A constructor that can be called with one argument also defines an implicit conversion from that argument’s type:
// explicit.cpp - a one-argument constructor is also a conversion
#include <iostream>
class Timeout {
public:
Timeout(int ms) : ms_(ms) {} // not explicit
int ms() const { return ms_; }
private:
int ms_;
};
void connect(const char* host, Timeout t)
{
std::cout << "connecting to " << host << " with a " << t.ms() << " ms timeout\n";
}
int main()
{
int retries = 3;
connect("db.local", retries); // compiles: 3 becomes a Timeout
}
Output:
connecting to db.local with a 3 ms timeout
A retry count was passed where a timeout belonged, and the compiler converted it without comment. Marking the constructor explicit Timeout(int ms) turns the call into an error:
error: could not convert 'retries' from 'int' to 'Timeout' (g++)
error: no matching function for call to 'connect' (clang++)
Make single-argument constructors explicit unless the conversion is genuinely what the type means, as it is for std::string from a string literal.
= default and = delete
= default asks the compiler for its own version of a special member function; = delete forbids the operation entirely. They say what you mean more clearly than writing an empty body, and they are not always equivalent to one:
// valueinit.cpp - "= default" and "{}" are not the same constructor
#include <iostream>
struct Defaulted {
Defaulted() = default; // not user-provided
int count;
};
struct Empty {
Empty() {} // user-provided, does nothing
int count;
};
int main()
{
Defaulted a{}; // value-initialization: count is zeroed
Empty b{}; // calls Empty(): count is left indeterminate
std::cout << "Defaulted{}.count = " << a.count << '\n';
std::cout << "Empty{}.count = " << b.count << '\n';
}
Defaulted() = default is not user-provided, so Defaulted a{} performs value-initialization, which zeroes count. Empty() {} is user-provided, so Empty b{} just calls it, and count is left indeterminate. Both printed Defaulted{}.count = 0 in every build; Empty{}.count did not:
| Build | Empty{}.count over three runs |
|---|---|
g++ -O0 / -O2 | 0, 0, 0 |
clang++ -O0 | -1414713208, 1323564840, -2096107544 |
clang++ -O2 | -1424781287, -1338396647, -2042974183 |
GCC’s zeros were luck: the program still read an uninitialized value, as MemorySanitizer reported for line 19. Neither compiler warned. Prefer = default (or no declaration at all) to an empty body.
= delete makes an operation a compile error. A class that owns a file handle should not be copied, because two copies would close the same handle, but it can be moved, handing ownership to the new object:
// deleted.cpp - a type that can be moved but not copied
#include <cstdio>
#include <utility>
class File {
public:
explicit File(const char* path) : fp_(std::fopen(path, "w")) {}
~File() { if (fp_) std::fclose(fp_); }
File(const File&) = delete; // two owners would close twice
File& operator=(const File&) = delete;
File(File&& other) noexcept : fp_(std::exchange(other.fp_, nullptr)) {}
File& operator=(File&& other) noexcept {
if (this != &other) {
if (fp_) std::fclose(fp_);
fp_ = std::exchange(other.fp_, nullptr);
}
return *this;
}
bool is_open() const { return fp_ != nullptr; }
private:
std::FILE* fp_;
};
int main()
{
File a("/tmp/demo.txt");
File b = std::move(a); // fine: ownership moves
std::printf("a open: %d, b open: %d\n", a.is_open(), b.is_open());
// File c = b; // error: copy constructor is deleted
}
Output:
a open: 0, b open: 1
Uncommenting File c = b; gives error: use of deleted function 'File::File(const File&)' (g++) and error: call to deleted constructor of 'File' (clang++). File is also a complete RAII class: the constructor acquires the handle, the destructor releases it, and ownership can move but never be duplicated.
The Special Member Functions and What Triggers Each
Copy construction and copy assignment look similar in code and are different functions. This program traces which special member runs for each line:
// special.cpp - which special member function runs for each line
#include <iostream>
#include <utility>
struct Trace {
Trace() { std::cout << "default constructor\n"; }
Trace(const Trace&) { std::cout << "copy constructor\n"; }
Trace(Trace&&) noexcept { std::cout << "move constructor\n"; }
Trace& operator=(const Trace&) { std::cout << "copy assignment\n"; return *this; }
Trace& operator=(Trace&&) noexcept { std::cout << "move assignment\n"; return *this; }
~Trace() { std::cout << "destructor\n"; }
};
Trace make() { return Trace(); }
int main()
{
std::cout << "1. Trace a; -> "; Trace a;
std::cout << "2. Trace b = a; -> "; Trace b = a;
std::cout << "3. b = a; -> "; b = a;
std::cout << "4. Trace c = std::move(a); -> "; Trace c = std::move(a);
std::cout << "5. Trace d = make(); -> "; Trace d = make();
std::cout << "6. end of main:\n";
}
Output:
1. Trace a; -> default constructor
2. Trace b = a; -> copy constructor
3. b = a; -> copy assignment
4. Trace c = std::move(a); -> move constructor
5. Trace d = make(); -> default constructor
6. end of main:
destructor
destructor
destructor
destructor
Line 2, Trace b = a;, is copy construction, even though it contains =: a new object is being initialized. Line 3, b = a;, is copy assignment, because b already exists. Line 5 ran only the default constructor: since C++17, returning a temporary of the same type initializes the caller’s object directly, with no copy or move at all. (Built as C++14 with -fno-elide-constructors, the same line also ran the move constructor twice.)
The Rule of Zero, Three and Five
Whether the compiler generates each special member depends on which ones you declare yourself, and the rules are not symmetrical. These were checked with static_assert on both compilers:
| If you declare… | Default constructor | Copy constructor and assignment | Move constructor and assignment |
|---|---|---|---|
| Nothing | Generated | Generated | Generated (and noexcept if the members’ are) |
| Any constructor | Not generated | Generated | Generated |
| A destructor | Generated | Generated (deprecated) | Not declared: moves become copies |
| A copy constructor or copy assignment | Not generated if you declared the copy constructor (it is a constructor); generated if you declared only copy assignment | The other one is generated (deprecated) | Not declared: moves become copies |
| A move constructor or move assignment | Not generated if you declared the move constructor; generated if you declared only move assignment | Deleted | The other one is not declared |
That is the reason behind three well-known rules. The rule of zero: if every member manages itself (std::string, std::vector, std::unique_ptr), declare none of the five and the compiler generates all of them correctly. The rule of three: a class that needs a custom destructor almost certainly needs a custom copy constructor and copy assignment too, or copies will share what the destructor releases; the guide to operator overloading in C++ shows a copy assignment that survives self-assignment. Our guide to encapsulation shows a class that crashed with a double free for exactly that reason. The rule of five extends it to the two move operations. cppreference’s rule of three/five/zero page states them formally.
The third row of that table is the one that causes silent damage, because a type that can only be copied still compiles wherever a move was requested:
// nomove.cpp - declaring a destructor quietly turns moves into copies
#include <chrono>
#include <iostream>
#include <utility>
#include <vector>
struct Plain { // rule of zero: no special members declared
std::vector<double> samples;
};
struct Logged { // identical, plus a destructor
std::vector<double> samples;
~Logged() {} // e.g. added later for logging or a breakpoint
};
template <class T>
__attribute__((noinline)) T take(T& from) { return std::move(from); } // keeps the timer honest
template <class T>
void transfer(const char* label)
{
T source{std::vector<double>(10'000'000, 1.0)};
auto t0 = std::chrono::steady_clock::now();
T target = take(source);
auto t1 = std::chrono::steady_clock::now();
std::cout << label << ": source still holds " << source.samples.size()
<< " samples, target holds " << target.samples.size() << ", took "
<< std::chrono::duration<double, std::micro>(t1 - t0).count() << " us\n";
}
int main()
{
transfer<Plain>("Plain ");
transfer<Logged>("Logged");
}
Output (g++ 13.3, -O2):
Plain : source still holds 0 samples, target holds 10000000, took 0.149 us
Logged: source still holds 10000000 samples, target holds 10000000, took 51678.6 us
Plain follows the rule of zero, so std::move transferred the vector’s buffer and left the source empty. Logged differs only by ~Logged() {}, which removed the implicit move constructor. std::move produced an rvalue, the rvalue bound to the copy constructor, and all ten million elements (80 MB) were copied. The source still held every sample. Five runs at -O2:
| Build | Plain move (median) | Logged “move” (median) |
|---|---|---|
| g++ 13.3 | 0.224 µs | 44,874 µs |
| clang++ 18.1.3 | 0.218 µs | 42,929 µs |
Neither compiler warned under -Wall -Wextra. g++ warns only with -Wdeprecated-copy-dtor (implicitly-declared 'Logged::Logged(const Logged&)' is deprecated), and clang++ only with -Wdeprecated (definition of implicit copy constructor for 'Logged' is deprecated because it has a user-provided destructor). The type also stopped being nothrow-move-constructible, which is exactly the property std::vector checks before deciding to move elements instead of copying them when it grows.
If a class needs a destructor only for logging or a breakpoint, write ~Logged() = default; and restore the moves explicitly with Logged(Logged&&) = default; and Logged& operator=(Logged&&) = default;. Better still, leave the destructor out.
Virtual Destructors
A class meant to be used through a base-class pointer needs a virtual destructor. Without one, deleting a derived object through the base pointer runs only the base destructor:
// virtual_dtor.cpp - deleting a derived object through a base pointer
#include <iostream>
#include <memory>
#include <vector>
struct Shape {
virtual double area() const = 0;
~Shape() { std::cout << " ~Shape\n"; } // not virtual
};
struct Polygon : Shape {
std::vector<double> points = std::vector<double>(1000);
double area() const override { return 0.0; }
~Polygon() { std::cout << " ~Polygon\n"; }
};
int main()
{
std::unique_ptr<Shape> s = std::make_unique<Polygon>();
std::cout << "deleting through Shape*:\n";
} // unique_ptr calls delete on a Shape*
Output:
deleting through Shape*:
~Shape
~Polygon never ran, so its vector was never freed. The behavior is undefined, and AddressSanitizer stopped the program with new-delete-type-mismatch. The two compilers disagreed about warning: clang++ issued -Wdelete-abstract-non-virtual-dtor (delete called on 'Shape' that is abstract but has non-virtual destructor), while g++ said nothing under -Wall -Wextra. g++ does have the warning: the same mistake written as a plain delete p; in user code produced deleting object of polymorphic class type 'S' which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]. Here the delete happens inside std::unique_ptr‘s library code, and the warning did not appear. With virtual ~Shape(), both builds printed:
deleting through Shape*:
~Polygon
~Shape
Rule of thumb: if a class has any virtual function, give it a public virtual destructor. If it is not meant to be deleted through a base pointer, make the destructor protected and non-virtual instead.
Which Destructors Run When the Program Ends
Destructors run when an object’s lifetime ends, and how the program ends decides which lifetimes end normally. This program has a global, a function-level static, a local, and a heap object that is never deleted:
// atexit.cpp - which destructors run when the program ends
#include <cstdlib>
#include <cstring>
#include <iostream>
struct Tracer {
const char* name;
explicit Tracer(const char* n) : name(n) {}
~Tracer() { std::cout << " destroy " << name << std::endl; }
};
Tracer global("global");
int main(int argc, char** argv)
{
static Tracer function_static("function static");
Tracer local("local");
Tracer* heap = new Tracer("heap object (never deleted)");
(void)heap;
const char* how = argc > 1 ? argv[1] : "return";
std::cout << "ending with " << how << ":" << std::endl;
if (std::strcmp(how, "exit") == 0) std::exit(0);
if (std::strcmp(how, "abort") == 0) std::abort();
return 0;
}
Output (g++ and clang++, identical):
ending with return:
destroy local
destroy function static
destroy global
(exit status 0)
ending with exit:
destroy function static
destroy global
(exit status 0)
ending with abort:
(exit status 134)
returnfrommaindestroys locals, then statics and globals in reverse order of construction.std::exitdestroys statics and globals but not locals: the stack is abandoned.std::abortdestroys nothing.- A heap object that is never deleted is never destroyed, however the program ends. The operating system reclaims the memory, but the destructor’s other work (flushing a file, sending a disconnect, removing a lock file) never happens. AddressSanitizer reported the object as an 8-byte leak.
An exception that nobody catches behaves like std::abort on GCC and Clang: no destructors run. The guide to exception handling in C++ demonstrates that, and why a constructor that throws never has its destructor called.
Common Mistakes
| Mistake | What happened in testing | Fix |
|---|---|---|
| Initializer list in a different order from the declarations | Three different results in four builds, one of them correct by accident; both compilers warned | Declare members in dependency order and match it in the list |
| Calling a constructor inside another constructor’s body | Members left uninitialized; no warning | A delegating constructor: Brick(double l) : Brick(l, 5.25, 4.55) {} |
Empty {} constructor instead of = default | T{} left a member indeterminate; no warning | = default, plus default member initializers |
Single-argument constructor without explicit | An int silently became a Timeout | explicit |
| A destructor added to a rule-of-zero class | std::move copied 80 MB; no warning under -Wall -Wextra | Omit the destructor, or default all five |
| No virtual destructor in a polymorphic base | Derived destructor skipped; g++ silent | virtual ~Base() |
Relying on destructors after std::exit or for leaked heap objects | Locals and heap objects were never destroyed | Return from main; own heap objects with std::unique_ptr |
Key Takeaways
- An object is built from its bases, then its members in declaration order, then its constructor body, and destroyed in exactly the reverse order.
- The initializer list does not control initialization order; the declarations do. One build of the reordered class printed the right answer from uninitialized data.
- Use delegating constructors and default member initializers. Calling a constructor inside a constructor’s body creates a temporary and left every member uninitialized, with no warning.
- Mark single-argument constructors
explicit. = defaultis not the same as an empty body. Only the defaulted version letT{}zero the members.- Follow the rule of zero. Declaring just a destructor turned a 0.2 µs move into a 45 ms copy of 80 MB, and neither compiler warned by default.
- Give polymorphic base classes a virtual destructor. Without it, the derived destructor never ran.
std::exitskips local destructors,std::abortskips all of them, and a leaked heap object is never destroyed.
Frequently Asked Questions
Conclusion
The rules for constructors and destructors are few, and every one of them is about order and ownership: what exists before the constructor body runs, what is left to clean up after the destructor body, and which object is responsible for a resource at any moment. The bugs in this article came from code that looked reasonable and broke one of those rules without a word from the compiler. Let members manage themselves, declare them in the order they depend on each other, and write special member functions only when the class truly owns something by hand.
The rest of the C++ programming tutorials build on these foundations, from operator overloading to templates and the standard containers.


