第16.5章 派生到基类的转换与截断
1. 派生对象就是一个基类对象
因为基类子对象是派生对象真实的一部分 (第16.1章), 所以在需要基类对象的地方可以直接使用派生对象,不需要任何转换:
| 目标 | 从一个 derived 左值出发 |
发生了什么 |
|---|---|---|
base& |
base& r = d; |
绑定到 d 内部的基类子对象;不拷贝 |
base const& |
base const& r = d; |
同上,只读 |
base* |
base* p = &d; |
指向基类子对象;不拷贝 |
base |
base b = d; |
只拷贝基类那部分 —— 这就是截断 |
前三种是有用的:免费、保留对象本身,而且通过引用写入会落到原对象上。第四种是陷阱, 也是本页的主题。
2. 截断:派生部分是会被扔掉的
base b = d; 不是“转换”d,而是用 d 的基类子对象
构造一个新的 base。新对象的大小和成员都是 base 的,
所以派生类新增的一切都消失了 —— 悄无声息,没有警告,因为对编译器来说这只是一次普通的
拷贝构造调用。
#include <fast_io.h>
#include <cstdint>
struct base
{
::std::int_least32_t v{};
};
struct derived : base
{
::std::int_least32_t extra{};
};
int main()
{
try
{
derived d{};
d.v = 1;
d.extra = 2;
base sliced = d; // copies only the base subobject
::fast_io::io::println("sliced.v=", sliced.v, " sizeof(sliced)=", sizeof(sliced));
base &ref = d; // refers to the base subobject inside d
ref.v = 5;
::fast_io::io::println("after ref.v=5: d.v=", d.v, " d.extra=", d.extra);
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
输出:
sliced.v=1 sizeof(sliced)=4
after ref.v=5: d.v=5 d.extra=2
sizeof(sliced) 是 4:那个对象是一个 base,
不是 derived。相反,ref 是真实的基类子对象 ——
写 ref.v 改变了 d.v,而 d.extra 没被动过,
因为它从头到尾没参与。
3. 你没写也会发生的截断
截断不是冷门操作。每当派生对象被拷贝进一个基类类型的值里,它就发生了:
- 按值接收的形参:
void f(base b); ... f(d);—— 派生部分在调用内部被丢掉。改成base const&就什么都不丢。 - 基类值的容器:
::fast_io::vector<base> v; v.push_back(d);—— 容器装的是base,所以它存的就是base。若你想要派生部分, 你要的其实是别的东西:variant,或者擦除类型 (第15.10章)。 - 赋值给基类对象:
b = d;—— 只赋值基类子对象。 - 从返回类型是基类的函数返回派生对象。
在没有 virtual 函数的层次结构里,这些至少还是类型诚实的:
一个 base 值确实是 base,对它的每个操作都是基类的操作。
一旦基类有了虚函数,截断还会变成逻辑 bug,因为那份拷贝不再表现得像原对象 ——
第18章会讲这件事。
4. 传引用,不要传值
由此得到的规则只有一行:形参列表里的基类永远是引用或指针,绝不是值。
inline void report(base const &b) throws; // uses the object you passed
inline void report(base b) throws; // uses a copy of its base subobject
inline void mutate(base &b) noexcept; // writes reach the original
这也是 fast_io 的例子都用引用传设备的原因:
::fast_io::io::println(out, ...) 用转发引用接收输出设备,
所以 obuf_file 仍然是 obuf_file,它的缓冲区不会被拷贝
(第11.1章)。
5. 经验法则
- 基类形参用
B const&或B&。按值的基类形参就是截断机器。 - 绝不要把派生对象存进基类值的容器,还指望把派生部分拿回来。
- 如果你需要“一个集合里放各种不同的派生东西”,答案不是
vector<base>,而是::std::variant,或者 第15.10章的shape那种类型擦除。 - 转成
base&是免费且保留身份的;转成base是新建一个对象。 不确定发生了哪一种时,打印sizeof。 - 截断不会报错。它是一次拷贝,而拷贝是合法的 —— 这正是它需要一条规则的原因。