
2026/05/17 17:58
C++ コンパイラが仮想関数の呼び出しを非多態化(devirtualize)できるのは、主に以下の条件を満たす場合です。 * **非多態性のクラス(Non-polymorphic class)**:コンパイラが、「このポインタで指し示されるオブジェクトは必ず A クラスそのものか、あるいはその派生クラスのいずれかである」と保証できるケースです。 具体的には、 - その関数が「純粋に仮想ではない(pure virtual な関数を呼ばない)」こと、 - また是该クラスから派生したすべてのサブクラスにおいて、虚関数テーブルへのポインタ(vptr)の解釈が一致していること を意味します。 * **完全な多態性が存在しない場合**:クラスの定義が完全に可視であり、かつそのクラス内で他の仮想関数が存在しないなどの状況で、コンパイラが静的に呼び出し元と呼び出し先を特定できる場合に該当します。 * **`override` 指定子が付いていない場合、または派生クラスで再定義されていない場合**:静的なバインディング(static binding)が可能であれば、虚関数テーブルの参照を行わず、直近の実装を直接コールすることができます。 基本的に、コンパイラが「虚関数テーブルを介さずにコードジャンプ可能である」と判断した時点で devirtualize が発生します。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
現代のコンパイラは、オブジェクトの動的型が明示的に既知であるか、「葉(leaf)のような」構造として証明可能である場合(つまり、さらに継承が存在しないことを意味する)、仮想メソッド呼び出しを信頼性高く最適化するためにデバージリチュアライゼーションを実行できます。
final とマークすることで継承を防止し、GCC、Clang、MSVC など主要なプラットフォームでこの最適化を一貫して確保する最も効果的な戦略です。高度なデータフロー解析は単一のコンパイルユニット内にある一部の複雑なシナリオに対処できますが、外部の翻訳ユニットには制限があり、全体プログラムのリンクタイム最適化(LTO)はこれらの知見に含まれていません。現在のコンパイラでは final キーワードを使用せずにプライベートなベースコンストラクタのみで葉性を証明することはできず、GCC の既存のバグが継承された final メソッドの最適化を妨げているため、明確な final 宣言に従うことが最も安全な道です。一貫したパフォーマンス向上を求めている開発者は、普遍的に動作しないかもしれない奇妙なコンパイラ固有のトリックよりも、明確な final の使用を優先すべきです。
Text to translate:
Modern compilers can reliably optimize virtual method calls through devirtualization when an object's dynamic type is explicitly known or provably "leaf-like"—meaning no further inheritance exists. Marking classes or methods as
final, which prevents inheritance, is the most effective strategy for ensuring this optimization across major platforms like GCC, Clang, and MSVC. Advanced dataflow analysis can handle some complex scenarios within a single compilation unit but faces limitations with external translation units; whole-program Link-Time Optimization (LTO) is excluded from these findings. Although current compilers cannot rely solely on private base constructors to prove leafness without using the final keyword—and some existing bugs in GCC still hinder optimization of inherited final methods—adhering to explicit final declarations remains the safest path. Developers seeking consistent performance gains should prioritize clear final usage over obscure, compiler-specific tricks that may not work universally.本文
最近、有人向我请教关于去虚化(devirtualization)优化的问题:它们何时发生?我们能在哪些情况下依赖去虚化?不同的编译器在实现去虚化时是否存在差异?这些问题如同往常一样,又将我带入了一个充满实验的“兔子洞”。
目前的结论似乎是:现代编译器对于最终方法(final methods)的调用进行去虚化的能力相当可靠。然而,其中仍存在许多有趣的边缘情况——我敢肯定其中有些是我尚未想到过的——而且不同的编译器所覆盖的边缘情况子集也各不相同。
首先,我们需要观察到的是:通过整程序分析(LTO),或许可以更有效地执行去虚化。不过,我对链接时去虚化领域的最新进展并不了解,且在 Compiler Explorer 上进行相关实验颇具困难,因此本文将完全不讨论 LTO。我们仅关注编译器本身所能实现的能力。
本质上,只有在以下两种情形中,编译器才拥有足够的信息来进行去虚化,且这两种情形并无太多共通之处:
-
当我们知道对象的动态类型时
此类情况最典型的例子如下: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 竟然能应对……除非你将转换到
的操作移入条件表达式内部!此时即便是 GCC 的分析也会失效(参见 Godbolt):Base*Derived da, db; Base *p = cond ? (Base*)&da : (Base*)&db; p->f(); -
当我们可以为其实例类型提供“无继承证明”时
假设我们接收一个来自系统其他部分的指针。我们知道其静态类型(例如
),但并不知道它所指向对象的实际动态类型。尽管如此,如果编译器能够以某种方式证明在整程序中没有任何类型会覆盖Derived*
,那么它仍然可以对Derived::f
的调用进行去虚化。Derived::f通过“最终性”证明(Proof-by-final)
最简单的“无继承证明”方式是将某个类标记为
。finalstruct 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
继承而来,都应适用。因此,编译器应同样能够处理如下情况:Basestruct Base { virtual int f() { return 1; } }; struct Derived final : public Base {}; int test(Derived *p) { return p->f(); }GCC、Clang 和 MSVC 均通过了此测试(参见 Godbolt case one);而 ICC 21.1.9 则被迷惑了。
另一种极为奇特的“无继承证明”方式是:观察到当类 C 的析构函数被声明为
时,C 必须没有子类——因为如果 C 有子类,则该子类也必须具备析构函数(毕竟任何类都不可避免地拥有析构函数),而这会尝试覆盖 C 的析构函数,这是不允许的。Clang 实际上不仅会对“最终析构函数”发出警告,还会针对它们进行优化。据我所知,其他所有供应商都认为这种情况非常荒谬,并未为此提供专门的代码路径。final通过“内部链接”证明(Proof-by-internal-linkage)
如果一个类的名称具有内部链接(internal linkage),则该名称无法在当前翻译单元(translation unit)之外被引用。因此,它也不可能从当前翻译单元之外的地方被派生!只要在当前翻译单元内没有子类(至少是没有覆盖其方法的子类),对其虚函数的调用就可以进行去虚化:
namespace { class BaseImpl : public Base {}; } int test(Base *p) { return static_cast<BaseImpl*>(p)->f(); }如果
确实指向“至少是p
"的对象实例,那么编译器可以证明该实例必须严格等于BaseImpl
。(而倘若BaseImpl
并非指向一个“至少是p
”的实例,那么程序本身就已处于未定义行为状态。)BaseImpl在我看来,这种情形在真实代码库中其实相当常见。通常做法是在头文件中公开暴露一个基类,而在单个
文件中限定作用域地定义一或多个派生实现。如果你更进一步,将这些派生实现放入匿名命名空间(anonymous namespace),或许就能帮助编译器的去虚化逻辑。当然,按照定义,此类优化所带来的好处仅限于该单一.cpp
文件之内!.cpp另一种使类型名称获得内部链接的方式是:当该类是一个模板实例化,且其中一个模板参数涉及具有内部链接的名称时。例如,若名称
具有内部链接,则即使T
本身具有外部链接,E
也会自动具有内部链接——因为要使用E<T>
,就必须同时使用E<T>
。(请注意,此处要求T
是一个“真名”,我们讨论的不是类型别名。)T此外,还有一种可能性:构造一个其名称具有外部链接的类型,但编译器却能证明在其他所有翻译单元中该类型必然是不完整的。例如:
namespace { class Internal {}; } class External { Internal m; };其他任意翻译单元都可以对
进行前向声明(作为不完整类型),但它们永远无法完成该类型的定义,因为它们无法为其数据成员指定具体类型。由于不能从不完整类型派生,因此所有从class External
派生的类型(如果存在的话)都必须出现在当前翻译单元中;若此处并无此类派生类型,那就构成了“无继承证明”!只有 GCC 能够识别这种情况。External测试结果汇总表
- Godbolt 提供了已知动态类型情形下的测试示例;
- Godbolt 还提供了“无继承证明”情形的测试示例。在后者中,我分别为“直接在
中定义的Derived
"和“从Derived::f
继承的Base
"设置了独立的测试。GCC 经常能正确判断Derived::g
,却未能对f
执行去虚化。针对此问题,我已向 GCC 提交了 bug #99093。g
测试用例编号 情况描述 GCC Clang MSVC ICC one trivial(平凡情况) ✓ ✓ ✓ ✓ two cast to Base*(强制转换为 Base*) ✓ ✓ three conditional, then cast(条件判断后转换) ✓ four cast, then conditional(先转换,再条件判断) one (final class) final class(最终类) ✓ ✓ ✓ f two (final method) final method(最终方法) ✓ ✓ ✓ ✓ three (silly final destructor) silly final destructor(荒谬的最终析构函数) ✓ four silly old-school trick(愚蠢的老派技巧) five I.L. class(内部链接类) f six I.L. template parameter(内部链接的模板参数) f seven I.L. base(内部链接基类) f eight I.L. member(内部链接成员) f nine I.L. with child(带子类的内部链接类型) f ten local class(局部类) f Steve Dewhurst 教会了我“four”中的“愚蠢老派技巧”:虚拟基类总是在最派生类的上下文中构造。因此,若类 C 拥有一个虚拟基类,且该虚拟基类的所有构造函数均为私有的,则没有任何 C 的子类能够自行构造自己;换言之,C 的任何子类都无法存在。(当然,为了使 C 自身可被构造,该虚拟基类必须将 C 列为其友元。)我认为这一技巧是绝对可靠的,因此可以构成对 C 的“无继承证明”;然而显然,没有任何编译器愿意追溯这纷繁复杂的逻辑链条,即便它确实牢不可破。
能否想到其他我尚未提及的、能够构造出“无继承证明”的方法?欢迎与我分享!