第18.3章 每个虚函数都要标注
1. 规则
如果你在遵循本书的代码库里写虚函数,它必须同时满足这两条:
- 它有标注 ——
virtual、override、final三者之一,永远要写,每个声明和每个定义都要写。 没有例外,包括你五秒前刚刚做成虚的那个函数。 - 它有错误契约 ——
noexcept或throws正好一个,就像其他所有函数一样 (第6.16章)。
哪个标注放在哪里不是风格选择:
| 位置 | 标注 | 机械含义 |
|---|---|---|
| 基类里的第一个声明 | virtual |
“这个槽位在表里存在” —— 这是唯一该出现这个词的地方 |
| 覆盖别人的函数 | override |
“我声称我在覆盖某物;如果其实没有,报错” |
| 关闭层次的函数或类 | final |
“不存在更进一步的覆盖;对这个的调用可以去虚化” |
绝不要在派生类里写 virtual。它合法、它冗余,而且它主动掩盖了你最可能犯的那个错误:
因为签名有一点点不对,你写了一个并不覆盖任何东西的
virtual 函数。
2. 不写 override 会怎样
基类有 who() const。派生类忘了 const。
编译器默默接受,并创建一个全新的、与那个虚函数无关的函数:
struct base
{
virtual ::std::string_view who() const noexcept { return "base"; }
virtual int rank() const noexcept { return 1; }
virtual ~base() noexcept = default;
};
struct forgetful : base
{
// missing const: a brand-new function, not an override
::std::string_view who() noexcept { return "forgetful"; }
};
struct marked : base
{
::std::string_view who() const noexcept override { return "marked"; }
};
forgetful direct=forgetful through base=base | marked direct=marked through base=marked
forgetful 在它自己身上调用时回答 “forgetful”,
通过 base& 调用时回答 “base”。两者是同一个对象。
程序完全合法,不加 -Wall 连警告都没有;加上
-Wall -Wextra 你会得到一条,而且很容易看漏:
mark.cc:6:36: warning: ‘virtual std::string_view base::who() const’ was hidden [-Woverloaded-virtual=]
把漏掉的 const 和 override 补上,
同一个调用就会按你的本意分派:marked direct=marked through base=marked。
写了 override,签名不对就是编译错误,而不是悄悄多出第三个函数。
这就是这个关键字全部的价值:它把你的意图变成一个会被检查的声明。
3. 契约会被检查,标注不会
好消息:错误契约是被编译器强制执行的,而且在真正要紧的那个方向上。 如果基类承诺不会失败,而派生类没有承诺,你就得到错误:
struct base
{
virtual int rank() const noexcept { return 1; }
virtual ~base() noexcept = default;
};
struct child : base
{
int rank() const override { return 2; }
};
mark2.cc:9:13: error: looser exception specification on overriding virtual function ‘virtual int child::rank() const’
9 | int rank() const override { return 2; }
| ^~~~
mark2.cc:3:21: note: overridden function is ‘virtual int base::rank() const noexcept’
3 | virtual int rank() const noexcept { return 1; }
| ^~~~
反方向是允许的,而且正是你想要的:一个 throws 的虚函数可以被
noexcept 的实现覆盖,因为收窄失败集合不可能弄坏调用者。
这就是为什么契约要写在基类上:基类决定每个实现必须遵守的最弱契约,
而调用者只需要看基类。
纯虚函数也要写契约 —— virtual double area() const noexcept = 0; ——
那才是调用者真正会读的东西。
4. final:不只是文档
final 出现在两个地方,两种收益:
- 写在函数上:更深的派生类不能再覆盖它。 于是只要编译器能证明静态类型,它就可以直接调用, 因为不可能存在派生的实现。
- 写在类上:没有任何东西能从它派生。这让编译器在对象是局部、
且从不逃逸时干脆跳过 vptr —— 在本书的工具链上实测:加
final且对象是局部的,-O2下 vtable 根本不会生成, 后续的dynamic_cast/链接步骤也就失去了它不再需要的 typeinfo。
final 是唯一有代码生成效果的标注。它也是唯一让层次可以安全关闭的标注:
类一旦 final,以后再想加派生类就是编译错误,而这通常正是你想知道的事。
5. 标注到底放哪儿
struct shape
{
virtual double area() const noexcept = 0; // base: virtual
virtual ::std::string_view name() const noexcept = 0;
virtual ~shape() noexcept = default; // virtual, noexcept
};
struct square final : shape // final class
{
double side{};
explicit square(double s) noexcept : side{s} {}
double area() const noexcept override { return side * side; } // const, then override, then noexcept
::std::string_view name() const noexcept override { return "square"; }
~square() noexcept override = default; // a destructor can override too
};
声明里的顺序是 const → override →
noexcept/throws,与本书其他地方一致
(第16.7章)。
把 override 放到别处通常是语法错误,而且永远影响阅读速度。
6. 没有编译器开关时怎么强制
这套工具链里没有 -Wrequire-override,
也没有任何开关会要求虚函数必须有契约(实测:对没有规范的虚函数,
-Wall -Wextra 一声不响)。所以这是一条项目规则,
而项目规则需要一条能让构建失败的 grep:
# every 'virtual' must also be = 0, noexcept or throws
grep -rn 'virtual ' include/ \
| grep -v '= 0' | grep -v 'noexcept' | grep -v 'throws'
# nothing outside a base declaration may say 'virtual'
grep -rn 'virtual ' include/ | grep -v 'struct \|class '
把这两行放进 CI。它们抓到的真实缺陷比编译器多,因为编译器对本章讲的错误全都满意。
7. 经验法则
- 每个虚函数都带
virtual、override或final。每一个。在补丁审查里,没标注的虚函数就是被打回的补丁。 - 每个虚函数都带
noexcept或throws。 基类决定,派生类可以收窄。 - 派生类里永远不要重复
virtual。写override, 让编译器检查你的签名。 - 叶子类标
final。它免费、会被检查,而且是唯一能删代码的标注。 - 如果一个类是
final而且对象都留在局部,干脆把virtual删掉,改用重载集或::std::variant—— 那样就没有东西需要标注了。