虚表

考虑以下程序:

#include <iostream>
#include <string_view>

class Base
{
public:
    std::string_view getName() const { return "Base"; }                // 非虚函数
    virtual std::string_view getNameVirtual() const { return "Base"; } // 虚函数
};

class Derived: public Base
{
public:
    std::string_view getName() const { return "Derived"; }
    virtual std::string_view getNameVirtual() const override { return "Derived"; }
};

int main()
{
    Derived derived {};
    Base& base { derived };

    std::cout << "base has static type " << base.getName() << '\n';
    std::cout << "base has dynamic type " << base.getNameVirtual() << '\n';

    return 0;
}

首先,我们来看对 base.getName() 的调用。由于这是一个非虚函数,编译器可以使用 base 的实际类型(Base)来确定(在编译时)这应该解析为 Base::getName()

尽管它看起来几乎相同,但对 base.getNameVirtual() 的调用必须以不同的方式解析。由于这是一个虚函数调用,编译器必须使用 base 的动态类型来解析调用,而 base 的动态类型直到运行时才可知。因此,只有在运行时才会确定这个特定的 base.getNameVirtual() 调用解析为 Derived::getNameVirtual(),而不是 Base::getNameVirtual()

那么虚函数到底是如何工作的呢?


虚表

C++ 标准没有指定虚函数应该如何实现(这一细节留给实现者自行决定)。

然而,C++ 实现通常使用一种称为 虚表 的晚期绑定形式来实现虚函数。

虚表是一个用于以动态/晚期绑定方式解析函数调用的查找表。虚表有时也被称为“vtable”、“虚函数表”、“虚方法表”或“调度表”。在 C++ 中,虚函数解析有时被称为动态调度。

术语

这里有一个更简单的方法来理解 C++ 中的这些概念:

  • 早期绑定/静态调度 = 直接函数调用重载解析
  • 晚期绑定 = 间接函数调用解析
  • 动态调度 = 虚函数重写解析

由于了解虚表的工作原理并非使用虚函数的必要条件,因此可以将本节视为选读内容。

虚表实际上相当简单,尽管用文字描述它有点复杂。首先,每个使用虚函数的类(或从使用虚函数的类派生的类)都有一个对应的虚表。这个表只是一个编译器在编译时设置的静态数组。虚表为类对象可以调用的每个虚函数包含一个条目。表中的每个条目只是一个函数指针,指向该类可以访问的最派生函数。

其次,编译器还会在使用虚函数的最基类中添加一个隐藏的指针成员,我们将其称为 __vptr__vptr 在类对象创建时(自动)设置,以便它指向该类的虚表。与 this 指针不同(this 指针实际上是编译器用于解析自引用的函数参数),__vptr 是一个真正的指针成员。因此,它会使每个类对象分配的大小增加一个指针的大小。这也意味着 __vptr 会被派生类继承,这是很重要的。

到目前为止,你可能对这些内容如何组合在一起感到困惑,因此我们来看一个简单的例子:

class Base
{
public:
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    void function1() override {};
};

class D2: public Base
{
public:
    void function2() override {};
};

由于这里有 3 个类,编译器将设置 3 个虚表:一个用于 Base,一个用于 D1,一个用于 D2

编译器还会在使用虚函数的最基类中添加一个隐藏的指针成员。尽管编译器会自动完成这一操作,但我们在下一个例子中将其加入,只是为了展示它被添加的位置:

class Base
{
public:
    VirtualTable* __vptr;
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    void function1() override {};
};

class D2: public Base
{
public:
    void function2() override {};
};

当创建类对象时,__vptr 被设置为指向该类的虚表。例如,当创建一个 Base 类型的对象时,__vptr 被设置为指向 Base 的虚表。当创建 D1D2 类型的对象时,__vptr 被设置为分别指向 D1D2 的虚表。

现在,我们来谈谈这些虚表是如何填充的。由于这里只有两个虚函数,每个虚表将有两个条目(一个用于 function1(),一个用于 function2())。记住,当这些虚表被填充时,每个条目都用该类类型的对象可以调用的最派生函数填充。

Base 对象的虚表很简单。Base 类型的对象只能访问 Base 的成员。Base 无法访问 D1D2 的函数。因此,function1 的条目指向 Base::function1()function2 的条目指向 Base::function2()

D1 的虚表稍微复杂一些。D1 类型的对象可以访问 D1Base 的成员。然而,D1 重写了 function1(),使 D1::function1()Base::function1() 更派生。因此,function1 的条目指向 D1::function1()D1 没有重写 function2(),因此 function2 的条目将指向 Base::function2()

D2 的虚表与 D1 类似,只是 function1 的条目指向 Base::function1()function2 的条目指向 D2::function2()

图形化表示:

Code::Blocks安装

虽然这个图表看起来有点复杂,但实际上它非常简单:每个类的 __vptr 指向该类的虚表。虚表中的条目指向该类的对象可以调用的最派生版本的函数。

那么,当我们创建一个 D1 类型的对象时会发生什么:

int main()
{
    D1 d1 {};
}

由于 d1 是一个 D1 类型的对象,d1__vptr 被设置为指向 D1 的虚表。

现在,我们用基类指针指向 D1

int main()
{
    D1 d1 {};
    Base* dPtr = &d1;

    return 0;
}

请注意,由于 dPtr 是一个基类指针,它只指向 d1Base 部分。然而,还要注意 __vptr 在类的 Base 部分中,因此 dPtr 可以访问这个指针。最后,要注意 dPtr->__vptr 指向 D1 的虚表!因此,即使 dPtrBase* 类型,它仍然可以通过 __vptr 访问 D1 的虚表。

那么,当我们尝试调用 dPtr->function1() 时会发生什么?

int main()
{
    D1 d1 {};
    Base* dPtr = &d1;
    dPtr->function1();

    return 0;
}

首先,程序识别出 function1() 是一个虚函数。其次,程序使用 dPtr->__vptr 来获取 D1 的虚表。第三,在 D1 的虚表中查找要调用的 function1() 的版本。这被设置为 D1::function1()。因此,dPtr->function1() 解析为 D1::function1()

现在,你可能会问:“但如果 dPtr 真的指向一个 Base 对象而不是 D1 对象呢?它还会调用 D1::function1() 吗?”答案是不会。

int main()
{
    Base b {};
    Base* bPtr = &b;
    bPtr->function1();

    return 0;
}

在这种情况下,当创建 b 时,b.__vptr 指向 Base 的虚表,而不是 D1 的虚表。由于 bPtr 指向 bbPtr->__vptr 也指向 Base 的虚表。Base 的虚表条目 function1() 指向 Base::function1()。因此,bPtr->function1() 解析为 Base::function1(),这是 Base 对象可以调用的 function1() 的最派生版本。

通过使用这些表,编译器和程序能够确保函数调用解析到适当的虚函数,即使你只使用基类的指针或引用!

调用虚函数比调用非虚函数慢,原因有几点:首先,我们需要使用 __vptr 来获取适当的虚表。其次,我们需要索引虚表以找到要调用的正确函数。只有这样,我们才能调用该函数。因此,我们需要执行 3 个操作来找到要调用的函数,而正常的间接函数调用需要 2 个操作,直接函数调用只需要 1 个操作。然而,对于现代计算机来说,这种额外的时间通常是微不足道的。

另外,需要提醒的是,使用虚函数的任何类都有一个 *__vptr,因此该类的每个对象都会因一个指针而变大。虚函数功能强大,但它们确实有性能成本。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"