【问题标题】:compiler's detail of this pointer, virtual function and multiple-inheritance编译器的 this 指针、虚函数和多重继承的细节
【发布时间】:2015-06-10 05:18:38
【问题描述】:

我正在阅读 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 中,类 ABC 具有与示例 1 完全相同的成员。Aint afBint 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-&gt;g(),这与示例2中的讨论有关。编译是否进行以下转换:

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 指向的BC 的一部分。所以我们必须存储运行时的 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

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

接着上面的代码sn-p,Bjarne继续写: 请注意,对象指针可能必须调整为 po 在寻找指向 vtbl 的成员之前 int 到正确的子对象。

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

【问题讨论】:

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


    【解决方案1】:

    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 )。

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

    (1) 当它是线性链派生时(示例1),为什么可以期望C 对象与B 和A 子对象位于同一地址?在示例1中使用C* 指针访问函数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”;如果您使用C* 调用B::bf,那么编译器生成的调用代码将需要添加该4 以将隐式this 参数添加到B::bf()B::bf() 不能简单地告诉 AC 从 +0 开始:B::bf() 对这两个类一无所知,也不知道如何到达 b 或其 RTTI,如果你给它的话指向除自己的 +4 地址以外的任何内容的指针。

    【讨论】:

    • 托尼,还有两个问题。请!谢谢。
    • @FihopZz:添加了一些进一步的解释。
    • 托尼,非常感谢。现在清楚多了。对于第二个问题,假设两个指针C pc 和*B pb 分别指向A start 和B start,要访问B 类中的成员b,我们需要*(pc+4) 和*(pb)?编译器或运行时如何知道 C 指针应添加偏移量 4 而 B* 指针仅添加 0?
    • @FihopZz:不客气。我假设您上面的问题是针对多重继承场景的:“需要*(pc+4)*(pb)?” - 没错。 “编译器如何知道...” - 它的工作是知道 - 它必须创建程序中不同类中数据成员的偏移量表,并在生成实际时使用这些表机器码。
    【解决方案2】:

    您的示例中的函数bf()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-&gt;bf() 时,您必须将pc 的值调整为某个固定偏移量(CB 的偏移量)并将结果值用作this 指针bf().

    【讨论】:

    • 我还有两个问题。你能再帮我一次吗?谢谢
    • 如果我们只是将 *C 指针作为this 传递,我认为我们仍然可以正确访问 B 的成员。例如,要在 B::bf() 中获取类 B 的成员,我们只需要执行以下操作:*(this+offset)。编译器可以知道这个偏移量。这个可以吗?更新原始问题。
    • @FihopZz:正如我在回答(和其他回答)中所说,当我们已经在里面 B::bf()我们不知道我们的B 是否嵌入到C(如您的代码中)或我们的BC 完全无关。请记住,B 可以是独立对象,例如 B b;。因为我们不知道,所以我们不知道是否应该在this 中添加一些offset。我们无法在B::bf() 内部知道这一点。为了解决这个问题,提供已经调整的this值的任务被放置在调用代码上。
    • 当我们进入B::bf()时,指针this必须已经被调用代码正确调整并准确指向B。这样我们就不必担心将任何offset 添加到this
    • 非常感谢,AnT。现在完全清楚了。我已经稍微更新了这个问题。你有空能不能帮我看看。
    【解决方案3】:

    如果您暂时忽略函数调用,而是考虑将C* 转换为B*,这可能更有意义调用bf() 之前。由于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的指针合并。这种简化仅适用于其中一个基类,因此存在单继承和多继承之间的区别。
    • C 类型的对象上调用fb() 时,编译器必须使用指针调用B::fb,以便满足上述保证。为此,它必须在调用函数之前调整对象的地址,使其指向B(偏移量+8)。
    • 如果C 覆盖fb(),编译器将生成该函数的两个版本。一个版本用于B 子对象的vtable,然后将B* this 作为隐藏参数。另一个将用于C 类的vtable 中的单独条目,它需要C*。第一个只会将指针从B 子对象调整到C 对象(偏移量-8)并调用第二个。
    • 以上三包均无必要。您还可以将成员变量ab 的偏移量存储在vtable 中。类似地,函数调用期间地址的调整可以通过嵌入在对象中的信息通过其 vtable 间接完成。不过这样效率会低得多。

    【讨论】:

    • Bjarne 的 " 变成了类似:" 为伪代码留出了空间,并且由于存在线性派生链而不是多重继承,因此可以预期 C 对象是与 B 和 A 子对象位于相同的地址,因此不需要实际的指针调整。 (我希望你知道这一切,但对于其他读者——只是我对为什么显式演员不会增加普通读者的理解的看法)。
    • @Tony D,所以线性派生链与多重继承的内存布局不同?
    • @FihopZz: 是的......在线性推导中,A、B 和 C 子对象通常都从相同的内存地址开始;具有多重继承,A 和 C 仍然具有相同的地址,但 B 在 A / 之后,在 C 内的某个非 0 偏移处。我从您更新的问题中看到,您在测试中观察到了这一点。
    • 我已经稍微更新了这个问题。乌尔里希,你有空可以帮我看看吗
    • 我不确定这是否对你有帮助,但考虑看看“C++ 的设计和演变”,我认为这应该澄清很多事情。另外,考虑学习汇编语言课程。 C 和 C++ 的许多特性仅仅是对汇编语言的简化。了解这些事情(并在两者之间进行心理转换)使您的问题的答案几乎显而易见。
    【解决方案4】:

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

    【讨论】:

    • 对不起,我不明白。你能解释得更详细一点吗?
    • 好吧,我还没有读过他的论文,我也不确定,但是我认为这就像有人在问“this 是什么?”你疑惑的看着他,什么是什么?除非那个人正在抓住或指向某物,否则你不知道他指的是什么this。因此,我的观点是,您正在谈论的另一个论点是指向物体的手指或抓住物体的手。我认为这样编译器才能理解this
    • 问题在于编译器做了什么,而不是它能理解什么。
    • @EJP,我已经稍微更新了这个问题。乌尔里希,你有时间能帮我看看吗。
    猜你喜欢
    • 1970-01-01
    • 2012-01-27
    • 2016-05-16
    • 1970-01-01
    • 2012-01-31
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多