你有没有遇到这么一个场景------
你写了一个基类指针,指向子类对象,然后调一个虚函数,心里美滋滋:"这就是多态!这就是面向对象!"
结果面试官轻飘飘一句:"那你说说,编译器是怎么知道该调用哪个函数的?"
你当场石化,大脑一片空白,最后憋出一句:"......靠......靠爱发电?"
别慌,今天我们就来瞅瞅那个男人的真面目------虚函数表(vtable)。
你可以把它想象成手机里的通讯录:每个有虚函数的类都有一个通讯录,上面记录着所有虚函数的电话号码。当你用基类指针调用函数时,程序并不直接喊名字,而是先去翻这个通讯录:"喂,这个函数应该找谁?" 然后根据指针指向的实际对象,找到对应的函数入口。
好了,废话不多说,让我们进入正题。
虚函数表
C++中的虚函数(virtual function)是实现运行时多态的核心机制。它允许通过基类指针或引用调用派生类重写的函数,具体调用哪个版本在运行时根据对象的实际类型决定。这一机制底层依赖于虚函数表(vtable)和虚指针(vptr)。
原理
我们先来看一段代码:
c++
#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;
}
程序输出:
csharp
sizeof int: 4
sizeof Base: 8
b is 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,之后就能拿这个地址去访问虚函数表下的函数了。
c++
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;
}
程序输出:
arduino
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++中,虚函数的继承关系是多态机制的核心。理解它有助于正确设计类的层次结构,并避免常见的陷阱。
先来看一段代码:
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虚函数表中的函数:
c++
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;
}
程序输出:
arduino
Derived::f()
Base::g()
Base::h()
Derived::g1()
好,可以看到打印出的结果一样,就不过多赘述了。
多重继承
接下来我们看看多重继承的情况,我们假设一个子类继承了两个基类: 
那么Derived虚函数表结构如图: 
可以看到,子类继承了两个基类并且拥有两张虚函数表。
但子类自己新增的虚函数被放到了第一个父类的表中,这是为了保证通过第一个基类指针正确调用这些新增虚函数,同时避免在其他基类的虚表中存放无用条目,简化了实现。
依旧看一段代码:
c++
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;
}
程序输出:
arduino
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 的继承路径),并引发访问冲突。
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类的成员。
c++
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)的主要作用是:确保通过基类指针或引用删除子类对象时,能够正确地调用子类的析构函数,从而完整地释放子类部分的资源,避免内存泄漏。
我们先定义一个基类和子类:
c++
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)动态绑定到实际对象的析构函数:
c++
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
可以看到派生类和基类的资源都被正确释放了。