为什么不在c++中把所有的函数都变成虚函数呢?

Why not have all the functions as virtual in C++?

本文关键字:函数 c++ 为什么不      更新时间:2023-10-16

我知道虚函数调用方法有解引用的开销。但我想以现代建筑的速度,这几乎可以忽略不计。

    有没有什么特别的原因为什么c++中的所有函数都不像Java中的虚函数?
  1. 据我所知,在基类中定义虚函数是足够/必要的。现在,当我编写父类时,我可能不知道哪些方法会被重写。那么,这是否意味着在编写子类时,某人必须编辑父类呢?这听起来不方便,有时是不可能的?

更新:
下面总结了Jon Skeet的回答:

这是显式地让某人意识到他们继承的功能[有潜在的风险[(检查Jon的回应)]][和潜在的小性能增益][strong>与之间的权衡,以换取更少的灵活性,更多的代码更改和更陡峭的学习曲线。

来自不同答案的其他原因:

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

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

除了性能之外,控制哪些方法是虚拟的有很好的理由。虽然我没有实际上使我的大多数方法在Java中是final的,但我可能应该…除非一个方法被设计成被重写,否则它可能不应该是虚拟的。

为继承设计可能会很棘手——特别是它意味着你需要记录更多关于什么可以调用它以及它可能调用什么。想象一下,如果您有两个虚拟方法,其中一个调用另一个——必须被记录,否则有人可能会用调用"调用"方法的实现覆盖"被调用"方法,无意中创建堆栈溢出(如果有尾部调用优化,则会创建无限循环)。在这一点上,你在实现上的灵活性就会降低——你不能在以后的日期切换它。

请注意,c#在许多方面与Java相似,但在默认情况下选择了非虚方法。有些人对此并不热衷,但我当然欢迎它——实际上我更希望类在默认情况下也是不可继承的。

基本上,这可以归结为Josh Bloch的建议:为继承设计或禁止继承。

  1. c++的一个主要原则是:你只为你使用的东西付费("零开销原则")。如果你不需要动态调度机制,你不应该为它的开销买单。

  2. 作为基类的作者,您应该决定哪些方法应该被允许被覆盖。如果您正在编写这两种代码,请继续重构您需要的内容。但是它是这样工作的,因为基类的作者必须有一种方法来控制它的使用。

但我想以现代建筑的速度,它几乎可以忽略不计。

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

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

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

大多数答案都与虚函数的开销有关,但还有其他原因不能在类中使用任何函数,因为它会将类从标准布局更改为非标准布局,如果需要序列化二进制数据,这可能是一个问题。这在c#中得到了不同的解决,例如,通过将struct s与class es作为不同的类型族。

从设计的角度来看,每个公共函数在你的类型和类型的用户之间建立了一个契约,每个虚函数(公共或非公共)与扩展你的类型的类建立了一个不同的契约。你签的这种合同越多,你改变的余地就越小。事实上,有相当多的人,包括一些著名的作家,认为公共接口不应该包含虚函数,因为您对客户端的妥协可能不同于您对扩展的要求。也就是说,公共接口显示您为客户端所做的工作,而虚拟接口显示其他人如何帮助您完成这些工作。

虚函数的另一个影响是它们总是被分配到最终的重写(除非您显式地限定调用),这意味着任何需要维护不变量(考虑私有变量的状态)的函数都不应该是虚的:如果一个类扩展它,它将不得不对父类进行显式的限定调用,否则将破坏您级别的不变量。

这类似于@Jon Skeet提到的无限循环/堆栈溢出的例子,只是方式不同:您必须在每个函数中记录它是否访问任何私有属性,以便扩展将确保在正确的时间调用该函数。而这反过来意味着你打破了封装,你有一个泄漏的抽象:你的内部细节现在是接口的一部分(文档+扩展的需求),你不能随心所欲地修改它们。

然后是性能…这将对性能产生影响,但在大多数情况下,这种影响被高估了,并且可以认为,只有在性能至关重要的少数情况下,您才会退回并声明函数是非虚拟的。不过,这在一个已构建的产品上可能并不简单,因为这两个接口(公共+扩展)已经绑定在一起了。

你忘了一件事。开销也在内存中,即为每个对象添加一个虚拟表和指向该表的指针。现在,如果你有一个对象,它有相当数量的期望实例,那么它是不可忽略的。例如,百万实例等于4兆字节。我同意,对于简单的应用程序,这并不多,但对于实时设备,如路由器,这很重要。

我来得太晚了,所以我将补充一件我在其他答案中没有注意到的事情,并快速总结…

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

  • 封装:类设计器控制客户端代码访问的成员的能力,确保类语义和不变量得到维护。例如,如果您从std::string派生(我可能会因为大胆建议而得到一些评论;- p),那么您可以使用所有正常的插入/擦除/追加操作,并确保-如果您不做任何std::string总是未定义的行为,例如将坏位置值传递给函数- std::string数据将是合理的。检查或维护代码的人不必检查您是否更改了这些操作的含义。对于类,封装确保了以后在不破坏客户端代码的情况下修改实现的自由。同一语句的另一种观点是:客户端代码可以随意使用类,而不必对实现细节敏感。如果任何函数都可以在派生类中更改,那么整个封装机制就完全失效了。

    • 隐藏依赖:当您既不知道其他函数依赖于您要覆盖的函数,也不知道该函数被设计为被覆盖时,那么您就无法推断更改的影响。例如,您认为"我一直想要这个",并更改std::string::operator[]()at(),以考虑负值(在类型转换为signed之后)从字符串的末尾向后偏移。但是,也许其他一些函数在尝试插入或删除之前使用at()作为一种断言索引是有效的-知道它会抛出否则…代码可能会以标准指定的方式抛出未定义(但可能致命)的行为。
    • Documentation:通过创建一个函数virtual,你记录它是一个预定的自定义点,并且是客户端代码使用的API的一部分。

  • 内联 -代码侧&CPU使用:虚拟调度使编译器计算何时内联函数调用的工作变得复杂,因此可能在空间/膨胀和CPU使用方面提供更糟糕的代码。

  • 调用期间的间接调用:即使以任何一种方式进行了脱线调用,在性能关键型系统中重复调用非常简单的函数时,虚拟调度的性能成本可能很小。(您必须读取指向虚拟调度表的每个对象指针,然后读取虚拟调度表项本身——这意味着VDT页也在消耗缓存。)

  • 内存使用:指向虚拟调度表的每个对象指针可能代表大量的内存浪费,特别是对于小对象数组。

  • 内存布局: c++定义类时,可以使用网络或各种库和协议的数据标准所指定的成员数据的精确内存布局,这对性能和互操作性都是至关重要的。这些数据通常来自c++程序之外,可能是用另一种语言生成的。这样的通信和存储协议对于指向虚拟调度表的指针不会有"间隙",正如前面所讨论的那样——即使它们有"间隙",并且编译器以某种方式让您有效地为传入数据的进程注入正确的指针,这也会阻碍多进程对数据的访问。粗糙但实用的基于指针/大小的序列化/反序列化/通信代码也会变得更复杂,可能会更慢。

按次付费(Bjarne Stroustrup的话)。

似乎这个问题可能有一些答案虚函数不应该过度使用-为什么?在我看来,突出的一点是,它只是增加了了解继承可以做什么的复杂性。

是的,这是因为性能开销。虚方法是通过虚表和间接调用的。

在Java中,所有的方法都是虚拟的,开销也存在。但是,与c++相反,JIT编译器在运行时对代码进行概要分析,并且可以内联那些不使用此属性的方法。因此,JVM知道哪些地方真正需要它,哪些地方不需要它,从而使您不必自己做决定。

问题是,当Java编译成在虚拟机上运行的代码时,对c++不能做出同样的保证。通常使用c++作为C的更有组织的替代品,并且C具有1:1的汇编转换。

如果你认为世界上10个微处理器中有9个不在个人电脑或智能手机中,当你进一步考虑有很多处理器需要这种低级别访问时,你就会看到这个问题。

c++的设计是为了避免在不需要时隐藏的顺从,从而保持1:1的性质。最初的一些c++代码实际上有一个中间步骤,即在通过C-to-assembly编译器运行之前被翻译成C。

由于运行时优化,Java方法调用远比c++有效。

我们需要的是将c++编译成字节码并在JVM上运行。