如果将对象强制转换为其实际类型,使用虚函数是否会产生任何成本?

Are there any costs to using a virtual function if objects are cast to their actual type?

我的理解是,由于两个问题,虚函数会导致性能问题:由 vtable 引起的额外取消引用以及编译器无法在多态代码中内联函数。

如果我将变量指针向下转换为它的确切类型会怎样?那还有额外的费用吗?

class Base { virtual void foo() = 0; };
class Derived : public Base { void foo() { /* code */} };

int main() {
    Base * pbase = new Derived();
    pbase->foo(); // Can't inline this and have to go through vtable
    Derived * pderived = dynamic_cast<Derived *>(pbase);
    pderived->foo(); // Are there any costs due to the virtual method here?
}

我的直觉告诉我,由于我将对象转换为它的实际类型,编译器应该能够避免使用虚函数的缺点(例如,如果需要,它应该能够内联方法调用).这是正确的吗?

编译器真的能知道我向下转型后pderived是Derived类型吗?在上面的示例中,很容易看出 pbase 是 Derived 类型,但在实际代码中,它在编译时可能是未知的。

既然我已经写下来了,我想既然 Derived class 本身可以被另一个 class 继承,将 pbase 向下转换为 Derived 指针实际上并不能确保对编译器,因此无法避免使用虚函数的成本?

虚构的 Sufficiently Smart Compiler can do, and what actual compilers end up doing. In your example, since there is nothing inheriting from Derived, the latest compilers will likely devirtualize the call to foo. However, since successful devirtualization and subsequent inlining is a difficult problem in general, help the compiler out whenever possible by using the final 关键字之间始终存在差距。

class Derived : public Base { void foo() final { /* code */} }

现在,编译器知道 Derived* 只能调用一种可能的 foo

(要深入讨论去虚拟化为何困难以及 gcc4.9+ 如何解决它,请阅读 Jan Hubicka 的 Devirtualization in C++ 系列文章。)

Pradhan 使用 final 的建议是合理的,如果更改 Derived class 是您的一个选项并且您不希望任何进一步的推导。

另一个可直接用于特定调用站点的选项是在函数名称前加上 Derived::,禁止虚拟分派到任何进一步的覆盖:

#include <iostream>

struct Base { virtual ~Base() { } virtual void foo() = 0; };

struct Derived : public Base
{
    void foo() override { std::cout << "Derived\n"; }
};

struct FurtherDerived : public Derived
{
    void foo() override { std::cout << "FurtherDerived\n"; }
};

int main()
{
    Base* pbase = new FurtherDerived();
    pbase->foo(); // Can't inline this and have to go through vtable
    if (Derived* pderived = dynamic_cast<Derived *>(pbase))
    {
        pderived->foo();  // still dispatched to FurtherDerived
        pderived->Derived::foo();  // static dispatch to Derived
    }
}

输出:

FurtherDerived
FurtherDerived
Derived

这可能很危险:实际的运行时类型可能取决于调用其覆盖以维护其不变量,因此除非存在紧迫的性能问题,否则使用它是个坏主意。

可用代码 here

去虚拟化实际上是常量传播的一种非常特殊的情况,其中传播的常量是类型(通常物理上表示为 v-ptr,但标准不作这种保证)。


完全去虚拟化

在多种情况下,编译器实际上可以将您可能没有想到的调用去虚拟化:

int main() {
    Base* base = new Derived();
    base->foo();
}

Clang 能够将上述示例中的调用去虚拟化,因为它可以跟踪 base 在作用域中创建时的实际类型。

同理:

struct Base { virtual void foo() = 0; };
struct Derived: Base { virtual void foo() override {} };

Base* create() { return new Derived(); }

int main() {
    Base* base = create();
    base->foo();
}

虽然这个例子稍微复杂一些,Clang前端不会意识到base一定是Derived类型,但后面的LLVM优化器会:

  • main
  • 中内联create
  • base->vptr
  • 中存储指向Derived的v-table的指针
  • 意识到 base->foo() 因此是 base->Derived::foo()(通过 v-ptr 解析间接寻址)
  • 并最终优化所有内容,因为在 Derived::foo
  • 中无事可做

这是最终结果(我认为即使对于那些没有启动 LLVM IR 的人也不需要评论):

define i32 @main() #0 {
  ret i32 0
}

在很多情况下,编译器(前端或后端)可以在可能不明显的情况下使调用去虚拟化,在所有情况下,它归结为它证明 运行 的能力-指向对象的时间类型。


部分去虚拟化

在他关于 gcc 编译器改进的系列文章中,主题为 devirutalization Jan Hubička 介绍了部分去虚拟化。

gcc 的最新版本能够列出一些可能的 运行-time 对象类型,特别是生成以下伪代码(在这种情况下,两个被认为是可能的,并不是所有的人都知道或可能足以证明一个特殊情况):

// Source
void doit(Base* base) { base->foo(); }

// Optimized
void doit(Base* base) {
    if (base->vptr == &Derived::VTable) { base->Derived::foo(); }
    else if (base->ptr == &Other::VTable) { base->Other::foo(); }
    else {
        (*base->vptr[Base::VTable::FooIndex])(base);
    }
}

虽然这看起来有点复杂,但如果预测正确,它确实提供了一些性能提升(正如您将从系列文章中看到的那样)。

看起来很奇怪?好吧,还有更多测试,但是 base->Derived::foo()base->Other::foo() 现在可以 内联 ,这本身就开辟了进一步的优化机会:

  • 在这种特殊情况下,由于 Derived::foo() 什么都不做,函数调用可以被优化掉; if 测试的惩罚小于函数调用的惩罚,因此如果条件匹配的频率足够高,这是值得的
  • 在函数参数之一已知或已知具有某些特定属性的情况下,后续的常量传播过程可以简化函数的内联主体

令人印象深刻,对吧?


好吧好吧,有点啰嗦了,不过我来谈谈dynamic_cast<Derived*>(base)

首先,一个dynamic_cast的成本不容小觑;实际上,它可能 比首先调用 base->foo() 更昂贵 ,你已经被警告过。

其次,使用 dynamic_cast<Derived*>(base)->foo() 确实可以允许对函数调用进行去虚拟化,前提是它为编译器提供了足够的信息(至少总是提供更多信息)。通常,这可以是:

  • 因为Derived::foofinal
  • 因为Derivedfinal
  • 因为 Derived 是在匿名命名空间中定义的,没有后代重新定义 foo,因此只能在这个翻译单元(大致是 .cpp 文件)中访问,所以它的所有后代是已知的,可以检查
  • 和许多其他情况(例如在部分去虚拟化的情况下 p运行ing 潜在候选集)

不过,如果您真的希望确保去虚拟化,那么将 final 应用于函数或 class 是您最好的选择。