你写下 p->foo()。
看起来像一句话。
像一句很普通的话。
可在当年。
这句话是很多人用 crash 堆出来的。
而且那种 crash。
通常还不在你写错的地方。
“C++ makes it harder to shoot yourself in the foot, but when you do, you blow off your whole leg.”
—— 这句老梗虽然夸张,但
p->foo()真的配得上。
那时候我们只有 C:没有“对象调用”这种语气
在 C 里。
你想要“一个东西,能有不同实现”。
最直觉的办法就是函数指针。
struct Ops {
void (*foo)(void* self);
};
struct Obj {
const struct Ops* ops;
int x;
};
void call_foo(struct Obj* p) {
p->ops->foo(p);
}
你看。
这已经很像“面向对象”了。
Obj 像对象。
Ops 像方法表。
p->ops->foo(p) 像 p->foo()。
只是这一切都靠你手动维护。
先把 -> 这件事说顺
你刚从 C 过来。
-> 你其实早就会了。
只是你可能没把它和“成员函数调用”连在一起。
struct Obj { int x; };
void demo(struct Obj* p) {
p->x = 1;
}
这句 p->x。
等价于 (*p).x。
也就是说。
-> 只是“先解引用,再取成员”。
你可能还会顺手问一句:那 . 呢?
d.foo() 其实也在做同一件事。
只不过它的“对象地址”是显然的。
struct T {
void foo() {}
};
int main() {
T d;
d.foo();
}
你可以粗暴地把它理解成:
foo 需要一个 this。
d.foo() 就是把 &d 当成 this 传进去。
所以当你看到 p->foo()。
你可以先把它读成:
“我手里有个指针 p,它指向一个对象,我想调用这个对象的 foo。”
人们踩过的坑:手写表很灵活,也很脆
手写的东西最怕“忘了做”。
更怕“做了,但做错了”。
struct Obj* p = /* ... */;
p->ops->foo(p);
如果 p->ops 没初始化。
那就是空指针。
你会在一个毫无心理准备的夜晚看到段错误。
struct Obj o = {0};
call_foo(&o); // 很可能直接崩
这不是 C 太“原始”。
这是它很诚实:你没填,它就没法替你兜底。
更隐蔽的是:表是填了,但填错了。
比如签名不匹配。
比如把 foo 和 bar 的位置写反。
位置固定,所以大家爱叫它“槽位”。
这些问题在代码评审里不容易一眼看出来。
因为它们“长得都差不多”。
而且往往要跑起来才暴露。
当年的人怎么想:能不能让编译器来管这张表?
C with Classes(后来叫 C++)出现时。
它想解决的并不是“概念不够漂亮”。
而是很工程化的一件事:我们已经在用“函数表”了。
能不能别每个项目都手搓一遍?
能不能让这件事更不容易写错?
于是 virtual 出现了。
你不用再显式写 Ops。
编译器会替你生成。
你也不用再手动传 self。
它会塞进一个隐藏参数。
还有个更关键的问题:那根“隐藏指针”是什么时候写进去的?
不是在你写 p->foo() 的时候。
更早。
在构造对象的时候。
你可以用一个小陷阱,反过来把它照出来。
#include <iostream>
struct Base {
Base() { foo(); }
virtual void foo() { std::cout << "Base\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
Derived() { foo(); }
void foo() override { std::cout << "Derived\n"; }
};
int main() {
Derived d;
}
你可能会猜:两次都打印 Derived。
但实际通常是:先 Base,再 Derived。
这说明对象在“长大”的过程中,编译器维护的那根隐藏指针会被反复写。
先按 Base 的规则写一次,等 Derived 的构造开始,再按 Derived 的规则改一次。
名字你可以先不背。 你先把它当成“对象里多出来的一根指针,指向那张函数表”。
先把简单版本说清:不写 virtual 时,p->foo() 很“老实”
先看一个没有 virtual 的世界。
#include <iostream>
struct Base {
void foo() { std::cout << "Base\n"; }
};
struct Derived : Base {
void foo() { std::cout << "Derived\n"; }
};
int main() {
Derived d;
Base* p = &d;
p->foo();
}
这会打印 Base。
不是因为对象不对。 是因为编译器在编译期就把目标定死了。
它看到的是 Base*。
它就调用 Base::foo()。
而且你可以把这行当成一种“翻译”。
// 概念上更接近编译器眼里的样子
Base::foo(p);
注意这里的 p。
它就是那个你没写出来的 this。
成员函数并没有魔法,只是多了一个隐藏参数。
你可以把它理解成:
你写的是 p->foo()。
编译器看到的是“把 p 当成 this 传进去”。
一句话成段:this 就是“被你省略的第一个参数”
你写的是成员函数。
编译器调用的是普通函数。
你可能会困惑:new Derived 这一句到底做了几步?
我第一次学到这里的时候。
也以为它就是“分配一下,然后构造一下”。
后来才知道,这里面塞了很多编译器的体力活。
Base* p = new Derived;
你可以先按这个顺序去理解它。
先在堆上找一块够大的内存。
然后开始构造。
先构造 Base 那一层。
这时候对象会先长成“Base 形态”。
接着再构造 Derived 那一层。
对象再长成“Derived 形态”。
关键点在于:
如果这个类型有虚函数。
那根“指向表的指针”也会在构造过程中被写进去。
所以你在构造函数里调用虚函数。
看到的往往是“当前正在构造的那一层”的版本。
这不是编译器耍赖。
它是在帮你避免更危险的事:在对象还没构造完时,就跳进派生类的逻辑里。
写上 virtual 之后:同一句 p->foo(),开始“先查表再跳转”
现在把 foo 变成虚函数。
#include <iostream>
struct Base {
virtual void foo() { std::cout << "Base\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
void foo() override { std::cout << "Derived\n"; }
};
int main() {
Base* p = new Derived;
p->foo();
delete p;
}
这次会打印 Derived。
你写的还是同一句 p->foo()。
但编译器不再把它翻译成“call 某个固定函数”。 它会做一件更像 C 时代的事,只是这次由它代劳。
它会让对象里多一个隐藏指针。
这个指针指向一张表。 表里放着一排函数地址。
然后把调用点改写成:
先从对象里拿到那张表。
再从表里拿到 foo 的函数地址。
最后跳过去。
你可以把它想象成这样(只是概念模型,不是标准写法):
// 概念示意:对象里有个“指向表的指针”
// 表里有个“foo 对应的槽位”
auto f = /* p 里的隐藏指针 */[/* foo 的槽位 */];
f(p);
这就把 C 时代那套“函数表调用”收编进了语言。
那根隐藏指针,很多资料叫它 vptr。 那张表很多资料叫它 vtable。
你不背也行。 它们其实就是“指向表的指针”和“那张表”。
一个小实验:为什么一写 virtual,sizeof 往往就变大了?
你不需要懂汇编。
用 sizeof 就能看到一点痕迹。
#include <iostream>
struct A {
int x;
};
struct B {
int x;
virtual void foo() {}
};
int main() {
std::cout << sizeof(A) << "\n";
std::cout << sizeof(B) << "\n";
}
很多平台上,B 会比 A 大一截。
那一截通常就是那根隐藏指针占的空间。
你可以把它当成:
为了能“查表再跳转”。
对象里必须留个入口。
再补一句:表通常是“每个类一张”,不是“每个对象一张”
这点很容易误会。
因为你看到的是“对象里多了一个指针”。
直觉就会以为:每个对象都带着一张表。
通常不是。
对象里一般只放那根指针。
真正那张表更像“类的公共资源”。
同一个类的多个对象,往往指向同一张表。
这样才省。
也更符合直觉:一个类的方法集合,本来就应该是共享的。
如果你想把它和 C 的写法对上号。
可以先在脑子里放一张“概念草图”(不是标准,也不保证和任何编译器一模一样)。
// 只是为了帮助你建立心智模型
struct VTable {
void (*foo)(void* self);
};
struct Obj {
const VTable* vptr;
int x;
};
然后 p->foo() 这种调用。
你就能把它想象成:先用 p 找到 vptr,再从表里拿到函数地址,再把 p 作为 self 传进去。
再来一个更像真实世界的例子:表里不止一个槽位
一个类通常不止一个虚函数。
表里也不止一个函数地址。
#include <iostream>
struct Base {
virtual void foo() { std::cout << "Base::foo\n"; }
virtual void bar() { std::cout << "Base::bar\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
void foo() override { std::cout << "Derived::foo\n"; }
// 没重写 bar()
};
int main() {
Base* p = new Derived;
p->foo();
p->bar();
delete p;
}
你会看到:
foo() 走了 Derived。
bar() 还在 Base。
这件事用“表”的视角就很好想象。
表里有两个槽位。
Derived 只把其中一个槽位换成了自己的版本。
另一个槽位继续沿用 Base。
你可能会困惑:纯虚函数是啥?它解决的其实是“约定不靠谱”
有时候你不想提供默认实现。
你想说的是:
“这个函数你必须实现。”
那就会用纯虚函数。
struct Base {
virtual void foo() = 0;
virtual ~Base() = default;
};
struct Derived : Base {
void foo() override {}
};
你不用把它当成玄学。
它就是让编译器替你盯着“有没有实现”。
少靠口头约定。
少靠群公告。
你可能会困惑:final 是不是很“霸道”?
它确实有一点。
所以我个人只在“确实不希望别人再改这条路”的时候用。
但它也很诚实:
我就是想把这里封住。
struct Base {
virtual void foo() {}
virtual ~Base() = default;
};
struct Derived final : Base {
void foo() final {}
};
对读代码的人来说,这是一句很清晰的沟通。
对编译器来说,有时也更容易做优化。
你可以强制指定调用谁:有时候这是救命绳
有时候你就是想调用基类版本。
比如你在调试。
比如你想复用一段默认实现。
这时可以用“限定名”直呼其名。
#include <iostream>
struct Base {
virtual void foo() { std::cout << "Base\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
void foo() override { std::cout << "Derived\n"; }
};
int main() {
Derived d;
Base* p = &d;
p->foo(); // 动态分发
p->Base::foo(); // 直接指定:就要 Base
}
你会看到两行不同的输出。
这也能帮你反向理解:
“动态分发”其实就是默认的选择机制。
但你仍然可以手动绕开它。
一句话成段:p->foo() 不是一行,它是一套制度
你写的是调用。
编译器替你维护的是一张表。
你可能会困惑:为什么“同名函数”有时生效,有时不生效?
这也是我见过最多新手皱眉的地方。
你看起来写的是同一件事:都叫 foo()。
但“你是通过谁的视角去看对象”,会改变结果。
先看没有 virtual 的世界。
#include <iostream>
struct Base {
void foo() { std::cout << "Base\n"; }
};
struct Derived : Base {
void foo() { std::cout << "Derived\n"; }
};
int main() {
Derived d;
d.foo();
Derived* pd = &d;
pd->foo();
Base* pb = &d;
pb->foo();
}
前两次会打印 Derived。
因为你用的是 Derived 的视角。
最后一次会打印 Base。
因为 pb 这个变量写着 Base*。
编译器在“不支持动态分发”的前提下,只能按 Base* 来决定调用。
写上 virtual 之后,这个“视角差异”才会被抹平。
#include <iostream>
struct Base {
virtual void foo() { std::cout << "Base\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
void foo() override { std::cout << "Derived\n"; }
};
int main() {
Derived d;
Base* pb = &d;
pb->foo();
Base& rb = d;
rb.foo();
}
这两次都会打印 Derived。
因为这时“对象真实是谁”会参与决策。
很多资料叫它“动态类型”。
你可以先把它理解成:指针里装的是地址,但地址后面到底站着谁,运行时才知道。
一句话成段:静态类型写在变量上,动态类型站在地址后面
Base* pb 的 “Base”。
是写在变量上的。
而 pb 指向的那块内存。
才决定了它真实是谁。
你可能会困惑:override 到底有什么用?我不写也能编译啊
对。
很多时候不写也能编译。
这也是坑的来源。
最常见的事故是:你以为你在重写。
其实你写成了另一个重载。
#include <iostream>
struct Base {
virtual void foo(int) { std::cout << "Base\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
void foo(double) { std::cout << "Derived\n"; } // 你以为是重写
};
int main() {
Derived d;
Base& b = d;
b.foo(1); // 还是 Base
}
这里 Derived::foo(double) 并没有重写 Base::foo(int)。
它只是多了一个新函数。
如果你写成这样:
void foo(double) override { }
编译器会直接拦你。
它会告诉你:你根本没在重写。
这就很值。
你可能会困惑:我明明写了一个同名函数,怎么把基类版本“藏起来”了?
这是另一个非常常见的坑。
叫名字遮蔽(name hiding)。
你不用先背名词。
先看现象。
#include <iostream>
struct Base {
void foo(int) { std::cout << "Base int\n"; }
};
struct Derived : Base {
void foo(double) { std::cout << "Derived double\n"; }
};
int main() {
Derived d;
d.foo(1);
}
你可能本来想调用 Base::foo(int)。
但这段代码会调用 Derived::foo(double)。
因为 Derived 里只要出现一个同名的 foo。
基类里所有同名的 foo,在 Derived 的“可见范围”里就都被挡住了。
怎么办?
你可以把基类的那组名字“拉回来”。
struct Derived : Base {
using Base::foo;
void foo(double) { std::cout << "Derived double\n"; }
};
这样 d.foo(1) 就会回到 Base::foo(int)。
经典坑:析构函数不 virtual,delete Base* 可能会漏资源
这事我自己也踩过。
因为它看起来真的很合理。
#include <iostream>
struct Base {
~Base() { std::cout << "~Base\n"; }
};
struct Derived : Base {
~Derived() { std::cout << "~Derived\n"; }
};
int main() {
Base* p = new Derived;
delete p;
}
如果 Base 的析构不是 virtual。
delete p; 很可能只跑 ~Base()。
~Derived() 被跳过。
你就会在“看起来正常”的代码里慢慢漏资源。
解决办法也很朴素:接口类(会被基类指针删除的那种)通常都写 virtual ~Base() = default;。
顺手再解释一句 delete。
它通常做两件事:
先调用析构函数。
再把那块内存还给系统。
如果析构不 virtual。
第一步就可能走错版本。
另一个更常见的 crash:指针没空,但已经“死了”
空指针还算好排。
更烦的是悬空指针。
也就是它看起来像个地址。
但那块内存已经不属于你了。
Base* p = new Derived;
delete p;
p->foo(); // UB
这类 bug 的气质很像“闹鬼”。
有时立刻崩。
有时不崩,但把别的内存踩坏。
最后崩在更远的地方。
所以我才说:当你看到崩溃落在 p->foo()。
别只盯着 foo()。
要先审 p 的生命周期。
经典坑:按值传参,会把对象“削平”
还有一个更阴的。
它不崩。
它只是悄悄变错。
#include <iostream>
struct Base {
virtual void foo() { std::cout << "Base\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
void foo() override { std::cout << "Derived\n"; }
};
static void run(Base b) { // 注意:这里是按值
b.foo();
}
int main() {
Derived d;
run(d);
}
这里传进去的 d。
会被拷贝成一个“只有 Base 那一层”的新对象。
你可以把它想成:Derived 被切掉了上半身,只剩 Base 的那块内存。
所以虚函数也救不了。
b.foo() 会打印 Base。
新名词小抄:先记住这些就够用
静态类型。
就是写在变量声明上的那个类型。
动态类型。
就是这个地址背后,运行时真正站着的那个类型。
动态分发。
就是“运行时再决定调用哪个版本”。
vptr。
就是对象里那根“指向表的指针”。
vtable。
就是那张“装着一排函数地址的表”。
槽位。
就是表里某个函数固定的位置。
UB。
就是标准不保证结果。
可能崩,可能不崩,但都不可信。
对象切片。
就是你按值把 Derived 传成 Base 时,Derived 那部分被拷贝丢了。
再给一个现实一点的提醒:p 可能是空的
很多刚从 C 过来的人会下意识想:反正是成员函数,应该会先判断空吧? C++ 不会。
Base* p = nullptr;
p->foo(); // UB
这里不是“稳稳地打印 Base”。
也不是“抛异常”。 而是未定义行为(UB)。
UB 的意思很朴素:标准没保证会发生什么。 可能崩溃,可能看起来正常,但都不可信。
为什么?
因为虚调用很可能第一步就要从 p 指向的对象里取出那根隐藏指针。
而 p 根本没指向对象。
你连“表在哪”都读不到。
小结:看懂这行代码,你就多了一种排错直觉
当你看到崩溃落在 p->foo()。
别急着盯着 foo() 的实现。
先回头看 p 是怎么来的。
它指向的到底是谁。
它有没有活着。
它是不是一个“本来就该是 Base”的地址。
你把这行看成“查表再跳转”。
很多 bug 就没那么玄学了。
最后收一下。
p->foo() 这行看起来像“语法糖”。
但它其实是在替你保管一张很容易写错的表。