第18.3章 每个虚函数都要标注

English

1. 规则

如果你在遵循本书的代码库里写虚函数,它必须同时满足这两条:

哪个标注放在哪里不是风格选择:

位置 标注 机械含义
基类里的第一个声明 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 出现在两个地方,两种收益:

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. 经验法则