跳转至

01. 对象模型、内存布局、类与多态(深挖版)

这一章是 C++ 面试最容易"从背诵题瞬间变成深水题"的部分。很多人会背:虚函数、虚表、空类大小、内存对齐、继承、多态。 真正的问题在于:你能不能把语言规则、编译器实现、对象布局、运行时代价、工程边界串起来讲清楚。

本章建议按"先理解知识主线,再练问答表达,最后吃透边界条件"的顺序阅读:

  • 先把对象的静态布局、动态行为、工程代价三层分清
  • 再理解虚函数、虚表、构造析构、继承如何映射到运行时
  • 最后把多继承布局、虚继承、dynamic_cast、RTTI 的代价吃透

先把这一章的知识骨架搭起来

C++ 对象模型这章最核心的主线,其实是 "对象是什么、对象怎么放在内存里、语言特性怎么映射到运行时行为"。如果这条主线不清楚,后面讲 sizeof、内存对齐、继承、多态、虚函数时就会变成一堆孤立结论。

先建立三个层次的理解:第一层是对象的静态布局,也就是成员变量、对齐、padding、基类子对象这些东西决定了对象长什么样;第二层是对象的动态行为,也就是 this、虚函数、虚表、析构链、多态分派这些机制决定了对象"怎么动起来";第三层是对象模型的工程代价,也就是对象大小、cache 局部性、ABI 稳定性、指针调整和多态调用开销。

所以你读这章时,不要把"虚函数"单独记成一个知识点,而要把它放回对象模型里:对象里为什么可能多一个 vptr,继承为什么会改变布局,虚析构为什么和删除语义绑定,多继承为什么会引入地址调整。这样整章才是连起来的。


第一部分:先把概念和主线讲清楚

进入问答前,先把最小前置知识补齐

对象模型不是"虚函数专题",而是回答一个更基础的问题:一个类在编译后会变成什么样的对象,运行时又靠什么支持继承和多态。你至少要先把三层东西分清:静态布局、动态分派、工程代价。

静态布局对应的是成员变量、对齐、padding、基类子对象;动态分派对应的是 this、虚函数、虚表、析构链;工程代价对应的是对象大小、cache locality、指针调整、ABI 稳定性。面试里一旦把这三层混在一起,就很容易出现"知道现象但解释不清原因"的情况。

所以这章最好的打开方式,不是上来背 vptr,而是先问:对象本体里到底有哪些实例状态,哪些东西不在对象里,为什么继承会改变布局,为什么多态又一定会引入额外间接层。


1. C++ 对象在内存中通常由哪些部分构成?

标准回答

一个 C++ 对象通常由以下部分组成:

  1. 非静态成员变量
  2. 为满足对齐要求而产生的 padding
  3. 如果类有虚函数,通常会有一个虚函数表指针 vptr
  4. 如果涉及继承,可能还会包含基类子对象的布局

成员函数代码本身通常不存储在对象里,而是在代码段中,由所有对象共享。

更深入的理解

为什么说"通常"而不是"必须"?因为 C++ 标准定义了语义,但没有强制规定所有 ABI 的具体物理布局。你在面试里常说的 vptrvtable、对象首地址放虚表指针,这些更多是主流实现(如 GCC/MSVC/Clang 对应 ABI)的典型做法,而不是标准原文写死的唯一实现方式。

对象里有什么,取决于它是否属于实例状态

  • 成员变量属于每个对象自己的状态,所以放在对象里
  • 静态成员变量属于类共享,不属于某个对象,所以不计入对象大小
  • 成员函数代码也不是对象独有,因此通常在代码段统一存放

对象布局真正影响什么?

  • sizeof
  • 内存占用
  • cache 友好性
  • 指针转换代价
  • ABI 兼容性
  • 多态调用成本

如果你做的是高性能服务、基础库、游戏引擎、序列化框架,这些都不是纯理论问题。


2. 空类为什么大小不是 0?

标准回答

空类通常 sizeof 为 1,而不是 0。原因是标准需要保证不同对象拥有不同地址表示;如果对象大小为 0,那么多个对象可能占据同一地址,很多语义就会混乱。

既然空类没数据,为什么非要给它 1 个字节?

因为对象必须是"可区分的实体"。你可以把这理解成:语言层面要求对象有独立身份,而不是仅仅代表一段数据。

空基类优化(EBO)和空类大小为 1 冲突吗?

不冲突。空类作为独立对象时通常有大小 1;但当它作为基类子对象时,编译器可以利用空基类优化,让空基类不额外占空间。

一句总结

空类作为独立对象通常大小是 1,这是为了保证对象身份;但在继承场景中,空基类可能通过 EBO 不额外占空间,所以"空类大小 1"和"空基类可不占空间"并不矛盾。


3. 类对象大小怎么计算?

标准回答

类对象大小通常由以下部分共同决定:

  • 所有非静态成员变量大小
  • 内存对齐要求带来的 padding
  • 基类子对象大小
  • 是否存在虚函数带来的 vptr
  • 多继承 / 虚继承额外引入的布局开销

最小例子

class A {
    char c;
    int i;
};

很多人第一反应是 1 + 4 = 5,但在常见 ABI 下 sizeof(A) 很可能是 8。原因:

  • char c 占 1 字节
  • 为了让 int i 按 4 字节对齐,中间要补 3 字节
  • 总大小还要向最大对齐单位对齐

为什么调整成员顺序会影响 sizeof

因为 padding 是按成员排列顺序插入的。比如:

class A { char c; int i; char d; };   // 可能 12 字节
class B { int i; char c; char d; };   // 可能  8 字节

两者语义类似,但布局可能明显不同。

什么时候需要关注对象大小?

  • 高性能服务中的热点对象
  • 大量对象驻内存场景
  • 序列化/反序列化框架
  • 跨进程共享内存结构
  • 网络协议结构体映射

面试里如果你能主动提这些场景,会显得不是在背书,而是知道它和工程有什么关系。


4. 什么是内存对齐?为什么需要它?

标准回答

内存对齐是编译器按照类型的对齐要求安排对象地址与成员偏移,使其满足特定边界,以提升 CPU 访存效率并满足某些硬件平台的要求。

为什么要对齐?

  • CPU 更喜欢按自然边界访问。比如一个 4 字节的 int 放在 4 字节对齐地址上,很多 CPU 可以更高效地一次取出
  • 某些架构未对齐访问代价高,甚至不允许
  • 对齐本质上是"空间换时间":牺牲一点额外空间,换来更稳定高效的访存性能

#pragma pack(1) 能不能无脑用来省空间?

不能。它可能让结构更紧凑,但也会降低访问效率,甚至造成平台兼容性问题。通常只在协议头、文件格式映射、硬件寄存器描述等必须精确控制布局的场景使用。工程上绝大多数业务对象不建议滥用紧凑对齐。


5. 静态成员和非静态成员有什么区别?this 指针是什么?

静态 vs 非静态成员变量

  • 非静态成员变量属于对象实例,每个对象一份
  • 静态成员变量属于类本身,所有对象共享一份,不计入 sizeof

this 指针是什么?

this 是非静态成员函数中的隐含参数,指向调用该成员函数的当前对象。你写 obj.func(),底层通常更接近 func(&obj) 这种调用模型。

静态成员函数为什么没有 this

因为静态成员函数不依赖具体对象实例,没有隐含对象上下文。

构造函数/析构函数里能用 this 吗?

可以,但要注意此时对象处于"构造中 / 析构中"的不完整状态,尤其和虚函数调用、对象转型一起使用时很危险。


6. 虚函数是什么?它在解决什么问题?

标准回答

虚函数用于实现运行时多态,让我们通过基类指针或引用调用函数时,能够根据对象实际类型而不是静态类型,动态决定调用哪个函数实现。

为什么需要虚函数?

因为在面向对象设计里,我们经常希望:

  • 接口写在基类
  • 行为由派生类定制
  • 调用端不用知道具体派生类类型

这就是"统一接口 + 动态行为分派"。

最小例子

class Base {
public:
    virtual void f() { std::cout << "Base\n"; }
};
class Derived : public Base {
public:
    void f() override { std::cout << "Derived\n"; }
};

Base* p = new Derived();
p->f(); // 输出 Derived

如果没有 virtual,这里通常调用的是 Base::f(),因为静态类型是 Base*


7. 虚函数通常怎么实现动态绑定的?虚表到底是什么?

标准回答

主流实现中,编译器通常为含虚函数的类生成一张虚函数表(vtable),对象中保存一个指向虚表的指针(vptr)。当通过基类指针/引用调用虚函数时,运行时根据对象当前的 vptr 去虚表中找到真正要调用的函数地址。

虚表是属于对象还是属于类?

通常虚表是一类对象共享的一张表,不是每个对象一份;每个对象只需一个 vptr 指向相应虚表。

vptr 一定在对象首地址吗?

很多实现里常见如此,但标准并没有硬性规定。不要把实现习惯说成语言强制规则。

虚函数调用的代价是什么?

  • 对象可能更大(多一个 vptr
  • 调用时多一次间接寻址
  • 对编译器内联优化更不友好
  • 可能影响分支预测

这也是为什么一些性能极敏感代码会避免滥用运行时多态,而转向 CRTP、模板多态或数据导向设计。


第二部分:围绕高频追问继续展开

8. 构造函数为什么不能是虚函数?

标准回答

构造函数不能是虚函数,因为构造阶段对象尚未完全构造完成,动态类型也尚未稳定到"完整最派生对象"的语义,无法对一个尚未构建完毕的对象进行有意义的动态分派。

更深入地解释

构造过程是逐层进行的:

  1. 先构造基类部分
  2. 再构造派生类部分

在基类构造阶段,派生类子对象还没准备好。如果这时允许"按完整派生类虚派发",那很多派生类状态根本不存在,语义会崩。

高分点

构造函数不是"在完整对象上调用的一个普通成员函数",而是"创建对象过程的一部分"。而虚调用的前提是对象已经有稳定的动态类型语义,所以构造函数不能做虚派发。


9. 析构函数为什么经常要设为虚函数?

标准回答

如果一个类会被作为多态基类使用,那么它的析构函数通常应定义为虚函数。这样通过基类指针删除派生类对象时,才能先调用派生类析构、再调用基类析构,避免资源泄漏或析构不完整。

如果基类析构不是虚的会怎样?

通过 Base* 删除 Derived 时,行为是未定义的。很多实现里你会看到只调用 Base::~Base(),导致派生类资源没释放,但不要把它简单说成"只调基类析构"——更准确说法是未定义行为

一句总结

不是"所有类都必须有虚析构",而是"所有可能通过基类指针删除派生对象的多态基类,都应该优先考虑虚析构"。


10. 构造/析构期间调用虚函数会发生什么?

标准回答

在构造函数和析构函数中调用虚函数时,不会按很多人直觉那样"动态调用到最派生类版本",而是按当前构造/析构阶段所属类层次来决定。

为什么?

因为:

  • 构造基类时,派生部分还没建好
  • 析构基类时,派生部分已经拆掉了

这不是"编译器偷懒",而是为了保证对象语义一致:不能在一个未构造完或已部分销毁的对象上执行依赖完整派生状态的逻辑。

工程建议

  • 构造函数只做构造相关工作
  • 不在构造/析构里依赖派生类重写逻辑
  • 如需两阶段初始化,显式设计初始化接口

11. 纯虚函数和抽象类是什么?

标准回答

纯虚函数是使用 = 0 声明的虚函数;包含纯虚函数的类称为抽象类,抽象类不能实例化。

class Shape {
public:
    virtual void draw() = 0;
};

抽象类能有成员变量和普通函数吗?

能。抽象类只是"不能直接实例化",不代表它不能包含实现。

纯虚析构函数可以有定义吗?

可以,而且往往需要提供定义,因为析构链最终还是会调用到基类析构。

class Base {
public:
    virtual ~Base() = 0;
};
Base::~Base() {}

这题如果你能答出来,通常面试官会觉得你不只会最浅层定义。


12. 什么是覆盖(override)、重载(overload)、隐藏(hide)?

override(覆盖)

派生类重写基类同签名虚函数,用于动态多态。

overload(重载)

同一作用域中同名函数参数列表不同。

hide(隐藏)

派生类中定义同名函数后,可能把基类同名函数整体隐藏掉。

高频陷阱

class Base {
public:
    virtual void f(int);
};
class Derived : public Base {
public:
    void f(double);  // 不是 override,而是隐藏了基类名字
};

为什么建议写 override

因为它能让编译器帮你检查"你以为你重写了,其实没有"。这不是语法糖,而是非常实用的接口安全工具。


第三部分:把难点、边界和代价吃透

13. 单继承、多继承、虚继承分别有什么影响?

单继承

最简单,布局和指针转换通常比较直观。

多继承

一个对象里可能包含多个基类子对象,因此:

  • 从派生类指针转为不同基类指针时,地址可能需要调整
  • 对象布局更复杂
  • 多态调用和 RTTI 处理也更复杂

虚继承

用于解决菱形继承中公共基类重复出现的问题,但代价是:

  • 对象布局更复杂
  • 访问路径更间接
  • 指针调整逻辑更复杂

菱形继承为什么有问题?

class A {};
class B : public A {};
class C : public A {};
class D : public B, public C {};

D 会有两份 A 子对象,带来数据冗余、二义性访问、指针转换复杂。虚继承通过让最终对象只保留一份共享基类子对象来解决。

一句总结

虚继承解决的是公共基类重复子对象问题,但会引入更复杂布局和访问代价,所以不是能不用脑子乱上的特性。


14. dynamic_cast 和 RTTI 为什么通常比 static_cast 更重?

标准回答

因为 dynamic_cast 要在运行期做类型安全检查,通常依赖 RTTI,尤其在多态层次中可能涉及更复杂的类型关系判断和指针调整。

什么时候该用?

  • 你确实处于多态体系里
  • 需要安全地下转型
  • 无法通过更好的接口设计避免类型分支

RTTI 是什么?

RTTI(Run-Time Type Information)是运行时类型信息机制,typeiddynamic_cast 是与 RTTI 密切相关的典型语言工具。真正和动态类型强相关的 RTTI 通常依赖多态类型。

高分点

真正成熟的回答不是"dynamic_cast 慢",而是"它有运行期检查和类型系统成本,能不用时最好通过虚函数和多态接口避免,但在框架边界和插件系统里它仍然是合理工具"。


15. 一组典型追问链

  1. C++ 对象在内存中由哪些部分构成?
  2. 空类为什么大小不是 0?EBO 是什么?
  3. 类对象大小怎么计算?成员顺序为什么有影响?
  4. 内存对齐在解决什么问题?
  5. 什么是虚函数?虚表是什么?
  6. 虚函数调用的代价是什么?
  7. 构造函数为什么不能是虚函数?
  8. 基类析构为什么最好是虚函数?
  9. 构造/析构时调用虚函数会怎么样?
  10. 多继承下基类指针转换为什么可能发生地址调整?
  11. 虚继承在解决什么问题?代价是什么?

16. 一份更像面试现场的总结回答

C++ 对象模型的核心不是死记 vptr/vtable,而是理解"对象实例状态如何组织""多态如何在运行期分派""继承如何影响布局和指针转换"。普通对象主要由非静态成员、padding 和继承子对象构成;有虚函数时通常引入 vptr,从而支持运行期多态。虚函数调用的好处是接口统一、扩展性强,代价是额外间接调用和更复杂布局。构造函数不能是虚函数,因为对象尚未完整建立;多态基类通常要有虚析构,以保证通过基类指针删除派生对象时析构链完整。多继承和虚继承进一步增加布局、指针调整和理解成本,所以它们不只是语法问题,而是对象模型复杂度问题。


17. 复习建议

至少做到:

  • 能手算简单对象 sizeof
  • 能解释空类大小与 EBO
  • 能讲清虚函数、虚表、虚析构
  • 能说清构造/析构期虚调用边界
  • 能解释多继承下的地址调整
  • 能把"语言规则"和"工程代价"联系起来

做到这里,这一章就不是在背八股,而是在讲 C++ 的对象语义。


附录:对象模型关键概念代码验证

下面的代码可以直接编译运行,用于验证对象模型中最常被面试问到的几个现象。

代码一:sizeof、对齐、虚函数对对象大小的影响

#include <iostream>

// 空类
class Empty {};

// 不含虚函数的简单类
class NoVirtual {
    int a;
    char b;
};

// 含虚函数的类(多了一个 vptr)
class WithVirtual {
    int a;
    char b;
public:
    virtual void f() {}
};

// 成员顺序影响 padding
class PaddingA { char c; int i; char d; };   // char(1) + pad(3) + int(4) + char(1) + pad(3) = 12
class PaddingB { int i; char c; char d; };    // int(4) + char(1) + char(1) + pad(2) = 8

int main() {
    std::cout << "sizeof(Empty)       = " << sizeof(Empty)       << "\n";  // 通常 1
    std::cout << "sizeof(NoVirtual)   = " << sizeof(NoVirtual)   << "\n";  // 通常 8
    std::cout << "sizeof(WithVirtual) = " << sizeof(WithVirtual) << "\n";  // 通常 16 (64位: +8 for vptr)
    std::cout << "sizeof(PaddingA)    = " << sizeof(PaddingA)    << "\n";  // 通常 12
    std::cout << "sizeof(PaddingB)    = " << sizeof(PaddingB)    << "\n";  // 通常 8
    std::cout << "sizeof(void*)       = " << sizeof(void*)       << "\n";  // 8 (64位)
    return 0;
}

代码二:虚函数动态绑定 + 构造/析构期虚调用行为

#include <iostream>

class Base {
public:
    Base() {
        std::cout << "Base ctor, calling f(): ";
        f();  // 构造期调用虚函数:只会调 Base::f(),不会调派生类版本
    }
    virtual ~Base() {
        std::cout << "Base dtor, calling f(): ";
        f();  // 析构期同理
    }
    virtual void f() { std::cout << "Base::f()\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived ctor\n"; }
    ~Derived() override { std::cout << "Derived dtor\n"; }
    void f() override { std::cout << "Derived::f()\n"; }
};

int main() {
    std::cout << "=== 动态绑定 ===\n";
    Base* p = new Derived();
    p->f();      // 运行时多态,调用 Derived::f()
    delete p;    // 虚析构保证完整析构链

    std::cout << "\n=== 构造/析构期虚调用 ===\n";
    Derived d;   // 观察构造和析构期 f() 的绑定行为
    return 0;
}
// 输出说明:
// 构造 Base 时 f() 调用 Base::f()(此时 Derived 部分尚未构造)
// 析构 Base 时 f() 调用 Base::f()(此时 Derived 部分已被析构)
// 这就是为什么"构造/析构期不要依赖多态分派"

代码三:多继承下的指针调整

#include <iostream>

class A {
public:
    int a = 1;
    virtual void fa() { std::cout << "A::fa()\n"; }
};

class B {
public:
    int b = 2;
    virtual void fb() { std::cout << "B::fb()\n"; }
};

class C : public A, public B {
public:
    int c = 3;
    void fa() override { std::cout << "C::fa()\n"; }
    void fb() override { std::cout << "C::fb()\n"; }
};

int main() {
    C obj;
    A* pa = &obj;
    B* pb = &obj;

    std::cout << "C  address: " << &obj << "\n";
    std::cout << "A* address: " << pa   << "\n";
    std::cout << "B* address: " << pb   << "\n";

    // 多继承下 B* 通常不等于 C* 的首地址,编译器会做指针调整
    // 但虚函数调用仍然正确分派到 C 的覆盖版本
    pa->fa();  // C::fa()
    pb->fb();  // C::fb()

    std::cout << "sizeof(C) = " << sizeof(C) << "\n";
    // 通常包含:A 的 vptr + A::a + B 的 vptr + B::b + C::c + padding
    return 0;
}