Virtual dispatch implementation details
首先,我想清楚地表明,我确实理解在C++标准中没有VTABLE和VPTR的概念。然而,我认为实际上所有实现都以几乎相同的方式实现虚拟调度机制(如果我错了,请纠正我,但这不是主要问题)。另外,我相信我知道虚拟函数是如何工作的,也就是说,我总是可以知道将调用哪个函数,我只需要实现细节。
假设有人问我:"您有带有虚拟函数v1、v2、v3的基类B和派生类D:B,它重写函数v1和v3并添加虚拟函数v4。解释虚拟调度的工作原理"。
我想这样回答:对于每个具有虚拟函数的类(在本例中是b和d),我们有一个单独的指向称为vtable函数的指针数组。b的vtable将包含
d的vtable将包含
1 2 3 4
| &D::v1
&B::v2
&D::v3
&D::v4 |
现在类B包含一个成员指针vptr。D自然地继承了它,因此也包含了它。在b的构造函数和析构函数中,将vptr设置为指向b的vtable。在d的构造函数和析构函数中,将其设置为指向d的vtable。对多态类X的对象X上的虚拟函数F的任何调用都被解释为对x.vptr[f在vtables中的位置]的调用。
问题是:1。上面的描述有错误吗?2。编译器如何知道f在vtable中的位置(请详细说明)三。这是否意味着如果一个类有两个基,那么它就有两个vptr?在这种情况下发生了什么?(尽量用与我相似的方式描述,尽可能详细)4。在钻石等级中,A在顶部B,C在中间,D在底部会发生什么?(A是B和C的虚拟基类)
事先谢谢。
1。上面的描述有错误吗?
一切都好。-)
2。编译器如何知道f在vtable中的位置?
每个供应商都有自己的方法来实现这一点,但我总是将vtable看作是成员函数签名到内存偏移量的映射。所以编译器只是维护这个列表。
三。这是否意味着如果一个类有两个基,那么它就有两个vptr?在这种情况下发生了什么?
通常,编译器组成一个新的vtable,它由所有虚拟基的vtable和虚拟基的vtable指针按指定的顺序附加在一起组成。然后是派生类的vtable函数。这是非常特定于供应商的,但是对于class D : B1, B2,您通常会看到D._vptr[0] == B1._vptr。
该图像实际上是用于组成对象的成员字段,但vtables可以由编译器以完全相同的方式(据我所知)组成。
4。在钻石等级中,A在顶部B,C在中间,D在底部会发生什么?(A是B和C的虚拟基类)
简短的回答?绝对的地狱。你实际上继承了这两个基地吗?只有一个?他们两个都没有?最后,使用了为类编写vtable的相同技术,但是这种方法千差万别,因为应该如何做根本不是一成不变的。这里有一个很好的解释来解决钻石等级问题,但是,像大多数情况一样,它是供应商特有的。
- @特拉维斯:谢谢你的回答。1。在第一点上,我不同意您的观点:析构函数不执行虚拟分派(标准明确提到了这一点)。2。好的,3。伟大的。4。我特别提到B和C实际上都来自A。再次感谢您的回答,谢谢。
- 哈!你说得对。我从来不知道。
- +1总的来说,但是第一点是错误的:vptr是由析构函数修改的。当层次结构中从最派生的类型到基类型的每个析构函数都完成时,它以与构造函数相反的方式更新vptr。你的例子很好,但是语言不会保护你,它是未定义的行为。许多编译器将引入纯虚拟方法thunk,它将打印错误消息(调用纯虚拟方法)。试试看。更新指针的决定的基本原理是,方法的最终重写器不能是已销毁的对象。
- @大卫:因为这个例子被删除了,我记不清细节,但它不应该是未定义的行为。如果我错了,请纠正,但行为是定义的,它只调用基类的cleanup()。
- @Armen:问题是我把cleanup作为纯虚拟函数,这意味着没有办法调用它。
- @大卫:如果(正如我刚刚学到的)析构函数不进行虚拟调用,那么为什么它们需要使用vptr,更不用说修改它了?
- @大卫,@travis:哦,那好吧:)
- @大卫:不是吗?:)我以为是……我没说是引言。我只是用我的话重新表述了我所记得的
- @奥利·查尔斯沃思:再说一遍,有人能从标准中引用一句话吗?我还没有找到一个引用,但是gcc和vs编译器在完成销毁之后会更新vptr,并且调用会动态地发送到最终的覆盖器,在每个阶段中,这是最派生的、尚未销毁的对象中实现最多的。
- @大卫:这是我的新闻。这背后的理由是什么?
- @大卫:假设d:b和f是从~b和~d调用的virt函数,而main函数只包含一个语句d d;msvc打印d::~d::f b::~b::f…
- 型@Armen:这种行为是因为~D完成后,~B开始前,vptr得到更新。考虑在B中添加一个调用f()的dispatch方法,并让~B调用dispatch。现在,对B::dispatch有一个单一的定义,因此只有一个实现。因为它可以在活动对象上调用,所以必须使用常规虚拟调度机制调用f。现在,如果运行测试,您将看到与以前相同的效果:当从B析构函数调用dispatch时调用B::f。
- 型@阿门:…我想我对&167;12.4/6的解释是错误的,我已经多次重读它,并且我同意它在构造函数运行时禁用虚拟分派:一旦析构函数启动,就不会向由此派生的对象分派任何虚拟方法,并且需要在vtable系统中通过更新最派生对象的析构函数之后的vptr。
- 型@oli charlesworth:如果问题是更新vptr的基本原理是什么,答案是可能调用虚拟方法的方法,同时可能从析构函数调用的方法在从析构函数调用时不能调用更派生的类(虚拟调度不会转到alrea)。Dy毁坏了对象)。为了确保这一点,必须在完成每个析构函数之后更新虚拟调度机制,以便vptrs指向派生程度较低的对象。
- 型@大卫:我明白了。所以不是动态分派不会发生,而是它不会相对于当前正在进行的析构函数分派给子重写?
- 型@阿门,@oli:我在代码板上写了一个小片段。这是我在那里的第一篇文章,所以如果它不起作用,告诉我,我将在其他地方生成代码。
- 型顺便说一句,关于第2点,您可以通过在头文件中玩虚拟函数的顺序来获得一些乐趣(或痛苦);编译实现、重新排序、编译用法、链接…路缘空间。(重点是大多数编译器在这方面都非常脆弱。)
- 型Base1称为"主基"
我觉得不错
实现是特定的,但大多数都只是按照源代码顺序——也就是它们在类中出现的顺序——从基类开始,然后从派生类添加新的虚拟函数。只要编译器有一种确定的方式来完成这项工作,那么它想要做的任何事情都是好的。但是,在Windows上,要创建与COM兼容的V表,必须按源代码顺序
(不确定)
(猜猜)菱形只是意味着您可以拥有一个基类B的两个副本。虚拟继承将它们合并到一个实例中。因此,如果通过d1设置成员,则可以通过d2读取它。(C源于d1,d2,每个源于b)。我相信在这两种情况下,vtables是相同的,因为函数指针是相同的——数据成员的内存是合并的。
- 对不起,我没有3和4的确切答案。我认为有一个步骤可以确保你有正确的vtable,但我不知道细节。
- 非常感谢你。对第2点的回答特别有价值
评论:
我不认为会有毁灭性的东西!
诸如D d; d.v1();之类的调用可能不会通过vtable实现,因为编译器可以在编译/链接时解析函数地址。
编译器知道f的位置,因为它放在那里!
是的,具有多个基类的类通常具有多个vptr(假设每个基类中都有虚拟函数)。
Scott Meyers的"有效C++"书解释了多重继承和钻石比我更好;我建议阅读他们的(和许多其他)原因。考虑他们的基本阅读!
- @奥利:关于第一点,它们是这样的,否则如何解释当在基的析构函数中调用虚函数f时,如何调用基::f而不是派生::f?
- 他说的对,销毁程序不会进行虚拟调用。为了在来自一个调用的调用中做到这一点,它可能会像他描述的那样替换vtable(注意,这都是特定于实现的)
- @armen:与Base的任何其他成员函数内部的虚拟调用相同。
- @奥利:恐怕你完全错了。如果从任何其他基成员调用虚函数,则可以根据对象的动态类型调用派生的重写。
- @我不知道!(虽然经过一段时间的思考,原因是显而易见的。)但是,对这一点的解释肯定是相当简单的吗?编译器可以将此作为一个异常,并在编译时解析地址。
- @奥利:编译器可以做任何事情。我的问题是它实际上做了什么:)
- @阿姆恩:好吧,很明显它确实做到了!如果你想要确切的细节,只需仔细阅读一些反汇编程序…
- @奥利:顺便说一句,我读过迈尔斯的书,在发表这篇文章之前,我查阅了相关章节,但他并没有完全回答我的具体问题。
- Aman:在"更有效的C++"项目24中,他给出了继承钻石的VPTR的一种可能的实现方式。没有一个明确的答案,因为实现者都会做得稍微不同!
- 对那些投反对票的人:你能告诉我你反对我回答的哪一部分吗?
- @Armen:在派生的覆盖上,可以根据层次结构中对象的动态类型调用Base<-derived<-rederived,在~rederived完成后,对象的动态类型为rederived的对象。