Chapter 5 gave you Vector with a constructor and destructor — but a class with a
destructor is only half the story. Chapter 6 completes it: constructors, destructors, and the
copy and move operations form a matched set that must be designed together, or the
class suffers logical or performance problems. This chapter explains the full complement of
"essential operations," why the default memberwise copy is a disaster for a resource handle
like Vector, how move semantics makes returning large objects cheap, and how
explicit, =default, and =delete give you precise
control over what the compiler generates. It closes with conventional operations
(==, swap(), hash<>), user-defined literals, and
the "rule of zero" — the guiding principle that makes all of this fit together.
Why essential operations matter now: Chapter 5's Vector was
carefully designed around RAII, but it was still incomplete — it copied by default, which
was subtly wrong. This chapter shows the whole matched set and the rule of zero:
most classes need no user-defined special members at all. You'll also see why move semantics
(the && from §5.2.3, now fully explained) is what makes returning
containers by value cheap, and why the standard library builds everything on it.
Some operations are fundamental: initialization, assignment, copy, and move — the
language rules make assumptions about them. Others, like == and
<<, have conventional meanings that it's perilous to ignore. Both
kinds are covered in this chapter.
Constructors, destructors, and copy and move operations are not logically separate — they must
be designed as a matched set. If a class
X has a destructor that performs a nontrivial task (free-store deallocation, lock
release), the class is likely to need the full complement:
class X {
public:
X(Sometype); // ''ordinary constructor'': create an object
X(); // default constructor
X(const X&); // copy constructor
X(X&&); // move constructor
X& operator=(const X&); // copy assignment: clean up target and copy
X& operator=(X&&); // move assignment: clean up target and move
~X(); // destructor: clean up
// ...
};
There are five situations in which an object can be copied or moved: as the source of an assignment, as an object initializer, as a function argument, as a function return value, and as an exception. Assignment uses a copy or move assignment operator; in principle the other cases use a copy or move constructor — but in practice the compiler often elides the copy by constructing the object directly in its target:
X make(Sometype);
X x = make(value); // compiler typically constructs the X from make() directly in x
Except for the ordinary constructor, these special member functions are generated by the
compiler as needed. You can be explicit with
=default and =delete:
class Y {
public:
Y(Sometype);
Y(const Y&) = default; // I really do want the default copy constructor
Y(Y&&) = default; // and the default move constructor
// ...
};
A =delete makes any attempted use a compile-time error — and it can suppress any
function, not just the essential members. The classic case is a base class in a hierarchy,
which must not be copied memberwise:
class Shape {
public:
Shape(const Shape&) = delete; // no copying
Shape& operator=(const Shape&) = delete;
// ...
};
void copy(Shape& s1, const Shape& s2) {
s1 = s2; // error: Shape copy is deleted
}
The rule of zero: define all of the essential operations or
none (using the defaults for all). A class whose members are themselves well-behaved
(like Vector and string) needs nothing:
struct Z {
Vector v;
string s;
};
Z z1; // default initialize z1.v and z1.s
Z z2 = z1; // default copy z1.v and z1.s — correct, because Vector's copy is correct
The one caution: when a class has a pointer member, it's usually a good idea to be explicit about copy and move — a pointer may point to something the class must delete (memberwise copy would be wrong) or must not delete (the reader wants to know). §6.2.1 shows the concrete failure.
A constructor taking a single argument defines a conversion from its argument type. For
complex that's ideal:
complex z1 = 3.14; // z1 becomes {3.14, 0.0}
complex z2 = z1 * 2; // z2 becomes z1*{2.0,0} == {6.28, 0.0}
But the same mechanism is dangerous for Vector:
Vector v1 = 7; // OK: v1 has 7 elements — but is that what the reader meant?
The standard-library vector does not allow this int-to-vector "conversion." The
fix is explicit:
class Vector {
public:
explicit Vector(int s); // no implicit conversion from int to Vector
// ...
};
Vector v1(7); // OK: v1 has 7 elements
Vector v2 = 7; // error: no implicit conversion from int to Vector
More types are like Vector than like complex: declare
single-argument constructors explicit unless there's a good reason not to.
A data member can carry its own default value — a default member initializer — used whenever a constructor doesn't provide one:
class complex {
double re = 0; // representation: two doubles with default value 0.0
double im = 0;
public:
complex(double r, double i) :re{r}, im{i} {} // construct from two scalars: {r,i}
complex(double r) :re{r} {} // construct from one scalar: {r,0}
complex() {} // default complex: {0,0}
// ...
};
This simplifies the constructors and, more importantly, makes it impossible to accidentally leave a member uninitialized.
By default, objects can be copied — user-defined types as well as built-ins. The default
meaning of copy is memberwise: copy each member. For a simple concrete type like
complex, that is exactly the right semantics. For a resource handle like
Vector, it is a disaster; for abstract types it almost never makes sense. When
you design a class, you must always consider if and how it might be copied.
When a class is a resource handle — responsible for an object accessed through a pointer — the
default memberwise copy violates its invariant. Two Vectors would share the same
elements:
void bad_copy(Vector v1) {
Vector v2 = v1; // copy v1's representation into v2
v1[0] = 2; // v2[0] is now also 2!
v2[1] = 3; // v1[1] is now also 3!
}
The fact that Vector has a destructor is a strong hint that default memberwise
copy is wrong — the compiler should at least warn. The fix is a proper
copy constructor and copy assignment that give each Vector its
own copy of the elements:
Vector::Vector(const Vector& a) // copy constructor
:elem{new double[a.sz]}, // allocate space for elements
sz{a.sz}
{
for (int i = 0; i != sz; ++i) // copy elements
elem[i] = a.elem[i];
}
Vector& Vector::operator=(const Vector& a) // copy assignment
{
double* p = new double[a.sz]; // allocate new space first
for (int i = 0; i != a.sz; ++i)
p[i] = a.elem[i];
delete[] elem; // delete old elements
elem = p;
sz = a.sz;
return *this; // *this: the object the member fn was called on
}
Note the order in the copy assignment: the new elements are copied
before the old ones are deleted, so if the copy throws an exception, the original
Vector is preserved — the strong exception-safety guarantee.
Copying is correct but can be costly for large containers. References avoid the cost when
passing into a function, but you can't return a reference to a local object — it's
destroyed before the caller sees it. Consider operator+:
Vector operator+(const Vector& a, const Vector& b) {
if (a.size() != b.size()) throw Vector_size_mismatch{};
Vector res(a.size());
for (int i = 0; i != a.size(); ++i)
res[i] = a[i] + b[i];
return res; // the local res is about to die — we want to move it out
}
With only copy operations, r = x+y+z copies a Vector at least twice
— embarrassing for 10,000 doubles. And the copies are pure waste: res is never
used again after the return. The move operations fix that:
class Vector {
public:
Vector(const Vector& a); // copy constructor
Vector& operator=(const Vector& a); // copy assignment
Vector(Vector&& a); // move constructor
Vector& operator=(Vector&& a); // move assignment
// ...
};
Vector::Vector(Vector&& a) // move constructor
:elem{a.elem}, // ''grab the elements'' from a
sz{a.sz}
{
a.elem = nullptr; // now a has no elements
a.sz = 0;
}
The && is an rvalue reference — a reference to a value you can't
assign to (an rvalue, like an integer returned from a function). Since nobody else can use
that value, the move constructor may safely "steal" it. A move constructor does
not take a const argument — it is supposed to remove the value from its
argument. And a move leaves the moved-from object in a state that allows a destructor to run
(and typically allows assignment too, as the standard-library algorithms assume).
Where the programmer knows a value won't be used again but the compiler can't prove it, use
std::move() — which doesn't actually move anything; it's a cast to an rvalue
reference:
Vector f() {
Vector x(1000), y(2000), z(3000);
z = x; // we get a copy (x might be used later in f())
y = std::move(x); // we get a move (move assignment)
// ... better not use x here ...
return z; // we get a move
}
Finally, copy elision: the standard obliges the compiler to eliminate most copies associated with initialization, so move constructors are invoked less often than you might imagine. But assignments are not elided, so move assignment can be critical for performance.
With the full set of essential operations, a programmer has complete control over a contained
resource's lifetime — and a move constructor lets an object move cheaply from one scope to
another. Things you can't or wouldn't want to copy out of a scope (a
thread holding a concurrent activity; a Vector of a million doubles)
can be moved out instead:
std::vector<thread> my_threads;
Vector init(int n) {
thread t {heartbeat}; // run heartbeat concurrently
my_threads.push_back(std::move(t)); // move t into my_threads
Vector vec(n);
for (auto& x : vec) x = 777;
return vec; // move vec out of init()
}
auto v = init(1'000'000); // start heartbeat and initialize v
Resource handles like Vector and thread are superior to direct use
of built-in pointers — in fact, the standard library's unique_ptr is itself a
resource handle. Done thoroughly, this achieves strong resource safety: no leaks for
a general notion of resource — memory, locks, sockets, file handles, thread handles.
Non-memory resources are called non-memory resources, and a good resource management
system handles all kinds.
Stroustrup's stance on garbage collection: it's the last choice, after cleaner and
better-localized alternatives are exhausted — "my ideal is not to create any garbage, thus
eliminating the need for a garbage collector: Do not litter!" Garbage collection is
fundamentally global, and locality matters more as systems become more distributed. Instead:
use resource handles, give each resource an owner in some scope, release at the end of the
owner's scope (RAII), move resources between scopes with move semantics or smart pointers. In
the standard library, RAII is pervasive: string, vector,
map, ifstream, thread, lock_guard,
unique_ptr, shared_ptr.
We can give meaning to most of C++'s operators for user-defined types — that's
operator overloading. It is not possible to define new operators
(**, ===, unary %…); allowing that would cause as much
confusion as good. And operators should be defined with conventional semantics — an
operator+ that subtracts would do nobody any good.
Overloadable: binary arithmetic (+ - * / %), bitwise (& | ^),
relational (== != < <= > >= <=>), logical (&& ||), unary (+ - ~ !), assignments (= += *=), increments and
decrements (++ --), pointer operations (->, unary *,
unary &), call (), subscript [], comma, and shift
<< >>. You cannot define operator . to get "smart
references."
An operator can be defined as a member function — conventionally for operators that
modify their first operand, and (for historical reasons) required for =,
->, (), and []:
class Matrix {
public:
Matrix& operator=(const Matrix& a); // assign a to *this; return a reference to *this
// ...
};
Or as a free-standing function — conventional for operators with symmetric operands, so both operands are treated identically (§5.2.1):
Matrix operator+(const Matrix& m1, const Matrix& m2); // return the sum
Returning a potentially large object relies on move semantics (§6.2.2) for good performance.
Some operations have conventional meanings that programmers and the standard library assume:
comparisons (§6.5.1), container operations (size(), begin(),
end(), §6.5.2), iterators and smart pointers (§6.5.3), I/O (>>
and <<, §6.5.4), swap() (§6.5.5), and hash functions
hash<> (§6.5.6). Conform to them when designing types for which they make
sense.
The meaning of equality is closely related to copying: after a copy, the copies should compare equal.
X a = something;
X b = a;
assert(a == b); // if a!=b here, something is very odd (§4.5)
If you define ==, also define != so that a!=b means
!(a==b). If you define <, define the rest so the usual
equivalences hold: a<=b means (a<b)||(a==b);
a>b means b<a; and so on. To treat both operands identically,
define binary operators as free-standing functions in the namespace of the class.
The spaceship operator <=> is a law onto itself. Defining the
default <=> implicitly defines all the other relational operators:
class R {
// ...
auto operator<=>(const R& a) const = default;
};
void user(R r1, R r2) {
bool b1 = (r1 <=> r2) == 0; // r1==r2
bool b2 = (r1 <=> r2) < 0; // r1<r2
bool b3 = (r1 <=> r2) > 0; // r1>r2
bool b4 = (r1 == r2); // implicitly defined
bool b5 = (r1 < r2); // implicitly defined
}
Like C's strcmp(), <=> implements three-way comparison:
negative means less-than, 0 equal, positive greater-than. If <=> is defined
non-default, == is not implicitly defined — but
< and the other relational operators are. The standard library's
string and vector follow the default pattern (lexicographical
order), and often provide a separate optimized == — comparing strings for
equality can stop at a length check, while <=> must read all characters to
determine the order.
Unless there is a really good reason not to, design containers in the style of the
standard-library containers: resource-safe, implemented as a handle with the appropriate
essential operations. Standard containers know their number of elements (size()),
and — more importantly — support iterators, pairs delimiting a sequence:
for (size_t i = 0; i != c.size(); ++i) // index-based traversal
c[i] = 0;
for (auto p = c.begin(); p != c.end(); ++p) // iterator-based
*p = 0;
for (auto& x : c) // range-for — built on begin() and end()
x = 0;
begin() points at the first element; end() points one-past-the-last.
Iterators support ++ (next) and * (access) like pointers, and are
used to pass sequences to standard algorithms:
sort(v.begin(), v.end());
The const versions are called cbegin() and cend(). This iterator
model gives great generality and efficiency (details in Chapters 12 and 13).
User-defined iterators and smart pointers implement the operators and aspects of a pointer
desired for their purpose: access (*,
->, []), iteration and navigation (++,
--, +=, -=, +, -), and
copy/move (=) — often adding semantics as needed.
For integers, << is left-shift and >>
is right-shift; for iostreams they are the output and input operators (§1.8, Chapter 11). Same
tokens, completely different conventional meanings, selected by overload resolution.
Many algorithms, most notably sort(), use a swap() that exchanges
two objects' values — and they assume it is very fast and doesn't throw. The standard library
provides std::swap(a,b) implemented as three move operations. If your type is
expensive to copy and could plausibly be swapped, give it move operations or a
swap() or both.
The standard-library unordered_map<K,V> is a hash table. To use a type
X as a key, you must define hash<X>; for common types like
std::string
the standard library defines it for you.
One purpose of classes is to mimic built-in types — and built-ins have
literals: 123 is an int, 0xFF00u is an
unsigned int, "Surprise!" is a const char[10].
User-defined literals (UDLs) give the same convenience to your types:
auto s1 = "Surprise!"s; // std::string
auto t = 123s; // seconds (chrono)
auto z = 12.7i + 47; // imaginary 12.7i plus 47 → complex {47, 12.7}
| Header | Namespace | Suffixes |
|---|---|---|
<chrono> |
std::literals::chrono_literals |
h, min, s, ms, us,
ns
|
<string> |
std::literals::string_literals |
s |
<string_view> |
std::literals::string_literals |
sv |
<complex> |
std::literals::complex_literals |
i, il, if |
UDLs are defined with literal operators, which convert a literal of their argument type followed by a suffix into their return type:
constexpr complex<double> operator""i(long double arg) // imaginary literal
{
return {0, arg};
}
operator"" introduces a literal operator; the suffix (i) follows it;
the argument type (long double) says the suffix applies to floating-point
literals; the return type (complex<double>) is the literal's type. Since
both the suffix implementation and + are constexpr,
2.7182818 + 6.283185i is computed at compile time.
Here is a summary of the guidance from this chapter. All 19 items, with the section where each is introduced. The C++ Core Guidelines link each item to its recommended practice.
| # | Guideline | § |
|---|---|---|
| 1 | Control construction, copy, move, and destruction of objects. | 6.1.1 |
| 2 | Design constructors, assignments, and the destructor as a matched set of operations. | 6.1.1 |
| 3 | Define all essential operations or none. | 6.1.1 |
| 4 | If a default constructor, assignment, or destructor is appropriate, let the compiler generate it. | 6.1.1 |
| 5 | If a class has a pointer member, consider if it needs a user-defined or deleted destructor, copy and move. | 6.1.1 |
| 6 | If a class has a user-defined destructor, it probably needs user-defined or deleted copy and move. | 6.2.1 |
| 7 | By default, declare single-argument constructors explicit. |
6.1.2 |
| 8 | If a class member has a reasonable default value, provide it as a data member initializer. | 6.1.3 |
| 9 | Redefine or prohibit copying if the default is not appropriate for a type. | 6.1.1 |
| 10 | Return containers by value (relying on copy elision and move for efficiency). | 6.2.2 |
| 11 | Avoid explicit use of std::copy(). |
16.6 |
| 12 | For large operands, use const reference argument types. |
6.2.2 |
| 13 | Provide strong resource safety; that is, never leak anything that you think of as a resource. | 6.3 |
| 14 | If a class is a resource handle, it needs a user-defined constructor, a destructor, and nondefault copy operations. | 6.3 |
| 15 | Manage all resources — memory and non-memory — using RAII. | 6.3 |
| 16 | Overload operations to mimic conventional usage. | 6.5 |
| 17 | If you overload an operator, define all operations that conventionally work together. | 6.1.1, 6.5 |
| 18 |
If you define <=> for a type as non-default, also define
==.
|
6.5.1 |
| 19 | Follow the standard-library container design. | 6.5.2 |
What does the rule of zero say about a class like
struct Z { Vector v; string s; };?
Why is the default memberwise copy a disaster for Vector (but fine for
complex)?
Why does Vector::operator= allocate the new array and copy elements
before calling delete[] elem?
Why does Vector v2 = 7; fail to compile when the constructor is declared
explicit Vector(int s)?
What does Vector::Vector(Vector&& a) do, and why is
a not const?
In y = std::move(x); (vs. z = x;) — what is
std::move(x) actually doing?
What does a default
auto operator<=>(const R& a) const = default; give you?
Which of these is NOT possible in C++ operator overloading?
What does
constexpr complex<double> operator""i(long double arg) enable?
essential ops ⇒ ctor, dtor, copy ctor/assign, move ctor/assign
designed as matched set ⇒ define all or none (rule of zero)
5 copy/move situations ⇒ assignment | initializer | argument | return | exception
=default ⇒ keep compiler version ; =delete ⇒ forbid use → compile error
pointer member ⇒ be explicit about copy/move
single-arg ctor ⇒ implicit conversion (eg int → complex)
explicit ⇒ only direct init → Vector v2 = 7 error
use explicit by default ⇒ more types like Vector than complex
default member initializer ⇒ double re = 0; used when ctor omits
benefit ⇒ no accidentally-uninitialized member
default copy ⇒ memberwise → right for complex, wrong for Vector
resource handle copy × memberwise ⇒ two handles share one array → double-delete
copy ctor ⇒ new array + copy elements
copy assign ⇒ allocate & copy first, delete old after → strong guarantee on throw
rvalue ref ⇒ && ; binds to values nobody else can
assign
move ctor ⇒ steal handle → null out source → dtor still safe
move args × const ⇒ moving removes the value
std::move ⇒ cast to rvalue ref ; selects move ops
copy elision ⇒ init copies eliminated ; assignments not
resource ⇒ anything acquired/released (eg memory, locks, sockets, threads)
RAII ⇒ owner in scope ; released at scope end
GC ⇒ last choice ; ''Do not litter!'' ; locality matters
strong resource safety ⇒ no leaks of any resource
overload existing ops × new tokens (eg **)
conventional semantics ⇒ mimic built-in usage
member ⇒ modifies first operand ; =, ->,
(), [] required member
free-standing ⇒ symmetric operands (eg +)
== ⇒ define with != ; copy must compare equal
<=> ⇒ three-way ; default → all relational ops
non-default <=> → < etc implicit,
== not
container ⇒ size(), begin(), end() ;
iterators for algorithms
swap() ⇒ 3 moves ; fast, no-throw for sort()
hash<> ⇒ needed for unordered_map keys
UDL ⇒ suffix on literal (eg "x"s, 123s,
12.7i)
literal operator ⇒ operator""suffix(arg)
constexpr ⇒ compile-time computation
Primary source: Stroustrup, B. (2022). A Tour of C++, 3rd ed.,
Chapter 6: "Essential Operations." Addison-Wesley.
Reference:
Chapter 1–6 Quick Reference & Glossary —
keep it beside you while you study.
Recommended supplement: cppreference on
copy constructor,
move constructor,
operator overloading,
defaulted comparisons, and
user-defined literals.
Questions? Ask your agent — your teacher — about anything unclear: why the rule of zero beats
hand-writing special members, how the move constructor interacts with
std::vector's reallocation, or when std::move is worth writing
explicitly. Follow-ups are expected, not optional.