摘要
最近有人问我关于去虚化优化的问题:它们何时发生?我们何时可以依赖去虚化?不同的编译器在去虚化方面有何不同?
通常,这会让我陷入实验的兔子洞。答案似乎是:现代编译器在调用 final 方法时去虚化非常可靠。但有许多有趣的边缘情况——包括一些我还没有想到的——不同的编译器会捕捉到这些边缘情况的不同子集。
当我们知道实例的动态类型
最典型的例子是:
void test() {
Apple o;
o.f();
}即使 Apple::f 是虚函数,这也没有关系;虚函数调用所做的只是在对象的实际动态类型上调用该方法,而在这里我们确切地知道实际动态类型是 Apple。在这种情况下,静态和动态调度应该给我们相同的结果。
足够智能的编译器将使用数据流分析来优化非平凡的案例,例如:
Derived d;
Base *p = &d;
p->f();事实证明,即使这个简单的技巧也足以愚弄 MSVC 和 ICC。 下一个测试案例是:
Derived da, db;
Base *p = cond ? &da : &db;
p->f();这超出了 Clang 的能力,但 GCC 实际上可以处理它……直到你在条件语句中进行 Base* 的转换!这里就是 GCC 的分析失败的地方[1]:
Derived da, db;
Base *p = cond ? (Base*)&da : (Base*)&db;
p->f();当我们知道其静态类型的“叶子证明”
假设我们从系统中的其他地方接收一个指针。我们知道它的静态类型(例如 Derived*),但不知道它所指向的对象的实际动态类型。尽管如此,编译器可以去虚化对 Derived::f 的调用,如果它能够证明整个程序中没有任何类型可以覆盖 Derived::f。
通过 final 证明
最简单的“叶子证明”是将 Derived 标记为 final。
struct Base {
virtual int f();
};
struct Derived final : public Base {
int f() override { return 2; }
};
int test(Derived *p) {
return p->f();
}类型为 Derived* 的指针必须指向一个“至少是 Derived”的对象实例——即 Derived 或其子类。由于 Derived 是 final,它不允许有子类;因此,实例的实际动态类型必须是 Derived,编译器可以去虚化这个调用。
或者,你可以将特定方法 Derived::f 标记为 final。
无论 Derived::f 是在 Derived 中声明的,还是从 Base 继承的,相同的分析都应适用。例如,编译器应该能够同样地去虚化:
struct Base {
virtual int f() { return 1; }
};
struct Derived final : public Base {};
int test(Derived *p) {
return p->f();
}GCC、Clang 和 MSVC 通过了这个测试[2]。ICC 21.1.9 被愚弄了。
通过内部链接证明
名称具有内部链接的类不能在当前翻译单元之外命名。因此,它也不能在当前翻译单元之外派生!只要它在当前 TU 中没有子类——或者至少没有覆盖其方法的子类——对其虚函数的调用就可以去虚化。
例如:
namespace {
class BaseImpl : public Base {};
}
int test(Base *p) {
return static_cast<BaseImpl*>(p)->f();
}如果 p 真正指向一个“至少是 BaseImpl”的对象实例,那么编译器可以证明该实例必须是 BaseImpl。如果 p 没有指向一个“至少是 BaseImpl”的实例,程序将有未定义行为。
这在实际代码库中可能相当常见。通常,头文件中会公开一个基类,然后在一个单独的 .cpp 文件中紧密地作用域化一个或多个派生实现。如果你更进一步,将这些派生实现放入匿名命名空间中,你可能会帮助编译器的去虚化逻辑。当然,根据定义,任何这样的好处将仅限于该单个 .cpp 文件!
另一个类型名称可以具有内部链接的方式是当它是类模板实例化,其中一个模板参数涉及具有内部链接的名称。如果名称 T 具有内部链接,那么 E<T> 也具有内部链接,即使 E 本身具有外部链接——因为你不能命名 E<T> 而不命名 T。(注意,这里的 T 必须是一个“真实名称”;我们不讨论类型别名。)
还有一种可能,类型名称具有外部链接,但编译器可以证明该类型在其他每个 TU 中必须是不完整的。例如:
namespace {
class Internal {};
}
class External { Internal m; };其他任何 TU 都允许将 class External; 声明为不完整的类型,但这些 TU 不能完成该类型,因为它们不能命名其数据成员的类型。你不能从不完整的类型派生。因此,所有从 External 派生的类型(如果有)都必须存在于这个 TU 中;如果这里没有,那么这就是一个“叶子证明”!只有 GCC 检测到这种情况。
测试结果表
在后者中,我分别对 Derived::f 直接在 Derived 中定义和 Derived::g 从 Base 继承的情况进行了测试。GCC 经常正确地处理 f,但未能去虚化 g。 我已经为这个问题提交了[3]。
| 测试案例 | 重点 | GCC | Clang | MSVC | ICC |
|---|---|---|---|---|---|
| 简单 | ✓ | ✓ | ✓ | ✓ |
| 转换为 | ✓ | ✓ | ||
| 条件,然后转换 | ✓ | |||
| 转换,然后条件 | ||||
|
| ✓ | ✓ | ✓ |
|
|
| ✓ | ✓ | ✓ | ✓ |
| 奇怪的 | ✓ | |||
| 奇怪的老技巧 | ||||
| 内部链接类 |
| |||
| 内部链接模板参数 |
| |||
| 内部链接基类 |
| |||
| 内部链接成员 |
| |||
| 内部链接带子类 |
| |||
| 本地类 |
|
其他
Steve Dewhurst 教了我他的“奇怪的老技巧”:虚基类总是以最派生类的上下文构造。因此,如果类 C 有一个虚基类,其所有构造函数都是私有的,那么 C 的任何子类都无法构造自己,因此 C 的任何子类都不存在。(当然,那个虚基类必须将 C 列为其朋友,以便 C 本身可以构造。) 我认为这个技巧是万无一失的,因此构成了一个(非常奇怪的)C 的“叶子证明”;但当然,编译器不会关心追踪这个逻辑上的纠结,即使它是万无一失的。