前两天帮同事 review 一段 C++ 代码,继承体系里子类想“重写”父类的某个虚函数,结果测试时发现怎么调都是进父类版本。代码编译通过、运行不报错,甚至连 -Wall 都没给出像样的提示,纯粹是行为不符合预期。我扫了一眼函数声明就明白了——参数类型不一致,int 被写成了 double。这个场景特别典型:函数重载、函数签名、虚函数表(vtable)三个机制在同一个类层级里相遇时,任何一个环节的认知有偏差,都会出现这种“编译期风平浪静、运行期南辕北辙”的问题。
这篇文章想做的,是把虚函数表(vtable)、函数重载、函数签名这三件事放在同一张桌上讲清楚。它们分别负责什么、如何协作、又会在哪里互相干扰。无论你是刚接触 C++ 的初学者,还是在面试前想梳理多态底层的候选人,希望这篇能帮你把零散的知识点收拢成一个整体框架,而不是再靠背八股应付。
1. 一段“静默失效”代码引出的三角关系:重载、覆盖与虚表
1.1 问题代码复现
先看这段代码,我建议你自己跑一下,结果可能和直觉有出入:
cpp复制#include <iostream>
class Base {
public:
virtual void fun(int x) {
std::cout << "Base::fun(int) -> " << x << std::endl;
}
};
class Derived : public Base {
public:
// 注意:参数类型是 double,不是 int
void fun(double x) {
std::cout << "Derived::fun(double) -> " << x << std::endl;
}
};
int main() {
Derived d;
Base& ref = d;
ref.fun(10); // Base::fun(int) -> 10
d.fun(10); // Derived::fun(double) -> 10
return 0;
}
很多人第一眼会以为 ref.fun(10) 走动态绑定,ref 的实际类型是 Derived,所以应该调用 Derived::fun。但实际输出是 Base::fun(int)。
原因一句话就能概括:Derived::fun(double) 和 Base::fun(int) 的函数签名不同,所以它没有覆盖虚函数表中的槽位,只是在 Derived 作用域里隐藏了基类的同名函数。
这个例子把三个概念全部串起来了:
- 函数签名决定“哪个函数是哪个函数”;
- 函数重载让同名不同签名的函数在同一作用域共存;
- 虚函数表的槽位是否被覆写,取决于派生类函数与基类虚函数的签名是否一致。
1.2 三个机制各自的职责边界
| 机制 | 发生阶段 | 决定“该调哪个函数”的依据 | 典型问题 |
|---|---|---|---|
| 函数签名 | 编译期 | 函数名、参数类型列表、cv/ref 限定 | 误以为返回类型参与签名 |
| 函数重载 | 编译期 | 同作用域同名函数,靠签名区分 | 重载决议产生二义性 |
| 虚函数表 | 运行期 | 对象动态类型(vptr -> vtable) | 签名不一致导致覆盖失效 |
表格里最关键的一行是第三行:虚函数表本身不关心“函数名”,它只关心槽位。编译器在编译期根据函数签名把虚函数放进 vtable 的某个偏移位置,派生类覆盖时也按签名
