90 / 103 · C++11 · 约 12 分钟
构造期虚调用:先准备配置,再启用策略
构造期间的虚调用不会进入尚未就绪的更派生层。通过安全的事件记录观察三个派发阶段,再区分语言允许的调用与不稳健的初始化设计,避免把 override 当成生命周期保证。
Effective C++: 55 Specific Ways to Improve Your Programs and Designs
已逐段读完第三版英文全部技术正文Items1–55(印刷页1–272)及附录A/B(273–279),补读转换遗漏的Item26结尾等段落;另逐图核对PDF177–180、197、202、214–215、219页的作用域、策略与继承图。full指全部技术条目及附录,不把营销页、索引逐项核对计入技术通读;不是只下载、看目录或读样章。
查看版本、实际阅读范围与原文入口 →先定位当前正在构造哪一层
读过第三版 Item 9 后,先不要把标题理解成编译器禁令。基类构造函数调用一个有定义的虚函数,可以是合法代码;但调用使用当前构造层的最终覆写者,不会跳到尚未构造的派生层。派生构造函数体开始时,基类与该层成员已初始化,派发才可以使用该层实现。
下面的记录器分别在基类构造、派生构造和完整对象使用阶段记录编号。它不读取未初始化成员,也不调用纯虚函数,所以能安全观察规则。三个结果不同,原因是对象所处阶段不同,而不是编译器忽略了 virtual。
构造所需信息应从已存在的数据取得
设想基类需要选择缓存大小,但选择函数被派生类覆写并依赖派生成员。即使把调用藏进普通成员函数,初始化顺序也不会改变,间接虚调用仍受同一规则约束。稳妥的设计是先根据构造参数算出大小,再传给基类构造函数。这个计算可以是静态函数,明确不依赖半成品对象。
另一种需求是对象完整后才启动工作,这时由外部代码在构造成功之后显式调用普通操作即可。不要为了省一次调用把未完成对象注册到全局回调系统,那会让别的代码在不满足不变量时访问它。
把观察实验和生产建议分开
示例特意保留合法的构造期虚调用,用来证明派发边界;生产接口通常应避免依靠这种阶段差异实现业务。析构方向相反:进入基类析构阶段后,也不能指望再调用派生覆写。若确有分层清理,各层应只处理自己负责的状态。
本书写于 C++11 之前,本例使用 override 检查覆写关系,并以 noexcept 标明不抛出的查询。它们没有改变生命周期规则。还要避免过度概括:当前类成员初始化期间,基类初始化完成之后可能合法发生当前层派发;不能简化成只有构造函数体才允许任何虚调用。
容易答错的地方
- 构造或析构中通过虚派发调用当前层纯虚函数会产生未定义行为;本例故意使用有定义的普通虚函数。
- 记录容器必须比被观察对象活得更久;本例先构造记录器,再构造对象,析构不向记录器追加内容。
运行一个例子
最低标准 C++11 · 完整程序 · 下载 .cpp
#include <cassert>
#include <vector>
class Layer {
public:
explicit Layer(std::vector<int>& events) { events.push_back(level()); }
virtual ~Layer() = default;
virtual int level() const noexcept { return 10; }
};
class Ready final : public Layer {
public:
explicit Ready(std::vector<int>& events) : Layer(events) {
events.push_back(level());
}
int level() const noexcept override { return 20; }
};
int main() {
std::vector<int> events;
Ready object(events);
const Layer& view = object;
events.push_back(view.level());
assert((events == std::vector<int>{10, 20, 20}));
}
在本地编译
g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread books-effective-cpp.cpp -o example && ./example预期结果
预期:正常退出、无输出;所有 assert 通过。
CHECK YOUR UNDERSTANDING
合上答案,试着解释。
基类初始化必须得到由外部参数 seed 决定的容量。如何避免依赖派生虚函数?
查看参考答案
给基类添加显式容量参数;派生构造函数在基类初始化器中传入静态函数 choose_capacity(seed) 的结果。choose_capacity 只访问 seed 与静态常量,不访问 this。这样基类取得真实输入,不需要调用半成品对象上的覆写。
继续查证
- Effective C++ 3rd, Item 9, pp.48–52
- C++ draft [class.cdtor] — construction, destruction and virtual calls
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。