5

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

在第 3 节第 370 页中,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 看起来像:

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*(成为它的 this 指针)。” 编译器将调用转换为:

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 af; Bint bbf(或称它为g);C:int 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

它表明pa和具有相同pbpc地址。

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

现在,pcandpa具有相同的地址,而与and有pb一些偏移。papc

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


示例 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::fthis指针必须指向C对象的开头(而不是B部分)。但是,在编译时通常不知道Bby 指向的pb是 a 的一部分,C因此编译器不能减去常量delta(B)

为什么我们不能在编译时知道B指向的对象pb是 a 的一部分?C根据我的理解,B* pb = new C,pb指向创建的C对象并C继承自B,因此B指针 pb 指向C.

(3) 假设我们在编译时不知道B指向 by的指针pb是 a 的一部分。C所以我们必须存储运行时的 delta(B),它实际上与 vtbl 一起存储。所以 vtbl 条目现在看起来像:

struct vtbl_entry {
    void (*fct)();
    int  delta;
}

比亚恩写道:

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*) 不是 (C*) 在(*vt->fct)((B*)((char*)pb+vt->delta))???? 根据我的理解和 Bjarne 在第 5.1 节第 377 页的第一句的介绍,我们应该在this这里传递一个 C* !!!!!!

紧接着上面的代码片段,Bjarne 继续写道: 注意,在寻找指向 vtbl 的成员之前,可能需要调整对象指针以指向正确的子对象。

天啊!!!我完全不知道 Bjarne 想说什么?你能帮我解释一下吗?

4

4 回答 4

3

Bjarne 写道:“自然,B::bf() 期望 B*(成为它的 this 指针)。” 编译器将调用转换为:

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

为什么在这里我们需要一个 B* 指针来作为 this?

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

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

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

线性推导 B : A 和 C : B 可以认为是依次在 A 的末尾添加 B 特定字段,然后在 B 末尾添加 C 特定字段(仍然是在 A 末尾添加 B 特定字段)。所以整个事情看起来像:

[[[A fields...]B-specific-fields....]C-specific-fields...]
 ^
 |--- A, B & C all start at the same address

然后,当我们谈论“B”时,我们谈论的是所有嵌入的 A 字段以及添加的字段,而对于“C”,仍然有所有 A 和 B 字段:它们都从同一个 address 开始

关于*(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使用 a调用,C*则编译器生成的调用代码将需要将该 4 添加thisB::bf(). B::bf()不能简单地告诉它在哪里AC从 +0 开始:如果你给它一个指向它自己的 +4 地址以外的任何东西的指针,那么它对B::bf()这些类中的任何一个一无所知,也不知道如何到达或它的 RTTI。b

于 2015-06-10T05:36:23.097 回答
2

如果您现在忽略函数调用,而是考虑在调用之前C*将 a转换为 a B*,那么这可能更有意义。由于子对象与对象的起始地址不同,因此需要调整地址。在只有一个基类的情况下,也会这样做,但偏移量 ( ) 为零,因此会被优化掉。然后,仅更改附加到地址的类型。bf()BCdelta(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 的地址。在该表的位置零处,我找到了fa()该对象的函数地址。虽然派生类中的实际函数可能会发生变化(由于覆盖),但表中的偏移量是固定的。
  • vtable 中函数的类型也是固定的。在 vtable 的位置 0 处是一个以 hiddenA* 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编译时是固定的,见上面的保证。为了允许将 a 替换CAor B(Liskov 替换原则),必须保留这些布局保证,因为处理对象的代码只知道 eg A,但不知道C

对此的一些评论:

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

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

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

这意味着当您调用 时pc->bf(),您必须pc通过某个固定偏移量(Bin 的偏移量C)调整 的值,并将结果值用作 的this指针bf()

于 2015-06-10T05:38:12.230 回答
-1

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

于 2015-06-10T05:25:01.010 回答