一个让人困惑的面试题
几年前,我面试一位有三年经验的 C++ 开发者。他简历上写着"熟练掌握面向对象编程",于是我在白板上写了这么一段代码:
class Base {
public:
virtual void foo() { cout << "Base" << endl; }
};
class Derived : public Base {
public:
void foo() override { cout << "Derived" << endl; }
};
int main() {
Derived d;
Base b = d; // 注意这里
b.foo(); // 输出什么?
}
他想了想说:"Derived。因为 d 是派生类对象,foo 是虚函数,所以会调用派生类的版本。"
答案是 Base。
他愣住了。"不是虚函数吗?怎么不多态了?"
这个问题的答案,三句话就能说清楚:Base b = d; 发生了对象切片——b 只拷贝了 d 中属于 Base 的那部分,Derived 的数据和行为都丢了。b 压根就是个纯粹的 Base 对象,跟 d 已经没关系了。
但如果你从来没听过"对象切片"这个词,如果你不知道派生类对象在内存里到底长什么样,你就只能靠猜——或者靠背。
这就是对象模型要回答的问题:你写的那些 class、继承、虚函数,在编译器眼里究竟是怎么回事?它们在内存里怎么摆放?调用的时候怎么跳转?
"对象模型"这个词从哪来?
1979 年,Bjarne Stroustrup 在贝尔实验室开始了一个副业项目:给 C 语言加上类(class)的概念。他管这门新语言叫"C with Classes",四年后正式改名 C++。
早期的 C++ 编译器叫 CFront,它的工作方式很有意思——把 C++ 代码翻译成 C 代码,然后用现成的 C 编译器完成后续的编译。这意味着 C++ 里的每一个"高级特性",最终都得用 C 的基本概念来实现。
你写一个 class,CFront 得把它翻译成 struct 加一堆函数。你写一个虚函数调用,CFront 得把它翻译成通过函数指针的间接调用。你写一个继承体系,CFront 得把派生类的内存布局安排妥当,确保指针转换能正确工作。
这套"翻译规则",就是 C++ 对象模型的雏形。
1996 年,Stanley Lippman(他曾在贝尔实验室参与 CFront 的开发)写了一本书,叫《Inside the C++ Object Model》。这本书第一次系统地把这套规则讲清楚了:对象在内存里怎么排布、虚函数表长什么样、继承时发生了什么。
从此,"C++ 对象模型"就有了一个相对明确的定义——它描述的是 C++ 对象在内存中的表示方式,以及构造、析构、继承、多态等机制在底层的实现原理。
对象模型 ≠ 面向对象编程
这两个概念听起来像,但方向完全相反。
面向对象编程(OOP)是一种设计哲学。它告诉你应该用类来封装数据和行为,用继承来表达 is-a 关系,用多态来写灵活的代码。它关心的是"怎么设计好的接口"。
对象模型是一种实现细节。它告诉你编译器在你写完那些漂亮的 class 之后,到底干了些什么。它关心的是"那些接口在机器上怎么跑起来"。
打个比方:OOP 是交通规则,告诉你红灯停绿灯行、右转让左转;对象模型是汽车的机械原理,告诉你发动机怎么把汽油变成动力、变速箱怎么把动力传到车轮。
你可以只学交规就上路开车,大多数时候没问题。但如果你想成为赛车手,或者你的车在高速上突然失灵,你最好知道引擎盖下面是怎么回事。
Stroustrup 的一句话
C++ 有一条著名的设计原则:"You don't pay for what you don't use."(你不为你不用的东西付出代价。)
这句话直接决定了 C++ 对象模型的走向。
比如,如果你的类没有虚函数,编译器就不会给对象塞一个 vptr(虚函数表指针)。一个简单的 struct Point { int x, y; }; 和 C 语言里的 struct 没有任何区别——内存布局一样,访问开销一样。
但如果你加了虚函数,对象就会多出一个指针大小的隐藏成员;如果你用了多重继承,布局会更复杂;如果是虚继承……我们后面再说,那是另一个故事了。
这就是为什么 C++ 程序员需要了解对象模型:你得知道每个语言特性的"价格标签"在哪。 不是说虚函数就不能用——而是你应该在用之前知道它会给你的对象增加多少字节、调用时会多几次间接跳转。
三个层次
在本专栏里,我会把"对象模型"的知识分成三个层次:
语法层:怎么写。class、struct、public、private、virtual、override 这些关键字怎么用。这是大多数教程会讲的内容,你可能已经比较熟了。
语义层:什么意思。"虚函数"意味着调用时根据对象的实际类型来决定调用哪个版本。"继承"意味着派生类可以被当作基类来使用。这是语言标准定义的行为。
实现层:怎么做到的。虚函数调用是通过 vptr 查 vtable 再跳转。继承意味着派生类对象里嵌了一块基类子对象的内存。这是编译器具体实现的细节。
语法层让你能写代码,语义层让你能预测行为,实现层让你能理解代价、排查诡异的 bug、在代码评审时讲出所以然。
本专栏主要在第三个层次上展开,但会不断回扣前两个层次——因为脱离了"怎么写"和"什么意思",单独讲"怎么实现"就成了屠龙之技。
你能从这个专栏里得到什么?
读完这个系列,你应该能够:
- 画出对象的内存布局草图。有人问你一个类的 sizeof 是多少,你不用编译就能估个八九不离十。
- 解释多态的底层机制。"虚函数是怎么实现的?"这种面试题对你来说不再是背诵,而是推导。
- 识别常见的坑。对象切片、虚析构函数遗漏、构造函数里调虚函数——你能在代码评审时一眼看出问题。
- 做出更好的设计决策。什么时候用虚函数,什么时候用模板?什么时候继承,什么时候组合?你的选择会有更扎实的理由。
一点说明
这个专栏不会追求覆盖所有平台的所有 ABI 细节。不同的编译器(GCC、Clang、MSVC)、不同的平台(x86、ARM)在具体实现上有差异,比如 vtable 里的条目顺序、名字修饰(name mangling)规则等。
但核心的概念——vptr、vtable、对象布局、构造/析构顺序——在主流编译器上是高度一致的。我会聚焦这些跨平台的共性,偶尔提一下实现差异,但不会深入到特定平台的 ABI 考古。
我们要建立的是一个实用的心智模型,而不是一份编译器规格说明书。
好了,热身结束。下一篇,我们正式开始——从最简单的 struct 开始,看看一个"对象"在内存里究竟是什么。