虚函数表:C++ 多态背后的那个男人

0 阅读10分钟

你有没有遇到这么一个场景——

你写了一个基类指针,指向子类对象,然后调一个虚函数,心里美滋滋:“这就是多态!这就是面向对象!”
结果面试官轻飘飘一句:“那你说说,编译器是怎么知道该调用哪个函数的?”
你当场石化,大脑一片空白,最后憋出一句:“……靠……靠爱发电?”

别慌,今天我们就来瞅瞅那个男人的真面目——虚函数表(vtable)。

你可以把它想象成手机里的通讯录:每个有虚函数的类都有一个通讯录,上面记录着所有虚函数的电话号码。当你用基类指针调用函数时,程序并不直接喊名字,而是先去翻这个通讯录:“喂,这个函数应该找谁?” 然后根据指针指向的实际对象,找到对应的函数入口。

好了,废话不多说,让我们进入正题。

虚函数表

C++中的虚函数(virtual function)是实现运行时多态的核心机制。它允许通过基类指针或引用调用派生类重写的函数,具体调用哪个版本在运行时根据对象的实际类型决定。这一机制底层依赖于虚函数表(vtable)和虚指针(vptr)。

原理

我们先来看一段代码:

#include <iostream>

class Base
{
public:
    virtual void f() { std::cout << "Base::f()" << std::endl; }
    virtual void g() { std::cout << "Base::g()" << std::endl; }
    virtual void h() { std::cout << "Base::h()" << std::endl; }
    int a;
};

int main()
{
    std::cout << "sizeof int: " << sizeof(int*) << std::endl;
    std::cout << "sizeof Base: " << sizeof(Base) << std::endl;

    Base b;
    b.a = 1024;
    std::cout << "b is address: " << (int*)(&b) << std::endl;

    return 0;
}

程序输出:

sizeof int4
sizeof Base: 8is address: 003AFBE8

我们可以看到在Base类中有一个int类型的成员a,大小为4,但为什么sizeof(Base)大小却为8?

因为含虚函数的类对象必须容纳至少一个虚指针(vptr),它占4字节(64位,因编译器不同可能是8字节),且受对齐影响可能插在开头或中间。

(这个根据不同的机器所占的字节数不一样,在64位机器上int为4字节,虚函数表地址可能为8字节,8+4 = 12字节,但是要遵循补齐原则,结构体的大小要为最大成员大小的整数倍,所以要补齐4字节,那么8+4+4 = 16字节。)

然后我们拿到了虚函数表地址:003AFBE8,之后就能拿这个地址去访问虚函数表下的函数了。

typedef void(*Func)(void);

int main()
{
    Base b;
    Func pf = (Func)(*(int*)*(int*)(&b));
    pf();

    pf = (Func)(*((int*)*(int*)(&b)+1));
    pf();

    pf = (Func)(*((int*)*(int*)(&b) + 2));
    pf();
    return 0;
}

程序输出:

Base::f()
Base::g()
Base::h()

好吧,这段代码确实看起来令人头疼。(因各编译器优化不同,在64位系统中运行可能会报错,可尝试32位系统)

我们定义了一个void()(void)函数指针类型的别名Func,避免每次声明函数指针时都重复void()(void)这种复杂写法。

接下来看这段代码Func pf = (Func)((int)(int)(&b)):

  • &b:取对象 b 的地址,类型为 Base*。
  • (int*)(&b):将 Base* 强制转换为 int*,此时指针指向对象起始位置,即vptr所在处。
  • (int)(&b):解引用这个 int*,得到 vptr 的值(虚函数表的地址)。
  • (int*)((int)(&b)): 将vptr的值强制转换为int*。
  • *(...):解引用这个指针,得到虚函数表的第一个条目(即第一个函数指针)。
  • 最后用 (Func) 将该 int 值强制转换为函数指针类型 Func。

因此 pf 指向了 Base::f() 的地址。调用 pf() 输出 "Base::f()"。

pf = (Func)(((int)(int)(&b)+1)):

  • (int*)(int)(&b)与上一步相同,得到指向虚函数表的指针。
  • +1:指针加 1,由于是 int*,移动 4 字节,指向虚函数表的第二个条目(即 g 的地址)。
  • *(...):解引用,得到第二个函数指针的值。

因此调用后输出Base::g()。

继承关系

上回我们说到,每个有虚函数的类都有一个通讯录——也就是虚函数表(vtable)。里面记着所有虚函数的地址,多态调用时,程序就靠它找到正确的函数。

但问题来了:当类与类之间产生了“血缘关系”(继承),这些通讯录是怎么传承的?

你可以把基类想象成那些努力打拼创业的大佬,他们有一套独属于自己的方法论。子类继承了家业后,有两种选择:

  • 老老实实的继承:父辈怎么搞,我就怎么搞。(不重写)
  • 改革创新:老登!你那方法过时了,我要闯出一片新天地。(重写/覆盖)

单继承

在C++中,虚函数的继承关系是多态机制的核心。理解它有助于正确设计类的层次结构,并避免常见的陷阱。

先来看一段代码:

class Base
{
public:
    virtual void f() { std::cout << "Base::f()" << std::endl; }
    virtual void g() { std::cout << "Base::g()" << std::endl; }
    virtual void h() { std::cout << "Base::h()" << std::endl; }
    int a;
};

class Derived : public Base
{
public:
    virtual void f() { std::cout << "Derived::f()" << std::endl; }
    virtual void g1() { std::cout << "Derived::g1()" << std::endl; }
    virtual void h1() { std::cout << "Derived::h1()" << std::endl; }

};

这个继承关系中,Derived重写了f()函数,还新加了g1()和h1()函数。

Derived虚函数表结构如图:


因为函数f被Derive重写,所以Derive的虚函数表存储的是自己重写的f()。
而虚函数g()和h()没有被Derive重写,所以Derive虚函数表存储的是基类的g()和h()。
另外Derive虚函数表里也存储了自己特有的虚函数g1()和h1()。

下面我们用寻址的方式调用一下Derived虚函数表中的函数:

int main()
{
    Derived d;
    Func pf = (Func)(*(int*)*(int*)(&d));
    pf();

    pf = (Func)(*((int*)*(int*)(&d)+1));
    pf();

    pf = (Func)(*((int*)*(int*)(&d) + 2));
    pf();

    pf = (Func)(*((int*)*(int*)(&d) + 3));
    pf();
    return 0;
}

程序输出:

Derived::f()
Base::g()
Base::h()
Derived::g1()

好,可以看到打印出的结果一样,就不过多赘述了。

多重继承

接下来我们看看多重继承的情况,我们假设一个子类继承了两个基类:
那么Derived虚函数表结构如图:
可以看到,子类继承了两个基类并且拥有两张虚函数表。

但子类自己新增的虚函数被放到了第一个父类的表中,这是为了保证通过第一个基类指针正确调用这些新增虚函数,同时避免在其他基类的虚表中存放无用条目,简化了实现。

依旧看一段代码:

class Base1
{
public:
    virtual void f() { std::cout << "Base1::f()" << std::endl; }
    virtual void g() { std::cout << "Base1::g()" << std::endl; }
    virtual void h() { std::cout << "Base1::h()" << std::endl; }
};

class Base2
{
public:
    virtual void f() { std::cout << "Base2::f()" << std::endl; }
    virtual void g() { std::cout << "Base2::g()" << std::endl; }
    virtual void h() { std::cout << "Base2::h()" << std::endl; }
};

class Derived : public Base1, public Base2
{
public:
    virtual void f() { std::cout << "Derived::f()" << std::endl; }
    virtual void g1() { std::cout << "Derived::g1()" << std::endl; }
    virtual void h1() { std::cout << "Derived::h1()" << std::endl; }

};

int main()
{
    Derived* d = new Derived();
    Base1* b1 = *(&d);
    Base2* b2 = *(&d);

    b1->f(); // Derived::f()
    b2->f(); // Derived::f()

    b1->g(); // Base1::g()
    b2->g(); // Base2::g()

    // 调用新增虚函数(使用主虚表)
    d->g1(); // Derived::g1()
    d->h1(); // Derived::h1()

    delete d;
    return 0;
}

程序输出:

Derived::f()
Derived::f()
Base1::g()
Base2::g()
Derived::g1()
Derived::h1()

总结:

  • 多重继承下,子类覆盖一个虚函数时,会在所有包含该虚函数的基类的虚表中更新对应条目。
  • 未被覆盖的虚函数在各基类虚表中保持不变,因此通过不同基类指针调用会得到各自基类的实现。
  • 新增的虚函数通常只出现在第一个基类的虚表中。

这种行为确保了通过任何基类指针都能正确调用被覆盖的函数,同时保留了各基类独立的未覆盖函数。

根据前面的图示和代码案例,我们可以清晰地看到多态调用的原理在多重继承下的具体体现。

具体的说就是通过基类指针或引用调用虚函数时,程序在运行时根据对象的实际类型决定调用哪个函数。

菱形继承问题

菱形继承(Diamond Inheritance)是面向对象编程中多继承引发的一个经典问题,主要出现在C++这类支持多继承的语言中。它描述了这样一种继承结构:一个派生类同时继承自两个基类,而这两个基类又共同继承自同一个基类,形成类似于菱形的继承关系图。

  • 类 A 是顶层基类。
  • 类 B 和类 C 分别继承自 A。
  • 类 D 同时继承自 B 和 C。

此时,类 D 中会包含两份 A 的子对象(分别来自 B 和 C 的继承路径),并引发访问冲突。

class A 
{
public:
    int _value;
    A(int value = 0) : _value(value) {}
    void show() const { std::cout << "_value = " << _value << std::endl; }
};

class B : public A 
{
public:
    B(int value = 10) : A(value) {}
};

class C : public A 
{
public:
    C(int value = 20) : A(value) {}
};

class D : public B, public C 
{
public:
    D(int value = 30) : B(value), C(value) {}
};

int main() {
    D d;
    // d._value = 30; 编译错误,_value不明确
    // d.show(); 编译错误

    // 必须指定路径:
    d.B::_value = 100;     // 修改B上的A
    d.C::show();          // 调用C上的show,输出value = 30

    return 0;
}

在无虚继承的情况下,D类将拥有两份A的成员变量,这会导致二义性问题。
我们可以通过虚继承(Virtual Inheritance),确保D类只有一份A类的成员。

class A 
{
    /*...省略...*/
};

class B : virtual public A // 虚继承
{
public:
    B(int value = 10) : A(value) {}
};

class C : virtual public A // 虚继承
{
public:
    C(int value = 20) : A(value) {}
};

class D : public B, public C 
{
public:
    D(int value = 30) : B(value), C(value) {}
};

int main() {
    D d;
    d._value = 30// 直接访问,无二义性
    d.show(); // 输出value = 30

    return 0;
}

我们通过虚继承,D 中只存在一个 A 子对象,所有对 A 成员的访问都指向这个共享实例,既解决了二义性,也消除了数据冗余。

不过虚继承会增加一定的开销,需根据具体需求权衡使用。

虚析构函数的作用

在C++中,将基类的析构函数声明为虚函数(virtual)的主要作用是:确保通过基类指针或引用删除子类对象时,能够正确地调用子类的析构函数,从而完整地释放子类部分的资源,避免内存泄漏。

我们先定义一个基类和子类:

class Base 
{
public:
    ~Base() { std::cout << "Base destructor" <<std::endl; }
};

class Derived : public Base 
{
public:
    ~Derived() { std::cout << "Derived destructor" << std::endl; }
};

int main() 
{
    Base* b = new Derived();
    delete b;  // 通过基类指针删除派生类对象

    return 0;
}

程序输出:

Base destructor

可以看到Derived的析构函数没有被调用,这意味着子类中可能分配的资源(如动态内存、文件句柄等)将不会被释放,从而导致资源泄漏。

我们把基类析构函数声明为virtual后,C++运行时将通过虚函数表(vtable)动态绑定到实际对象的析构函数:

class Base 
{
public:
    virtual ~Base() { std::cout << "Base destructor" <<std::endl; }
};

class Derived : public Base 
{
public:
    ~Derived() { std::cout << "Derived destructor" << std::endl; }
};


int main() 
{
    Base* b = new Derived();
    delete b; 

    return 0;
}

此时执行 delete p; 会先调用Derived的析构函数,再自动调用Base的析构函数(遵循派生类到基类的析构顺序)。输出为:

Derived destructor
Base destructor

可以看到派生类和基类的资源都被正确释放了。