编译器的指针,虚函数和多继承的详细信息

时间:2015-06-10 05:18:38

标签: c++ pointers multiple-inheritance virtual-functions this-pointer

我正在阅读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中,类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具有相同的地址,而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::f时,this指针必须指向C对象的开头(而不是B部分) 。但是,在编译时通常不知道B指向的pbC的一部分,因此编译器不能减去常量delta(B)

为什么我们无法知道B指向的pb对象是编译时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继续写道: 请注意,可能必须将对象指针调整为po 在查找指向vtbl的成员之前,将int转换为正确的子对象。

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

4 个答案:

答案 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()不能简单地告知AC从+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*的地址。虽然实际函数可能在派生类中发生变化(由于覆盖),但表中的偏移量是固定的。
  • vtable中的函数类型也是固定的。在vtable的零位置是一个隐藏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替换CA(Liskov替换原则),这些布局保证必须保留,因为处理对象的代码只知道例如B,但不是A

对此有一些评论:

  • 上面,您已经找到了一个优化,类C的vtable指针已经与类C的指针合并。这种简化只适用于其中一个基类,因此单个和多个继承之间存在差异。
  • A类型的对象上调用fb()时,编译器必须使用指针调用C,以便满足上述保证。为此,它必须调整对象的地址,使其在调用函数之前指向B::fb(偏移+8)。
  • 如果B覆盖C,编译器将生成该函数的两个版本。一个版本用于fb()子对象的vtable,然后将B作为隐藏参数。另一个将用于B* this类的vtable中的单独条目,它需要C。第一个只调整指针从C*子对象到B对象(偏移-8)并调用第二个。
  • 上述三项保证不是必需的。您还可以将成员变量Ca的偏移量存储在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的值调整一些固定的偏移量(BC的偏移量)并使用结果值为this的{​​{1}}指针。

答案 3 :(得分:-1)

理论上应该是编译器会在代码中使用任何this并且如果引用指针以便它知道this指的是什么。