编译器对此指针、虚函数和多重继承的详细信息

compiler's detail of this pointer, virtual function and multiple-inheritance

本文关键字:多重继承 详细信息 函数 指针 编译器      更新时间:2023-10-16

我正在阅读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 中,类 AB C具有与示例 1 完全相同的成员。 Aint af ; Bint bbf(或称之为g(; Cint ch .为什么不直接使用内存布局,例如:

 -----------               
+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

它表明papbpc具有相同的地址。

class A {...};
class B {...};
class C: A, B {...};
C* pc = new C();
B* pb = NULL;
pb = (B*)pc;
A* pa = NULL;
pa = (A*)pc;

现在,pcpa具有相同的地址,而pbpapc的一些偏移量。

为什么编译会产生这些差异?


例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指向的BC的一部分,因此编译器不能减去常量delta(B)

为什么我们无法知道pb指向的B对象在编译时是C的一部分?根据我的理解,B* pb = new Cpb指向一个创建的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()不能简单地告诉AC从 +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()时,你必须通过一些固定的偏移量(CB的偏移量(来调整pc的值,并使用结果值作为bf() this指针。

如果您暂时忽略函数调用,而是考虑在调用bf()之前C*转换为所需的B*,也许这更有意义。由于B子对象与C对象的地址不同,因此需要调整地址。在只有一个基类的情况下,也会做同样的事情,但偏移量(delta(B)(为零,所以它被优化了。然后,仅更改附加到地址的类型。

顺便说一句:您引用的代码(*((*pc)[1]))(pc, 2)不执行此转换,这在形式上是错误的。由于它无论如何都不是真正的代码,因此您必须通过阅读字里行间来推断。也许 Bjarne 只是打算在那里使用隐式转换为基类。

BTW 2:我认为您误解了具有虚函数的类的布局。此外,正如免责声明一样,实际布局取决于系统,即编译器和CPU。无论如何,考虑两个类AB具有单个虚函数:

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 指针!原因很简单:类的布局AB在编译时是固定的,请参阅上面的保证。为了允许将C替换为AB(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 中ab。类似地,函数调用期间地址的调整可以通过嵌入对象内部的信息通过其 vtable 间接完成。不过,效率会低得多。

理论上,编译器应该在代码中获取任何this,如果引用指针,则知道this所指的内容。