C++ 编译器何时可以反虚拟化调用?

Hacker News Top 工具

摘要

探讨 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中虚表的工作原理

Lobsters Hottest

一篇详细的博客文章,解释了Itanium C++ ABI中虚表(vtable)的实现方式,涵盖虚表结构、修饰名称和虚函数调度。

关于C扩展、可移植性和替代编译器

Lobsters Hottest

本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。

当编译器让你惊喜

Lobsters Hottest

Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。

C语言中的一切皆为未定义行为

Hacker News Top

一位经验丰富的C++开发者认为,所有非平凡的C和C++代码都包含未定义行为,使得内存安全无法实现,并质疑这些语言在现代软件开发中的持续使用。

Fil-C 优化调用约定

Hacker News Top

Fil-C 优化调用约定确保 C 程序即使在恶意滥用情况下也能保持内存安全性,同时通过在常见情况下省略安全检查来保持效率。它解释了通过 panic 或定义明确的行为来处理类型违规的通用优化和寄存器传递优化。