Ch16.3: public, protected and private Inheritance

中文(简体)

1. Three Kinds of Inheritance, Three Jobs

The word before the base class name is an access specifier, and it answers two questions at once: what may the derived class do with the base's members, and what may the rest of the program do with the derived-to-base relationship?

Written as Means Outside code may Use it for
: public base is-a convert D& to B&, use public base members the only kind that states a type relationship
: private base implemented in terms of nothing at all adopting an existing implementation and publishing exactly the operations you choose with using — §5, and this is one of the most useful idioms in the language
: protected base implemented in terms of nothing: the relationship is visible only inside the hierarchy a further-derived class must see the base's protected members — the rarest of the three

Members of the base keep their own access inside the derived class: base public members are usable by the derived class and by outside code (under public inheritance); base protected members are usable by the derived class and by further derived classes; base private members are usable by nobody but the base itself.

2. protected Is a Promise to Derived Classes


class machine_part
{
protected:
	::std::int_least32_t serial_{};

public:
	::std::int_least32_t serial() const noexcept { return serial_; }
};

class machine : public machine_part
{
	::std::int_least32_t power_{};

public:
	void set_serial(::std::int_least32_t s) noexcept
	{
		serial_ = s;              // legal: protected member, derived class
	}

	::std::int_least32_t power() const noexcept { return power_; }
};

serial_ is protected, so machine may write it but the rest of the world may only call serial(). That is a real choice with a real cost: every derived class is now part of the base's invariant. If machine_part later wants serial_ to be validated on every write, it cannot, because it handed the write permission to an unbounded number of future classes.

This book's rule, stated in Ch13.4, still holds: prefer private data, and reach for protected only when a derived class genuinely must maintain the invariant itself — a serialisation base, an iterator base, a stream base. A protected data member is public data with a smaller audience.

3. What Each Kind of Inheritance Allows, Measured

The differences are not a matter of opinion; they are visible in the type system, and ::std::is_convertible_v prints them (Ch15.1):


class base
{
protected:
	::std::int_least32_t prot_{7};

public:
	::std::int_least32_t pub_{1};
};

struct public_derived : public base
{
	[[nodiscard]] ::std::int_least32_t read() const noexcept { return prot_ + pub_; }
};

struct protected_derived : protected base
{
	[[nodiscard]] ::std::int_least32_t read() const noexcept { return prot_ + pub_; }
};

struct private_derived : private base
{
	[[nodiscard]] ::std::int_least32_t read() const noexcept { return prot_ + pub_; }
};

All three can read prot_ and pub_ and produce 8. Only the first is a base as far as the outside world is concerned. That is the whole difference, and it is exactly what makes private inheritance useful: everything of the base is available to you, and nothing of it is visible to your users until you say so.

4. using Chooses What to Publish

A using-declaration names a base member and re-publishes it at the access level where the declaration appears:


class buffer
{
protected:
	char *data_{};
	::std::size_t size_{};

public:
	[[nodiscard]] ::std::size_t size() const noexcept { return size_; }
};

class message : private buffer
{
public:
	using buffer::size;            // size() is public again
};

using buffer::size; changes the access of that one name inside message; the inheritance itself stays private, so a message& still does not convert to a buffer&. The next section uses this systematically.

5. Private Inheritance Is Very Useful: Selective Exposure

Consider building a stack on top of a vector. Composition means writing a forwarding function for every operation you want, and getting each signature, each overload and each noexcept/throws contract right by hand. Private inheritance plus using means writing nothing: you adopt the whole implementation and then publish a chosen subset of it, with the original signatures intact.


// adopt a vector's storage, publish only a stack's operations
class int_stack : private ::fast_io::vector<int>
{
	using base = ::fast_io::vector<int>;

public:
	using base::is_empty;
	using base::push_back;
	using base::size;

	[[nodiscard]] int top() const throws { return base::back(); }

	void pop() noexcept { base::pop_back(); }
};

size=3 is_empty=0 top=3
after pop: size=2 top=2

What the three using lines bought, and what they did not:

Compare with the alternative from Chapter 15: a concept plus a template is even more flexible, but it makes int_stack a template, and sometimes you want one concrete type. Private inheritance is the tool for “one concrete type that is a strict subset of an existing one”. The standard library itself uses it: ::std::stack in several implementations is a private adapter over ::std::deque, and the pre-C++11 ::std::istream-based adapters were built the same way.

Where it is the wrong tool: when the base is not a good implementation of your abstraction, when you want to expose most of the base anyway (then it is public inheritance or a member), and when you need to swap the implementation at run time (then it is a variant or an erased type).

6. Testing: #define private public

Access specifiers are a compile-time, header-level property, which means a test translation unit can change them before including the header it is testing:


// counter.hpp
#pragma once
#include <cstddef>

class counter
{
public:
	counter() noexcept = default;
	void bump() noexcept { ++hidden_; }
	[[nodiscard]] ::std::size_t value() const noexcept { return hidden_; }

private:
	::std::size_t hidden_{};
};

#include <fast_io.h>

#define private public
#include "counter.hpp"
#undef private

int main()
{
	try
	{
		counter c{};
		c.bump();
		c.hidden_ = 41;            // reachable only because of the #define
		::fast_io::io::println("value()=", c.value(), " hidden_=", c.hidden_);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln(e);
	}
}

value()=41 hidden_=41

Without the two preprocessor lines the same statement is rejected, which is what makes the trick worth knowing:


error: ‘std::size_t counter::hidden_’ is private within this context

It is very useful for testing — checking an invariant directly, setting up a state that the public interface cannot reach, asserting that a moved-from object really is empty — and it is a sharp tool. The rules that keep it from becoming a disaster:

7. The Whole Program


#include <fast_io.h>
#include <cstdint>
#include <type_traits>

class base
{
protected:
	::std::int_least32_t prot_{7};

public:
	::std::int_least32_t pub_{1};
};

struct public_derived : public base
{
	[[nodiscard]] ::std::int_least32_t read() const noexcept { return prot_ + pub_; }
};

struct protected_derived : protected base
{
	[[nodiscard]] ::std::int_least32_t read() const noexcept { return prot_ + pub_; }
};

struct private_derived : private base
{
	[[nodiscard]] ::std::int_least32_t read() const noexcept { return prot_ + pub_; }
};

int main()
{
	try
	{
		public_derived a{};
		protected_derived b{};
		private_derived c{};

		::fast_io::io::println("reads: ", a.read(), " ", b.read(), " ", c.read());
		::fast_io::io::println("public -> base convertible: ", ::std::is_convertible_v<public_derived &, base &>);
		::fast_io::io::println("protected -> base convertible: ", ::std::is_convertible_v<protected_derived &, base &>);
		::fast_io::io::println("private -> base convertible: ", ::std::is_convertible_v<private_derived &, base &>);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln(e);
	}
}

Output:


reads: 8 8 8
public -> base convertible: 1
protected -> base convertible: 0
private -> base convertible: 0

Three classes, one behaviour, and only one of them is a base. The two zeros are not a restriction to work around — they are the point: the implementation is adopted, the relationship is not published.

8. Rules of Thumb