为什么不应过度使用虚函数?

14

我刚看到一篇文章,指出我们不应该过度使用虚函数。人们认为,较少的虚函数往往会有较少的错误,减少维护成本。

由于虚函数需要进行v表查找,可能比普通函数慢,这是我能想到的一个原因。

关于C++或Java的上下文,会有哪些虚函数的错误和缺点呢?


实际上我也在寻找这个链接。我几天前读到了这篇文章,一直在思考。 我能想到的一个原因是虚函数可能比普通函数慢,因为需要进行v-table查找。 我指的是C++/Java。 - GG.
1
值得注意的是,在共享库中,虚函数调用并不一定比非虚函数调用慢,因为非虚函数调用将通过 PLT 条目间接调用。 - jchl
10个回答

15

您发表了一些笼统的言论,我认为大多数务实的程序员会视其为误解或曲解。但是,确实存在反虚拟化狂热者,他们的代码对性能和维护而言可能同样糟糕。

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

在C++中,必须声明一个函数为虚函数,但在适当的情况下使用它们是完全可以接受的。

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

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

人们觉得少用虚函数 tend to have fewer bugs and reduces maintenance. 我无法理解由于虚函数可能出现什么样的错误和缺点。

设计不良的代码难以维护。没有别的。

如果您是库维护者,则在高高的类层次结构中嵌入的代码调试可能很困难,没有强大的IDE的帮助,通常很难确定哪个类覆盖了行为。这可能导致在跟踪继承树时在文件之间来回跳转。

因此,有一些原则,都有例外:

  • 保持层次结构的浅显。高度的树会使类变得混乱。
  • 在c++中,如果您的类具有虚函数,请使用虚析构函数(如果没有,则可能存在错误)
  • 与任何层次结构一样,派生类和基类之间保持“是一个”关系。
  • 您必须意识到,虚函数可能根本不被调用…所以不要添加隐含的期望。
  • 虚函数较慢存在无法反驳的情况。它是动态绑定的,因此通常是这种情况。是否在大多数引用的情况下很重要是可以争论的。进行分析和优化 :)
  • 在C++中,不需要时不要使用虚函数。标记一个函数为虚函数有着语义上的含义 - 不要滥用它。让读者知道“是的,这个函数可能被覆盖!”
  • 优先选择使用纯虚接口而不是混合实现的层次结构。这样做更加简洁易懂。
  • 事实上,虚函数非常有用,这些疑虑很可能并不来自平衡的来源 - 虚函数已经被广泛应用了很长时间。比起其他语言,更多较新的语言将其作为默认选项。


    8
    虚函数比普通函数稍微慢一些。但是这种差异非常小,除了在极端情况下几乎不会有影响。
    我认为避免使用虚函数的最好理由是为了防止接口误用。
    编写可扩展的类是一个好主意,但是有“太开放”的事情。通过仔细规划哪些函数是虚函数,可以控制(和保护)类如何被扩展。
    当一个类被扩展以打破基类的契约时,就会出现漏洞和维护问题。以下是一个例子:
    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”了。

    2
    虚函数比普通函数稍微慢一些。但实际上,由于它们做更多的事情,所以并不是这样的。当你在非虚函数周围添加必要的if/else链或switch语句,以便进行动态分派时,非虚函数的性能可能会更差。 - user207421
    @EJP 这样的 switch 语句很容易进行优化,因为所有信息都在一个地方。 - curiousguy

    6
    每个依赖都增加了代码的复杂性,使其更难以维护。当您将函数定义为虚拟函数时,您创建了类对某些其他代码的依赖,这些代码甚至可能并不存在。
    例如,在 C 语言中,您可以轻松地找到 foo() 的功能 - 只有一个 foo()。在没有虚拟函数的 C++ 中,情况略微复杂:您需要探索您的类及其基类,以查找我们需要哪个 foo()。但至少您可以提前确定地进行操作,而不是在运行时进行操作。使用虚拟函数,我们无法确定执行哪个 foo(),因为它可以在其中一个子类中定义。
    (另外一个问题是您提到的性能问题,由于虚函数表)。

    我喜欢这个答案,但我认为@Stephen的答案更好,因为它更完整和具体。但它错过了你所提出的非常重要的依赖性点。 - Omnifarious

    3

    我怀疑你误解了这个陈述。

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

    根据我的经验,一些学生在学习虚函数并因忘记将函数设置为虚函数而受挫后,认为最好是将每个函数都设置为虚函数

    由于虚函数确实会对每个方法调用产生成本(在C++中通常无法避免,因为存在分离编译),因此您现在要为每个方法调用付出代价,并防止内联。许多教师不鼓励学生这样做,尽管“过度”这个词是一个非常不恰当的选择。

    在Java中,“虚拟”行为(动态分派)是默认值。然而,JVM可以在运行时优化代码,并且在编译时可以将最终类中的最终方法或方法解析为单个目标。


    是的,我们确实知道虚函数的威力,但有时会导致滥用这些功能,从而导致错误。我就是在寻找这个问题。抱歉没有描述清楚问题。 - GG.

    2

    最近我们有一个完美的例子说明了虚函数的误用会引入bug。

    有一个共享库提供了消息处理程序:

    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++的角度来看,重载函数停止成为重载函数 - 它们变成了新的、其他的、不相关的函数

    基类具有默认实现,没有标记为纯虚函数,因此派生类没有被强制重载默认实现。最终,该函数仅在错误处理情况下调用,而这种情况并非经常出现。因此,这个错误被悄悄地引入并长时间未被注意到。

    彻底消除它的唯一方法是搜索整个代码库并编辑所有相关的代码片段。


    1
    好的示例,但应该在使用重载(overload)的地方改为使用覆盖(override)。 - Ozan
    C++0x 对这个问题有一个解决方案。它允许你声明,如果一个函数的名称在派生类中被隐藏而没有明确声明你的意图是这样做的,那么你希望出现一个错误。 - Omnifarious

    2
    在C++中:--
    1. 虚函数会带来轻微的性能损失。通常情况下,这种损失太小以至于不会有任何影响,但在紧密循环中可能会显著。

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

    3. 带有虚函数的类通常用于继承。派生类可以替换某些、所有或没有虚函数。这可能会增加额外的复杂性,而复杂性是程序员的死敌。例如,派生类可能会糟糕地实现虚函数。这可能会破坏依赖于虚函数的基类的某个部分。

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


    2
    我不知道你在哪里看到这个,但我认为这与性能无关。
    也许更多是关于“优先使用组合而非继承”的问题,以及如果您的类/方法不是final(我在这里主要谈论Java),但并非真正设计用于重用,则可能会出现的问题。有很多事情可能会出现严重问题:
    - 也许你在构造函数中使用虚方法 - 一旦它们被覆盖,基类将调用覆盖的方法,该方法可能使用在子类构造函数中初始化的资源 - 后者稍后运行(NPE上升)。 - 想象一下列表类中的添加和添加所有方法。添加所有方法多次调用添加方法,两者都是虚拟的。有人可能会覆盖它们来计算已添加的所有项目数。如果您没有记录addAll调用add,开发人员可能(并且会)覆盖add和addAll(并向其中添加一些counter ++内容)。但是现在,如果您使用addAll,则每个项目都会计算两次(添加和addAll),这会导致不正确的结果和难以找到的错误。
    总之,如果您没有为扩展设计类(提供钩子,记录一些重要的实现事项),则根本不应允许继承,因为这可能会导致严重的错误。此外,如果需要,可以轻松删除类之一的final修饰符(并可能重新设计它以实现可重用性),但是无法使非最终类(其中子类化会导致错误)成为最终类,因为其他人可能已经对其进行了子类化。
    也许这确实与性能有关,那么我至少离题了。但如果不是,那么您有很多理由不要使您的类可扩展,如果您真的不需要它。
    在Blochs Effective Java中可以找到更多关于此类问题的信息(本文写于我阅读第16项(“优先使用组合而非继承”)和17项(“设计和记录用于继承或禁止继承”)几天后 - 令人惊叹的书)。

    顺便说一下,Matts的答案非常接近我的,我想他在我打字的时候进行了编辑 - 对于重复的信息感到抱歉。 - atamanroman

    0

    我在大约7年的时间里零散地作为一名顾问在同一个C++系统上工作,检查了大约4-5个程序员的工作。每次回去时,系统都变得越来越糟糕。在某个时候,有人决定删除所有虚函数,并用非常晦涩的基于工厂/RTTI的系统替换它们,这本质上做了虚函数已经在做但更糟糕、更昂贵、代码多达数千行、需要大量工作和测试的事情。完全毫无意义,显然是出于对未知的恐惧。

    他们还手写了几十个复制构造函数,带有错误,而编译器会自动产生没有错误的版本,只有三个例外需要手写版本。

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


    0
    每个具有虚函数或从包含虚函数的类派生的类都会创建一个虚表。这将消耗比通常更多的空间。
    编译器需要默默地插入额外的代码,以确保后期绑定而不是早期绑定发生。这将消耗比通常更多的时间。

    -1
    在Java中,没有 virtual 关键字,但所有方法(函数)都是虚拟的,除了那些标记为最终的静态方法和私有实例方法。使用虚拟函数并不是一种坏习惯,但通常它们无法在编译时解析,编译器无法对它们执行优化,因此它们往往会变得稍微慢一些。JVM必须在运行时找出需要调用的确切方法。注意,这绝不是一个大问题,只有在您的目标是创建非常高性能的应用程序时才需要考虑它。
    例如,Apache Spark 2的最大优化之一(该软件运行在JVM上)是减少虚拟函数分派的数量,以获得更好的性能。

    将所有东西都虚拟化/可替换的问题在于,很少定义替换软件组件的语义。 - curiousguy

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