25-6 虚表

请考虑以下程序:

#include <iostream>
#include <string_view>

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

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;
}

image

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

尽管看起来几乎相同,但 base.getNameVirtual() 的调用必须通过不同方式解析。由于这是虚拟函数调用,编译器必须使用基类的动态类型来解析调用,而基类的动态类型在运行时之前无法确定。因此,只有在运行时才能确定此特定调用base.getNameVirtual()将解析为派生类::getNameVirtual(),而非基类::getNameVirtual()。

那么虚拟函数究竟是如何工作的呢?


虚拟表

C++标准并未规定虚拟函数的具体实现方式(该细节由具体实现决定)。

然而,C++实现通常采用一种称为虚拟表的延迟绑定机制来实现虚拟函数。

虚函数表virtual table是用于动态/延迟绑定方式解析函数调用的查找表。该表有时也称为“vtable”、‘虚函数表’、“虚方法表”或“分派表”。在C++中,虚函数解析有时被称为动态分派dynamic dispatch

术语说明:
以下是理解C++中概念的简化方式:
早期绑定/静态分派 = 直接函数调用重载解析
后期绑定 = 间接函数调用解析
动态分派 = 虚函数重载解析

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

虚函数表的原理其实相当简单,只是用文字描述起来稍显复杂。首先,每个使用虚函数的类(或从使用虚函数的类派生而来的类)都拥有对应的虚函数表。该表本质上是由编译器在编译时动态生成的静态数组。虚拟表包含该类对象可调用的每个虚拟函数的条目。每个条目本质上是一个函数指针,指向该类可访问的最派生函数。

其次,编译器还会在基类中添加一个隐藏的指针成员,我们称之为__vptr。当类对象创建时,__vptr会被自动设置为指向该类的虚函数表。与实际作为函数参数用于解析自我引用的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的虚函数表;构造D1或D2类对象时,*__vptr则分别指向D1或D2的虚函数表。

现在我们来讨论这些虚拟表如何填充。由于这里只有两个虚函数,每个虚拟表将包含两个条目(分别对应 function1() 和 function2())。请注意,填充虚拟表时,每个条目都指向该类对象可调用的最高派生函数。

基类对象的虚拟表结构简单。基类对象只能访问基类的成员。基类无法访问D1或D2的函数。因此function1条目指向Base::function1(),function2条目指向Base::function2()。

D1类的虚函数表稍复杂些。D1类对象既可访问自身成员,也可访问基类成员。但D1已重写function1(),使得D1::function1()比Base::function1()更具派生性。因此function1条目指向D1::function1()。由于D1未重写function2(),其条目指向Base::function2()。

D2的虚函数表与D1类似,区别在于:function1条目指向Base::function1(),而function2条目指向D2::function2()。

以下是该结构的示意图:

image

尽管这个图看起来有些复杂,其实原理很简单:每个类的*__vptr都指向该类的虚函数表。虚函数表中的条目指向该类对象允许调用的最高派生版本函数。

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

int main()
{
    D1 d1 {};
}

由于 d1 是 D1 对象,其 *__vptr 已设置为指向 D1 虚拟表。

现在,让我们将基指针设置为指向 D1:

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

    return 0;
}

请注意,由于 dPtr 是基类指针,它仅指向 d1 的基类部分。但同时需注意 __vptr 位于类的基类部分,因此 dPtr 可访问该指针。最后需注意,dPtr->__vptr 指向 D1 的虚函数表!因此,尽管dPtr的类型是Base,它仍可访问D1的虚函数表(通过__vptr)。

那么当我们尝试调用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 指向 b,因此 bPtr->__vptr 同样指向基类 Base 的虚拟函数表。基类Base的函数表条目function1()指向Base::function1()。因此,bPtr->function1()最终解析为Base::function1()——这是基类对象能调用的最高派生版本的function1()。

通过这些函数表,编译器和程序能确保函数调用准确定位到对应的虚函数,即使你仅使用基类的指针或引用也是如此!

调用虚函数比调用非虚函数更耗时,原因有二:首先,我们需要通过 *__vptr 获取对应的虚函数表。其次,我们需要对虚拟表进行索引以查找正确的函数。只有这样才能调用函数。因此,我们需要执行3次操作来查找要调用的函数,而普通间接函数调用只需2次操作,直接函数调用则只需1次操作。不过,在现代计算机上,这额外的时间消耗通常微不足道。

另需提醒:任何使用虚函数的类都包含*__vptr,因此该类对象的大小会增加一个指针的空间。虚函数功能强大,但确实存在性能代价。

posted @ 2026-02-04 00:27  游翔  阅读(1)  评论(0)    收藏  举报