Ch16.4: The Rule of Five in a Derived Class
1. A Derived Class Has Five Members Built Out of Its Parts
Chapter 14 listed the five operations every class that owns something must get right. In a derived class the same five exist, and every one of them has the same shape: do it to the base subobject, then to every member. The compiler writes them that way when you do not, and that default is correct as long as the parts behave.
| Member | What it must do with the base | Contract |
|---|---|---|
| copy constructor | copy-construct the base subobject | noexcept only if the base's copy is |
| move constructor | move-construct the base subobject | noexcept only if the base's move is |
| copy assignment | base::operator=(other) — explicitly |
what the base's assignment gives |
| move assignment | base::operator=(::std::move(other)) |
what the base's move assignment gives |
| destructor | destroy the base subobject last | always noexcept |
swap |
swap the base subobject as a base& |
noexcept only if the base's swap is |
Three of those are automatic. The interesting question is which ones you have to write, and the answer is the same as in Ch14.1: write the ones your base and members cannot do for you.
2. Defaulting Is Not the Same as Forget
A derived class that writes nothing gets all five, implicitly defined, each one doing the right thing with the base subobject. That is the rule of zero, and it is the preferred answer whenever the base and every member are well behaved:
struct quiet_base
{
::std::size_t id{};
};
struct rule_of_zero : quiet_base
{
::std::size_t extra{};
};
rule_of_zero is copyable, movable, destructible, and its move is
noexcept — the compiler worked all of that out from
::std::size_t. Writing = default for each of the five changes
nothing, and writing a body for each is how bugs get in.
What the compiler does not do is compensate for a part that is broken. If a base deletes its copy constructor, the derived class has no copy constructor, and the trait says so:
struct no_copy_base
{
::std::size_t id{};
no_copy_base() noexcept = default;
no_copy_base(no_copy_base const &) = delete;
};
struct derived_no_copy : no_copy_base
{
::std::size_t extra{};
};
class guarded
{
~guarded() noexcept = default; // private destructor on purpose
};
struct derived_from_guarded : guarded
{
};
is_copy_constructible<derived_no_copy>=0
is_destructible<guarded>=0
is_destructible<derived_from_guarded>=0
The second and third lines are the same rule applied to destruction: a base whose
destructor is inaccessible (here private) makes the derived destructor
deleted too. That is one more reason to keep base destructors
public and noexcept.
3. The Move Constructor Must Move the Base
derived(derived &&other) noexcept : base{::std::move(other)}, extra{other.extra}
{
other.extra = 0;
}
::std::move(other) has type derived&&, and a
derived&& binds to a base&& parameter, so the
base subobject is move-constructed by the base's move constructor
(Ch16.5). Two things follow:
- Both halves are moved, and both halves are left empty. The base's move constructor zeroes its own state; the body zeroes the member. A moved-from derived object is a moved-from base plus a moved-from member (Ch14.10).
noexceptis a promise about the base too. If the base's move can throw, this constructor cannot claimnoexcept. Write the contract conditionally and let the compiler check it (Ch15.8):throws(!noexcept(base{::std::move(other)})).
And verify with the traits rather than by memory, because
::std::is_nothrow_move_constructible_v answers exactly this question for
the whole object — base plus every member.
4. Copy Assignment: the Line Everybody Forgets
Assignment operators are not inherited and are not generated from the base's: you write the derived one and you must assign the base subobject yourself. Forgetting that line compiles, runs, and leaves the base part of the object untouched:
// the same class with the classic bug: the base is never assigned
struct buggy : base
{
::std::int_least32_t extra{};
explicit buggy(::std::size_t i, ::std::int_least32_t e) noexcept : base{i}, extra{e} {}
buggy &operator=(buggy const &other) noexcept
{
extra = other.extra; // base::operator= is missing
return *this;
}
};
buggy after assign: u.id=7 (expected 9) u.extra=90
u was given v, the member was copied, and the base kept the
old id. No warning, no error: the base subobject simply was not part of the
assignment. The fix is one line in both assignment operators:
derived &operator=(derived const &other) noexcept
{
base::operator=(other);
extra = other.extra;
return *this;
}
derived &operator=(derived &&other) noexcept
{
base::operator=(::std::move(other));
extra = other.extra;
other.extra = 0;
return *this;
}
Two more rules about assignment in a hierarchy:
5. swap and Copy-and-Swap
A derived swap is the same two-step pattern as any other member: swap the
base, then swap the members. The base has to be named as a
base& so that overload resolution finds the base's swap
rather than recursing into this one:
void swap(derived &other) noexcept
{
using ::std::swap;
swap(static_cast<base &>(*this), static_cast<base &>(other));
swap(extra, other.extra);
}
With those two lines, the copy-and-swap assignment of Ch14.8 works unchanged for derived classes, and it keeps the strong exception-safety guarantee: the new value is built completely before anything is touched.
6. The Whole Program
#include <fast_io.h>
#include <cstddef>
#include <cstdint>
#include <utility>
#include <type_traits>
struct base
{
::std::size_t id{};
base() noexcept = default;
explicit base(::std::size_t i) noexcept : id{i} {}
base(base const &) noexcept = default;
base(base &&other) noexcept : id{other.id} { other.id = 0; }
base &operator=(base const &) noexcept = default;
base &operator=(base &&other) noexcept
{
id = other.id;
other.id = 0;
return *this;
}
~base() noexcept = default;
};
// the derived class that does it right
struct derived : base
{
::std::int_least32_t extra{};
derived() noexcept = default;
explicit derived(::std::size_t i, ::std::int_least32_t e) noexcept : base{i}, extra{e} {}
derived(derived const &) noexcept = default;
derived(derived &&other) noexcept : base{::std::move(other)}, extra{other.extra}
{
other.extra = 0;
}
derived &operator=(derived const &other) noexcept
{
base::operator=(other); // the line everybody forgets
extra = other.extra;
return *this;
}
derived &operator=(derived &&other) noexcept
{
base::operator=(::std::move(other));
extra = other.extra;
other.extra = 0;
return *this;
}
~derived() noexcept = default;
void swap(derived &other) noexcept
{
using ::std::swap;
swap(static_cast<base &>(*this), static_cast<base &>(other));
swap(extra, other.extra);
}
};
// the same class with the classic bug: the base is never assigned
struct buggy : base
{
::std::int_least32_t extra{};
explicit buggy(::std::size_t i, ::std::int_least32_t e) noexcept : base{i}, extra{e} {}
buggy &operator=(buggy const &other) noexcept
{
extra = other.extra; // base::operator= is missing
return *this;
}
};
int main()
{
try
{
derived a{1, 10};
derived b{::std::move(a)};
::fast_io::io::println("after move: b.id=", b.id, " b.extra=", b.extra, " a.id=", a.id, " a.extra=", a.extra);
derived c{2, 20};
c = b;
::fast_io::io::println("after copy assign: c.id=", c.id, " c.extra=", c.extra);
buggy u{7, 70};
buggy v{9, 90};
u = v;
::fast_io::io::println("buggy after assign: u.id=", u.id, " (expected 9) u.extra=", u.extra);
derived x{3, 30};
derived y{4, 40};
x.swap(y);
::fast_io::io::println("after swap: x.id=", x.id, " y.id=", y.id);
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
Output:
after move: b.id=1 b.extra=10 a.id=0 a.extra=0
after copy assign: c.id=1 c.extra=10
buggy after assign: u.id=7 (expected 9) u.extra=90
after swap: x.id=4 y.id=3
Four lines, four lessons. The move took both halves and emptied both halves of the source. The copy assignment took both halves. The buggy assignment took one. The swap exchanged the base subobjects as well as the members.
7. Contracts: A Derived Constructor Inherits Every Failure
- A base constructor that allocates is
throws, so every derived constructor isthrowstoo, whether or not it does anything itself (Ch16.2). - A derived destructor is
noexcept, therefore a base destructor must benoexceptas well — and it is, or the program would not compile. - If the base's move constructor is
throws, the derived move cannot benoexcept. Say so explicitly withthrows(!noexcept(base{::std::move(other)}))so that the condition lives next to the code that made it true. - Copy assignment is
noexceptonly if the base's is; if any member allocates, it isthrowsand copy-and-swap is the shape to use.
8. Rules of Thumb
- Try the rule of zero first: if the base and members behave, write nothing for the derived class.
- If you write a move constructor, move the base in the initialiser list and empty both halves.
- If you write an assignment operator, assign the base first. It is the single most commonly forgotten line in C++ inheritance.
- If you write a
swap, swap the base as abase&andusing ::std::swap;first, so the members and the base use the same lookup. - Do not
= defaulta member you have also written; the two cannot coexist, and the compiler will tell you. - When in doubt about a contract, measure:
::std::is_nothrow_move_constructible_v<derived>answers for the whole object, base included.