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

cod*_*ver 53 c++ java virtual-functions

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

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

更新:
总结来自Jon Skeet的答案如下:

明确地让某人意识到他们正在继承功能[这本身就有潜在的风险[(检查Jon的回应)] [和潜在的小的性能提升]之间需要权衡,需要通过权衡来减少灵活性,增加代码更多,以及更陡峭的学习曲线.

不同答案的其他原因:

虚函数不能内联,因为内联必须在运行时发生.当您希望通过内联功能获益时,这会对性能产生影响.

可能还有其他原因,我很想知道并总结它们.

Jon*_*eet 76

有充分的理由来控制哪些方法是虚拟的,超出性能.虽然我实际上并没有在Java中使我的大多数方法最终完成,但我可能应该......除非设计一个方法被覆盖,否则它可能不应该是虚拟的IMO.

设计继承可能很棘手 - 特别是它意味着你需要更多地记录可能调用它的内容以及它可能调用的内容.想象一下,如果你有两个虚拟方法,一个调用另一个 - 必须记录,否则有人可以用一个调用"调用"方法的实现覆盖"被调用"方法,无意中创建一个堆栈溢出(或无限循环,如果有的话)尾调用优化).那时你的实现灵活性会降低 - 你不能在以后切换它.

请注意,C#是一种与Java类似的语言,但默认情况下选择将方法设置为非虚拟方法.其他一些人并不热衷于此,但我当然欢迎它 - 而且我实际上更喜欢默认情况下课程也是不可行的.

基本上,它归结为Josh Bloch的建议:继承设计或禁止它.

  • +1遵循*所有*的原则是:**始终将常见情况作为默认值.**Java的设计者(非常合理地)认为虚拟方法应该是默认的; 然而,结果证明这是一个错误(有关更多信息,请参阅"有效Java").从设计的角度来看,C#做出了正确的设计; 但是,非虚拟的默认方法使得单元测试**极度痛苦**因为类不再像Java那样被模拟.C#需要设施来使单元测试合理(模拟类,访问私有方法以测试类等) (4认同)

era*_*ran 52

  1. C++的主要原则之一是:您只需支付使用的费用("零开销原则").如果您不需要动态调度机制,则不应支付其开销.

  2. 作为基类的作者,您应该决定应该允许覆盖哪些方法.如果您正在写两篇文章,请继续并重构您需要的内容.但它以这种方式工作,因为必须有一种方法让基类的作者控制它的使用.

  • @DanielGoldfarb,非虚拟成员函数不能_overridden_​​,句点。但是,它们可以是_hidden_​​-但这是另一回事。我的观点是,防止覆盖是封装的另一个方面。隐藏成员函数不会改变基类的行为,也不会添加可能会限制将来更改基类能力的依赖项。 (2认同)

Kon*_*lph 29

但我想现代建筑速度几乎可以忽略不计.

这个假设是错误的,我猜,这个决定的主要原因.

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

虚拟功能也是如此.我们之前有过类似的讨论; 例如,有没有理由使用C++而不是C,Perl,Python等?

  • 是的,基本上虚拟函数不能内联,也不能优化其参数传递. (2认同)

Dav*_*eas 14

大多数答案都涉及虚函数的开销,但还有其他原因不能在类虚拟中创建任何函数,因为它会将类从标准布局更改为,非标准布局,以及如果需要序列化二进制数据可能会出现问题.这在C#中以不同的方式解决,例如,通过使structs成为与classes 不同的类型族.

从设计的角度来看,每个公共函数都在您的类型和类型的用户之间建立契约,并且每个虚函数(公共或非公共)与扩展您的类型的类建立不同的契约.您签署的此类合同的数量越多,您所拥有的更改的空间就越小.事实上,有很多人,包括一些着名的作家,都捍卫公共界面永远不应该包含虚拟功能,因为您对客户的妥协可能与您的扩展所需的妥协不同.也就是说,公共界面显示您为客户执行的操作,而虚拟界面则显示其他人可以帮助您执行此操作.

虚函数的另一个影响是它们总是被调度到最终的覆盖(除非你明确限定调用),这意味着维护你的不变量所需的任何函数(想想私有变量的状态)都不应该是虚函数:如果一个类扩展了它,它必须要么向父代做一个显式的合格调用,否则会破坏你级别的不变量.

这类似于@Jon Skeet提到的无限循环/堆栈溢出的示例,只是以不同的方式:您必须在每个函数中记录它是否访问任何私有属性,以便扩展将确保在合适的时间.而这反过来意味着你正在打破封装并且你有一个泄漏的抽象:你的内部细节现在是界面的一部分(文档+扩展的要求),你不能按照自己的意愿修改它们.

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


小智 8

你忘了一件事.开销也在内存中,即为每个对象添加一个虚拟表和指向该表的指针.现在,如果您有一个具有大量实例的对象,则它不可忽略不计.例如,百万个实例等于4兆字节.我同意,对于简单的应用而言,这并不多,但对于诸如路由器之类的实时设备而言,这很重要.


Ton*_*roy 6

我在这里参加派对的时间比较晚,所以我会在其他答案中添加一些我没有注意到的内容,并快速总结一下......

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

  • 封装:类设计者控制客户端代码访问的成员的能力,确保维护类语义和不变量.例如,如果你派生自std::string(我可能会得到一些评论,因为敢于暗示;-P)那么你可以使用所有正常的插入/擦除/追加操作并确保 - 如果你不做任何总是这样的事情未定义的行为,std::string如同将错误的位置值传递给函数 - std::string数据将是合理的.检查或维护代码的人不必检查您是否更改了这些操作的含义.对于类,封装可确保以后修改实现的自由,而不会破坏客户端代码.对同一语句的另一个观点是:客户端代码可以以任何方式使用该类,而不会对实现细节敏感.如果可以在派生类中更改任何函数,那么整个封装机制就会被吹走.

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

  • 内联 - 代码端和CPU使用:虚拟分派使编译器在内联函数调用时的工作变得复杂,因此可能在空间/膨胀和CPU使用方面提供更差的代码.

  • 呼叫期间的间接:即使以任何方式进行外线呼叫,虚拟分派的性能成本也很低,在性能关键系统中重复调用简单的简单功能时可能会很重要.(您必须读取指向虚拟调度表的每个对象指针,然后是虚拟调度表条目本身 - 意味着VDT页面也在消耗缓存.)

  • 内存使用:虚拟分派表的每个对象指针可能代表重要的内存浪费,特别是对于小对象的数组.这意味着较少的对象适合缓存,并且可能会对性能产生重大影响.

  • 内存布局:它对性能至关重要,并且非常便于互操作性,C++可以使用网络指定的成员数据的精确内存布局或各种库和协议的数据标准来定义类.该数据通常来自您的C++程序之外,并且可能以另一种语言生成.这样的通信和存储协议对于指向虚拟调度表的指针没有"间隙",并且正如前面所讨论的那样 - 即使他们这样做了,并且编译器以某种方式让你有效地为进程数据注入正确的指针,这会让人感到沮丧多进程访问数据.粗略但实用的基于指针/大小的序列化/反序列化/通信代码也将变得更加复杂并且可能更慢.


iam*_*ind 5

按使用付费(以Bjarne Stroustrup的话说).