聊到 C++,有三样东西候选人一定要分清楚:虚函数表(vtable)、函数重载、函数签名。看起来像三个独立知识点,背起来也不难,可真拿到具体代码里问一句“为什么这里调错了函数”,很多人就开始含糊了。其实这三者是一条主线上的事:编译器用什么唯一标识一个函数(函数签名),编译期怎么在多个同名函数里挑一个出来(函数重载),运行期又是怎么根据对象的真实类型找到该执行的实现(虚函数表 vtable)。搞懂这条线,你才算真正跨过了 C++ 面向对象那道坎。这篇文章适合正在系统学习 C++ 的人、被面试八股折磨的求职者,以及想排查线上诡异分发问题的开发者。
1. 先把“函数签名”这件事讲透
1.1 编译器和人认识函数的方式不一样
人看到下面两个函数,会觉得它俩都叫 add,于是容易混:
void add(int a, int b);
void add(double a, double b);
但对编译器来说,它们从进入编译流程的那一刻起就是两个完全不同的东西。C++ 编译器会为每个函数生成一个“修饰名”(mangled name),也就是把函数名和参数类型这些信息编码成一个唯一符号。比如在 Linux 上使用 GCC/Clang 的 Itanium ABI,void add(int, int) 会被修饰成类似 _Z3addii 的符号,void add(double, double) 则是另一个符号 _Z3adddd。二者底层符号不同,链接器分得清清楚楚。
为什么会这样?因为 C++ 允许“同名函数”存在,如果只按名字记录,整个链接过程就会乱套。所以编译器必须有一套规则,把一个函数从语言层面的“名字”扩展成具备唯一性的“完整身份”。这套身份,就是函数签名。
提示:我这里说的“函数签名”不是指函数声明长得像什么样,而是指编译器用于区分函数的关键属性集合。签名一致,两个声明就是同一个函数;签名不同,哪怕函数名相同,也是不同函数。
1.2 函数签名到底由哪些东西组成
严格来说,一个普通函数的签名由下面这些信息共同决定:
- 函数名
- 参数的个数
- 每个参数的类型
- 参数的顺序
- 对于成员函数,类作用域本身也是身份的一部分
这里有一个关键点:签名不包含返回类型。所以 void f(int) 和 int f(int) 不能共存,因为编译器认为它们要表达的是同一个函数,返回类型不同会导致定义冲突。
成员函数会比普通函数多一层信息:const、volatile、引用限定符(& / &&)。这些会参与签名认定。同一个类里可以同时存在 void show() 和 void show() const,因为从编译器视角看,这两个函数的隐式 this 参数类型不一样:
cpp复制class Printer {
public:
void show(); // this 是 Printer*
void show() const; // this 是 const Printer*
void show(int); // 参数不同,又是一个新签名
void show(double); // 还能继续重载
};
这里的 show() 与 show() const 并不是“重复声明”,而是两个合法的重载。面试里常考这一点,很多人只记得“成员函数可以加 const 重载”,但说不清底层原因。实际原因就是 this 指针类型不同,两个函数签名不同。
1.3 为什么返回类型不能参与重载
我们反过来想:如果允许 void f(int) 和 int f(int) 一起存在,调用 f(1) 时编译器怎么知道你想要哪个返回值版本?C++ 在调用点选择重载时,主要依据的是实参,而不是你对返回值的期望。如果你写 int x = f(1); 且只有一个 int f(int),那是赋值兼容;如果同时存在 void f(int) 和 int f(int),光凭 f(1) 这种表达式根本无法从两个候选里选出一个。
有些语言尝试过从返回值推导重载,但 C++ 没有走这条路。它的设计哲学是:函数调用本身必须能自洽地决定调用目标。返回类型是调用之后才用到的信息,不能提前
