编译器对此指针、虚函数和多重继承的详细信息
compiler's detail of this pointer, virtual function and multiple-inheritance
我正在阅读Bjarne的论文:C++的多重继承。
在第 370 页的第 3 节中,Bjarne 说:"编译器将成员函数的调用转换为带有"额外"参数的"普通"函数调用;该"额外"参数是指向为其调用成员函数的对象的指针。
我对这个额外的论点感到困惑。请参阅以下两个示例:
例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 如下所示:
三:
----------- 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( 第一个问题是关于与例2中的讨论有关的pc->g()
。编译是否执行以下转换:
pc->g() ==> g__F1B((*B)((char*)pc+delta(B)))
还是我们必须等待运行时执行此操作?
(2(Bjarne写道:在进入C::f
时,this
指针必须指向C
对象的开头(而不是B
部分(。但是,在编译时通常不知道pb
指向的B
是C
的一部分,因此编译器不能减去常量delta(B)
。
为什么我们无法知道pb
指向的B
对象在编译时是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继续写道:请注意,对象指针可能必须调整为 point 到正确的子对象,然后再查找指向 vtbl 的成员。
哦,伙计!!我完全不知道比亚恩想说什么?你能帮我解释一下吗?
Bjarne写道:"当然,B::bf((期望B*(成为它的指针(。编译器将调用转换为:
bf__F1B((B*)((char*)pc+delta(B)), 2);
为什么这里我们需要一个 B* 指针来做这个?
孤立地考虑B
:编译器需要能够编译代码,B::bf(B* this)
。 它不知道哪些类可以从B
进一步派生(并且派生代码的引入可能要到编译B::bf
很久之后才会发生(。 B::bf
的代码不会神奇地知道如何从其他类型的指针转换(例如 C*
(到可用于访问数据成员和运行时类型信息(RTTI/虚拟调度表,类型信息(的B*
。
相反,调用方负责在任何涉及的实际运行时类型中提取对B
子对象的有效B*
(例如 C
(。 在这种情况下,C*
保存整个C
对象的开头地址,该地址可能与A
子对象的地址匹配,并且B
子对象是内存中一些固定但非 0 的偏移量:必须将该偏移量(以字节为单位(添加到C*
中才能获得调用B::bf
的有效B*
- 该调整是在指针从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++添加 pointee 大小的倍数的行为(。
(2(为什么我们可以使用相同的内存布局(线性链派生(进行多重继承?假设在示例 2 中,类 A、B、C 具有与示例 1 完全相同的成员。答: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";如果你使用C*
调用B::bf
,那么编译器生成的调用代码将需要添加该4以形成隐式this
参数到B::bf()
。 B::bf()
不能简单地告诉A
或C
从 +0 开始的位置:B::bf()
对这些类中的任何一个一无所知,如果你给它一个指向它自己的 +4 地址以外的任何内容的指针,也不知道如何到达b
或其 RTTI。
的函数bf()
是类 B
的成员。在B::bf()
内部,您将能够访问B
的所有成员。该访问是通过指针执行this
。因此,为了使该访问正常工作,您需要this
内部B::bf()
以精确指向B
。这就是原因。
B::bf()
的实现不知道这个B
对象是独立的B
对象,还是嵌入到C
对象中的B
对象,还是嵌入到其他对象中的其他B
对象。因此,B::bf()
无法对this
执行任何指针更正。 B::bf()
希望提前完成所有指针更正,以便当B::bf()
开始执行时,this
精确地指向B
而不是其他任何地方。
这意味着当你调用pc->bf()
时,你必须通过一些固定的偏移量(C
中B
的偏移量(来调整pc
的值,并使用结果值作为bf()
this
指针。
如果您暂时忽略函数调用,而是考虑在调用bf()
之前将C*
转换为所需的B*
,也许这更有意义。由于B
子对象与C
对象的地址不同,因此需要调整地址。在只有一个基类的情况下,也会做同样的事情,但偏移量(delta(B)
(为零,所以它被优化了。然后,仅更改附加到地址的类型。
顺便说一句:您引用的代码(*((*pc)[1]))(pc, 2)
不执行此转换,这在形式上是错误的。由于它无论如何都不是真正的代码,因此您必须通过阅读字里行间来推断。也许 Bjarne 只是打算在那里使用隐式转换为基类。
BTW 2:我认为您误解了具有虚函数的类的布局。此外,正如免责声明一样,实际布局取决于系统,即编译器和CPU。无论如何,考虑两个类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
的保证是等价的(:
- 给定一个指针
A*
,在该指针的偏移量为零处,我找到了 vtable 的地址。在该表的位置 0 处,我找到该对象的函数fa()
地址。虽然实际函数可能会在派生类中更改(由于重写(,但表中的偏移量是固定的。 - vtable 中的函数类型也是固定的。在 vtable 的零位置是一个将隐藏
A* this
作为参数的函数。实际函数可以在派生类中重写,但必须保留此处函数的类型。 - 给定一个指针
A*
,在该指针的偏移量为四处,我找到成员变量的值a
。
现在,考虑第三类C
:
class C: A, B {
int c;
virtual void fa();
};
它的布局会像
----------- ---vtbl---
+0: vptr1 -------------> +0: A::fa
+4: a
+8: vptr2 -------------> +4: B::fb
+12: b +8: C::fc
+16: c ----------
-----------
是的,这个类包含两个 vtable 指针!原因很简单:类的布局A
和B
在编译时是固定的,请参阅上面的保证。为了允许将C
替换为A
或B
(Liskov 替换原则(,必须保留这些布局保证,因为处理对象的代码只知道例如 A
,但不是C
。
对此的一些评论:
- 上面,您已经找到了一个优化,类
C
的 vtable 指针已与类A
的 vtable 指针合并。这种简化仅适用于其中一个基类,因此单继承和多继承之间的区别。 - 当在
C
类型的对象上调用fb()
时,编译器必须使用指针调用B::fb
才能满足上述保证。为此,它必须在调用函数之前调整对象的地址,使其指向B
(偏移量 +8(。 - 如果
C
覆盖fb()
,编译器将生成该函数的两个版本。一个版本是用于B
子对象的 vtable,然后它将B* this
作为隐藏参数。另一个将用于C
类的 vtable 中的单独条目,它需要一个C*
.第一个只会调整从B
子对象到C
对象的指针(偏移量 -8(并调用第二个。 - 以上三个保证是没有必要的。您还可以将成员变量的偏移量存储在 vtable 中
a
和b
。类似地,函数调用期间地址的调整可以通过嵌入对象内部的信息通过其 vtable 间接完成。不过,效率会低得多。
理论上,编译器应该在代码中获取任何this
,如果引用指针,则知道this
所指的内容。
- 关于C++中具有多重继承"this"指针的说明
- C++中模板化异常类的多重继承
- std::extent 实现详细信息说明
- 虚拟继承中是否存在多重继承?
- 如何在 c++ 多重继承中调用父非虚函数?
- VisualC++ 2010 有没有办法找出有关未处理异常错误的更多详细信息
- 如何使用软化工具包从 OPC UA 服务器异步读取操作回调中的数据值响应中获取 NodeId 详细信息
- C++获取所有继承类信息的方法
- 多重继承相同的方法名,没有歧义
- protobuf,如何在protobuf消息中遍历所有集合字段,我不知道详细信息?(C++)
- AVX2收集指令使用详细信息
- 只知道运行时的数据类型.如何将数据详细信息隐藏到使用它们的其他类
- CppUnit:如何立即打印故障详细信息
- 使用enable_if解决多重继承歧义
- 多重继承导致虚假的模糊虚拟函数过载
- 多重继承和访问不明确的元素
- C++ 多重继承:使用基类 A 的实现实现基类 B 的抽象方法
- 多重继承中的派生类的行为类似于聚合
- 为什么我的 Hippomock 期望在使用多重继承时失败
- 编译器对此指针、虚函数和多重继承的详细信息