第16.3章 public、protected 与 private 继承
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 换来了什么,以及没有带来什么:
- 没有样板代码。
push_back有两个重载(const 左值与右值) 和它自己的错误契约;using把两个都原样公开,永远与基类同步。 手写的转发函数会把它压成一个签名,最后还会丢掉契约。 - 别的都不泄漏。
int_stack没有operator[]、没有insert、没有data()、 没有迭代器、也没有push_back_unchecked—— 对任何调用者来说, 一个int_stack都不是::fast_io::vector<int>, 也不能传给需要它的地方。可替换性链条被有意切断了。 - 零空间代价。基类子对象就是整个对象,所以
sizeof(int_stack) == sizeof(::fast_io::vector<int>)。 用成员也一样大,但你必须给它起名字、写转发。 - 它仍然只是实现,不是类型。因为关系是 private 的,
基类的非虚析构函数无害:没人能通过
::fast_io::vector<int>*去删除一个int_stack(第16.2章)。
对比 第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
它对测试非常有用 —— 直接检查不变式、构造公开接口到不了的状态、 断言被移动后的对象真的是空的 —— 同时它也是一把快刀。让它不至于变成灾难的规则:
- 只在测试源文件里用。紧贴在
#include之前, 紧接其后#undef。绝不写在头文件里,绝不写进任何发布出去的东西。 - 它只改写
private这个记号。在第一个访问说明符之前声明的 成员仍然是 private 的,因为它位于class隐含的私有区;宏碰不到它。 (更重的手法#define class struct能改,但改的远不止这一点 —— 不要用。) - 严格说这是 ODR 违规:这个类在测试翻译单元里和在库里有着不同的定义。 访问说明符不影响布局,所以实践中什么都不会坏;而当被测库是一个你不会改其 ABI 的预编译 二进制时,它是无害的。
- 它把测试和实现绑在一起。测试读到的每个
hidden_都是作者明天可以改名的东西。测公开行为;只有公开接口观察不到的不变式才伸进去看。 - 用
#define/#undef成对写,不要用-Dprivate=public。命令行形式会作用于你包含的每一个头文件, 包括标准库的,那是在自找麻烦。
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. 经验法则
- is-a 那句话为真时用
public继承;想接管一个实现、并用using公开其中选定子集时用private继承。 - 优先用
private数据加protected访问函数,而不是protected数据成员。 - 不要在脑子里混淆“基类的访问说明符”和“成员的访问级别”:它们是相乘的。
private继承下派生类照样能用基类的public成员。 - private 继承下,把要公开的操作写成一组
using声明, 让公开接口一眼看得清。 - 记住 private 继承是实现细节:没有转换、不能持有基类指针、没有
virtual接口。这是目的,不是限制。 #define private public只属于测试源文件,并且要以#define/#undef成对包住一次包含。