那是一个很老的坑。
老到它比“现代 C++”还早。
老到你第一次见到它。
会怀疑:是不是我把语言学错了。
它不报错。
它也不崩。
它只是很安静地。
做了你没预期的事。
当年没有“虚函数”这个词
先把时间拨回去。
那会儿很多代码还在写 C。 没有类。 更没有“接口层次”这种说法。
但需求很现实。 我写一个框架,你来扩展几个点。 大家别互相改代码。
于是就有了老办法:函数指针。 一张表。 谁实现谁填。
这套东西很 C。 也很诚实。 你塞什么,它就调用什么。
这类写法有个副作用。
你得自己保证“调用时机”。
什么时候可以调用回调? 对象是不是准备好了? 谁负责初始化?
这些问题,在 C 里通常靠自律。 靠约定。 靠 README。 靠那个“知道内幕”的老同事。
C with Classes 的诱惑
后来 C with Classes 出现了。 Bjarne Stroustrup 在 Bell Labs 做系统软件。 他想把 Simula 那套“类/继承”的组织方式带进 C 的世界。
但现实也很硬。 代码得和 C 混着跑。 还得跑得快。
于是 C++ 把“扩展点”做成了语言特性。 你写一个基类(base class)。 再写一个派生类(derived class)。
基类像一份约定。 派生类像一份实现。
这时候,很多人自然会冒出一个想法。
基类构造的时候。 我想调用一个“可被重写的函数”。 让派生类自己把初始化补齐。
听起来很合理。 坑也就是这么来的。
先把坑复现出来
下面这段代码很短。 但它能把问题掀开一角。
你会看到: 构造/析构这两段时间里。 多态这件事是“暂停”的。
#include <iostream>
struct Base {
Base() {
std::cout << "Base() calling who() -> ";
who();
}
virtual ~Base() {
std::cout << "~Base() calling who() -> ";
who();
}
virtual void who() {
std::cout << "Base::who\n";
}
};
struct Derived : Base {
Derived() {
std::cout << "Derived()\n";
}
~Derived() override {
std::cout << "~Derived() calling who() -> ";
who();
}
void who() override {
std::cout << "Derived::who\n";
}
};
int main() {
std::cout << "scope begin\n";
{
Derived d;
std::cout << "after construction -> ";
Base* p = &d;
p->who();
}
std::cout << "scope end\n";
}
这段代码里。
一共有四次调用 who()。
构造阶段:
Base() 里那次,会打印 Base::who。
构造完成后:
Base* p = &d; p->who(); 这次,会打印 Derived::who。
析构阶段:
~Derived() 里那次,会打印 Derived::who。
最后:
~Base() 里那次,会打印 Base::who。
这不是“编译器偷懒”
关键点在时间。 Base 的构造函数在跑的时候,Derived 还没开始构造。
这时如果真的跳进 Derived::who(),会发生什么?
它很可能会用到 Derived 的成员。
而那些成员还没初始化。
所以 C++ 选择了一条更保守的路。 在构造/析构期间,把对象当成“当前这一层”的类型。 虚调用也只分发到当前这一层。
你可以把这条规则记成一句话:
构造时,多态还没生效。
析构时,多态已经失效。
如果你愿意再“抬杠”一句。
那 ~Derived() 里调用 who() 为什么能到 Derived::who?
因为那时对象至少还是一个完整的 Derived。
更底下那层(Base)还没开始析构。
等轮到 ~Base()。
派生类那部分已经不在了。
就只能回到 Base::who。
这就是“只对当前这一层分发”。
名词掰开揉碎一下
构造函数(constructor)。 对象“出生”时自动执行的函数。
析构函数(destructor)。 对象“收尾”时自动执行的函数。
虚函数(virtual function)。 同一个调用点,运行时根据对象真实类型选择实现。 这就是很多人说的“动态多态”。
至于编译器怎么做到的。 常见实现里会有一张“函数表”和一个指向它的小指针。 你以后会在别的文章里见到 vtable / vptr 这两个名词。
你现在只要记住: 构造和析构,会让这套机制处在一个“过渡期”。
先把顺序说清楚:谁先构造,谁先析构
这是新手很容易忽略的一件事。 但它几乎是这类坑的“根”。
当你写 Derived d;。
构造顺序是: 先构造 Base。 再构造 Derived。
析构顺序相反: 先析构 Derived。 再析构 Base。
如果你把它想成“盖房子”。
Base 像地基。 没地基就谈不上二楼。
而拆房子也是先拆二楼。 再拆地基。
所以在 Base 构造期。 你让程序去执行 Derived 的逻辑。 它只能回你一句:现在还没到那个阶段。
如果你想象 vptr:这里发生了什么
我尽量不用你现在还不熟的实现细节。 但给你一个“够用的直觉”。
你可以把对象里那根 vptr 想成一张“当前版本说明书”的指针。
在 Base 构造期间。 它指向 Base 那份说明书。
等 Base 构造完成。 进入 Derived 构造。 它才会切到 Derived 那份说明书。
析构时反过来。 Derived 的那份先作废。 vptr 会回到 Base 的那份。
新手最容易困惑的几个点
第一个困惑通常是: “我明明写了 override,为什么没进去?”
这里你需要区分两个词。 静态类型。 动态类型。
静态类型,跟变量写法有关。
比如 Base* p,它的静态类型就是 Base*。
动态类型,跟对象实际是谁有关。
比如 p 可能指向 Derived。
在普通情况下。 虚函数调用会看动态类型。 所以才叫“运行时分发”。
但在构造/析构里。 语言特意加了一条例外。 把动态类型临时当成“当前这一层”。
第二个困惑是: “那我把调用藏到一个普通函数里行不行?”
不行。 只要你还在构造/析构的那段时间里。 结果就不会变。
你从构造函数里调用 init()。
init() 再去调用虚函数。
它依然只会走到当前这一层。
第三个困惑是: “这样设计是不是很反直觉?”
有点。 但它在工程上很现实。
因为派生类成员还没初始化。 就去跑派生类逻辑。 风险太高。
更狠一点的版本:纯虚函数
很多人第一次在线上看到这个坑。 不是因为“输出不对”。
而是因为直接崩了。
struct Base {
Base() { who(); }
virtual void who() = 0;
};
struct Derived : Base {
void who() override {}
};
int main() {
Derived d;
}
对新手来说,这段代码看起来很合理。
“我都 override 了,怎么还不行?”
原因还是同一个。 Base 构造时,Derived 还没构造。 所以这次虚调用不会去 Derived。
而 Base 的 who() 又是纯虚函数。
很多实现会在运行时直接报“pure virtual function called”。
所以一个朴素的经验是: 构造/析构里不要依赖虚函数。 尤其别让它碰到纯虚函数。
关键结论
在构造/析构函数里调用虚函数。
不要指望多态。
那应该怎么写
如果你真的需要“根据派生类做初始化”。 更稳的办法是把这件事挪到对象构造完成之后。
比如工厂函数创建对象后,再显式调用一个初始化函数。 或者让派生类构造函数自己做完该做的事,再对外暴露“已就绪”的对象。
这里给一个很“直白”的写法。 不优雅。 但很稳。
#include <memory>
struct Base {
virtual ~Base() = default;
virtual void init() = 0;
};
struct Derived : Base {
void init() override {
// 这里可以安全用 Derived 的成员了
}
};
std::unique_ptr<Base> make() {
auto p = std::make_unique<Derived>();
p->init();
return p;
}
如果你还没学过 std::unique_ptr。
你先把它当成“会自动 delete 的指针”。
它的意思是: 这块对象只有一个主人。 主人走了,对象就自动释放。
这里用它只是为了把例子写得更像真实 C++。
重点还是那句:init() 发生在构造完成之后。
你可能会说。 “这不就是两阶段初始化吗?”
对。 就是那个土办法。
土。 但它把时间点说清楚了。
初始化发生在对象构造完成之后。 这时候虚函数分发也恢复正常。
这听起来不如“一行搞定”省事。 但它更像工程。
多态有点像合同。
构造时,合同还没生效。
析构时,合同已经作废。