第16.4章 派生类中的五法则
1. 派生类的五个成员,都是由它的部件搭出来的
第14章列出了“拥有资源的类必须做对的五个操作”。 派生类里也是同样五个,而且每一个形状都一样:先对基类子对象做,再对每个成员做。 你不写的时候编译器就是这么生成的,而只要各部件行为正常,这个默认就是对的。
| 成员 | 它必须对基类做什么 | 契约 |
|---|---|---|
| 拷贝构造 | 拷贝构造基类子对象 | 只有当基类的拷贝是 noexcept 时才是 |
| 移动构造 | 移动构造基类子对象 | 只有当基类的移动是 noexcept 时才是 |
| 拷贝赋值 | base::operator=(other) —— 显式调用 |
取决于基类的赋值 |
| 移动赋值 | base::operator=(::std::move(other)) |
取决于基类的移动赋值 |
| 析构函数 | 最后销毁基类子对象 | 永远是 noexcept |
swap |
把基类子对象当作 base& 交换 |
只有当基类的 swap 是 noexcept 时才是 |
其中三个是自动的。真正有意思的问题是哪些需要你自己写,而答案和 第14.1章一样: 基类和成员替你做不到的那几个。
2. “= default”不等于“忘记写”
什么都不写的派生类会得到全部五个:隐式定义,每一个都正确地处理基类子对象。 这就是零法则,而当基类与每个成员都行为良好时,它就是首选答案:
struct quiet_base
{
::std::size_t id{};
};
struct rule_of_zero : quiet_base
{
::std::size_t extra{};
};
rule_of_zero 可拷贝、可移动、可析构,而且它的移动是
noexcept —— 这一切都是编译器从 ::std::size_t 推出来的。
给五个成员各写一个 = default 什么都不会改变,而给每个都写函数体正是 bug 的来源。
编译器不会替你弥补坏掉的部件。如果基类删除了拷贝构造,派生类就没有拷贝构造, 而特征会如实报告:
struct no_copy_base
{
::std::size_t id{};
no_copy_base() noexcept = default;
no_copy_base(no_copy_base const &) = delete;
};
struct derived_no_copy : no_copy_base
{
::std::size_t extra{};
};
class guarded
{
~guarded() noexcept = default; // private destructor on purpose
};
struct derived_from_guarded : guarded
{
};
is_copy_constructible<derived_no_copy>=0
is_destructible<guarded>=0
is_destructible<derived_from_guarded>=0
第二、三行是同一条规则用在析构上:析构函数不可访问(这里是 private)的基类,
会让派生类的析构函数也被删除。这也是要保持基类析构函数 public 且
noexcept 的又一个理由。
3. 移动构造必须移动基类
derived(derived &&other) noexcept : base{::std::move(other)}, extra{other.extra}
{
other.extra = 0;
}
::std::move(other) 的类型是 derived&&,而
derived&& 可以绑定到 base&& 形参上,
于是基类子对象由基类的移动构造函数移动构造
(第16.5章)。由此有两点:
- 两半都被移动,两半都被清空。基类的移动构造函数清空它自己的状态, 函数体清空成员。一个被移动后的派生对象 = 被移动后的基类 + 被移动后的成员 (第14.10章)。
noexcept同时也是对基类的承诺。如果基类的移动会抛, 这个构造函数就不能声称noexcept。把契约写成条件形式,让编译器替你去检查 (第15.8章):throws(!noexcept(base{::std::move(other)}))。
而且要靠特征去验证,而不是靠记忆,因为
::std::is_nothrow_move_constructible_v 回答的正是“整个对象”的这个问题 ——
基类加每一个成员。
4. 拷贝赋值:人人都忘的那一行
赋值运算符不能被继承,也不是从基类生成出来的:派生类的赋值要你自己写,而基类子对象也得你自己赋值。 忘掉那一行能编译、能运行,并且让对象的基类部分保持原样:
// the same class with the classic bug: the base is never assigned
struct buggy : base
{
::std::int_least32_t extra{};
explicit buggy(::std::size_t i, ::std::int_least32_t e) noexcept : base{i}, extra{e} {}
buggy &operator=(buggy const &other) noexcept
{
extra = other.extra; // base::operator= is missing
return *this;
}
};
buggy after assign: u.id=7 (expected 9) u.extra=90
u 被赋成了 v,成员被拷贝了,基类却留着旧的
id。没有警告、没有错误:基类子对象就是没参与这次赋值。
修法是两个赋值运算符里各加一行:
derived &operator=(derived const &other) noexcept
{
base::operator=(other);
extra = other.extra;
return *this;
}
derived &operator=(derived &&other) noexcept
{
base::operator=(::std::move(other));
extra = other.extra;
other.extra = 0;
return *this;
}
关于层次结构里的赋值,还有两条规则:
5. swap 与拷贝并交换
派生类的 swap 和其他成员是同一个两步模式:先交换基类,再交换成员。
基类必须写成 base&,这样重载解析才能找到基类的 swap,
而不是递归回自己:
void swap(derived &other) noexcept
{
using ::std::swap;
swap(static_cast<base &>(*this), static_cast<base &>(other));
swap(extra, other.extra);
}
有了这两行,第14.8章的 “拷贝并交换”赋值对派生类原样适用,而且保留了强异常安全保证: 新值在触碰任何东西之前就已经完整构造好。
6. 完整程序
#include <fast_io.h>
#include <cstddef>
#include <cstdint>
#include <utility>
#include <type_traits>
struct base
{
::std::size_t id{};
base() noexcept = default;
explicit base(::std::size_t i) noexcept : id{i} {}
base(base const &) noexcept = default;
base(base &&other) noexcept : id{other.id} { other.id = 0; }
base &operator=(base const &) noexcept = default;
base &operator=(base &&other) noexcept
{
id = other.id;
other.id = 0;
return *this;
}
~base() noexcept = default;
};
// the derived class that does it right
struct derived : base
{
::std::int_least32_t extra{};
derived() noexcept = default;
explicit derived(::std::size_t i, ::std::int_least32_t e) noexcept : base{i}, extra{e} {}
derived(derived const &) noexcept = default;
derived(derived &&other) noexcept : base{::std::move(other)}, extra{other.extra}
{
other.extra = 0;
}
derived &operator=(derived const &other) noexcept
{
base::operator=(other); // the line everybody forgets
extra = other.extra;
return *this;
}
derived &operator=(derived &&other) noexcept
{
base::operator=(::std::move(other));
extra = other.extra;
other.extra = 0;
return *this;
}
~derived() noexcept = default;
void swap(derived &other) noexcept
{
using ::std::swap;
swap(static_cast<base &>(*this), static_cast<base &>(other));
swap(extra, other.extra);
}
};
// the same class with the classic bug: the base is never assigned
struct buggy : base
{
::std::int_least32_t extra{};
explicit buggy(::std::size_t i, ::std::int_least32_t e) noexcept : base{i}, extra{e} {}
buggy &operator=(buggy const &other) noexcept
{
extra = other.extra; // base::operator= is missing
return *this;
}
};
int main()
{
try
{
derived a{1, 10};
derived b{::std::move(a)};
::fast_io::io::println("after move: b.id=", b.id, " b.extra=", b.extra, " a.id=", a.id, " a.extra=", a.extra);
derived c{2, 20};
c = b;
::fast_io::io::println("after copy assign: c.id=", c.id, " c.extra=", c.extra);
buggy u{7, 70};
buggy v{9, 90};
u = v;
::fast_io::io::println("buggy after assign: u.id=", u.id, " (expected 9) u.extra=", u.extra);
derived x{3, 30};
derived y{4, 40};
x.swap(y);
::fast_io::io::println("after swap: x.id=", x.id, " y.id=", y.id);
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
输出:
after move: b.id=1 b.extra=10 a.id=0 a.extra=0
after copy assign: c.id=1 c.extra=10
buggy after assign: u.id=7 (expected 9) u.extra=90
after swap: x.id=4 y.id=3
四行输出,四个教训。移动取走了两半、也清空了两半。拷贝赋值取走了两半。有 bug 的赋值只取走一半。 交换则把基类子对象和成员都换了过来。
7. 契约:派生构造函数继承了所有失败可能
- 会分配的基类构造函数是
throws,所以每个派生构造函数也是throws,不管它自己有没有做事 (第16.2章)。 - 派生析构函数是
noexcept,因此基类析构函数也必须是 —— 事实上它一定是,否则编译不过。 - 如果基类的移动构造函数是
throws,派生类的移动就不能是noexcept。用throws(!noexcept(base{::std::move(other)}))显式写出来,让条件与造成它的代码待在一起。 - 拷贝赋值只有在基类的也是
noexcept时才是;只要有成员会分配, 它就是throws,那就该用拷贝并交换的形状。
8. 经验法则
- 先试零法则:基类和成员都行为良好时,派生类什么都不用写。
- 写移动构造时,在初始化列表里移动基类,并把两半都清空。
- 写赋值运算符时,先给基类赋值。这是 C++ 继承里最常被忘掉的一行。
- 写
swap时,把基类当作base&交换,并先写using ::std::swap;,让成员和基类用同一套查找。 - 不要给已经自己写了函数体的成员再写
= default;两者不能共存, 编译器会告诉你。 - 对契约拿不准就去测量:
::std::is_nothrow_move_constructible_v<derived>回答的是整个对象(含基类)的问题。