【问题标题】:Virtual Function Compared to Pointer Casting虚函数与指针转换的比较
【发布时间】:2011-07-15 22:30:32
【问题描述】:

我正在使用的某些代码的当前版本使用了一种稍微奇怪的方式来实现我认为可以通过多态性来实现的东西。更具体地说,我们目前使用类似

for(int i=0; i<CObjList.size(); ++i)
{
   CObj* W = CObjList[i];
   if( W->type == someTypeA )
   {
       // do some things which also involve casts such as
       //  ((SomeClassA*) W->objectptr)->someFieldA
   }
   else if( W->type == someTypeB )
   {
       // do some things which also involve casting such as
       //  ((SomeClassB*) W->objectptr)->someFieldB
   }
}

澄清;每个对象 W 都包含一个void *objectptr;,即指向任意位置的指针。 W-&gt;type 字段跟踪 objectptr 指向的对象类型,以便在 if/else 语句中,我们可以将 W-&gt;objectptr 转换为正确的类型并使用它的字段。

但是,从代码设计的角度来看,这似乎天生就很糟糕,原因有几个;

  1. 我们无法保证 W-&gt;objectptr 指向的对象实际上与 W-&gt;type 中所说的内容相匹配,因此强制转换本质上是不安全的。
  2. 每次我们希望添加另一种类型时,都必须添加另一个 elseif 语句并确保 W-&gt;type 设置正确。

似乎用类似的东西解决这个问题会好得多

class CObj
{
public:
   virtual void doSomething(/* some params */)=0;
};

class SomeClassA : public CObj
{
public:
   virtual void doSomething(/* some params */);
   int someFieldA;
}

class SomeClassB : public CObj
{
public:
   virtual void doSomething(/* some params */);
   int someFieldB;
}

// sometime later...

for(int i=0; i<CObjList.size(); ++i)
{
   CObj* W = CObjList[i];
   W->doSomething(/* some params */);
}

话虽如此,但前提是在此设置中性能很重要。此代码将从(相对)紧密的循环中调用。

那么我的问题是;一些 vtable 查找所增加的复杂性是否被改进的代码设计和可扩展性所抵消,这是否可能对性能产生很大影响?

编辑:我突然想到,由于缓存未命中等原因,以这种方式通过指针访问字段可能与 vtable 查找一样糟糕。对此有什么想法吗?

---- 编辑 2:我也忘了提及(我知道这有点偏离原始主题),在 if 语句内部有许多对周围类的成员函数的调用。您将如何设计结构以便能够从 doSomething() 内部调用这些?

【问题讨论】:

  • 你真的对此做过一些性能测试吗?还是您“优化得太早了?”
  • 虽然基于类的 OOP 可能并不完美,但不妨利用它所提供的优势......在这种情况下,是一种多态性。
  • 您是否尝试测量性能差异?如果没有太大区别,那么更简洁的代码将为可维护性带来巨大的好处。
  • 尚未尝试衡量差异,因为这需要很多地方进行更改。此外,通过谷歌搜索,很多人认为简单的测试可能不具有代表性。
  • @Dan:这是一个嵌入式系统吗?如果是这样,请考虑标记“嵌入式”——您会得到与“直接”C++ 略有不同的答案,因为性能/可维护性的权衡对于嵌入式编码人员来说通常更明显。

标签: c++ oop polymorphism vtable


【解决方案1】:

我将专门从性能角度回答,因为我在性能关键的环境中工作,不久前我碰巧对类似案例进行了测量,以找出最快的解决方案。

如果您使用的是 x86、PPC 或 ARM 处理器,则在这种情况下您需要虚拟功能。调用虚函数的性能代价主要是由mispredicting an indirect branch引起的pipeline bubble。因为 CPU 的取指令阶段不知道计算出的 jmp 去哪里,所以在分支执行之前它不能从目标地址开始取字节,因此你在管道中会有一个与阶段数相对应的停顿。第一个 fetch 阶段和分支退出。 (在我最了解的 PPC 上,大概是 25 个周期。)

您也有加载 vtable 指针的延迟,但这通常被指令重新排序所隐藏(编译器移动 load 指令,因此它在您真正需要结果之前开始几个周期,而 CPU 会在数据缓存向您发送它的电子。)

如果使用 if-cascade 方法,您将拥有一些 n 个直接条件分支——其中目标在编译时是已知的,但是否进行跳转是在运行时确定的。 (即,jump-on-equal 操作码。)在这种情况下,CPU 将猜测(预测)是否采用每个分支,并相应地开始获取指令。所以,只有当 CPU 猜错时,你才会有泡沫。由于您可能每次都使用不同的输入调用此函数,因此它至少会错误预测这些分支中的一个,并且您将拥有与使用 virtuals 完全相同的气泡。事实上,你会有更多的气泡——每个 if() 有条件的气泡!

对于虚函数,在加载 vtable 时还存在额外的数据缓存未命中以及跳转目标上的 icache 未命中的风险。如果此函数处于紧密循环中,那么您可能会经常查找并调用相同的子例程,因此 vtable 和函数体可能仍会在缓存中。 You could measure that 如果你想确定的话。

【讨论】:

  • 不错的答案,干杯。那么,当您运行测试时,哪种情况最快?
【解决方案2】:

使用虚函数,这个假设的优化没有任何意义。重要的是代码的可读性、可维护性和质量。

如果您确实需要调整热点,请稍后借助分析器进行优化。用这种废话让你的代码无法维护是一条失败之路。

此外,虚函数将帮助您进行单元测试、模拟接口等。 编程就是管理复杂性......

【讨论】:

  • 不完全正确,即使是几个 % 的速度差异在这里也很重要,对我来说,哪种情况更快并不明显。我完全同意哪个更具可读性,但要改变它需要付出很多努力(因此我没有在这种情况下对其进行测试),如果它会更慢,我不想浪费我的时间。
  • -1:OP 知道可维护性和性能之间存在潜在冲突,并且正在寻求性能权衡方面的经验。
  • 这样的态度就是为什么我的 Comcast DVR 需要三秒钟以上的时间来响应每一个按钮的按下。
  • 三秒响应按钮按下肯定不是因为 vtable 查找,而可能是因为算法、IO、图像加载以使按钮漂亮或太大等原因。跨度>
  • @Dan 什么是“这里”,所以这些微秒很重要?我相信 99% 的情况并非如此。
【解决方案3】:

那么我的问题是;一些 vtable 查找所增加的复杂性是否被改进的代码设计和可扩展性所抵消,这是否可能对性能产生很大影响?

C++ 编译器应该能够非常高效地实现虚函数,所以我认为使用它们没有缺点。 (当然还有巨大的可维护性/可读性优势!)但是您应该测量以确保。

它们的典型实现方式是每个对象都有一个 vtable 指针。 (在多重继承的情况下使用多个指针,但我们暂时忘记这一点)与非虚拟函数相比,它具有以下相对成本。

  • 数据空间:每个对象一个指针
  • 数据空间:每个类一个 vtable(不是每个对象!)
  • 时间:最坏情况 = 每次函数调用两次内存读取(1 次获取 vtable 地址,1 次获取 vtable 内的函数地址)。 vtable 中的偏移量在编译时是已知的,因为您知道要调用哪个函数。没有额外的跳跃。

将此与现有软件的非 OOP 方法的成本进行比较。

  • 数据空间:每个对象一个类型 ID
  • 代码空间:每次您希望调用依赖于对象类型的函数时使用一个 if/else 树或 switch 语句
  • 时间:必须评估 if/else 树或 switch 语句。

我会投票支持虚函数方法,因为它实际上比非 OOP 方法更快,因为它不需要花时间弄清楚它是什么类型的对象。

【讨论】:

  • 虚拟函数的时间成本比仅计算指令看起来更糟糕,因为现代 CPU 很复杂并且流水线很长。特别是,vcall 使用的“间接分支”操作可能比条件中使用的“直接分支”花费更长的时间。 (见citeseerx.ist.psu.edu/viewdoc/…)。但是 vtable 仍然是从可能性表中选择函数指针的最快方法,这基本上就是这里发生的事情。
  • @Crashworks:很有趣。如果间接分支较慢,那么寻址的一种方法可能是将 vtable 从只是地址表更改为跳转指令表。无论如何,编译器应该做最快的事情。
【解决方案4】:

我对一些较大的(我认为是 1M+ 行)科学计算代码有一些经验,这些代码使用了类似的基于类型的 switch 构造。他们重构为基于适当多态的方法,并获得了显着的加速。与他们的预期完全相反!

事实证明编译器能够更好地优化该结构中的某些内容。

但是这是很久以前(8 年左右)......所以谁知道现代编译器会做什么。不要猜测 - 分析它。

【讨论】:

    【解决方案5】:

    正如 piotr 所说,正确的答案可能是虚函数。您必须进行测试。

    但为了解决您对演员表的担忧:

    1. 切勿在 C++ 程序中使用 C 样式转换,使用 static_cast、dynamic_cast 等。
    2. 在您的具体情况下,使用 dynamic_cast。至少,如果类型不正确相关,你会得到一个异常,这比疯狂的崩溃要好。

    【讨论】:

    • 动态转换究竟是如何实现的?它是否需要 RTTI,如果需要,这是否会影响性能?
    • 我不确定细节,但我相信两者的答案都是肯定的。它肯定会添加一些代码来检查类型。
    • dynamic_cast 在性能关键的环境中非常糟糕。在我们的应用程序中,我在 3GHZ 处理器上以超过 10 微秒的时间对单个投射进行计时。
    • @Jason S :而且,很明显,dynamic_cast 确实需要启用 RTTI。 (gcc.gnu.org/onlinedocs/gcc-4.4.4/gcc/…)
    【解决方案6】:

    CRTP 对于这种情况来说是个好主意。

    编辑:在你的情况下,

    template<class T>
    class CObj
    {
    public:
       void doSomething(/* some params */)
       {
         static_cast<T*>(this)->doSomething(...);
       }
    };
    
    class SomeClassA : public CObj<SomeClassA>
    {
    public:
       void doSomething(/* some params */);
       int someFieldA;
    };
    
    class SomeClassB : public CObj<<SomeClassB>
    {
    public:
       void doSomething(/* some params */);
       int someFieldB;
    };
    

    现在您可能必须以不同的方式选择循环代码以适应不同CObj&lt;T&gt; 类型的所有对象。

    【讨论】:

    • 我不这么认为。 CRTP 仅适用于“静态多态性”,例如当对象的真实类型已知时。如果你只有一个超类类型的指针,你不知道它是哪个子类。
    • “现在你可能不得不以不同的方式选择你的循环代码来适应所有不同 CObj 类型的对象”——你还不如只使用虚函数!
    • @Jason,真的。这就是为什么我告诉 OP 可能必须将循环代码和逻辑更改为 accommodate all objects of different type. 这是一个选项,如果他真的不想使用 virtual 函数但要具有同样的优雅。
    • 嗯...好吧,好吧,但恕我直言,CRTP 在这种情况下不会增加价值。区别有点像在黑暗的房间里与某人交谈:静态多态性就像与一个人交谈,在那里你可以省去一大堆开销,因为你知道你在和谁说话,而试图使用相同的多人的技术只是失败了。
    猜你喜欢
    • 1970-01-01
    • 2012-10-05
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-11-03
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多