虚函数并修改this指针

virtual function and modified this pointer

考虑以下代码

class B1 {
public:
  void f0() {}
  virtual void f1() {}
  int int_in_b1;
};

class B2 {
public:
  virtual void f2() {}
  int int_in_b2;
};


class D : public B1, public B2 {
public:
  void d() {}
  void f2() {int temp=int_in_b1;}  // override B2::f2()
  int int_in_d;
};

以及对象 d 的以下内存布局:

d:
  +0: pointer to virtual method table of D (for B1)
  +4: value of int_in_b1
  +8: pointer to virtual method table of D (for B2)
 +12: value of int_in_b2
 +16: value of int_in_d

Total size: 20 Bytes.

virtual method table of D (for B1):
  +0: B1::f1()  // B1::f1() is not overridden

virtual method table of D (for B2):
  +0: D::f2()   // B2::f2() is overridden by D::f2()

D  *d  = new D();

d->f2();

调用d->f2();时,D::f2需要访问B1的数据,但是修改了这个指针

(*(*(d[+8]/*pointer to virtual method table of D (for B2)*/)[0]))(d+8) /* Call d->f2() */

传给了D::f2,那么D::f2怎么访问呢?

代码取自(并修改)自:https://en.wikipedia.org/wiki/Virtual_method_table#Multiple_inheritance_and_thunks

同样,我无法发表评论,所以必须在这里回答。

代码没问题!

D::f2 needs access to data from B1

then how is D::f2 able to access it?

只需写入D::f2B1::int_in_b1即可访问int值。

你的情况实际上太简单了:编译器可以知道你有一个指向D对象的指针,所以它可以从右边执行查找table,传递未修改的this 指向 f2() 实现的指针。


有趣的情况是,当您有一个指向 B2:

的指针时
B2* myD = new D();
myD->f2();

现在我们从调整后的基指针开始,需要找到整个对象的this指针。实现这一点的一种方法是在函数指针旁边存储一个偏移量,该函数指针用于从用于访问 vtable.[=21 的 B2 指针生成有效的 this 指针=]

因此,在您的情况下,代码可能会像这样隐式编译

D* myD = new D();
((B2*)myD)->f2();

调整指针两次(一旦从 D* 导出 B2*,然后使用 vtable 的偏移量进行逆运算)。不过,您的编译器可能足够聪明,可以避免这种情况。

无论如何,这都在实施领域之内。您的编译器可以任何事情,只要它表现标准指定的方式。

在您的示例中,当调用 d->f2() 时,编译器知道 d 是指向 class D 的指针。要调用 f2(),它会将 d 的指针调整为 "this" 的 B2,然后将其传递给虚拟 f2(),如您所述。现在,在 D::f2() 内部,编译器知道这是 D::f2() 并且它知道 D 如何从 B2 继承,因此它将 B2 的 "this" 固定为 "this" 的 D 在函数的最开始,所以当你的代码执行时,它会看到 "this" 是 D 的。因此它可以访问 D::f2() 内部的 D 的任何成员. 如果你有

B2* b = d;
b->f2();

当调用b->f2()时,传递给f2()的指针是B2的"this"。在D::f2()中,传递的指针固定指向D的this。

首先,您描述为 "modifying a this pointer" 的效果是某些特定编译器的实现细节。没有具体要求编译器像您描述的那样修改指针。

也没有要求对象必须有 vtables,更不用说它们像你描述的那样布局了。实际要求是在运行次调用虚函数的正确重载,能够正确访问数据成员和调用成员函数。现在,在实践中,编译器倾向于使用 vtables,但这是一个实现细节,因为从各种措施来看,替代方案的效率较低。

也就是说,下面的讨论将假设每个 class 具有虚函数的对象都有一个 vtable。查看您的示例,这是做什么的?

D  *d  = new D();

d->f2();

首先是编译器知道d是指向D的指针,并且知道D有一个名为f2()的函数。它还会知道 f2() 是从 B2 继承的虚函数(这是不可能调用 class 成员函数的原因之一,除非编译器可以看到完整的 class 定义).

在这种情况下,我们知道 dD 是什么,所以我们知道应该调用 D::f2()this 指针的值等于 d。编译器有相同的信息(它知道 d 是一个 D *)所以它只是这样做。现在,好吧,它可能会或可能不会在 vtable 中查找 D::f2(),但到此为止。

像cmaster所说的更有趣的例子是

B2* myD = new D();
myD->f2();

在这种情况下,myD 是指向 B2 的指针。编译器知道 B2 有一个名为 f2() 的虚函数,因此知道它必须调用正确的重载。

问题是,在语句 myD->f2() 中,编译器可能不知道 myD 实际上指向一个 D(例如对象的构造和对象的调用成员函数可能在不同的函数中,在不同的编译单元中)。但是,它确实知道 B2 有一个名为 f2() 的虚函数,这是正确调用实际重载版本所必需的。

这意味着编译器需要两位信息。首先,它需要识别要调用的实际函数 (D::f2()) 的信息。第二位信息是对 myD 的一些调整,以使 D::f2() 的调用正常工作。这第二位信息本质上是从 myD.

生成(您所说的)"modified this pointer" 所需的信息

如果编译器在 vtables 的帮助下完成所有这些工作,它可能会在 B2 的 vtable 中包含这两个信息位。所以(假设第二位信息是一个偏移量)编译器转

myD->f2();

变成类似

的东西
(myD + myD->vtable->offset_for_f2)->(myD->vtable->entry_for_f2)();

(myD + myD->vtable->offset_for_f2) 部分本质上就是您描述为 "the modified this pointer" 的内容,D::f2() 将在调用时看到。 (myD->vtable->entry_for_f2) 部分本质上是 D::f2() 的地址(比如成员函数的地址)。

下一个要问的问题是编译器如何填充 vtable?简短的回答是它在构造对象时这样做。

B2* myD = new D();

新表达式 (new D()) 本质上扩展为

void *temp = ::operator new(sizeof (D));    // assuming class does not supply its own operator new

//  construct a `D` in the memory pointed to by temp

temp = (D *)myD;    // the compiler knows we're creating a D, so doesn't use offsets or anything funky here

把指向temp的内存变成D的过程很重要。首先,它调用基础 classes(B2B2)的构造函数,然后构造或初始化 Ds 成员,然后它调用 D 的构造函数(C++ 标准实际上非常详细地描述了事件的顺序)。另一件事是编译器进行簿记以确保我们实际上从进程中获得有效的 D。其中一部分是填充 vtable。

现在,由于编译器对 class D 的定义具有完整的可见性(即基 classes 及其成员等的完整定义),它具有所有填充 vtable 所需的信息。换句话说,它具有为 myD->vtable->offset_for_f2myD->vtable->entry_for_f2

提供合理值所需的所有信息

在多重继承的情况下,假设每个基有一个 vtable class,编译器拥有以类似方式填充所有 vtable 所需的所有信息。换句话说,编译器知道它如何在内存中布置对象,包括它们的 vtable,并适当地使用这些知识。

但是,话又说回来,它可能不会。正如我所说,vtables 是一种常用于编译器 implement/support 虚函数分派的技术。还有其他方法。