有段时间。
大家写“面向对象”。
但代码看起来还是 C。
一个 struct。
再塞几根函数指针。
能跑。
也挺灵活。
然后某天。 线上炸了。
你去看 core dump。
发现是一个空指针。
指向一张你以为“永远不会空”的表。
还没有 vtable 的年代:多态靠人肉
上世纪八十年代前后,系统软件的主力还是 C。你想写“框架”,让别人来扩展功能,最顺手的工具就是函数指针。
你会把一组操作塞进一个 struct。这里我把它叫 ShapeVTable,你可以把它当成“这类对象能做什么”的菜单。
typedef struct Shape Shape;
typedef struct {
void (*draw)(Shape*);
} ShapeVTable;
struct Shape {
const ShapeVTable* vptr;
};
static void draw(Shape* s) {
s->vptr->draw(s);
}
对象里放一根指针,指向那张表。调用的时候先取表,再取函数地址,再跳过去。
我这里先借用一个名字:vptr(virtual pointer 的缩写)。在 C 里当然没有 virtual,但这个指针干的活,跟后来的 vptr 很像。
它指向一张“函数指针表”。你可以先把它理解成:这个对象该用哪套实现。
坑也很朴素:表没填,或者填错了
表是人填的,人就会忘。你只要漏掉一次初始化,炸的往往不是编译期,而是运行时。
static void circle_draw(Shape* s) {
(void)s;
}
static const ShapeVTable circle_vt = { circle_draw };
int main() {
Shape s = { 0 }; // 忘了把 vptr 设成 &circle_vt
draw(&s); // boom
}
忘了初始化 vptr,就是空指针解引用。你会在一个很远的地方看到崩溃,而不是在这里得到一个友好的错误。
更阴一点的是“填错”:两张表字段顺序不一致,或者某个函数签名改了但另一边没同步。它能编译,能链接,然后用一种很含蓄的方式把你叫回去加班。
一句话。
在 C 时代,多态靠约定。
C++ 的做法:把“这张表”变成制度
Bjarne Stroustrup 当年在 Bell Labs 做系统软件。需求很朴素:想要 Simula 那套“类/继承”,又必须跟 C 的世界混着跑。
所以 C++ 没有否定函数表这套路。它做的是:你写 virtual,表怎么生成、对象什么时候指向哪张表,交给编译器。
你不用到处手写 ShapeVTable,也不用靠口头约定维持字段一致。编译器把它“制度化”之后,你至少少掉了一类低级坑。
struct Base {
virtual void f() {}
virtual void g() {}
int x = 1;
};
struct Derived : Base {
void f() override {}
int y = 2;
};
你没有写 vptr,但它大概率会出现。它通常藏在对象里,每个对象一份。
vptr 是什么:对象里藏的一根“暗线”
vptr 这个词可以拆开读:v 是 virtual,ptr 是 pointer。合起来就是“指向虚函数表的指针”。
它的存在感很低,但它很忙。因为只要你通过 Base* 调用虚函数,第一步几乎总要先摸到它。
你可以把一个对象想象成这样(概念图,不是标准):
Derived object:
+----------------+
| vptr | -> vtable(Derived)
+----------------+
| Base::x |
+----------------+
| Derived::y |
+----------------+
一句话。
vptr 是“这个对象当前用哪张表”的入口。
vtable 长什么样:通常就是一排“可调用的地址”
vtable 也很好拆:v 是 virtual,table 是表。它通常更像一张数组,里面按顺序放着“该调用哪个实现”。
vtable(Base):
[0] Base::f
[1] Base::g
vtable(Derived):
[0] Derived::f // 覆盖了 f
[1] Base::g // 没覆盖就沿用
这里我故意说“通常”,因为真实世界里不同编译器/ABI 可能还会塞 RTTI、调整 thunk 之类的东西。
但你刚学完 C,这些先别背。记住主干就够了:表里装的是“下一跳”。
有人喜欢用一句话概括它:
All problems in computer science can be solved by another level of indirection.
你现在看到的 vptr/vtable,就是“再加一层间接”。
p->f() 背后发生了什么:两步
你写代码的时候只写了一行:
void call(Base* p) {
p->f();
}
概念上它做两步:先从对象里拿到 vptr,再用 vptr 去表里找 f 对应的那一格,然后跳过去。
这就是动态绑定。
不是魔法。
是一次“查表”。
先分清两种调用:静态绑定 vs 动态绑定
刚学 C++ 时,最容易困惑的一点是:你写的也是 p->f(),但有时候“能多态”,有时候“不多态”。
区别不在 ->,也不在 *。区别在:这个函数是不是 virtual。
#include <iostream>
struct Base {
void h() { std::cout << "Base\n"; }
};
struct Derived : Base {
void h() { std::cout << "Derived\n"; }
};
void test(Base* p) {
p->h();
}
int main() {
Derived d;
test(&d);
}
这段会输出 Base。
原因很朴素:h 不是 virtual,编译器在编译期就把调用“定死”了。它只看得见 Base*,所以就调用 Base::h()。
再看同样的写法,加上 virtual 之后:
#include <iostream>
struct Base {
virtual void h() { std::cout << "Base\n"; }
};
struct Derived : Base {
void h() override { std::cout << "Derived\n"; }
};
void test(Base* p) {
p->h();
}
int main() {
Derived d;
test(&d);
}
这次输出才会变成 Derived。
一句话。
动态绑定不是“写指针就有”。
它是 virtual 带来的。
引用也能多态:Base& 和 Base* 是一回事
很多人学完 C,会把“引用”当成一种更安全的指针。
这个直觉挺接近真相。
你只要记住一件事:虚函数的动态绑定,看的是对象的动态类型,不看你是用 * 还是 &。
#include <iostream>
struct Base {
virtual void h() { std::cout << "Base\n"; }
};
struct Derived : Base {
void h() override { std::cout << "Derived\n"; }
};
void test(Base& r) {
r.h();
}
int main() {
Derived d;
test(d);
}
输出还是 Derived。
所以你以后看到“接口函数参数写成 Base&”,别担心它就失去多态了。
override 不是装饰品,它是新手的护身符
很多新手第一次写继承,会以为“函数名一样,就是重写”。
但 C++ 里还有一个词叫“重载”。签名不一样的时候,它会悄悄变成另一件事。
struct Base {
virtual void f(int) {}
};
struct Derived : Base {
void f(long) {} // 看起来像重写,其实不是
};
这里 Derived::f(long) 并不会覆盖 Base::f(int)。
你把对象当 Base* 用的时候,调用的还是基类版本。你还会怀疑人生:我不是写了新函数吗?
这时候 override 会很诚实:
struct Base {
virtual void f(int) {}
};
struct Derived : Base {
void f(long) override {} // 编译器:你没在 override 任何东西
};
它会直接报错,把问题拦在编译期。
我自己的经验是:只要你真的想“重写”,就给它写上 override。省心很多。
vtable 里到底放的是什么:把“成员函数”掰开
你可能会问:vtable 里放的是“函数指针”。
可成员函数不是普通函数啊。
它怎么能塞进一张表?
诀窍在于:成员函数调用时,有一个你看不见的参数。
也就是 this。
你可以把这句:
p->h();
先暂时想象成这样(概念模型,不是标准):
// 伪代码:把成员函数当成“普通函数 + 显式 this”
using Fn = void(*)(Base*);
Fn f = p->vptr[slot_of_h];
f(p);
这一下就顺了。
vtable 里那一格,概念上就是“一个可调用地址”。
它接收 this(也就是对象指针),然后去干活。
一句话。
虚函数表解决的不是“怎么存函数”。
它解决的是“同一个调用点,运行时到底该跳到哪个实现”。
vptr 的成本到底是什么?先用 sizeof 看一眼
新手常问一个问题:有虚函数,会不会“很重”?
你不用先背一堆性能结论。先用 sizeof 看看它在对象里多塞了什么。
#include <iostream>
struct A {
int x;
void f() {}
};
struct B {
int x;
virtual void f() {}
};
int main() {
std::cout << sizeof(A) << "\n";
std::cout << sizeof(B) << "\n";
}
在很多 64 位平台上,B 会比 A 大一个指针左右(再加上对齐的影响)。
这个“多出来的东西”,通常就是那根 vptr。
一句话。
vtable 通常是“类级别的一份表”。
vptr 通常是“对象里的一根指针”。
为什么要这么设计:表共享,指针放对象里
你可能会想:那我干脆把函数地址直接放对象里不就行了?
可以。
但那样每个对象都得重复存一堆函数地址。
同一类的 1000 个对象,会把同样的地址存 1000 份。
很浪费。
所以常见实现会让 vtable 共享:同一个类的对象,大概率指向同一张表。
对象里只保留一根 vptr,就够它找到那张表了。
vptr 什么时候会变?答案藏在构造过程里
你前面见过“构造/析构里调用虚函数”的坑。
背后其实是同一个现实:对象在构造过程中,会经历“先当 Base,再当 Derived”。
Base 的构造函数跑的时候,编译器倾向于让对象先使用 vtable(Base)。等 Derived 的部分开始构造,vptr 才会被调整到 vtable(Derived)。
所以在 Base() 里调虚函数,它更像是在对一个“还没长全的对象”说话。
你可能会困惑的几个词(我当年也绕过弯)
静态类型。
是编译器在代码里“看到”的类型,比如 Base*、Base&。
动态类型。
是运行时对象“真实”的类型,比如你传进来的是 Derived。
写了 virtual,调用时才有机会用动态类型来决定跳到哪里。
函数签名。
就是“参数列表 + 返回类型(以及 const 等限定)”这套组合。
你以为自己在重写,但签名差一点点,就会变成重载。
重写(override)。
是在派生类里替换掉基类虚函数的实现。
重载(overload)。
是同名但参数不同的一组函数。它们可以同时存在,但它们不是多态。
vptr / vtable。
你可以暂时把它们当成“编译器替你维护的回调表”。
不用神化它,也不用急着去背 ABI 细节。
你只要能用它解释清楚:为什么这一行会多态,为什么那一行不会。
一个老坑:构造/析构里调用虚函数
这事能坑到今天。
因为它看起来很像“应该可以”。
#include <iostream>
struct B {
B() { f(); }
virtual void f() { std::cout << "B\n"; }
};
struct D : B {
void f() override { std::cout << "D\n"; }
};
int main() {
D d;
}
很多人第一反应是:输出 D。
但它输出 B。
原因也不玄。
构造 B 的那一刻,B 只敢把 vptr 先指向 vtable(B)。
因为 D 还没构造完。
你让它在半成品上调用 D::f(),那才是真正的冒险。
一句话。
在构造/析构期间,多态会“收缩”。
最后一个洞见:vtable 是“类的”,vptr 是“对象的”
很多人第一次听 vtable,会下意识担心:
“那每个对象都要存一整张表?”
不用。
表通常是一份。
一类一份。
对象里只放一根指针。
所以多态的常见成本很具体:
每个多态对象,多一个指针大小。
以及一次间接调用。
也就这么多。
你知道它在哪,就不会把它当成神秘力量。你会更愿意在需要扩展点的地方用虚函数,而不是在所有地方都“先上继承再说”。
一句话。
vtable 不神秘。
它就是编译器替你维护的那张函数指针表。