摘要
最近有人问我关于去虚化优化的问题:它们何时发生?我们何时可以依赖去虚化?不同的编译器在去虚化方面有何不同?
通常,这会让我陷入实验的兔子洞。答案似乎是:现代编译器在调用 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
的“叶子证明”;但当然,编译器不会关心追踪这个逻辑上的纠结,即使它是万无一失的。