第16.8章 继承还是组合?
1. 复用有两种写法
车有一台发动机。把它写成继承,得到的句子是“车是发动机”,这是假的,而这个假有后果:
每个 engine& 形参都会接受一辆车,一辆车不能有两台发动机,
而改动 engine 会悄悄改变 car 的公开接口。
struct engine
{
::std::int_least32_t power{};
};
struct car_inherits : engine // "a car is an engine"
{
::std::string_view model{};
};
struct car_has // "a car has an engine"
{
engine eng{};
::std::string_view model{};
};
两者在这台机器上一样大(24 字节:一个
::std::int_least32_t 加一个 ::std::string_view,凑到
8 的倍数),存的数据也一样。不同的是类型关系,而价值全在那里。
2. 组合买到了什么
| 继承 | 组合 | |
|---|---|---|
| is-a 断言的正确性 | 必须字面为真 | 根本不断言 |
| 能否装两台发动机 | 不能 | 能,再加一个成员 |
| 部件接口变化时 | 整体接口跟着变,可能还动 ABI | 被包住了;只有包装类自己的成员才算数 |
| 访问控制 | 继承来的成员按名字就能用 | 只能通过成员名用,包装类因此能设关卡 |
| 运行期换实现 | 不行(基类子对象换不掉) | 可以,如果成员是 variant 或擦除类型 |
| sizeof | 与部件加上新成员相同 | 相同,除非部件是空类型(§4) |
倒数第二行要记住:成员是一道边界。每个派生类永远待在基类的不变式里; 每个持有成员的人都能决定公开什么。
3. 继承才是对的答案的情形
当下面四条全部成立时,继承才配得上它的位置:
- is-a 那句话对每个调用者都成立,
- 基类就是设计来被继承的(有文档、有正确的析构函数、该
protected的地方protected), - 派生类增加的是行为,不只是数据,
- 派生类型集合要么封闭且已知,要么基类真的是一个接口。
本章自己的例子都通过:labeled_point 是一个点,machine 是一个
machine_part。车在第一条就失败了,再怎么共享实现也救不回来。
4. 空基类:继承在尺寸上真正赢的唯一地方
有一种情况下继承在 sizeof 上胜过成员:空基类不占字节,而空成员仍然必须可寻址
—— 至少要一个字节,再加填充。
struct empty
{
};
struct with_member
{
empty e{};
::std::int_least32_t x{};
};
struct with_member_no_unique
{
[[no_unique_address]] empty e{};
::std::int_least32_t x{};
};
struct derived : empty
{
::std::int_least32_t x{};
};
| 类型 | sizeof |
原因 |
|---|---|---|
empty |
1 |
每个对象都需要唯一地址 |
derived |
4 |
空基类优化:基类不占空间 |
with_member |
8 |
成员必须有自己的地址,于是 1 字节 + 3 填充 |
with_member_no_unique |
4 |
[[no_unique_address]](C++20)给了成员同样的许可 |
C++20 之后尺寸这条理由也没了:[[no_unique_address]] 让空成员免费,
所以组合不再花字节。注意它没有解决的问题:类型关系、两台发动机的问题、接口的 containment。
尺寸从来都不是有意思的部分。
5. 第三种选择:完全没有关系
当层次结构的理由是“这些东西必须被统一处理”时,继承和成员都不是现代答案:
- 封闭的类型集合:
::std::variant加访问者。 没有基类、没有指针、没有分配,而编译器会检查每一个分支。 - 开放的集合:第15.10章 的类型擦除 —— 一个非模板类、一张函数指针表、值语义,完全没有继承。
- 编译期要求:概念加模板 (第15.3章),运行期零成本, 但把调用者锁定在一种类型上。
继承是这张清单里第四种情况的正解,对前三种都是错解。fast_io 自己的容器就是这样: 它们不共享基类,它们是模板,而真的需要一个公共类型时,库选择擦除而不是搬出一个基类。
6. 完整程序
#include <fast_io.h>
#include <cstdint>
#include <cstddef>
#include <string_view>
struct engine
{
::std::int_least32_t power{};
};
struct car_inherits : engine
{
::std::string_view model{};
};
struct car_has
{
engine eng{};
::std::string_view model{};
};
struct empty
{
};
struct with_member
{
empty e{};
::std::int_least32_t x{};
};
struct with_member_no_unique
{
[[no_unique_address]] empty e{};
::std::int_least32_t x{};
};
struct derived : empty
{
::std::int_least32_t x{};
};
int main()
{
try
{
car_inherits a{};
car_has b{};
a.power = 120;
b.eng.power = 120;
::fast_io::io::println("inherits: ", a.power, " sizeof=", sizeof(car_inherits));
::fast_io::io::println("has: ", b.eng.power, " sizeof=", sizeof(car_has));
::fast_io::io::println("sizeof(empty)=", sizeof(empty), " sizeof(derived)=", sizeof(derived));
::fast_io::io::println("sizeof(with_member)=", sizeof(with_member),
" sizeof(with_member_no_unique)=", sizeof(with_member_no_unique));
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
输出:
inherits: 120 sizeof=24
has: 120 sizeof=24
sizeof(empty)=1 sizeof(derived)=4
sizeof(with_member)=8 sizeof(with_member_no_unique)=4
7. 经验法则
- 默认用成员。继承必须被论证,而论证就是那句 is-a。
- “用……实现”是组合,不是 private 继承 —— 除非你要用
using精确挑选公开的 操作子集(第16.3章)。 - 不要为了加几个辅助函数而继承;写自由函数或模板。
- 不要继承你不拥有的类型,除非它自己声明了它是基类。
- 加基类之前先看看:概念
(第15.3章)、
::std::variant,或者擦除的 第15.10章的 shape 是不是不用类型关系就能表达同一件事。 - 如果基类的唯一理由是省一个字节,用
[[no_unique_address]], 不要用继承。