【问题标题】:Why should virtual functions not be used excessively?为什么不能过度使用虚函数?
【发布时间】:2011-03-04 07:25:44
【问题描述】:

我刚刚读到我们不应该过度使用虚函数。人们认为,较少的虚拟功能往往会产生较少的错误并减少维护。

虚函数会出现什么样的bug和缺点?

我对 C++ 或 Java 的上下文感兴趣。


我能想到的一个原因是由于 v-table 查找,虚函数可能比普通函数慢。

【问题讨论】:

  • 其实我也在找链接。几天前我读了这篇文章,仍在思考。我能想到的一个原因是由于 v-table 查找,虚函数可能比正常函数慢。我指的是 C++/Java。
  • 值得注意的是,在共享库中,虚函数调用不一定比非虚函数调用慢,因为非虚函数调用将通过 PLT 条目间接调用.

标签: java c++ virtual-functions


【解决方案1】:

每个依赖项都会增加代码的复杂性,并使其更难维护。当您将函数定义为虚拟函数时,您会创建类对其他代码的依赖关系,这些代码目前甚至可能不存在。

例如,在 C 语言中,您可以轻松找到 foo() 的作用 - 只有一个 foo()。在没有虚函数的 C++ 中,它稍微复杂一些:您需要探索您的类及其基类以找到我们需要的 foo()。但至少你可以提前确定,而不是在运行时。使用虚函数,我们无法判断哪个 foo() 被执行,因为它可以在一个子类中定义。

(另一件事是您提到的性能问题,由于 v-table)。

【讨论】:

  • 我喜欢这个答案,但我认为@Stephen 的答案更好,因为它更完整、更具体。但它错过了您正在制作的真正重要的依赖点。
【解决方案2】:

您发布了一些笼统的陈述,我认为大多数务实的程序员会因为被误导或误解而耸耸肩。但是,确实存在反虚拟狂热者,他们的代码可能同样不利于性能和维护。

在 Java 中,默认情况下一切都是虚拟的。说你不应该过度使用虚函数是相当强的。

在 C++ 中,您必须声明一个虚拟函数,但在适当的时候使用它们是完全可以接受的。

我刚刚读到我们不应该过度使用虚函数。

很难定义“过度”...当然“在适当的时候使用虚函数”是个好建议。

人们认为,较少的虚拟功能往往会产生较少的错误并减少维护。 我不知道由于虚函数会出现什么样的错误和缺点。

设计不佳的代码很难维护。期间。

如果您是库维护者,正在调试隐藏在高层次类层次结构中的代码,则可能很难跟踪代码实际执行的位置,如果没有强大的 IDE 的优势,通常很难判断哪些类覆盖行为。它可能导致在跟踪继承树的文件之间跳来跳去。

所以,有一些经验法则,但都有例外:

  • 保持层次结构浅。高大的树木会使课程混乱。
  • 在 C++ 中,如果您的类具有虚函数,请使用虚析构函数(如果没有,则可能是错误)
  • 与任何层次结构一样,在派生类和基类之间保持“is-a”关系。
  • 您必须注意,可能根本不会调用虚函数...所以不要添加隐式期望。
  • 有一个很难争论的例子,即虚函数更慢。它是动态绑定的,所以经常出现这种情况。在它所引用的大多数情况下,它是否重要当然值得商榷。改为分析和优化 :)
  • 在 C++ 中,不要在不需要时使用 virtual。将功能标记为虚拟涉及语义 - 不要滥用它。让读者知道“是的,这可能会被覆盖!”。
  • 更喜欢纯虚拟接口而不是混合实现的层次结构。它更简洁,更容易理解。

实际情况是,虚函数非常有用,而这些疑点不太可能来自平衡的来源——虚函数已被广泛使用了很长时间。更多的新语言将它们作为默认设置。

【讨论】:

    【解决方案3】:

    在 C++ 中:--

    1. 虚拟函数有轻微的性能损失。通常它太小而无法产生任何影响,但在紧密的循环中它可能很重要。

    2. 虚函数将每个对象的大小增加一个指针。同样,这通常是微不足道的,但如果您创建数百万个小对象,这可能是一个因素。

    3. 具有虚函数的类通常意味着继承自。派生类可以替换部分、全部或不替换任何虚函数。这会产生额外的复杂性,而复杂性是程序员的死敌。例如,派生类可能很难实现虚函数。这可能会破坏依赖虚函数的基类的一部分。

    现在让我明确一点:我不是说“不要使用虚函数”。它们是 C++ 的重要组成部分。请注意潜在的复杂性。

    【讨论】:

      【解决方案4】:

      虚拟函数比常规函数稍慢。但这种差异是如此之小,以至于除了最极端的情况外,在所有情况下都没有影响。

      我认为避免使用虚函数的最佳理由是防止接口滥用。

      编写对扩展开放的类是个好主意,但有一种情况是太开放了。通过仔细规划哪些函数是虚函数,您可以控制(和保护)类的扩展方式。

      当一个类被扩展从而破坏了基类的契约时,就会出现错误和维护问题。这是一个例子:

      class Widget
      {
          private WidgetThing _thing;
      
          public virtual void Initialize()
          {
              _thing = new WidgetThing();
          }
      }
      
      class DoubleWidget : Widget
      {
          private WidgetThing _double;
      
          public override void Initialize()
          {
              // Whoops! Forgot to call base.Initalize()
              _double = new WidgetThing();
          }
      }
      

      这里,DoubleWidget 破坏了父类,因为Widget._thing 为空。有一个相当标准的方法来解决这个问题:

      class Widget
      {
          private WidgetThing _thing;
      
          public void Initialize()
          {
              _thing = new WidgetThing();
              OnInitialize();
          }
      
          protected virtual void OnInitialize() { }
      }
      
      class DoubleWidget : Widget
      {
          private WidgetThing _double;
      
          protected override void OnInitialize()
          {
              _double = new WidgetThing();
          }
      }
      

      现在小部件以后不会遇到NullReferenceException

      【讨论】:

      • '虚拟函数比普通函数稍慢。'不,他们不是,因为他们做得更多。在您在非虚拟函数周围添加必要的 if/else 链或 switch 语句以便它进行一些动态调度之后,非虚拟函数的性能可能会更差。
      • @EJP 这样的 switch 语句很容易优化,因为所有信息都在一个地方。
      【解决方案5】:

      我们最近有一个很好的例子来说明滥用虚函数如何引入错误。

      有一个具有消息处理程序的共享库:

      class CMessageHandler {
      public:
         virtual void OnException( std::exception& e );
         ///other irrelevant stuff
      };
      

      目的是您可以从该类继承并将其用于自定义错误处理:

      class YourMessageHandler : public CMessageHandler {
      public:
         virtual void OnException( std::exception& e ) { //custom reaction here }
      };
      

      错误处理机制使用CMessageHandler* 指针,因此它不关心对象的实际类型。该函数是虚函数,因此只要存在重载版本,就会调用后者。

      很酷,对吧?是的,直到共享库的开发者更改了基类:

      class CMessageHandler {
      public:
         virtual void OnException( const std::exception& e ); //<-- notice const here
         ///other irrelevant stuff
      };
      

      ...重载刚刚停止工作。

      你明白发生了什么吗?更改基类后,重载不再是从 C++ 角度来看的重载 - 它们变成了新的、其他不相关的函数

      基类的默认实现未标记为纯虚拟,因此派生类不会强制重载默认实现。最后,仅在错误处理的情况下才调用该函数,该错误处理并非到处都使用。所以这个 bug 被悄悄地引入并在很长一段时间内被忽视了。

      一劳永逸地消除它的唯一方法是搜索所有代码库并编辑所有相关代码。

      【讨论】:

      • 很好的例子,但它应该在它说过载的地方说覆盖。
      • C++0x 有解决这个问题的办法。如果一个函数的名称隐藏在派生类中,但没有明确说明您的意图是什么,它可以让您声明您想要一个错误。
      【解决方案6】:

      我不知道你在哪里读到的,但恕我直言,这根本与性能无关。

      也许它更多地是关于“更喜欢关于继承的组合”以及如果你的类/方法不是最终的(我在这里主要谈论 java)但不是真正为重用而设计的问题。有很多事情可能会出错:

      • 也许你在你的 构造函数——一旦它们被覆盖, 你的基类调用被覆盖的 方法,可能使用资源 在子类中初始化 构造函数 - 稍后运行(NPE 上升)。

      • 想象一下 add 和 addAll 方法 在列表类中。 addAll 调用添加 很多时候,两者都是虚拟的。 有人可能会覆盖它们来计数 添加了多少项目 全部。如果您不记录 addAll 调用添加,开发人员可以(和 will) 覆盖 add 和 addAll (并添加一些 counter++ 的东西到 他们)。但是现在,如果你使用 addAll, 每个项目计数两次(添加和 addAll) 导致不正确 结果和难以发现的错误。

      总而言之,如果你不设计你的类来扩展(提供钩子,记录一些重要的实现的东西),你根本不应该允许继承,因为这会导致严重的错误。如果需要,它也很容易从您的一个类中删除最终修饰符(并可能重新设计以实现可重用性),但不可能将非最终类(子类化导致错误)设为最终类,因为其他人可能已经对其进行了子类化。

      也许这真的是关于性能,那么我至少跑题了。但如果它不是,那么如果你真的不需要它,你就有充分的理由不让你的类可扩展。

      有关 Blochs Effective Java 中类似内容的更多信息(这篇特别的文章是在我阅读第 16 条(“优先组合优于继承”)和第 17 条(“设计和文档以用于继承,否则禁止它”)后写的- 很棒的书。

      【讨论】:

      • 顺便说一句,Matts 的答案非常接近我的答案,我想他是在我打字的时候编辑的——抱歉重复信息。
      【解决方案7】:

      我怀疑您误解了该声明。

      过度是一个非常主观的术语,我认为在这种情况下它的意思是“当你不需要它时”,而不是当它有用时你应该避免它。

      根据我的经验,有些学生在学习虚函数并第一次忘记将函数虚化时被烧毁,认为将每个函数都虚化是谨慎的做法

      由于虚函数确实会在每个方法调用上产生成本(在 C++ 中,由于单独编译,通常无法避免这种情况),因此您现在实际上是在为每个方法调用付费,并且还要防止内联。许多教师不鼓励学生这样做,尽管“过度”一词是一个非常糟糕的选择。

      在 Java 中,“虚拟”行为(动态调度)是默认设置。但是,JVM 可以动态优化,理论上可以在目标身份明确时消除一些虚拟调用。此外,final 方法或 final 类中的方法通常也可以在编译时解析为单个目标。

      【讨论】:

      • 没错,我们知道虚函数的威力,但有时它会导致对这些威力的滥用。有时会变成错误。我只是在寻找那个。很抱歉没有很好地描述问题。
      【解决方案8】:

      在大约 7 年的时间里,我偶尔在同一个 C++ 系统上担任顾问,检查大约 4-5 名程序员的工作。每次我回去,系统都变得越来越糟。在某个时候,有人决定移除所有的虚函数,并用一个非常迟钝的基于工厂/RTTI 的系统来代替它们,该系统基本上完成了虚函数已经在做的所有事情,但更糟糕的是,更昂贵,代码增加了数千行,工作量很大,大量的测试,......完全毫无意义,显然是对未知的恐惧驱动的。

      他们还手写了几十个有错误的复制构造函数,而编译器会自动生成它们,没有错误,除了大约三个需要手写版本的例外情况。

      道德:不要与语言抗争。它给你东西:使用它们。

      【讨论】:

        【解决方案9】:

        为每个类创建虚拟表,具有虚函数或派生自包含虚函数的类。这比平常消耗更多的空间。

        编译器需要静默插入额外代码,以确保发生后期绑定而不是早期绑定。这会比平时花费更多的时间。

        【讨论】:

          【解决方案10】:

          在Java中,没有virtual关键字,但是所有的方法(函数)都是虚拟的,除了那些标记为final、静态方法和私有实例方法的方法。使用虚函数根本不是一个坏习惯,但是因为它们通常无法在编译时解析,并且编译器无法对它们进行优化,所以它们往往会慢一些。 JVM 必须在运行时找出需要调用的确切方法。请注意,这无论如何都不是什么大问题,只有当您的目标是创建一个非常高性能的应用程序时才应该考虑它。

          例如,Apache Spark 2(在 JVM 上运行)中最大的优化之一是减少虚拟函数调度的数量,以获得更好的性能。

          【讨论】:

          • 让所有东西都成为虚拟/可替换的问题在于,替换软件组件的语义很少被定义。
          猜你喜欢
          • 2013-06-24
          • 2012-12-16
          • 2021-07-04
          • 2015-12-12
          • 2017-07-11
          • 2018-08-01
          • 2011-09-07
          • 2012-02-08
          • 2013-10-26
          相关资源
          最近更新 更多