为什么我们需要虚拟表?

28

我正在寻找关于虚拟表的信息,但是没有找到容易理解的内容。

有人可以给我一些带有解释的好例子吗?


4
你不需要它,但编译器需要。 - user207421
5个回答

29

没有虚拟表,你不能使运行时多态性起作用,因为所有对函数的引用都将在编译时绑定。以下是一个简单的例子:

struct Base {
  virtual void f() { }
};

struct Derived : public Base {
  virtual void f() { }
};

void callF( Base *o ) {
  o->f();
}

int main() {
  Derived d;
  callF( &d );
}

callF函数内部,你只知道o指向一个Base对象。然而,在运行时,代码应该调用Derived::f(因为Base::f是虚函数)。在编译时,编译器无法知道o->f()调用将执行哪段代码,因为它不知道o指向什么。

因此,需要使用所谓的“虚表”,它基本上是一张函数指针表。每个具有虚函数的对象都有一个“虚表指针”,指向其类型的对象的虚表。

上述 callF函数中的代码只需要在虚表中查找Base::f的条目(根据对象中的虚表指针找到虚表),然后调用该表项指向的函数。那可能是Base::f,但也有可能指向其他东西-例如Derived::f

这意味着由于虚表,您能够在运行时实现多态性,因为通过在虚表中查找函数指针并通过该指针调用函数,从而确定实际被调用的函数-而不是像非虚函数一样直接调用函数。


5
也许值得一提的是,虚函数表中查找 Base::f 的方式:对于所有类型兼容的类,指向函数 f() 的指针始终存储在相同的偏移量上(根据 维基百科)。例如,如果在 Base 的虚函数表中,指向 Base::f() 的指针存储在偏移量 +4 处,则在 Derived 的虚函数表中,指向 Derived::f() 的指针也存储在偏移量 +4 处。 - kafman

13

虚函数表是编译器实现类中多态方法的一个细节。

考虑以下示例:

class Animal
{
   virtual void talk()=0;
}

class Dog : Animal
{
   virtual void talk() {
       cout << "Woof!";
   }
}

class Cat : Animal
{
   virtual void talk() {
       cout << "Meow!";
   }
}

现在我们已经有了

   A* animal = loadFromFile("somefile.txt"); // from somewhere
   animal->talk();

我们如何知道调用了哪个版本的talk()方法?动物对象有一个指向使用该动物的虚函数的表格。例如,如果有两个其他的虚方法,则talk可能位于第3个偏移量处。

   dog
   [function ptr for some method 1]
   [function ptr for some method 2]
   [function ptr for talk -> Dog::Talk]

   cat
   [function ptr for some method 1]
   [function ptr for some method 2]
   [function ptr for talk -> Cat::Talk]

当我们有一个Animal的实例时,我们不知道调用哪个talk()方法。我们通过查看虚拟表并获取第三个条目来找到它,因为编译器知道它对应于talk指针(编译器知道Animal的虚拟方法,因此知道vtable中指针的顺序)。

给定一个Animal,为了调用正确的talk()方法,编译器添加代码来获取第3个函数指针并使用它。这样就可以将其指向适当的实现。

对于非虚拟方法,这是不必要的,因为可以在编译时确定要调用的实际函数——对于非虚拟调用只有一个可能被调用的函数。


A* animal = ....; // 来自某处 animal->talk();在这行代码中,你能更具体一些吗?谢谢。 - lego69
1
当然 - 想象一下动物是从文件中加载的,或者根据用户输入创建了不同的动物。意图是编译器无法知道我们拥有什么类型的动物。 - mdma

5
为了回答你的标题问题 - 你不需要,而且C++标准也没有规定必须提供一个。你所需要的是能够这样说:
struct A {
  virtual ~A() {}
  virtual void f() {}
};

struct B : public A {
  void f() {}
};

A * p = new B;
p->f();

需要调用B类的f函数而不是A类的f函数。虚函数表是实现这一点的一种方式,但对于普通的C++程序员来说并不重要 - 只有在回答这样的问题时才会考虑它。


作为替代的例子,Python将方法和属性一起存储在对象中。因此,它可以实现这种行为而不使用“虚拟”表格,尽管它非常相似。 - Matthieu M.
@Matthieu M,Python与C++完全相同-它存储对方法对象的一些引用(在C-Python中使用指针实现),而C++存储方法的地址。区别主要在于Python表是按对象而不是按类存储的,因为Python允许在运行时添加属性和方法。 - gnud
1
我其实不同意:在C++中,虚函数表并没有存储指向函数的实际信息,编译器只知道所需方法存储在给定索引上。另一方面,在Python中,属性和方法(通常)存储在字典中,并通过名称进行查找。这是一种重要的实现差异,使得Python具有更高的灵活性,但性能上会有所损失。 - Matthieu M.

3
假设PlayerMonster都继承自一个抽象基类Actor,该类定义了一个虚拟的name()操作。进一步假设您有一个函数,用于向演员询问他的名字:
void print_information(const Actor& actor)
{
    std::cout << "the actor is called " << actor.name() << std::endl;
}

在编译时无法确定演员实际上是玩家还是怪物。由于它们具有不同的name()方法,因此必须推迟到运行时决定调用哪个方法。编译器向每个演员对象添加附加信息,以允许在运行时做出此决策。
在我所知道的每个编译器中,这些附加信息都是指向函数指针表(通常称为vtbl)的指针(通常称为vptr),这些函数指针表是特定于具体类的。也就是说,所有玩家对象共享相同的虚拟表格,其中包含指向所有玩家方法的指针(怪物也是如此)。在运行时,通过选择指向应调用方法的对象的vptr所指向的vtbl中的方法来找到正确的方法。

1
简短回答:虚函数调用,basePointer->f(),根据basePointer的历史记录意义不同。如果它指向真正的派生类,将调用不同的函数。
为此,编译器进行了一场简单的函数指针游戏。不同类型要调用的函数的地址存储在虚表中。
虚表不仅用于函数指针。RTTI机制还使用它来获取由一个基本类型地址引用的对象的实际类型的运行时类型信息。
一些new/delete实现会在虚表中存储对象大小。
Windows COM编程使用虚表来破解并将其作为接口推送。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接