编译器关于 this 指针、虚函数和多重继承的详细信息

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

我正在阅读 Bjarne 的论文:Multiple Inheritance for C++

在第 3 节的第 370 页中,Bjarne 说 "The compiler turns a call of a member function into an "普通" 函数调用带有 "extra" 参数;"extra" 参数是指向对象的指针,该对象的成员函数被调用。"

我对额外的 this 参数感到困惑。请看下面两个例子:

示例 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); };

A class 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 页)

给定两个 classes

class A {...};
class B {...};
class C: A, B {...};

class 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 写道:"Naturally, B::bf() expects a B* (to become its this pointer)." 编译器将调用转换为:

bf__F1B((B*)((char*)pc+delta(B)), 2);

为什么这里我们需要一个B*指针作为this? 如果我们只传递一个 *C 指针作为 this,我认为我们仍然可以正确访问 B 的成员。例如,要在 B::bf() 中获取 class B 的成员,我们只需要执行如下操作: *(this+offset)。编译器可以知道此偏移量。这样对吗?


跟进示例 1 和 2 的问题:

(1)当是线性链推导时(例1),为什么C对象可以期望和B在同一个地址,又是A的子对象?例1中函数B::g中使用C*指针访问classB的成员没有问题?比如我们要访问成员b,运行时会发生什么? *(pc+8)?

(2)为什么多重继承可以使用相同的内存布局(线性链推导)?假设在示例 2 中,class ABC 与示例 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)第一个问题是关于pc->g()的问题,涉及到例2的讨论,compile是否做了如下转换:

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) 假设我们不知道 pb 指向的 B 指针在编译时是 C 的一部分。所以我们必须存储实际与 vtbl 一起存储的运行时的 delta(B)。所以 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

我在这里完全糊涂了。为什么 (*vt->fct)((B*)((char*)pb+vt->delta)) 中的 (B*) 不是 (C*)????根据我的理解和 Bjarne 在第 377 页 5.1 部分第一句的介绍,我们应该在这里传递一个 C* as this!!!!!!

接着上面的代码片段,Bjarne 继续写道: 请注意,对象指针可能必须调整为 po 在查找指向 vtbl 的成员之前,将其转换为正确的子对象。

哦,伙计!我完全不知道 Bjarne 想说什么?你能帮我解释一下吗?

理论上应该是编译器会在代码中获取任何 this,如果引用指针,那么它就知道 this 指的是什么。

Bjarne wrote: "Naturally, B::bf() expects a B* (to become its this pointer)." The compiler transforms the call into:

bf__F1B((B*)((char*)pc+delta(B)), 2);

Why here we need a B* pointer to be the this?

单独考虑 B:编译器需要能够编译代码 ala B::bf(B* this)。它不知道 类 可能会从 B 进一步派生什么(并且派生代码的引入可能要等到 B::bf 被编译很久之后才会发生)。 B::bf 的代码不会神奇地知道如何将指针从其他类型(例如 C*)转换为 B* 它可以用来访问数据成员和运行时类型信息(RTTI / 虚拟调度 table, typeinfo).

相反,调用者有责任将有效的B*提取到B子涉及任何实际运行时类型的对象(例如 C)。在这种情况下,C* 保存整个 C 对象的起始地址,该地址可能与 A 子对象的地址相匹配,而 B 子对象object 是一些固定的但非 0 的偏移量进一步进入内存:必须将偏移量(以字节为单位)添加到 C* 以获得有效的 B* 以调用 B::bf - 当指针从 C* 类型转换为 B* 类型时完成调整。

(1) When it's a linear chain derivation (example 1), why the C object can be expected to be at the same address as the B and in turn A sub-objects? There is no problem to use a C* pointer to access class B's members inside the function B::g in example 1? For example, we want to access the member b, what will happen in runtime? *(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) Why can we use the same memory layout (linear chain derivation) for the multiple-inheritance? Assuming in example 2, class A, B, C have exactly the same members as the example 1. A: int a and f; B: int b and bf (or call it g); C: int c and h. Why not just use the memory layout like:

-----------               
+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 以形成 B::bf() 的隐式 this 参数。 B::bf() 不能简单地被告知 AC 从 +0 开始的位置:B::bf() 对这两个 类 中的任何一个一无所知,也不知道如何到达 b 或者它的 RTTI 如果你给它一个指向除它自己的 +4 地址之外的任何东西的指针。

如果您现在忽略函数调用,而是考虑将 C* 转换为 B*,这在 before 之前可能更有意义] 呼叫 bf()。由于 B 子对象与 C 对象的起始地址不同,因此需要调整地址。如果你只有一个 baseclass,也是一样的,但是偏移量 (delta(B)) 是零,所以它被优化掉了。然后,仅更改附加到地址的类型。

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

顺便说一句 2:我认为您误解了带有虚函数的 classes 的布局。此外,正如免责声明,实际布局取决于系统,即编译器和 CPU。无论如何,考虑两个 classes 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                     ----------  
-----------                

换句话说,class A的三保(B等价):

  • 给定一个指针 A*,在该指针的零偏移处我找到 vtable 的地址。在 table 的位置零处,我找到了该对象的函数 fa() 的地址。虽然实际函数在派生的 classes 中可能会发生变化(由于覆盖),但 table 中的偏移量是固定的。
  • vtable中函数的类型也是固定的。在 vtable 的零位置是一个函数,它接受一个隐藏的 A* this 作为参数。实际函数在派生中可能会被覆盖class,但这里函数的类型必须保留
  • 给定一个指针 A*,在该指针的偏移量四处,我找到成员变量 a.
  • 的值

现在,考虑第三个 class 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                     ----------  
-----------

是的,这个class包含两个vtable指针!原因很简单:classes AB 的布局在编译时是固定的,参见上面的保证。为了允许用 C 替换 AB(Liskov 替换原则),这些布局保证 必须 保留,因为代码处理对象只知道例如A,但不是 C

对此的一些评论:

  • 上面,你已经找到了一个优化,classC的vtable指针已经和classA的vtable指针合并了。这种简化仅适用于其中一个基classes,因此存在单继承和多继承之间的区别。
  • C 类型的对象上调用 fb() 时,编译器必须使用指针调用 B::fb 才能满足上述保证。为此,它必须调整对象的地址,使其在调用函数之前指向 B(偏移量 +8)。
  • 如果 C 覆盖 fb(),编译器将生成该函数的两个版本。一个版本是针对 B 子对象的 vtable,然后将 B* this 作为隐藏参数。另一个将用于 C class 的 vtable 中的单独条目,它需要一个 C*。第一个只会调整从B子对象到C对象的指针(偏移量-8)并调用第二个
  • 以上三项保证都不是必须的。您还可以将成员变量 ab 的偏移量存储在 vtable 中。类似地,函数调用期间地址的调整可以通过 vtable 嵌入对象内部的信息间接完成。不过这样效率会低很多。

您示例中的函数 bf() 是 class B 的成员。在 B::bf() 内,您将能够访问 B 的所有成员。该访问是通过 this 指针执行的。因此,为了使该访问正常工作,您需要 B::bf() 内的 this 精确指向 B。这就是为什么。

B::bf() 的实现不知道这个 B 对象是独立的 B 对象,还是嵌入到 C 对象中的 B 对象,或嵌入到其他东西中的其他 B 对象。因此,B::bf() 无法对 this 执行任何指针更正。 B::bf() 期望提前完成所有指针更正,因此当 B::bf() 开始执行时,this 精确指向 B 而不是其他任何地方。

这意味着当你调用pc->bf()时,你必须将pc的值调整一些固定的偏移量(BC中的偏移量)和使用结果值作为 bf().

this 指针