虚函数和虚表是如何实现的?

138
我们都知道在C++中什么是虚函数,但是它们在深层实现上是如何实现的?
vtable能被修改或者在运行时直接访问吗?
vtable是否存在于所有类中,还是只存在于至少有一个虚函数的类中?
抽象类是否仅在至少有一个条目的函数指针上具有NULL值?
是否拥有单个虚函数会减慢整个类的速度?或者只有调用虚函数的速度会减慢?如果虚函数被覆盖,速度会受到影响吗,还是只要它是虚函数就没有影响?

4
建议阅读Stanley B. Lippman的杰作《Inside the C++ Object Model》(第4.2节,第124-131页)。 - smwikipedia
12个回答

142

虚函数是如何在深层实现的?

根据《C++中的虚函数》,每当程序有一个声明了虚函数的类时,就会为该类构建一个v-table。 v-table包含包含一个或多个虚函数的类的虚函数地址。 包含一个或多个虚拟函数的类的对象包含一个虚拟指针,该指针指向内存中虚拟表的基地址。 每当有虚拟函数调用时,将使用v-table来解析到函数地址。 含有一个或多个虚拟函数的类的对象在内存中的最开始处有一个叫做vptr的虚拟指针。 因此,在这种情况下,对象的大小增加了指针的大小。此vptr包含虚拟表在内存中的基地址。 请注意,虚拟表是特定于类的,即一个类只有一个虚拟表,无论它包含多少个虚拟函数。 虚拟表反过来包含类的一个或多个虚拟函数的基地址。 在调用对象上的虚拟函数时,该对象的vptr提供该类在内存中的虚拟表的基地址。 此表用于解析函数调用,因为它包含该类的所有虚拟函数的地址。 这就是在虚函数调用期间如何解决动态绑定的过程。

能否在运行时修改或直接访问vtable?

普遍而言,我的回答是“不行”。您可以进行一些内存操作以查找v-table,但仍无法知道函数签名的外观以调用它。可以在没有直接访问或在运行时修改vtable的情况下实现此功能(支持语言)。此外,请注意,C++语言规范不要求 使用vtables - 但这是大多数编译器实现虚函数的方式。

vtable是否存在于所有对象中,还是只有那些至少具有一个虚函数的对象中存在?

我认为这里的答案是“要看具体实现”,因为规范本身并没有要求使用vtable。但是在实践中,我认为所有现代编译器只有在类中至少有一个虚函数时才会创建vtable。使用虚函数与非虚函数相比存在空间开销和时间开销。

抽象类是否只对至少一个函数指针使用NULL?

答案是由语言规范未指定,所以取决于具体实现。如果调用纯虚函数但未定义它(通常情况下不定义),则结果是未定义行为(参见ISO/IEC 14882:2003 10.4-2)。实际上,它确实为该函数分配了一个vtable槽位,但没有给它分配地址。这使得vtable不完整,需要派生类来实现该函数并完成vtable。一些实现仅在vtable条目中放置一个NULL指针;其他实现将指针放置到执行某种类似于断言的虚拟函数中。

请注意,抽象类可以为纯虚函数定义一个实现,但只能通过限定符号语法(即,在方法名中完全指定类,类似于从派生类调用基类方法)调用该函数。这样做是为了提供易于使用的默认实现,同时仍要求派生类提供覆盖。

一个虚函数是否会拖慢整个类的速度,还是只有调用该虚函数时速度变慢?

这已经超出了我的知识范围,所以如果我说错了,请有人帮助我!

我认为只有类中的虚函数才会受到与调用非虚函数相比调用虚函数的时间性能影响。类的空间开销无论如何都存在。请注意,如果存在vtable,则每个类仅有1个vtable,而不是每个对象都有一个。

如果虚函数实际上被覆盖或未覆盖,速度是否会受到影响,或者只要它是虚函数就没有影响?

我不相信覆盖虚函数的执行时间会减少,相比于调用基类的虚函数。然而,为派生类定义另一个vtable与基类相比,会导致额外的空间开销。

其他资源:

http://www.codersource.net/published/view/325/virtual_functions_in.aspx(通过回溯机器)
http://en.wikipedia.org/wiki/Virtual_table
http://www.codesourcery.com/public/cxx-abi/abi.html#vtable


2
根据Stroustrup对C ++的哲学,对于不需要虚表指针的对象,编译器不应该在其中放置不必要的虚表指针。规则是除非你要求,否则您不会得到不属于C的开销,并且编译器打破这个规则是不礼貌的。 - Steve Jessop
3
我同意,任何认真对待自己的编译器都不会在不存在虚拟函数时使用vtable是很愚蠢的。然而,我认为重要的是指出,据我所知,C++标准并没有"要求"这样做,因此在依赖它之前请注意。 - Zach Burlingame
11
甚至虚函数也可以非虚地调用。这实际上是很常见的:如果对象在堆栈上,在作用域内,编译器会知道确切的类型并优化掉虚表查找。 对于析构函数尤其如此,必须在相同的堆栈范围内调用。 - MSalters
4
常见的实现方式是:每个对象都有一个指向虚函数表的指针;类拥有该表。构造函数的神奇之处在于,在基类构造函数完成后,更新派生类构造函数中的虚函数表指针即可。 - MSalters
1
这会导致虚函数表不完整,需要派生类实现该函数并完成虚函数表。但是,派生类并不会“完成”基类的虚函数表:派生类有自己的虚函数表。不存在“不完整”的虚函数表。 - curiousguy
显示剩余11条评论

45
  • 虚函数表(vtable)可以在运行时被修改或直接访问吗?

不是跨平台的,但如果您不介意使用一些技巧,当然可以!

警告:此技术不建议儿童、969岁以下的成年人或来自半人马座阿尔法星系的小毛茸茸生物使用。可能会出现从鼻子中飞出的恶魔Yog-Sothoth 突然成为所有后续代码审查的必要审批者,或将 IHuman::PlayPiano() 后置到所有现有实例中。]

在我所见过的大多数编译器中,虚函数表 * 是对象的前4个字节,并且其中的虚函数指针内容只是在那里形成的成员指针数组(通常按照声明顺序排列,基类的在前)。当然,还有其他可能的布局,但这是我通常观察到的情况。

class A {
  public:
  virtual int f1() = 0;
};
class B : public A {
  public:
  virtual int f1() { return 1; }
  virtual int f2() { return 2; }
};
class C : public A {
  public:
  virtual int f1() { return -1; }
  virtual int f2() { return -2; }
};

A *x = new B;
A *y = new C;
A *z = new C;

现在是时候发挥一些特技了...

在运行时更改类:

std::swap(*(void **)x, *(void **)y);
// Now x is a C, and y is a B! Hope they used the same layout of members!

替换所有实例的方法(猴子补丁类)

这个有点棘手,因为虚函数表本身可能在只读内存中。

int f3(A*) { return 0; }

mprotect(*(void **)x,8,PROT_READ|PROT_WRITE|PROT_EXEC);
// Or VirtualProtect on win32; this part's very OS-specific
(*(int (***)(A *)x)[0] = f3;
// Now C::f1() returns 0 (remember we made x into a C above)
// so x->f1() and z->f1() both return 0

由于mprotect操作,后者很可能会使病毒检查器和链接程序注意到它。在使用NX位的进程中,它很可能会失败。


7
嗯,这个被悬赏了让人感到不祥的预兆。我希望这并不意味着@Mobilewits认为这样的小动作实际上是个好主意... - puetzk
1
请考虑明确而强烈地反对使用这种技术,而不是“眨眼”。 - einpoklum
"vtbl内容就是一个成员指针的数组",实际上它是一个记录(结构体),具有不同的条目,这些条目恰好均匀间隔。 - curiousguy
1
你可以从两个角度看待它;函数指针具有不同的签名,因此具有不同的指针类型;在这个意义上,这确实类似于结构体。但在其他上下文中,vtbl索引的概念是有用的(例如,在typelibs中描述双重接口时,ActiveX使用它),这是一个更像数组的视图。 - puetzk
但是请注意 - *** 表示一个指向函数指针的指针。我们将对象的数据重新解释为其 vtbl 的指针(即假设编译器选择将其放在第一位),然后对其进行解引用,并假定 vtbl 由函数指针数组组成,索引元素 [0],然后用指向 f3 的指针替换它。这些都不是 C++ 所保证的,但大多数编译器实现的方式通常是如此。研究这个可以满足你对深层次工作原理的好奇心,但不要真的使用这种技术! - puetzk
显示剩余2条评论

22

一个虚函数会减慢整个类的速度吗?

或者只会减慢调用该虚函数的速度?如果虚函数实际上被重写了,速度是否会受到影响,或者只要它是虚函数就没有影响。

拥有虚函数会减慢整个类的速度,因为在处理这样一个类的对象时需要初始化、复制等更多的数据项。对于一个包含半打成员的类,差异应该可以忽略不计。对于只包含单个char成员或根本没有成员的类,差异可能显著。

此外,需要注意的是,并非每次调用虚函数都是虚函数调用。如果你有一个已知类型的对象,编译器可以为普通函数调用生成代码,甚至可以内联该函数。只有当您通过指向基类对象或某个派生类对象的指针或引用进行多态调用时,才需要vtable间接调用,并以性能为代价。

struct Foo { virtual ~Foo(); virtual int a() { return 1; } };
struct Bar: public Foo { int a() { return 2; } };
void f(Foo& arg) {
  Foo x; x.a(); // non-virtual: always calls Foo::a()
  Bar y; y.a(); // non-virtual: always calls Bar::a()
  arg.a();      // virtual: must dispatch via vtable
  Foo z = arg;  // copy constructor Foo::Foo(const Foo&) will convert to Foo
  z.a();        // non-virtual Foo::a, since z is a Foo, even if arg was not
}

无论函数是否被覆盖,硬件需要执行的步骤基本相同。从对象中读取虚表的地址,从适当的插槽检索函数指针,并通过指针调用函数。就实际性能而言,分支预测可能会产生一些影响。例如,如果大多数对象都引用给定虚函数的相同实现,则在检索指针之前,分支预测器有一定机会正确地预测要调用哪个函数。但是,无论哪个函数是常见的,都没有关系:它可能是大多数对象委托给未覆盖的基本情况,或者是大多数对象属于同一个子类,因此委托给相同的已覆盖案例。

它们在深层次上是如何实现的?

我喜欢jheriko的想法,使用模拟实现来演示这一点。但我会使用C语言来实现类似上面代码的东西,以便更容易地看到底层。

父类Foo

typedef struct Foo_t Foo;   // forward declaration
struct slotsFoo {           // list all virtual functions of Foo
  const void *parentVtable; // (single) inheritance
  void (*destructor)(Foo*); // virtual destructor Foo::~Foo
  int (*a)(Foo*);           // virtual function Foo::a
};
struct Foo_t {                      // class Foo
  const struct slotsFoo* vtable;    // each instance points to vtable
};
void destructFoo(Foo* self) { }     // Foo::~Foo
int aFoo(Foo* self) { return 1; }   // Foo::a()
const struct slotsFoo vtableFoo = { // only one constant table
  0,                                // no parent class
  destructFoo,
  aFoo
};
void constructFoo(Foo* self) {      // Foo::Foo()
  self->vtable = &vtableFoo;        // object points to class vtable
}
void copyConstructFoo(Foo* self,
                      Foo* other) { // Foo::Foo(const Foo&)
  self->vtable = &vtableFoo;        // don't copy from other!
}

派生类Bar

typedef struct Bar_t {              // class Bar
  Foo base;                         // inherit all members of Foo
} Bar;
void destructBar(Bar* self) { }     // Bar::~Bar
int aBar(Bar* self) { return 2; }   // Bar::a()
const struct slotsFoo vtableBar = { // one more constant table
  &vtableFoo,                       // can dynamic_cast to Foo
  (void(*)(Foo*)) destructBar,      // must cast type to avoid errors
  (int(*)(Foo*)) aBar
};
void constructBar(Bar* self) {      // Bar::Bar()
  self->base.vtable = &vtableBar;   // point to Bar vtable
}

执行虚函数调用的函数f

void f(Foo* arg) {                  // same functionality as above
  Foo x; constructFoo(&x); aFoo(&x);
  Bar y; constructBar(&y); aBar(&y);
  arg->vtable->a(arg);              // virtual function call
  Foo z; copyConstructFoo(&z, arg);
  aFoo(&z);
  destructFoo(&z);
  destructBar(&y);
  destructFoo(&x);
}

因此,可以看出,vtable只是内存中的静态块,主要包含函数指针。每个多态类的对象都将指向其对应的动态类型的vtable。这也使得RTTI和虚函数之间的联系更加清晰:您可以通过查看它指向的vtable来检查类的类型。上述方式在许多方面(如多重继承)进行了简化,但总体概念是正确的。
如果arg是Foo*类型,并且您获取了arg->vtable,但实际上是Bar类型的对象,则仍然会获得vtable的正确地址。这是因为无论在正确类型的表达式中称为vtable还是base.vtable,vtable始终是对象地址的第一个元素。

每个多态类的对象都将指向自己的虚函数表。你是说每个对象都有自己的虚函数表吗?据我所知,虚函数表在同一类的所有对象之间是共享的。如果我错了,请告诉我。 - Bhuwan
1
@Bhuwan:不,你是对的:每个类型只有一个虚表(在模板的情况下可能会针对每个模板实例化)。我想说的是每个多态类的对象都指向适用于它的虚表,因此每个对象都有这样一个指针,但对于相同类型的对象,它将指向相同的表。也许我应该重新措辞一下。 - MvG
1
@MvG "对象类型相同,它将指向同一张表",但在具有虚基类的基类构造期间不是这样!(一个非常特殊的情况) - curiousguy
1
@curiousguy:我会将其归类为“上述内容在许多方面都有简化”,特别是由于虚拟基类的主要应用是多重继承,而我也没有进行建模。但感谢您的评论,对于可能需要更深入了解的人来说,这里很有用。 - MvG

5
通常使用VTable,它是一个指向函数的指针数组。

4
这里是一个在现代C++中实现虚表的可运行手册。它具有明确定义的语义,没有任何tricks和void*。
注意: .*和->*与*和->不同。成员函数指针的工作方式也不同。
#include <iostream>
#include <vector>
#include <memory>

struct vtable; // forward declare, we need just name

class animal
{
public:
    const std::string& get_name() const { return name; }

    // these will be abstract
    bool has_tail() const;
    bool has_wings() const;
    void sound() const;

protected: // we do not want animals to be created directly
    animal(const vtable* vtable_ptr, std::string name)
    : vtable_ptr(vtable_ptr), name(std::move(name)) { }

private:
    friend vtable; // just in case for non-public methods

    const vtable* const vtable_ptr;
    std::string name;
};

class cat : public animal
{
public:
    cat(std::string name);

    // functions to bind dynamically
    bool has_tail() const { return true; }
    bool has_wings() const { return false; }
    void sound() const
    {
        std::cout << get_name() << " does meow\n"; 
    }
};

class dog : public animal
{
public:
    dog(std::string name);

    // functions to bind dynamically
    bool has_tail() const { return true; }
    bool has_wings() const { return false; }
    void sound() const
    {
        std::cout << get_name() << " does whoof\n"; 
    }
};

class parrot : public animal
{
public:
    parrot(std::string name);

    // functions to bind dynamically
    bool has_tail() const { return false; }
    bool has_wings() const { return true; }
    void sound() const
    {
        std::cout << get_name() << " does crrra\n"; 
    }
};

// now the magic - pointers to member functions!
struct vtable
{
    bool (animal::* const has_tail)() const;
    bool (animal::* const has_wings)() const;
    void (animal::* const sound)() const;

    // constructor
    vtable (
        bool (animal::* const has_tail)() const,
        bool (animal::* const has_wings)() const,
        void (animal::* const sound)() const
    ) : has_tail(has_tail), has_wings(has_wings), sound(sound) { }
};

// global vtable objects
const vtable vtable_cat(
    static_cast<bool (animal::*)() const>(&cat::has_tail),
    static_cast<bool (animal::*)() const>(&cat::has_wings),
    static_cast<void (animal::*)() const>(&cat::sound));
const vtable vtable_dog(
    static_cast<bool (animal::*)() const>(&dog::has_tail),
    static_cast<bool (animal::*)() const>(&dog::has_wings),
    static_cast<void (animal::*)() const>(&dog::sound));
const vtable vtable_parrot(
    static_cast<bool (animal::*)() const>(&parrot::has_tail),
    static_cast<bool (animal::*)() const>(&parrot::has_wings),
    static_cast<void (animal::*)() const>(&parrot::sound));

// set vtable pointers in constructors
cat::cat(std::string name) : animal(&vtable_cat, std::move(name)) { }
dog::dog(std::string name) : animal(&vtable_dog, std::move(name)) { }
parrot::parrot(std::string name) : animal(&vtable_parrot, std::move(name)) { }

// implement dynamic dispatch
bool animal::has_tail() const
{
    return (this->*(vtable_ptr->has_tail))();
}

bool animal::has_wings() const
{
    return (this->*(vtable_ptr->has_wings))();
}

void animal::sound() const
{
    (this->*(vtable_ptr->sound))();
}

int main()
{
    std::vector<std::unique_ptr<animal>> animals;
    animals.push_back(std::make_unique<cat>("grumpy"));
    animals.push_back(std::make_unique<cat>("nyan"));
    animals.push_back(std::make_unique<dog>("doge"));
    animals.push_back(std::make_unique<parrot>("party"));

    for (const auto& a : animals)
        a->sound();

    // note: destructors are not dispatched virtually
}

2
您可以使用函数指针作为类的成员以及静态函数作为实现,或者使用成员函数指针和成员函数进行实现,从而在C++中重新创建虚函数的功能。这两种方法之间只存在符号优势...事实上,虚函数调用本身就是一种符号便利。事实上,继承只是一种符号便利...它完全可以在不使用继承的语言特性的情况下实现。 :)
以下是未经测试、可能存在缺陷的代码示例,但希望能够演示该思路。
例如:
class Foo
{
protected:
 void(*)(Foo*) MyFunc;
public:
 Foo() { MyFunc = 0; }
 void ReplciatedVirtualFunctionCall()
 {
  MyFunc(*this);
 }
...
};

class Bar : public Foo
{
private:
 static void impl1(Foo* f)
 {
  ...
 }
public:
 Bar() { MyFunc = impl1; }
...
};

class Baz : public Foo
{
private:
 static void impl2(Foo* f)
 {
  ...
 }
public:
 Baz() { MyFunc = impl2; }
...
};

void(*)(Foo*) MyFunc; 这是一些Java语法吗? - curiousguy
不,这是函数指针的 C/C++ 语法。引用我的话:“你可以使用函数指针在 C++ 中重新创建虚函数的功能”。虽然它是一种讨厌的语法,但如果你认为自己是 C 程序员的话,应该要熟悉它。 - jheriko
一个 C 函数指针看起来更像是:int (PROC)(); 而一个类成员函数的指针则看起来像是:int (ClassName:: MPROC)(); - Menace
1
@menace,你忘记了一些语法... 也许你想到的是typedef吧? typedef int(*PROC)(); 这样你就可以稍后使用PROC foo而不是int(*foo)()了? - jheriko

2

我会尽力让它简单易懂 :)

我们都知道C++中的虚函数,但是它们在深层次上是如何实现的?

这是一个指向函数的指针数组,这些函数是特定虚函数的实现。该数组中的索引表示类定义的特定虚函数的索引。这包括纯虚函数。

当一个多态类从另一个多态类派生时,我们可能会遇到以下情况:

  • 派生类既不添加新的虚函数也不覆盖任何虚函数。在这种情况下,该类与基类共享vtable。
  • 派生类添加并覆盖虚方法。在这种情况下,它将获得自己的vtable,其中添加的虚函数的索引从最后一个派生函数开始。
  • 继承中有多个多态类。在这种情况下,第二个和下一个基类之间存在索引偏移,并且在派生类中的索引。

能否在运行时修改或直接访问vtable?

没有标准方式 - 没有API可以访问它们。编译器可能具有一些扩展或私有API来访问它们,但那可能只是扩展。

vtable是否存在于所有类中,还是只存在于至少有一个虚函数的类中?

只存在于至少有一个虚函数(即使是析构函数)或继承至少一个具有其vtable的类(“是多态的”)的类中。

抽象类是否仅在至少一个条目的函数指针为NULL?

这是一种可能的实现方式,但不太常用。相反,通常有一个函数打印类似“调用纯虚函数”的内容,并执行abort()。如果您尝试在构造函数或析构函数中调用抽象方法,则可能会发生这种情况。

如果只有一个虚函数,是否会拖慢整个类?或者仅对虚函数的调用有影响?如果虚函数实际上被覆盖或未被覆盖,速度是否会受到影响,或者只要它是虚拟的,就没有影响?

减速仅取决于该调用是作为直接调用还是作为虚拟调用解析。其他任何事情都无关紧要。 :)

如果通过指向对象的指针或引用调用虚函数,则它将始终作为虚拟调用实现 - 因为编译器永远无法知道在运行时将分配给此指针的对象类型是什么,以及它是否属于覆盖此方法的类或不属于。只有在两种情况下,编译器才能将调用解析为直接调用:

  • 如果你通过一个值(一个变量或者一个返回值的函数)调用这个方法,在这种情况下,编译器就可以在编译时确定对象的真实类,从而进行"硬解析"。
  • 如果一个虚方法在你要通过指针或者引用调用的类中被声明为final (只有在C++11中)。在这种情况下,编译器知道这个方法不能被进一步重载,它只能是这个类的方法。

请注意,虚方法调用只需要两个指针来解除引用的开销。使用RTTI(尽管仅适用于多态类)比调用虚方法更慢,如果您发现有一种情况可以用两种方式实现同样的事情。例如,定义virtual bool HasHoof() { return false; },然后仅作为bool Horse::HasHoof() { return true; }覆盖,将为您提供调用if (anim->HasHoof())的能力,这将比尝试if(dynamic_cast<Horse*>(anim))要快。这是因为dynamic_cast在某些情况下甚至需要递归地遍历类层次结构,以查看是否可以从实际指针类型和所需的类类型构建路径。而虚调用始终相同-解引用两个指针。


2
这个答案已经被整合到社区Wiki答案中。
  • 抽象类是否只是在至少一个条目的函数指针上有一个NULL?
答案是未指定的 - 如果未定义(通常情况下不会定义),调用纯虚函数会导致未定义行为(ISO/IEC 14882:2003 10.4-2)。一些实现确实在vtable条目中放置了一个空指针;其他实现则放置了指向一个类似于断言的虚拟方法的指针。
请注意,抽象类可以为纯虚函数定义实现,但该函数只能使用限定符语法调用(即,在方法名称中完全指定类,类似于从派生类调用基类方法)。这样做是为了提供易于使用的默认实现,同时仍要求派生类提供覆盖。

此外,我认为抽象类不能为纯虚函数定义实现。根据定义,纯虚函数没有函数体(例如,bool my_func() = 0;)。但是,您可以为常规虚函数提供实现。 - Zach Burlingame
一个纯虚函数可以有定义。请参阅Scott Meyers的“Effective C ++,第3版”项目#34,ISO 14882-2003 10.4-2或http://bytes.com/forum/thread572745.html。 - Michael Burr

1

这里所有答案中未提到的一点是,如果存在多重继承,其中基类都有虚方法。那么派生类将拥有多个指向虚函数表的指针。 结果是每个此类对象实例的大小都会更大。 众所周知,具有虚方法的类会额外占用4个字节用于虚函数表,但在多重继承的情况下,每个具有虚方法的基类都会额外占用4个字节。4表示指针的大小。


1
每个对象都有一个指向成员函数数组的虚表指针。

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