第16.2章 构造与析构顺序
1. 顺序是固定的,而且不是初始化列表的顺序
构造一个派生对象分三个阶段,顺序由语言规定:
- 基类子对象被构造,按基类列出的顺序;
- 成员被构造,按它们在类中声明的顺序 —— 不是初始化列表提到它们的顺序(第13.6章);
- 派生类构造函数的函数体执行。
析构完全相反:函数体作用域结束,然后成员按声明逆序销毁,最后是基类。这就是为什么派生类的 析构函数可以读基类的成员:它们还活着。而基类的析构函数绝不能碰派生类新增的东西 —— 那些已经没了。
2. 初始化基类
如果基类有默认构造函数,派生类不必提到它。如果没有,派生类构造函数必须在初始化 列表里点名:
class machine_part
{
protected:
::std::int_least32_t serial_{};
public:
machine_part() noexcept = default;
explicit machine_part(::std::int_least32_t s) noexcept : serial_{s} {}
};
class machine : public machine_part
{
::std::int_least32_t power_{};
public:
machine(::std::int_least32_t serial, ::std::int_least32_t power) noexcept
: machine_part{serial}, power_{power}
{
}
};
基类是用它的类型点名的,不是用某个成员名:
machine_part{serial}。写成 serial_{serial} 是错误 ——
serial_ 属于基类子对象,而基类子对象要通过它自己的构造函数初始化。
3. 析构函数不能打印
第6.16章说每个函数要么
noexcept 要么 throws,而析构函数永远是
noexcept。打印会失败(管道被关、磁盘写满),所以会打印的析构函数会被编译器
拒绝。它说的是:
error: call to a ‘throws’ or ‘return_failure{E}’ function must be handled by an enclosing
‘try { } catch throws’, or the calling function needs a herbceptions specification
so the error can propagate
这条信息很精确:noexcept 函数不能调用 throws 函数除非它自己
处理失败。光这条诊断就是教训,但修法也要讲,所以下面的例子把事件记进一个小缓冲区
(写入函数是 noexcept),之后在 main 里把轨迹打印出来 ——
那里允许失败。
4. 观察顺序
namespace trace
{
inline ::std::string_view events[8]{};
inline ::std::size_t count{};
inline void add(::std::string_view e) noexcept
{
if (count != 8)
{
events[count++] = e;
}
}
} // namespace trace
struct base
{
base() throws { trace::add("base()"); }
~base() noexcept { trace::add("~base()"); }
};
struct engine
{
engine() throws { trace::add(" engine()"); }
~engine() noexcept { trace::add(" ~engine()"); }
};
struct machine : base
{
engine eng{};
::std::string_view tag{};
machine() throws : base{}, tag{"built"} { trace::add("machine()"); }
~machine() noexcept { trace::add("~machine()"); }
};
注意两个契约:构造函数是 throws,因为可能失败的派生构造函数必须被允许向上
传播;析构函数是 noexcept,所以只碰一个固定数组。如果基类构造函数抛出,
派生对象根本不存在,只有那些已经完整构造好的部分才会被析构。
5. 完整程序
#include <fast_io.h>
#include <cstddef>
#include <string_view>
namespace trace
{
inline ::std::string_view events[8]{};
inline ::std::size_t count{};
inline void add(::std::string_view e) noexcept
{
if (count != 8)
{
events[count++] = e;
}
}
} // namespace trace
struct base
{
base() throws { trace::add("base()"); }
~base() noexcept { trace::add("~base()"); }
};
struct engine
{
engine() throws { trace::add(" engine()"); }
~engine() noexcept { trace::add(" ~engine()"); }
};
struct machine : base
{
engine eng{};
::std::string_view tag{};
machine() throws : base{}, tag{"built"} { trace::add("machine()"); }
~machine() noexcept { trace::add("~machine()"); }
};
int main()
{
try
{
{
machine m{};
::fast_io::io::println("in scope: ", m.tag);
}
::fast_io::io::println("out of scope");
for (::std::size_t i{}; i != trace::count; ++i)
{
::fast_io::io::println(trace::events[i]);
}
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
输出:
in scope: built
out of scope
base()
engine()
machine()
~machine()
~engine()
~base()
把最后六行读成对称的两半。构造:基类、成员、函数体。析构:函数体、成员逆序、基类。
tag 成员在 machine() 之前构造,所以函数体已经可以依赖它。
6. 人们常搞错的两个后果
- 派生类的函数体帮不上基类的忙。
machine()的函数体执行时, 基类已经完整。基类需要的一切都必须传给它:构造函数实参、虚调用(绝不要在构造期间 —— 第18章),或者之后用 setter 设置。 - 除非基类析构函数是
virtual,否则通过基类指针析构派生对象不会调用 派生析构函数。用base*删除一个machine而没有虚析构函数 是未定义行为。本章这种非虚层次结构的规则很简单:不要通过基类指针持有对象。
7. 经验法则
- 初始化列表按声明顺序写。实际按声明顺序执行,一个自相矛盾的列表就是在撒谎。
- 基类没有默认构造函数时必须在初始化列表里点名;最好总是点名,它把依赖关系写在了明面上。
- 析构函数是
noexcept:不能有意义地分配、不能打印、不能抛。 把状态记下来,之后再报告。 - 可能失败的派生构造函数是
throws;一个可能失败的基类构造函数会让每个 派生构造函数都是throws。 - 除非基类有
virtual析构函数,否则不要把派生对象的指针存进基类指针 (第16.7章)。