C++ 编译器何时可以反虚拟化调用?
摘要
探讨 C++ 编译器何时可以对虚函数调用进行去虚拟化,涵盖已知动态类型和 final 关键字等情况,并在 GCC、Clang、MSVC 和 ICC 之间进行比较。
暂无内容
查看缓存全文
缓存时间: 2026/05/19 04:00
# C++编译器何时可以消除虚拟函数调用?
来源:https://quuxplusone.github.io/blog/2021/02/15/devirtualization/
最近有人问我关于编译器消除虚拟函数调用优化的问题:这些优化何时发生?我们何时可以依赖它?不同编译器的实现方式是否不同?和往常一样,这个问题让我深入实验的兔子洞。答案似乎是:现代编译器可以相当可靠地消除对`final`方法的虚拟函数调用。但仍有许多有趣的边缘情况——我确信还有我没想到的!——不同编译器确实会捕获这些边缘情况的不同子集。
首先,我们注意到通过LTO(https://quuxplusone.github.io/blog/2019/08/02/the-tough-guide-to-cpp-acronyms/#lto)使用全程序分析,可能(?)可以更有效地进行消除虚拟调用。我对链接时消除虚拟调用的最新技术了解不多,而且在Compiler Explorer上很难进行实验,所以我完全不讨论LTO。我们只看编译器本身能做什么。
基本上有两种情况编译器知道足够多的信息来消除虚拟调用。它们之间没有太多共同点:
## 当我们知道实例的动态类型时
典型的情况是:
```cpp
void test() {
Apple o;
o.f();
}
```
无论`Apple::f`是否为虚函数,所有虚拟调用的作用都只是在实际动态类型上调用方法,而这里我们知道实际动态类型就是`Apple`。在这种情况下,静态和动态调度应该产生相同的结果。足够聪明的编译器会使用数据流分析来优化一些非平凡的情况,例如:
```cpp
Derived d;
Base *p = &d;
p->f();
```
但事实证明,即使这种简单的技巧也足以欺骗MSVC和ICC。下一个测试用例是:
```cpp
Derived da, db;
Base *p = cond ? &da : &db;
p->f();
```
这对Clang来说太复杂了,但GCC实际上成功处理了……直到你将转换为`Base*`的表达式放入条件表达式内部!这时连GCC的分析也失效了(Godbolt:https://godbolt.org/z/GE7vsE):
```cpp
Derived da, db;
Base *p = cond ? (Base*)&da : (Base*)&db;
p->f();
```
## 当我们知道其静态类型的“叶子性证明”时
好的,假设我们从系统的其他地方接收一个指针。我们知道它的静态类型(例如`Derived*`),但我们不知道它指向的实例的实际动态类型。不过,如果编译器能以某种方式证明整个程序中没有任何类型可以重写`Derived::f`,那么它就可以消除对`Derived::f`的虚拟调用。
### 通过`final`证明
最简单的“叶子性证明”是将`Derived`标记为`final`。
```cpp
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`继承而来,相同的分析都应适用。例如,编译器应该同样能够消除下面的虚拟调用:
```cpp
struct Base { virtual int f() { return 1; } };
struct Derived final : public Base {};
int test(Derived *p) {
return p->f();
}
```
GCC、Clang和MSVC通过了这个测试(Godbolt:https://godbolt.org/z/MnqoM7,案例`one`);ICC 21.1.9则被欺骗了。
一个极其奇怪的叶子性证明是:当类`C`的析构函数是final时,`C`必定没有子类——因为如果`C`有子类,该子类必须有一个析构函数(因为没有析构函数的类无法创建),而这会重写`C`的析构函数,这是不允许的。Clang实际上对final析构函数既发出警告,也会基于此进行优化。据我所知,其他每个编译器都把这种情况视为非常愚蠢(https://en.wikipedia.org/wiki/The_Colonel_(Monty_Python)),不愿意为其提供代码路径。
### 通过内部链接证明
名称具有内部链接的类不能在当前翻译单元之外被命名。因此,它也不能在当前翻译单元之外被继承!只要它在当前TU中没有子类——或者至少没有重写其方法的子类——对其虚函数的调用就可以被消除。
```cpp
namespace {
class BaseImpl : public Base {};
}
int test(Base *p) {
return static_cast<BaseImpl*>(p)->f();
}
```
如果`p`确实指向一个“至少是`BaseImpl`”的对象实例,那么编译器可以证明该实例必须恰好是`BaseImpl`。(如果`p`不指向一个“至少是`BaseImpl`”的实例,那么程序本来就有未定义行为。)
我认为这是一个在实际代码库中可能非常常见的场景。通常,基类在头文件中公开暴露,然后一个或多个派生实现被限制在单个`.cpp`文件中。如果你再进一步,将这些派生实现放入匿名命名空间中,可能会帮助编译器的消除虚拟调用逻辑。当然,根据定义,任何这样的好处只会局限于那个单个的`.cpp`文件!
类型的名称获得内部链接的另一种方式是:当它是类模板实例化,并且其中一个模板参数涉及具有内部链接的名称时。如果名称`T`具有内部链接,那么`E`也具有内部链接,即使`E`本身具有外部链接——因为在不命名`T`的情况下无法命名`E`。(注意,这里`T`必须是一个“真正的名称”;我们不讨论类型别名。)
还有一种可能是创建一个名称具有外部链接的类型,但编译器可以证明该类型在每个其他TU中必定是不完整的。例如:
```cpp
namespace {
class Internal {};
}
class External {
Internal m;
};
```
任何其他TU可以前置声明`class External;`作为不完整类型,但这些TU永远无法完成该类型,因为它们无法命名其数据成员的类型。你无法从不完整类型派生。因此,所有从`External`派生的类型(如果有的话)必须出现在这个TU中;如果这里没有,那这就是一个叶子性证明!只有GCC检测到了这种情况。
## 结果表格
Godbolt链接:已知动态类型情况(https://godbolt.org/z/GE7vsE);叶子性证明情况(https://godbolt.org/z/MnqoM7)。
在后一种情况中,我分别测试了`Derived::f`(直接在`Derived`中定义)和`Derived::g`(从`Base`继承)。GCC经常能正确处理`f`,但未能消除`g`的虚拟调用。我已经为此提交了GCC错误#99093(https://gcc.gnu.org/bugzilla/show_bug.cgi?id=99093)。
| 测试案例 | 要点 | GCC | Clang | MSVC | ICC |
|---------|------|-----|-------|------|-----|
| `one` | 平凡情况 | ✓ | ✓ | ✓ | ✓ |
| `two` | 强制转换为`Base*` | ✓ | ✓ | | |
| `three` | 条件表达式,然后强制转换 | ✓ | | | |
| `four` | 强制转换,然后条件表达式 | | | | |
| `one` | final类`f` | ✓ | ✓ | ✓ | |
| `two` | final方法 | ✓ | ✓ | ✓ | ✓ |
| `three` | 愚蠢的final析构函数 | | ✓ | | |
| `four` | 古老的愚蠢技巧 | | | | |
| `five` | 内部链接类`f` | ✓ | ✓ | | |
| `six` | 内部链接模板参数`f` | ✓ | ✓ | | |
| `seven` | 内部链接基类`f` | ✓ | ✓ | | |
| `eight` | 内部链接成员`f` | ✓ | | | |
| `nine` | 内部链接有子类`f` | | | | |
| `ten` | 局部类`f` | ✓ | | | |
Steve Dewhurst教给我`four`的“古老的愚蠢技巧”:虚基类总是在最派生类的上下文中构造。所以,如果类`C`有一个虚基类,其所有构造函数都是私有的,那么`C`的任何子类都无法构造自身,因此`C`不可能存在子类。(当然,这个虚基类必须将`C`列为其友元,以便`C`本身是可构造的。)我认为这个技巧是万无一失的,因此构成了(一个非常愚蠢的)`C`的叶子性证明;但是,当然,没有编译器会费心去追溯这个逻辑纠葛,即使它确实是万无一失的。
---
你能想到我遗漏的某个构造“叶子性证明”的方法吗?告诉我吧!
相似文章
Itanium C++ ABI中虚表的工作原理
一篇详细的博客文章,解释了Itanium C++ ABI中虚表(vtable)的实现方式,涵盖虚表结构、修饰名称和虚函数调度。
关于C扩展、可移植性和替代编译器
本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。
当编译器让你惊喜
Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。
C语言中的一切皆为未定义行为
一位经验丰富的C++开发者认为,所有非平凡的C和C++代码都包含未定义行为,使得内存安全无法实现,并质疑这些语言在现代软件开发中的持续使用。
Fil-C 优化调用约定
Fil-C 优化调用约定确保 C 程序即使在恶意滥用情况下也能保持内存安全性,同时通过在常见情况下省略安全检查来保持效率。它解释了通过 panic 或定义明确的行为来处理类型违规的通用优化和寄存器传递优化。