为什么不把所有函数都设为虚函数在C++中?

55

我知道虚函数在调用方法时有解引用的开销。但我认为随着现代架构速度的提高,这几乎可以忽略不计。

  1. 是否有任何特定原因导致C++中的所有函数不像Java一样都是虚函数?
  2. 根据我的了解,在基类中定义一个函数为虚函数就足够/必要了。现在当我编写父类时,可能不知道哪些方法会被覆盖。那么这是否意味着在编写子类时,某人必须编辑父类。这听起来很不方便,有时也不可能吗?

更新:
从Jon Skeet的下面的回答概括:

这是一个权衡取舍,即明确地让某人意识到他们正在继承功能[这本身具有潜在风险[(请检查Jon的回答)] [和潜在的小性能收益]与更少的灵活性、更多的代码更改和更 steeeper 学习曲线的交换。

其他答案的原因:

虚函数不能内联,因为内联必须在运行时发生。当您期望函数受益于内联时,这会对性能产生影响。

可能还存在其他原因,我很想知道并总结它们。


还可以内联非虚函数,这样可以进行许多编译器优化,而在将函数定义为虚函数的情况下无法使用。 - Thomas Andrews
嗨,Thoman,你能解释一下为什么无法内联虚函数吗?这是可用编译器的限制还是有理论上的阻碍?JVM如何优化它? - codeObserver
在虚函数中,关于调用哪个方法的决定是在运行时做出的。而在内联函数中,方法的主体被编译到调用者中,这是在编译时必须做出的决定。 - DJClayworth
11个回答

78

除了性能因素外,控制哪些方法是虚方法还有很好的理由。在Java中,虽然我实际上没有将大多数方法设为final,但我可能应该这样做......除非一个方法被设计成可重写,否则在我看来它可能不应该是虚方法。

为继承设计程序可能比较棘手-特别是这意味着您需要更详细地记录谁会调用方法以及它可能调用的内容。想象一下如果您有两个虚方法,并且其中一个调用另一个-那么就必须对它进行说明文档,否则某人可能会使用一个实现来覆盖“被调用”的方法,而这个实现将调用“调用”方法,从而无意中创建一个栈溢出(或如果有尾递归优化,则为无限循环)。此时,您的实现将变得不够灵活,并且不能在后期将其反转。

请注意,C#与Java在各种方面都是类似的语言,但选择默认使方法不可重写。有些其他人不喜欢这种方法,但我肯定欢迎它-而且我实际上更希望默认情况下类也是不可继承的。

基本上,这归结为Josh Bloch的建议:要么为继承设计,要么禁止它。


4
+1 适用于 所有事情 的原则是: 让常见情况成为默认情况。 Java的设计者(非常合理地)认为虚拟方法应该是默认的;然而,这被证明是一个错误(有关更多信息,请参见《Effective Java》)。从设计角度来看,C#做出了正确的设计;但是,默认情况下非虚拟方法使单元测试非常麻烦,因为类不能像Java中那样被模拟。 C#需要提供一些工具,以使单元测试变得合理(如模拟类、访问私有方法以测试类等)。 - BlueRaja - Danny Pflughoeft
@BlueRaja:只要为依赖项设置了适当的接口,我不认为单元测试会是一种极端痛苦。诚然,如果有一种更简单的表达接口的方式,以避免接口和实现类之间的重复,那就太好了。 - Jon Skeet
1
@Jon Skeet,我想说它们可能更正确地被称为隐藏。然而,允许子类这样“隐藏”超类的方法仍然似乎是不明智的。 - Winston Ewert
1
@Jon Skeet,我想主要是考虑到具有相同签名(或足够接近)的函数可以成为虚拟重载。很容易尝试重载一个不是虚拟的方法。但我看到它也可能有用处。 - Winston Ewert
快速问题:难道不是所有反对将所有类标记为“final”的论点都适用于这里吗?你明确地阻止了任何人扩展该类,这意味着要添加功能,他们必须复制该类(不好)或修改源代码。 - TheLQ
显示剩余15条评论

54
  1. C++ 的主要原则之一是:只为所用付费(“零开销原则”)。如果您不需要动态分发机制,就不应为其开销买单。

  2. 作为基类的作者,您应该决定哪些方法可以被覆盖。如果您同时编写这两个类,可以进行必要的重构。但是它的工作方式是这样的,因为必须有一种方法让基类的作者控制其使用。


请纠正我如果我错了,但我认为你的观点2是不正确的。在基类中将方法设置为非虚拟的并不能阻止它们被覆盖,因此作者实际上无法决定哪些方法应该允许被覆盖。这只能防止以多态的方式调用它们(即通过基类指针)。但是它们仍然可以被重写,并且当直接从派生类实例(或派生类指针)调用时仍然可以被调用。 - Daniel Goldfarb
3
@DanielGoldfarb,非虚成员函数不能被覆盖,这是确定的。但它们可以被隐藏,但那是另一回事。我的观点是,防止覆盖是封装的另一个方面。隐藏成员函数不会改变基类的行为,并且不会增加可能限制将来更改基类能力的依赖关系。 - Eran

32

但我认为现代架构的速度已经几乎可以忽略不计了。

这种假设是错误的,我想这也是做出这个决定的主要原因。

以内联函数为例。C++的sort函数在某些情况下比C的类似qsort函数执行得快得多,因为它可以内联其比较器参数,而C不能(因为使用函数指针)。在极端情况下,这可能意味着高达700%的性能差异(Scott Meyers,《Effective STL》)。

对于虚函数来说,情况也是如此。我们之前也有过类似的讨论;例如“是否有理由使用C++而不是C、Perl、Python等?”


2
是的,基本上虚函数无法内联,也无法优化其参数传递。 - edA-qa mort-ora-y
1
即使这个说法开始变得不准确了...例如,gcc能够通过函数指针进行内联。可以推测,这可以扩展到虚拟方法。 - Dennis Zickefoose
1
gcc 支持内联虚函数,只要你在已知动态类型的对象上调用该函数(因为虚拟机制不需要使用)。如果您正在对“Base”容器进行排序,则已知动态类型:如果是 “Base*”,则不是,并且您可能会开始担心性能损失。使用函数指针类似-如果对 qsort 的调用被内联,则 DFA 可能会证明函数指针的值,在这种情况下,调用可能被内联,尽管我从未深入研究过 gcc 在执行此操作时的成功率。 - Steve Jessop
@Steve 这是正确的。我甚至感到惊讶,因为函数对象相对于函数指针的优势似乎仍然存在,尽管这显然是一种明显的优化。实际上,我怀疑例如对 qsort 的调用很少被内联。另一方面,sort 是一个模板,因此对于给定的比较器,即使没有内联,其类型也在sort内部已知。我怀疑对于已知动态类型的虚函数调用的内联也是如此。 - Konrad Rudolph
1
@Konrad:qsort有时会因为在不同的TU中而被禁止内联,而sort则始终在TU中可用。有点意思的是,可以采取链接时优化的实现,并查看它们在内联qsort和内联比较器方面是否比内联sort及其比较器更好或更差(当将函数指针传递给sort而不是用户定义类型的函数对象时,以保持比较公平)。 - Steve Jessop
显示剩余3条评论

14
大多数答案都涉及虚函数的开销问题,但不将类中的任何函数声明为虚函数还有其他原因,比如这会使类从“标准布局”变成非“标准布局”,如果您需要序列化二进制数据,则可能会出现问题。例如,在C#中以不同的类型族定义structclass

从设计角度来看,每个公共函数都在您的类型与类型用户之间建立契约,而每个虚函数(无论是公共的还是非公共的)则在扩展您的类型的类之间建立不同的契约。签署的这些契约越多,您所做的更改空间就越小。事实上,有相当多的人,包括一些知名作家,认为公共接口不应包含虚函数,因为您对客户的承诺可能与您要求扩展的承诺不同。也就是说,公共接口显示您为客户做了什么,而虚接口显示其他人如何帮助您完成它。

虚函数的另一个影响是它们总是被分派到最终覆盖者(除非您明确限定调用),这意味着任何用于维护您的不变式(例如私有变量的状态)的函数都不应该是虚函数:如果一个类扩展它,它将不得不作出显式的限定调用以回到父类,否则会在您这一层上破坏不变式。

这就像@Jon Skeet提到的无限循环/堆栈溢出的例子,只是以不同的方式:您必须在每个函数中记录它是否访问任何私有属性,以便扩展程序确保正确地调用该函数。这反过来意味着您正在破坏封装,并且您具有泄漏的抽象层:您的内部详细信息现在成为接口(文档+对扩展的要求)的一部分,您不能随心所欲地修改它们。

接下来是表现问题… 在大多数情况下,性能影响被夸大了,可以说只有在性能至关重要的少数情况下,您才会放弃使用虚函数并将其声明为非虚函数。但是,在已构建的产品上可能并不简单,因为两个接口(公共接口+扩展接口)已经绑定。


8
你忘了一件事。开销也存在于内存中,也就是为每个对象添加一个虚表和指向该表的指针。如果你有一个预计会有大量实例的对象,那么这个开销就不容忽视。例如,百万个实例等于4兆字节。我同意对于简单应用程序来说,这并不多,但对于路由器等实时设备来说,这很重要。

我正在处理嵌入式设备,其中一些只有2k的RAM。在这些设备上,你真的想避免指针开销,还要通过额外的指针间接调用方法所带来的额外时间成本,这是个好观点! - Droggl

6

我来晚了,所以我会补充一件事情,我没有看到其他答案中提到的内容,并且快速概括一下...

  • 在共享内存中的可用性:虚函数调度的典型实现在每个对象中都有一个指向特定类虚函数调度表的指针。这些指针中的地址是特定于创建它们的进程的,这意味着访问共享内存中的对象的多进程系统无法使用另一个进程的对象进行调度!考虑到共享内存在高性能多进程系统中的重要性,这是一个不可接受的限制。

  • 封装:类设计者控制客户端代码访问的成员,确保类语义和不变量得以维护。例如,如果你从std::string派生(我可能会因为敢于提出这个问题而得到一些评论;-P),那么你可以使用所有正常的插入/删除/追加操作,并确信 - 只要你不做任何对于std::string总是未定义行为的事情,比如将错误的位置值传递给函数 - std::string数据将是安全的。检查或维护您的代码的人不必检查您是否改变了这些操作的含义。对于一个类来说,封装确保了在不破坏客户端代码的情况下随后修改实现的自由。同样的声明的另一个角度是:客户端代码可以任意使用类,而不必关心实现细节。如果派生类中的任何函数都可以更改,那么整个封装机制就会被破坏。

    • 隐藏依赖项:当您既不知道其他函数对您要覆盖的函数有什么依赖性,也不知道该函数是否设计为可覆盖时,您无法推断出您的更改的影响。例如,您认为“我一直想要这个”,并将std::string::operator[]()at()更改为将负值(在类型强制转换后)视为从字符串末尾向后偏移的偏移量。但是,也许某些其他函数正在使用at()作为一种断言,即索引是有效的 - 知道它会抛出异常 - 然后尝试插入或删除...该代码可能从按照标准规定抛出变为具有未定义的(但可能致命的)行为。
    • 文档:通过使函数成为virtual,您正在记录它是预期的定制点,并且是客户端代码使用的API的一部分。

  • 内联 - 代码侧和CPU使用:虚函数调度使编译器难以确定何时内联函数调用,因此可能会提供更糟糕的代码,从空间/膨胀和CPU使用方面来看。

  • 调用期间的间接性:即使正在进行线外调用,虚拟调度也存在小的性能成本,当在性能关键系统中重复调用微不足道的简单函数时,这个成本可能是显著的。(您必须读取指向虚函数调度表的每个对象指针,然后读取虚函数调度表条目本身 - 这意味着VDT页面也在消耗缓存。)

  • 内存使用


5

3

2
是的,这是因为性能开销。虚拟方法使用虚拟表和间接调用。
在Java中,所有方法都是虚拟的,开销也存在。但与C++相反,JIT编译器在运行时对代码进行分析,并可以内联那些不使用此属性的方法。因此,JVM知道何时真正需要它以及何时不需要,从而使您免于自己做出决策。

+1:这正是我要说的。JVM可以在运行时做出编译器无法静态决定的决策。 - Peter Lawrey
2
实际上,JIT 可以做得更好,它可以内联使用属性的方法,但对于非常常见的类型进行快速类型检查。如果类型检查失败,则进行虚拟调用。因此,obj.foo() 的代码看起来有点像 if (obj.getClass() == Class.forName("BaseClass")) { /* 来自 BaseClass.foo() 的内联代码 */ } else { obj.foo(); };。当然,getClass 的调用被内联,只需从对象中获取指针,而 forName 的调用结果是一个类对象的指针,并且该值也被内联到代码中。 - Steve Jessop
@Rekin:抱歉,我不记得来源了,那只是我对JIT可以做的事情类型的一般了解。任何当前版本的JIT是否实际执行它是另一回事... - Steve Jessop
1
@Steve Jessop:理论上,JIT可以进行这种优化。但是同样的检查理论上也可以由C++编译器插入,特别是使用PGO。因此,我不同意“JIT可以做得更好”的说法。 - MSalters
1
正如你所暗示的,从原理上讲,C++编译器可以发出自修改代码,利用JIT书中的每个技巧进行运行时优化。实际上,我不知道有哪个C++编译器会这样做,但很有用,因为根据输入情况,动态类型可能是99.9%的Foo或99.9%的Bar,而(一些)JIT在运行时开始后继续进行优化,这就是它们优化程序的运行的原因。 在我的经验中,基于剖面的C ++编译器仅优化程序的某些标准开发运行。 - Steve Jessop
显示剩余3条评论

2
问题在于,虽然Java编译为在虚拟机上运行的代码,但对于C++来说无法做出同样的保证。通常将C ++ 用作比C更有组织的替代品,而C与汇编语言之间存在1:1的映射关系。
如果您考虑到世界上10个微处理器中有9个不在个人计算机或智能手机中,那么当您进一步考虑有许多处理器需要这种低级别的访问时,就会看到问题所在。
C ++ 的设计是为了避免在不需要它的情况下隐藏解除引用,从而保持1:1的映射关系。实际上,一些最早的C ++ 代码在通过C-to-assembly编译器运行之前经历了一个转换到C的中间步骤。

1
C语言与汇编有1:1的翻译?那可真是个惊喜。哪个CPU有switch(foo)?该死,哪个CPU有for?大多数都使用比较和分支指令。 - MSalters
也许1:1不是一个好的说法...但通常在C语言结构和生成的汇编代码之间存在直接的翻译,这是C语言设计的主要特点。C++旨在尽可能地保持这种关系。 - Ape-inago
1
无论是异常、模板还是虚函数,C++与C最明显的区别都与CPU指令无关。更不用说标准库了。你所观察到的实际上是一种无虚拟机语言的结果。因此,隐藏的代码就会更少。 - MSalters
我试图表达的观点是,如果你不使用C++中的那些部分(如模板、继承等),生成的汇编代码很大一部分都和C语言非常相似。唯一的区别在于隐式结构被传递给成员函数,而大多数复杂的C代码只是明确地传递结构体。对于那些因为可以将C语言轻松转换为各种形式的汇编代码而使用C语言的开发者来说,C++是一个简单的过渡,因为它具有“如果你不使用它,你就不需要付费”的特性。这是有意为之,以保持与C语言的兼容性。 - Ape-inago

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