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:

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

8. Rules of Thumb