【问题标题】:How does the compiler know which entry in vtable corresponds to a virtual function?编译器如何知道 vtable 中的哪个条目对应一个虚函数?
【发布时间】:2016-01-05 11:41:45
【问题描述】:

假设我们在父类和派生类中有多个虚函数。在父派生类的 vtable 中都会为这些虚函数创建一个 vtable。

编译器如何知道 vtable 中的哪个条目对应哪个虚函数?

例子:

class Animal{
public:
 void fakeMethod1(){}
 virtual void getWeight(){}
 void fakeMethod2(){}
 virtual void getHeight(){}
 virtual void getType(){}
};

class Tiger:public Animal{
public:
 void fakeMethod3(){}
 virtual void getWeight(){}
 void fakeMethod4(){}
 virtual void getHeight(){}
 virtual void getType(){}
};
main(){
Animal a* = new Tiger();
a->getHeight(); // A  will now point to the base address of vtable Tiger
//How will the compiler know which entry in the vtable corresponds to the function getHeight()?
}

我的研究中没有找到确切的解释 -

https://stackoverflow.com/a/99341/437894 =

"此表用于解析函数调用,因为它包含 该类的所有虚函数的地址。”

该表究竟是如何用于解析函数调用的?

https://stackoverflow.com/a/203136/437894 =

"所以在运行时,代码只是使用对象的 vptr 来定位 vtbl,并从那里得到实际覆盖函数的地址。”

我无法理解这一点。 Vtable 保存的是虚函数的地址,而不是实际重写函数的地址。

【问题讨论】:

  • 没有规定 vtbl 布局。但一种自然的方法是编译器按连续顺序对类中的虚函数进行编号。这些数字用作 vtbl 的索引,它实际上是一个函数指针数组。
  • 编译器知道 vtable 中的内容,因为它创建了 vtable。不清楚您在这里真正要问的是什么。
  • @Gene 烦人的是,MSVC 也会将重载组合在一起,即使它们没有按该顺序声明。 (哦,虚拟继承等自然会变得很奇怪)
  • 但这没关系,不是吗?它将条目放入 vtable 中,并生成代码以再次读取它们。它使用什么映射无关紧要。 Stroustrup 使用了声明的顺序,但它可以是任何东西,只要它是一致的。
  • 注意“VTable 保存的是虚拟函数的地址,而不是实际覆盖函数的地址”不正确。它保存当前类的覆盖地址。否则就没有意义了。

标签: c++ compiler-construction virtual-functions vtable


【解决方案1】:

我将对您的示例稍作修改,以便展示面向对象的更多有趣方面。

假设我们有以下内容:

#include <iostream>

struct Animal
{
  int age;
  Animal(int a) : age {a} {}
  virtual int setAge(int);
  virtual void sayHello() const;
};

int
Animal::setAge(int a)
{
  int prev = this->age;
  this->age = a;
  return prev;
}

void
Animal::sayHello() const
{
  std::cout << "Hello, I'm an " << this->age << " year old animal.\n";
}

struct Tiger : Animal
{
  int stripes;
  Tiger(int a, int s) : Animal {a}, stripes {s} {}
  virtual void sayHello() const override;
  virtual void doTigerishThing();
};

void
Tiger::sayHello() const
{
  std::cout << "Hello, I'm a " << this->age << " year old tiger with "
            << this->stripes << " stripes.\n";
}

void
Tiger::doTigerishThing()
{
  this->stripes += 1;
}


int
main()
{
  Tiger * tp = new Tiger {7, 42};
  Animal * ap = tp;
  tp->sayHello();         // call overridden function via derived pointer
  tp->doTigerishThing();  // call child function via derived pointer
  tp->setAge(8);          // call parent function via derived pointer
  ap->sayHello();         // call overridden function via base pointer
}

我忽略了一个好的建议,即具有 virtual 函数成员的类应该有一个 virtual 析构函数用于本示例的目的。反正我要泄露对象。

让我们看看如何将这个示例翻译成没有成员函数的旧 C 语言,只剩下virtual 那些。以下所有代码都是 C,而不是 C++。

struct animal 很简单:

struct animal
{
  const void * vptr;
  int age;
};

除了age 成员之外,我们还添加了一个vptr,它将作为指向vtable 的指针。我为此使用了void 指针,因为无论如何我们都必须进行丑陋的演员表,而使用void * 可以稍微减少丑陋。

接下来,我们可以实现成员函数了。

static int
animal_set_age(void * p, int a)
{
  struct animal * this = (struct animal *) p;
  int prev = this->age;
  this->age = a;
  return prev;
}

注意额外的第 0 个参数:在 C++ 中隐式传递的 this 指针。同样,我使用了void * 指针,因为它会在以后简化事情。请注意,任何成员函数中,我们总是知道静态的this指针的类型,所以转换没有问题。 (而且在机器级别,它无论如何都不会做任何事情。)

sayHello 成员的定义类似,只是这次this 指针是const 限定的。

static void
animal_say_hello(const void * p)
{
  const struct animal * this = (const struct animal *) p;
  printf("Hello, I'm an %d year old animal.\n", this->age);
}

动物 vtable 的时间。首先我们必须给它一个类型,它是直截了当的。

struct animal_vtable_type
{
  int (*setAge)(void *, int);
  void (*sayHello)(const void *);
};

然后我们创建一个 vtable 实例并使用正确的成员函数对其进行设置。如果Animal 有一个纯virtual 成员,则相应的条目将有一个NULL 值并且最好不要取消引用。

static const struct animal_vtable_type animal_vtable = {
  .setAge = animal_set_age,
  .sayHello = animal_say_hello,
};

请注意,animal_set_ageanimal_say_hello 被声明为 static。这很正常,因为它们永远不会被按名称引用,而只能通过 vtable(并且 vtable 只能通过 vptr 所以它也可以是 static)。

我们现在可以实现Animal的构造函数了……

void
animal_ctor(void * p, int age)
{
  struct animal * this = (struct animal *) p;
  this->vptr = &animal_vtable;
  this->age = age;
}

……以及对应的operator new

void *
animal_new(int age)
{
  void * p = malloc(sizeof(struct animal));
  if (p != NULL)
    animal_ctor(p, age);
  return p;
}

唯一有趣的是在构造函数中设置vptr 的那一行。

让我们继续讨论老虎。

Tiger 继承自 Animal,因此它得到一个 struct tiger 子对象。我通过将struct animal 作为第一个成员来做到这一点。这是第一个成员很重要,因为这意味着该对象的第一个成员 - vptr - 与我们的对象具有相同的地址。我们稍后会在进行一些棘手的转换时需要它。

struct tiger
{
  struct animal base;
  int stripes;
};

我们也可以在struct tiger 定义的开头简单地复制struct animal 的成员,但这可能更难维护。编译器不关心这些风格问题。

我们已经知道如何实现老虎的成员函数了。

void
tiger_say_hello(const void * p)
{
  const struct tiger * this = (const struct tiger *) p;
  printf("Hello, I'm an %d year old tiger with %d stripes.\n",
         this->base.age, this->stripes);
}

void
tiger_do_tigerish_thing(void * p)
{
  struct tiger * this = (struct tiger *) p;
  this->stripes += 1;
}

请注意,我们这次将this 指针转换为struct tiger。如果调用了老虎函数,this 指针最好指向老虎,即使我们是通过基指针调用的。

在 vtable 旁边:

struct tiger_vtable_type
{
  int (*setAge)(void *, int);
  void (*sayHello)(const void *);
  void (*doTigerishThing)(void *);
};

请注意,前两个成员与animal_vtable_type 完全相同。这是必不可少的,基本上是您问题的直接答案。如果我将struct animal_vtable_type 作为第一个成员,它可能会更明确。我想强调的是,对象布局会完全相同,只是在这种情况下我们不能玩我们讨厌的转换技巧。同样,这些是 C 语言的方面,不存在于机器级别,因此编译器不会受此困扰。

创建一个 vtable 实例:

static const struct tiger_vtable_type tiger_vtable = {
  .setAge = animal_set_age,
  .sayHello = tiger_say_hello,
  .doTigerishThing = tiger_do_tigerish_thing,
};

并实现构造函数:

void
tiger_ctor(void * p, int age, int stripes)
{
  struct tiger * this = (struct tiger *) p;
  animal_ctor(this, age);
  this->base.vptr = &tiger_vtable;
  this->stripes = stripes;
}

tiger 构造函数做的第一件事就是调用 animal 构造函数。还记得动物构造函数是如何将vptr 设置为&amp;animal_vtable 的吗?这就是为什么从基类构造函数调用virtual 成员函数经常让人感到惊讶的原因。只有在基类构造函数运行后,我们才将vptr重新分配给派生类型,然后进行我们自己的初始化。

operator new 只是样板文件。

void *
tiger_new(int age, int stripes)
{
  void * p = malloc(sizeof(struct tiger));
  if (p != NULL)
    tiger_ctor(p, age, stripes);
  return p;
}

我们完成了。但是我们如何调用一个虚成员函数呢?为此,我将定义一个辅助宏。

#define INVOKE_VIRTUAL_ARGS(STYPE, THIS, FUNC, ...)                     \
  (*((const struct STYPE ## _vtable_type * *) (THIS)))->FUNC( THIS, __VA_ARGS__ )

现在,这很难看。它的作用是将静态类型 STYPEthis 指针 THIS 和成员函数的名称 FUNC 以及任何其他参数传递给函数。

然后,它从静态类型构造 vtable 的类型名称。 (## 是预处理器的标记粘贴操作符。例如,如果STYPEanimal,那么STYPE ## _vtable_type 将扩展为animal_vtable_type。)

接下来,THIS 指针被转换为指向刚刚派生的 vtable 类型的指针。这是可行的,因为我们确保将vptr 作为每个对象中的first 成员,因此它具有相同的地址。这是必不可少的。

完成后,我们可以取消引用指针(以获取实际的vptr),然后请求它的FUNC 成员,最后调用它。 (__VA_ARGS__ 扩展为额外的可变参数宏参数。)请注意,我们还将 THIS 指针作为第 0 个参数传递给成员函数。

现在,实际情况是我必须再次为不带参数的函数定义一个几乎相同的宏,因为预处理器不允许可变参数宏参数包为空。就这样吧。

#define INVOKE_VIRTUAL(STYPE, THIS, FUNC)                               \
  (*((const struct STYPE ## _vtable_type * *) (THIS)))->FUNC( THIS )

它有效:

#include <stdio.h>
#include <stdlib.h>

/* Insert all the code from above here... */

int
main()
{
  struct tiger * tp = tiger_new(7, 42);
  struct animal * ap = (struct animal *) tp;
  INVOKE_VIRTUAL(tiger, tp, sayHello);
  INVOKE_VIRTUAL(tiger, tp, doTigerishThing);
  INVOKE_VIRTUAL_ARGS(tiger, tp, setAge, 8);
  INVOKE_VIRTUAL(animal, ap, sayHello);
  return 0;
}

你可能想知道发生了什么

INVOKE_VIRTUAL_ARGS(tiger, tp, setAge, 8);

打电话。我们正在做的是在通过struct tiger 指针引用的Tiger 对象上调用Animal 的非覆盖setAge 成员。该指针首先被隐式转换为void 指针,并作为this 指针传递给animal_set_age。然后该函数将其转换为 struct animal 指针。它是否正确?因为我们小心地将struct animal 作为struct tiger 中的第一个成员,所以struct tiger 对象的地址与struct animal 子对象的地址相同。这与我们使用vptr 玩的技巧相同(仅少一级)。

【讨论】:

  • 感谢您的详细解释。这有帮助。还找到了另一个例子。在这里,他们讨论了一个具有两个基类和派生类的示例,它们覆盖了两个基类的虚函数。 link
  • 对于您的下一堂课,请介绍virtual 继承以及如何在 C 中模拟它。:) +1 顺便说一句,该系统的一个优点是您可以将 vtable 与数据分离(不要连续存储它),这可以允许一些技巧。当您需要进行拆分时,这些技术在 C++ 中很有用(例如,如果您想将数据存储在某个内部缓冲区中,但以多态方式对其进行操作:或者,将小型轻量级对象打包在一个数组中,运行长度 -其他地方编码的 vtable,您可能会在文本处理中使用)
  • 非常感谢您的回答。一个问题:编译器如何首先知道它必须进行虚函数调用?如果我有: X* z = new z();这样X没有虚函数,Y继承了X有虚函数,Z继承了Y。上面所有从“z”调用的函数都应该通过vptr,但是类型是X*,那么编译器怎么做知道对“z”上的函数的调用是否应该通过 vptr?
  • @DeanLeitersdorf 在您的示例中,变量的静态类型为X*。而且由于(你说过)X 没有虚函数,所以你不可能调用任何虚函数。这成为问题的地方是(在 C++ 中)您使用 delete 再次释放对象时。实际上,编译器此时无法知道它应该通过 vtable 来找到动态类型的析构函数。在 C++ 中会发生的是静态类型 (X) 的析构函数将被调用,并且您的程序将调用未定义的行为。 tl;博士不要这样做。
【解决方案2】:

它可以帮助你自己实现类似的东西。

struct Bob;
struct Bob_vtable {
  void(*print)(Bob const*self) = 0;
  Bob_vtable(void(*p)(Bob const*)):print(p){}
};
template<class T>
Bob_vtable const* make_bob_vtable(void(*print)(Bob const*)) {
  static Bob_vtable const table(+print);
  return &table;
}
struct Bob {
  Bob_vtable const* vtable;
  void print() const {
    vtable->print(this);
  }
  Bob():vtable( make_bob_vtable<Bob>([](Bob const*self){
    std::cout << "Bob\n";
  })) {}
protected:
  Bob(Bob_vtable const* t):vtable(t){}
};
struct Alice:Bob {
  int x = 0;
  Alice():Bob( make_bob_vtable<Alice>([](Bob const*self){
    std::cout << "Alice " << static_cast<Alice const*>(self)->x << '\n';
  })) {}
};

live example.

这里我们有一个显式的 vtable 存储在 Bob 中。它指向一个函数表。非虚成员函数print 使用它来动态调度到正确的方法。

Bob 和派生类 Alice 的构造函数将 vtable 设置为不同的值(在本例中创建为静态本地),并在表中具有不同的值。

要使用的指针被纳入Bob::print 含义的定义中——它知道表中的偏移量。

如果我们在 Alice 中添加另一个虚函数,它只是意味着 vtable 指针实际上将指向一个struct Alice_vtable:Bob_vtable。静态/重新解释转换将为我们提供“真实”表,我们可以轻松访问额外的函数指针。

当我们谈论虚拟继承以及虚拟函数时,事情变得奇怪了。我没有资格描述它是如何工作的。

【讨论】:

    猜你喜欢
    • 2021-10-27
    • 2010-09-17
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-03-21
    • 1970-01-01
    • 2019-11-30
    • 1970-01-01
    相关资源
    最近更新 更多