第16.3章 public、protected 与 private 继承

English

1. 三种继承,三种职责

基类名前面那个词是访问说明符,它同时回答两个问题:派生类能用基类的哪些成员, 而程序的其余部分能用这个“派生到基类”的关系做什么?

写法 含义 外部代码可以 用来做什么
: public base is-a 把 D& 转成 B&,使用基类的 public 成员 唯一表达“类型关系”的一种
: private base 用……实现 完全不行 接管一个现成实现,再用 using 精确公开你选中的那些操作 —— 见 §5,这是语言里最有用的惯用法之一
: protected base 用……实现 不行:这个关系只在层次结构内部可见 更深层的派生类必须看到基类的 protected 成员 —— 三者中少见的一种

基类的成员在派生类内部保留它们自己的访问级别:基类的 public 成员派生类和外部代码 都能用(在 public 继承下);基类的 protected 成员派生类和更深层派生类能用; 基类的 private 成员除了基类自己谁都别想用。

2. protected 是对派生类的承诺


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_ 是 protected,所以 machine 可以写它, 但外部世界只能调用 serial()。这是一个真实的取舍,代价也真实: 现在每个派生类都成了基类不变式的一部分。如果 machine_part 将来想要求每次写入 都校验 serial_,它做不到 —— 因为它已经把写权限交给了一个数量不受限的未来类集合。

第13.4章里的规则仍然成立:数据优先 private,只有当派生类确实必须自己维持不变式时才用 protected —— 序列化基类、迭代器基类、流基类。一个 protected 数据成员,就是受众更小的 public 数据。

3. 每种继承允许什么:实测

这些差别不是口味问题,它们在类型系统里看得见,而 ::std::is_convertible_v 会把它们打印出来 (第15.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_; }
};

三者都能读 prot_ 与 pub_,都得到 8。 但对外部世界而言,只有第一个是一个 base。差别就这一点, 而这正是 private 继承有用的原因:基类的一切你都能用,而其中最没有一样 对你的使用者可见,除非你亲口说。

4. using 决定公开什么

一条 using 声明点名一个基类成员,并在声明所在的位置把它按那个访问级别 重新公开:


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; 只改变 message 内部那一个名字的访问级别; 继承关系本身依然是 private,所以 message& 仍然不能转成 buffer&。下一节系统性地使用它。

5. private 继承非常有用:选择性公开

设想在一个 vector 之上做一个栈。用组合,意味着你要为每个想公开的操作手写一个转发函数, 还要把每个签名、每个重载、每个 noexcept/throws 契约都写对。 用 private 继承加 using,意味着什么都不用写: 接管整个实现,然后公开其中你选中的子集,签名保持原样。


// 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

那三行 using 换来了什么,以及没有带来什么:

对比 第15章的替代方案:概念加模板更灵活, 但它让 int_stack 变成模板,而有时你要的正是一个具体类型。 private 继承是“一个具体类型,它是某个现成类型的严格子集”这件事的工具。标准库自己也在用: 若干实现里的 ::std::stack 就是建立在 ::std::deque 之上的私有适配器, C++11 之前那些基于 ::std::istream 的适配器也是同样的做法。

它用错的地方:当基类并不是你这个抽象的好实现时;当你其实想公开基类的大部分时 (那应该是 public 继承或成员);当你需要在运行期换实现时(那应该是 variant 或擦除类型)。

6. 测试:#define private public

访问说明符是编译期的、头文件层面的属性,这意味着一个测试翻译单元可以在包含被测头文件之前 改掉它们:


// 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

去掉那两行预处理指令,同一条语句就会被拒绝 —— 这正是这个技巧值得知道的原因:


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

它对测试非常有用 —— 直接检查不变式、构造公开接口到不了的状态、 断言被移动后的对象真的是空的 —— 同时它也是一把快刀。让它不至于变成灾难的规则:

7. 完整程序


#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);
	}
}

输出:


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

三个类,一种行为,而只有一个是 base。那两个 0 不是要绕开的限制 —— 它们就是目的:接管实现,但不公开关系。

8. 经验法则