我正在阅读Bjarne的论文:Multiple Inheritance for C++。
在第370页的第3节中,Bjarne说"编译器将成员函数的调用转换为"普通"使用"额外"进行函数调用参数; "额外" argument是指向调用成员函数的对象的指针。"
我对这个额外的论点感到困惑。请参阅以下两个示例:
示例1 :(第372页)
class A {
int a;
virtual void f(int);
virtual void g(int);
virtual void h(int);
};
class B : A {int b; void g(int); };
class C : B {int c; void h(int); };
类c对象C看起来像:
C:
----------- vtbl:
+0: vptr --------------> -----------
+4: a +0: A::f
+8: b +4: B::g
+12: c +8: C::h
----------- -----------
对虚函数的调用由编译器转换为间接调用。例如,
C* pc;
pc->g(2)
变得像:
(*(pc->vptr[1]))(pc, 2)
Bjarne的论文告诉我上述结论。传递this
点是C *。
在下面的例子中,Bjarne讲了另一个让我困惑的故事!
示例2 :(第373页)
给出两个班级
class A {...};
class B {...};
class C: A, B {...};
C类的对象可以像这样的连续对象布局:
pc--> -----------
A part
B:bf's this--> -----------
B part
-----------
C part
-----------
在给定C *:
的情况下调用B的成员函数C* pc;
pc->bf(2); //assume that bf is a member of B and that C has no member named bf.
Bjarne写道:"当然,B :: bf()期望B *(成为它的指针)。"编译器将调用转换为:
bf__F1B((B*)((char*)pc+delta(B)), 2);
为什么我们需要一个B *指针作为this
?
如果我们只是将* C指针作为this
传递,我们仍然可以正确地访问B的成员。例如,要在B :: bf()中获取B类成员,我们只需要执行以下操作:*(this + offset)。编译器可以知道此偏移量。这是对的吗?
示例1和2的后续问题:
(1)当它是线性链推导(例1)时,为什么可以期望C对象与B处于同一地址,进而是A子对象?在示例1中使用C *指针访问函数B :: g中的B类成员是没有问题的吗?例如,我们想要访问成员b,运行时会发生什么? *(PC + 8)?
(2)为什么我们可以使用相同的内存布局(线性链派生)进行多重继承?假设在示例2中,类A
,B
,C
具有与示例1完全相同的成员。A
:int a
和f
; B
:int b
和bf
(或称之为g
); C
:int c
和h
。为什么不直接使用内存布局:
-----------
+0: a
+4: b
+8: c
-----------
(3)我写了一些简单的代码来测试线性链派生和多重继承之间的差异。
class A {...};
class B : A {...};
class C: B {...};
C* pc = new C();
B* pb = NULL;
pb = (B*)pc;
A* pa = NULL;
pa = (A*)pc;
cout << pc << pb << pa
它表明pa
,pb
和pc
具有相同的地址。
class A {...};
class B {...};
class C: A, B {...};
C* pc = new C();
B* pb = NULL;
pb = (B*)pc;
A* pa = NULL;
pa = (A*)pc;
现在,pc
和pa
具有相同的地址,而pb
则偏向pa
和pc
。
为什么编译会产生这些差异?
示例3 :(第377页)
class A {virtual void f();};
class B {virtual void f(); virtual void g();};
class C: A, B {void f();};
A* pa = new C;
B* pb = new C;
C* pc = new C;
pa->f();
pb->f();
pc->f();
pc->g()
(1)第一个问题是pc->g()
与示例2中的讨论有关。编译是否进行了以下转换:
pc->g() ==> g__F1B((*B)((char*)pc+delta(B)))
或者我们必须等待运行时才能执行此操作?
(2)Bjarne写道:在进入C::f
时,this
指针必须指向C
对象的开头(而不是B
部分) 。但是,在编译时通常不知道B
指向的pb
是C
的一部分,因此编译器不能减去常量delta(B)
。
为什么我们无法知道B
指向的pb
对象是编译时C
的一部分?根据我的理解,B* pb = new C
,pb
指向已创建的C
对象,C
继承自B
,因此B
指针指向pb点部分C
。
(3)假设我们不知道B
指向pb
的指针是编译时C
的一部分。因此,我们必须为运行时存储delta(B),该运行时实际存储在vtbl中。所以vtbl条目现在看起来像:
struct vtbl_entry {
void (*fct)();
int delta;
}
Bjarne写道:
pb->f() // call of C::f:
register vtbl_entry* vt = &pb->vtbl[index(f)];
(*vt->fct)((B*)((char*)pb+vt->delta)) //vt->delta is a negative number I guess
我在这里完全糊涂了。为什么(B *)不是(*vt->fct)((B*)((char*)pb+vt->delta))
中的(C *)????根据我的理解和Bjarne的第一句5.1节和377页的介绍,我们应该在这里传递一个C * this
!!!!!!
接着上面的代码片段,Bjarne继续写道: 请注意,可能必须将对象指针调整为po 在查找指向vtbl的成员之前,将int转换为正确的子对象。
哦,伙计!我完全不知道Bjarne试图说什么?你能帮我解释一下吗?
答案 0 :(得分:3)
Bjarne写道:“当然,B :: bf()期望B *(成为它的指针)。”编译器将调用转换为:
bf__F1B((B*)((char*)pc+delta(B)), 2);
为什么我们需要一个B *指针来做这个?
单独考虑B
:编译器需要能够编译代码ala B::bf(B* this)
。它不知道可能从B
进一步派生哪些类(并且在编译B::bf
之后很久才会引入派生代码)。 B::bf
的代码不会神奇地知道如何将指针从其他类型(例如C*
)转换为可用于访问数据成员的B*
和RunTime类型信息(RTTI) / virtual dispatch table,typeinfo)。
相反, 调用者 有责任在涉及实际运行时类型的B*
子对象中提取有效B
(例如C
)。在这种情况下,C*
保存整个C
对象的开头地址,该对象可能与A
子对象的地址匹配,B
子对象对象是一些固定但非0偏移进一步进入内存:它是必须添加到C*
的偏移量(以字节为单位)才能获得用于调用B*
的有效B::bf
} - 当指针从C*
类型转换为B*
类型时,将完成调整。
(1)当它是一个线性链推导(例1)时,为什么可以期望C对象与B处于同一地址,进而是A子对象?使用
C*
指针访问示例1中函数B::g
中的B类成员是没有问题的吗?例如,我们想要访问成员b,运行时会发生什么?*(pc+8)
?
线性推导B:A和C:B可以被认为是在A的末尾连续添加B特定文件,然后是B末端的特定于C的字段(这仍然是特定于B的字段。 A)结束。所以整个事情看起来像:
[[[A fields...]B-specific-fields....]C-specific-fields...]
^
|--- A, B & C all start at the same address
然后,当我们谈论“B”时,我们谈论的是所有嵌入的A字段以及添加内容,而对于“C”,仍然存在所有A和B字段:它们都是从相同的地址。
关于*(pc+8)
- 这是正确的(理解我们在地址中添加8个字节,而不是添加指针大小的倍数的通常C ++行为)。
(2)为什么我们可以使用相同的内存布局(线性链派生)进行多重继承?假设在示例2中,类A,B,C具有与示例1完全相同的成员.A:int a和f; B:int b和bf(或称之为g); C:int c和h。为什么不直接使用内存布局:
-----------
+0: a
+4: b
+8: c
-----------
没有理由 - 这正是发生的事情......相同的内存布局。不同之处在于B子对象不认为A
是其自身的一部分。它现在是这样的:
[[A fields...][B fields....]C-specific-fields...]
^ ^
\ A&C start \ B starts
因此,当您致电B::bf
时,它想知道B
对象的起始位置 - 您提供的this
指针应位于上面列表中的“+4”处;如果使用B::bf
调用C*
,那么编译器生成的调用代码将需要添加该4以形成this
的隐式B::bf()
参数。 B::bf()
不能简单地告知A
或C
从+0开始:B::bf()
对这两个类中的任何一个都一无所知,也不知道如何到达b
1}}或者它的RTTI,如果你给它指向除了它自己的+4地址以外的任何东西。
答案 1 :(得分:2)
如果您现在忽略函数调用,可能会更有意义,而是考虑在调用{{1}之前将C*
转换为所需的B*
}}。由于bf()
子对象不是从B
对象的相同地址开始,因此需要调整地址。如果您只有一个基类,则完成相同的操作,但偏移量(C
)为零,因此它已经过优化。然后,只更改附加到地址的类型。
BTW:您引用的代码delta(B)
没有执行此转换,这是正式错误的。既然它不是真正的代码,你必须通过读取行来推断它。也许Bjarne打算在那里使用隐式转换为baseclass。
BTW 2:我认为你误解了具有虚函数的类的布局。此外,正如免责声明一样,实际布局取决于系统,即编译器和CPU。无论如何,考虑使用单个虚函数的两个类(*((*pc)[1]))(pc, 2)
和A
:
B
布局将是:
class A {
virtual void fa();
int a;
};
class B {
virtual void fb();
int b;
};
和
----------- ---vtbl---
+0: vptr --------------> +0: A::fa
+4: a ----------
-----------
简而言之,课程----------- ---vtbl---
+0: vptr --------------> +0: B::fb
+4: b ----------
-----------
有三种保证(A
的保证是等效的):
B
,在该指针的零偏移处,我找到了vtable的地址。在该表的零位置,我找到该对象的函数A*
的地址。虽然实际函数可能在派生类中发生变化(由于覆盖),但表中的偏移量是固定的。fa()
作为参数的函数。可以在派生类中重写实际函数,但必须保留此处函数的类型。A* this
,在该指针的偏移量四处,我找到成员变量A*
的值。现在,考虑第三课a
:
C
它的布局就像
class C: A, B {
int c;
virtual void fa();
};
是的,这个类包含两个vtable指针!原因很简单:类----------- ---vtbl---
+0: vptr1 -------------> +0: A::fa
+4: a
+8: vptr2 -------------> +4: B::fb
+12: b +8: C::fc
+16: c ----------
-----------
和A
的布局在编译时是固定的,参见上面的保证。为了允许用B
替换C
或A
(Liskov替换原则),这些布局保证必须保留,因为处理对象的代码只知道例如B
,但不是A
。
对此有一些评论:
C
的vtable指针已经与类C
的指针合并。这种简化只适用于其中一个基类,因此单个和多个继承之间存在差异。A
类型的对象上调用fb()
时,编译器必须使用指针调用C
,以便满足上述保证。为此,它必须调整对象的地址,使其在调用函数之前指向B::fb
(偏移+8)。B
覆盖C
,编译器将生成该函数的两个版本。一个版本用于fb()
子对象的vtable,然后将B
作为隐藏参数。另一个将用于B* this
类的vtable中的单独条目,它需要C
。第一个只调整指针从C*
子对象到B
对象(偏移-8)并调用第二个。C
和a
的偏移量存储在vtable中。类似地,函数调用期间地址的调整可以通过其vtable内嵌在对象内部的信息间接完成。但这样效率会低得多。答案 2 :(得分:2)
示例中的函数bf()
是类B
的成员。在B::bf()
内,您将能够访问B
的所有成员。该访问是通过this
指针执行的。因此,为了使该访问权限正常运行,您需要this
内的B::bf()
精确指向B
。这就是原因。
B::bf()
的实现不知道此B
对象是独立的B
对象,还是嵌入B
对象的C
对象,或者其他一些B
对象嵌入到其他内容中。因此,B::bf()
无法对this
执行任何指针更正。 B::bf()
期望所有指针更正都提前完成,以便B::bf()
开始执行时,this
精确指向B
而不是其他地方。
这意味着当您致电pc->bf()
时,您必须将pc
的值调整一些固定的偏移量(B
中C
的偏移量)并使用结果值为this
的{{1}}指针。
答案 3 :(得分:-1)
理论上应该是编译器会在代码中使用任何this
并且如果引用指针以便它知道this
指的是什么。