在C++中使用接口会导致性能下降吗?

49

在C++中使用接口(抽象基类)是否会导致运行时性能降低?


这个问题的答案也与这个相关。 - Richard Corden
16个回答

50

简短回答:不会。

长回答: 一个类的基类或祖先数并不会影响它的速度,唯一影响速度的是方法调用的成本。

非虚方法调用有成本(但可以内联)
虚方法调用的成本稍高,因为你需要在调用之前查找要调用的方法(但这只是一个简单的表查找,而不是搜索)。由于接口上的所有方法都是虚的,因此会有这种成本。

除非您正在编写某些超级敏感的应用程序,否则这不应该是问题。使用接口所带来的额外清晰度通常可以弥补任何感知到的速度降低。


2
值得注意的是,在具有多个基类的对象上进行虚拟方法调用的成本比在具有单一继承层次结构的对象上进行虚拟方法调用的成本略微更高。 - Greg Hewgill
你确定吗?你有这个评论的来源可以查看吗? - Martin York
1
成本在于转换。如果你有'D*',想要转换为'B2*',那么,当类的布局为[ B1,B2 ]时,编译器需要返回'D* + 偏移量到B2'。然而,我认为这并不值得注意 - 这将是微不足道的。 - Richard Corden
1
可能甚至为零,这取决于您的CPU提供的寻址模式。访问寄存器+偏移量并不罕见。 - Steve Jessop
2
@Martin:实际上,虚方法将在与非虚方法相同的情况下进行内联,即当对象的动态类型可以在编译时确定且该方法可内联(不太大+在类定义中声明或使用“inline”)时。 - j_random_hacker
显示剩余2条评论

28

使用虚函数调用的函数无法内联

有一种很容易被忽视的虚函数惩罚是:虚函数调用在对象类型不知道编译时(常见情况下)时不会被内联。如果您的函数很小并且适合内联,这种惩罚可能非常显著,因为不仅会增加调用开销,而且编译器在优化调用函数方面也受到限制(它必须假设虚函数可能已更改某些寄存器或内存位置,它不能在调用者和被调用者之间传播常量值)。

虚函数调用成本因平台而异

就与普通函数调用相比的调用开销而言,答案取决于目标平台。如果您的目标是带有x86 / x64 CPU的PC,则调用虚函数的惩罚非常小,因为现代x86 / x64 CPU可以对间接调用进行分支预测。但是,如果您的目标是PowerPC或一些其他RISC平台,则虚函数调用惩罚可能相当大,因为在某些平台上从不预测间接调用(参见PC/Xbox 360跨平台开发最佳实践)。


8
错误的说虚拟调用不会被内联。只要编译器可以在编译时确定对象的最终类型,那么该对象上的方法调用就有可能被内联。只有通过基类指针调用时才无法进行内联。 - j_random_hacker
1
@j_random_hacker 这只是通常情况下您将其用作基类指针的情况。 - Ghita
2
@Ghita:即使如此,优化编译器可能能够推断出对象的动态类型。我猜大多数编译器可以将对foo()的调用内联到Base* x = new Derived; x->foo();中。 - j_random_hacker
3
可以。但在正常情况下,通常将指向基类的指针传递给周围。有趣的是最简单的情况由编译器处理。 - Ghita
1
即使指向基类的指针被用于模块化和可测试性,并且只有一个子类,当进行完整系统分析并且接口仅用于上述目的时,LTO编译可以内联该指针。不幸的是,我不确定当前是否有任何编译器能够实现该功能。 - Lothar

10

与常规调用相比,每个虚函数调用会有一个小的惩罚。除非每秒进行数十万次调用,否则您不太可能观察到差异,通常为了增加代码清晰度而支付的代价通常是值得的。


5
当您调用虚函数(例如通过接口)时,程序必须在表格中查找该对象的要调用的函数。相比直接调用函数,这会带来一些小的惩罚。
此外,当您使用虚函数时,编译器无法内联函数调用。因此,对于某些小型函数,使用虚函数可能会带来一定的惩罚。这通常是您可能遇到的最大性能“损失”。只有在函数很小且被多次调用(例如在循环内部)时,才会真正出现这个问题。

不要让它破坏你的设计,但只有在真正需要时才使用虚函数 - 当你考虑迭代一大堆元素并在每个元素上调用该方法时,内联可以带来巨大的性能提升。 - xtofl
1
错误的说虚函数调用不能被内联。只要编译器能够在编译时确定对象的最终类型,该对象上的方法调用就有可能被内联。只有通过指向基类的指针进行调用时才无法执行内联。 - j_random_hacker

4

在某些情况下适用的另一种选择是使用模板进行编译时多态性。例如,当您想要在程序开始时进行实现选择,然后在执行期间使用它时,这将非常有用。以下是一个使用运行时多态性的示例:

class AbstractAlgo
{
    virtual int func();
};

class Algo1 : public AbstractAlgo
{
    virtual int func();
};

class Algo2 : public AbstractAlgo
{
    virtual int func();
};

void compute(AbstractAlgo* algo)
{
      // Use algo many times, paying virtual function cost each time

}   

int main()
{
    int which;
     AbstractAlgo* algo;

    // read which from config file
    if (which == 1)
       algo = new Algo1();
    else
       algo = new Algo2();
    compute(algo);
}

使用编译时多态性的相同操作。
class Algo1
{
    int func();
};

class Algo2
{
    int func();
};


template<class ALGO>  void compute()
{
    ALGO algo;
      // Use algo many times.  No virtual function cost, and func() may be inlined.
}   

int main()
{
    int which;
    // read which from config file
    if (which == 1)
       compute<Algo1>();
    else
       compute<Algo2>();
}

不幸的是,这并不适用于插件类和其他动态加载的类型(是的,在C++中这是可能的 :-))。 - André Caron
这会大大增加代码膨胀,我仍在等待任何研究来检查模板代码膨胀对性能的影响(缓存问题、分支预测等)。 - Lothar

3
我认为成本比较不是在虚函数调用和直接函数调用之间。如果您考虑使用抽象基类(接口),那么您需要根据对象的动态类型执行多个操作中的一个。您必须以某种方式做出选择。一种选择是使用虚函数。另一种是通过对象类型的开关,通过RTTI(可能很昂贵)或将type()方法添加到基类(可能增加每个对象的内存使用)来实现。因此,应该将虚函数调用的成本与替代方案的成本进行比较,而不是与什么都不做的成本进行比较。

3

大多数人都会注意到运行时间的惩罚,这是正确的。

然而,在我从事大型项目开发的经验中,清晰接口和适当封装带来的好处很快抵消了速度上的优势。模块化的代码可以被替换为改进后的实现,因此净效果是巨大的收益。

个人结果可能有所不同,并且显然取决于您正在开发的应用程序。


我想提出一个相反的观点:对于嵌入式系统(其他人已经提到了视频游戏主机),性能损失可能会太大,除非从可维护性/可读性/清晰度等方面获得了很多好处,否则应该避免使用。 - It'sPete

3
请注意,多重继承会使对象实例膨胀为多个虚表指针。在x86上使用G ++,如果你的类有一个虚方法且没有基类,则有一个指向虚表的指针。如果你有一个带有虚方法的基类,则仍然有一个指向虚表的指针。如果你有两个带有虚方法的基类,则每个实例上有两个虚表指针。
因此,在多重继承(这就是C ++中实现接口的方式)中,你需要支付基类乘以指针大小的对象实例大小。内存占用的增加可能会间接影响性能。

2

需要注意的是,虚函数调用的成本可能因平台而异。在游戏机上,它们可能更加明显,因为通常 vtable 调用意味着缓存未命中,会影响分支预测。


2
我经常听到这种说法,但从未得到过验证。全球性能应用程序的成本效益,在控制台或其他地方,实际上取决于您调用方法的频率。我的大多数游戏行业朋友根本不允许使用虚函数,即使是针对您的论点。他们最终会遇到混乱的代码,以规避此限制,即使是每个渲染周期调用一次的高级函数也是如此。 - André Caron

0

在C++中使用抽象基类通常需要使用虚函数表,所有接口调用都将通过该表进行查找。与原始函数调用相比,成本微不足道,因此请确保您需要更快的速度之前再担心它。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接