虚函数和虚表是如何工作的?

4

虽然C++标准将虚拟分派的实现留给编译器,但目前只有3个主要的编译器(gcc、clang和msvc)。

当您通过指向该抽象基类的指针调用抽象基类上的方法时,它们如何实现虚拟分派?在构造期间vtable是如何设置的?

一个简单的“好像”示例会很有用。

1个回答

9
每个主要的C++实现都使用vtable。这是一个指向方法指针数组(或结构体)的指针。对于给定类型有一个vtable(不包括涉及动态加载的某些特殊情况)。
如果你写了这个:
class Iswitch {
public:
  virtual void turn_on() = 0;
  virtual void turn_off() = 0;
  virtual ~Iswitch() = default;
};

编译器会创建出类似于这样的东西:

struct switch_vtable {
  void(*turn_on)(void*) = 0;
  void(*turn_off)(void*) = 0;
  void(*dtor)(void*) = 0;
};

在C++编译器生成的汇编级别上,打包函数指针数组和打包函数指针结构是相同的。这里我将使用结构体,以免涉及到强制类型转换。
struct Iswitch {
  switch_vtable const* vtable = 0;
  Iswitch() {};
  void dynamic_destroy() { vtable->dtor(this); }
  void turn_on() { vtable->turn_on(this); }
  void turn_off() { vtable->turn_off(this); }
};

当你继承自 Iswitch 时:

class MySwitch:public Iswitch {
  std::string message;
public:
  void turn_on() final { std::cout << message << " turns on\n"; }
  void turn_off() final { std::cout << message << " turns off\n"; }
};

编译器生成类似于以下输出:

struct MySwitch:public Iswitch {
  static MySwitch* from_pvoid(void* self) {
    return static_cast<MySwitch*>(static_cast<Iswitch*>(self));
  }
  static switch_vtable make_MySwitch_vtable() {
    return {
      [](void* self){ from_pvoid(self)->turn_on_impl(); },
      [](void* self){ from_pvoid(self)->turn_off_impl(); },
      [](void* self){ from_pvoid(self)->~MySwitch(); }
    };
  }
  static switch_vtable const* get_MySwitch_vtable() {
    static const auto vtable = make_MySwitch_vtable();
    return &vtable;
  }

  std::string message;
  void turn_on_impl() { std::cout << message << " turns on\n"; }
  void turn_off_impl() { std::cout << message << " turns off\n"; }
  MySwitch() { vtable = get_MySwitch_vtable(); }
};

当你创建派生对象时,它会在构造函数中将基础对象中的vtable指针设置为指向自己的vtable。
然后,当你通过虚拟调度访问方法(通常只是调用它)时,代码会在vtable中查找方法指针并调用它。
如果你有...
void off_and_on( Iswitch* pswitch ) {
  pswitch->turn_off();
  pswitch->turn_on();
}

这个函数中运行的代码是相同的,无论 pswitch 指向哪个继承自 Iswitch 的派生类;至少在进入 turn_onturn_off 函数体之前。

MySwitch bob;
on_and_off(&bob);

这在IswitchMySwitch的“编译器自动完成”版本中的class与手动struct版本中基本上是相同的。

dynamic_destroy是我添加的一个特殊辅助程序。当您通过带有虚析构函数的指针删除C++对象时,它会查找vtable中的析构函数并使用它来清理对象。

Iswitch* pswitch = new MySwitch;
delete pswitch;

在手册的struct案例中,上述内容变为:
// Iswitch* pswitch = new MySwitch; translates to:
Iswitch* pswitch = ::new( malloc( sizeof(MySwitch) ) ) MySwitch{};
// (with an extra try-catch to clean up the malloc memory if the ctor throws)
// delete pswitch; translates to:
pswitch->dynamic_destroy();
free(pswitch);
// (usually dtors are nothrow, so no try-catch needed here)

在这里,您可以看到两个版本并排编译的情况。

在实际生成的代码中,有一些差异。

  1. 我创建了帮助函数,而实际生成的代码则将它们内联。
  2. vtable函数的调用约定通常与普通函数不同。
  3. gcc在vtable中生成了2个析构函数助手;一个销毁,另一个销毁并释放内存。我只是销毁了。
  4. gcc生成的vtable中有RTTI(运行时类型信息)。这对于像动态转换之类的东西是必需的。

此外,如果您使用virtual进行继承,则会变得更加有趣,因为现在您的vtable中有指向vtable的指针,而不仅仅是方法。

如果我们扩展Iswitch接口,我们只会得到一个更大的数组/vtable结构,在扩展vtable的第一部分与基类匹配。

class Iswitch_extended:public Iswitch {
  virtual bool is_on() const = 0;
};

对应于:

struct Iswitch_extended_vtable:Iswitch_vtable {
  bool(*is_on)(void const*)=0;
};

struct Iswitch_extended:public Iswitch {
  Iswitch_extended_vtable const* get_vtable() const { return static_cast<Iswitch_extended_vtable const*>(vtable); }

  bool is_on() const { return get_vtable()->is_on(this); }
};

实现它的类必须指向具有vtable的完整表。

在C++被指定之前,所有这些技术都存在于C代码库中。当Bjarn设计C++时,他考虑到了这些东西;这个想法是写这个样板很烦人,让编译器为你写是很好的。

缺点是有多种方法来完成所有这些。虽然每个主要的编译器都使用了上述技术,但MFC使用了基于开关的分派机制来实现其多态性。

上述vtable方法的问题在于,每个具体类都需要一个O(虚拟方法数)表。在MFC的情况下,需要可能重写的方法数量很大(几乎每个Windows消息!)。因此,消息ID被传递给分派函数,该函数被链接在一起。如果子类想要在此对象上拦截该消息,则停止链并返回函数指针(或直接在函数指针上执行消息)。

但是,由于语言中内置了动态分派代码的固定生成器,因此替代方法的成本要高得多,并且即使它们对于特定用例更好,也会被忽略。

就我而言,我期待反射技术的到来。有了反射技术,我们将能够像编译器一样高效地生成动态分派代码,但可以针对不同的面向对象模型进行优化。


编译器有时也可以“去虚拟化”函数调用并消除间接寻址。 - Jesper Juhl
我不明白C++编译器和MFC之间有什么联系。 - engf-010
MFC提供了一种使用宏和开关在C++程序中实现替代动态调度机制的方法。由于我所描述的原因,他们没有使用C++ vtables。 - Yakk - Adam Nevraumont
@JesperJuhl 是的;在 struct 版本中,编译器会在去虚拟化时调用 MySwitch::turn_on_impl() 而不是进行动态分派。 - Yakk - Adam Nevraumont

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