⚡C++ 对象模型与多态

对象大小与内存布局

面试回答

常见问法

  • sizeof 一个对象,到底由哪些因素决定?
  • 为什么空类不是 0,而是 1?
  • 成员变量顺序会影响对象大小吗?
  • 虚函数、继承、虚继承会怎样影响对象内存布局?
  • 为什么要内存对齐?不对齐会怎样?
  • sizeof(class) 和对象实际占用内存是一样的吗?

回答

对象大小本质上由 成员大小 + 对齐要求 + 填充字节 + 继承布局 + 多态开销 共同决定。

一个比较完整、面试里好用的回答是:

sizeof(对象) 不是简单把成员大小相加。编译器在布局对象时,会同时考虑成员本身的大小、每个成员的对齐要求、为了满足对齐插入的填充字节,以及继承带来的基类子对象布局。如果类里有虚函数,通常还会额外有一个虚表指针 vptr;如果有虚继承,布局会更复杂,往往还会引入额外的指针或偏移信息。

至于空类为什么大小不是 0,是因为 C++ 要保证两个不同对象通常拥有不同地址,所以即使没有成员,空类对象也至少要占 1 个字节。

工程上判断对象大小,通常优先看三件事:成员顺序是否造成浪费、是否引入多态、是否用了复杂继承。这三类问题最容易让对象“变胖”。

追问

  • 为什么编译器要做内存对齐?
  • 成员顺序为什么会影响大小?
  • 虚函数一定会增加一个指针大小吗?
  • 空类作基类时,为什么有时看起来“不占空间”?
  • #pragma pack(1) 能不能直接用来省内存?
  • sizeof 和堆上 new 出来的总开销有什么区别?
  • 多重继承、虚继承时,指针转换为什么会变复杂?

原理展开

1. 对象大小由什么决定

影响对象大小的核心因素可以按下面顺序理解:

① 非静态成员变量

只有 非静态成员变量 直接参与对象大小计算。 静态成员变量属于类本身,不属于某个对象实例,所以 不计入 sizeof。 成员函数也不计入对象大小,普通成员函数本质上和对象实例分离存放。

struct S {
    int x;              // 计入对象大小
    static int cnt;     // 不计入 sizeof(S)
    void func() {}      // 不计入 sizeof(S)
};

② 对齐要求

每个类型通常都有自己的对齐要求,例如:

  • char 通常按 1 字节对齐
  • int 通常按 4 字节对齐
  • double 通常按 8 字节对齐

编译器会把成员放到满足其对齐要求的位置上。 这样做的主要原因是 提高 CPU 访问效率,某些平台上未对齐访问甚至可能更慢,或者直接触发硬件异常。

③ 填充字节(padding)

为了满足对齐,编译器会在成员之间,甚至对象尾部插入填充字节。 所以对象大小常常 大于成员大小之和

struct A {
    char c;   // 1
    int  i;   // 4,通常要求4字节对齐
    char d;   // 1
};
// 典型布局:c + 3字节填充 + i + d + 3字节尾部填充
// sizeof(A) 常见为 12

④ 类整体对齐

对象总大小通常还要满足“整个对象的对齐要求”, 这个要求通常等于其成员中最严格的那个。

所以尾部也可能补 padding,使得数组中每个元素都能正确对齐。

struct B {
    int  i;   // 4
    char c;   // 1
};
// 成员和为 5,但常见 sizeof(B) == 8
// 因为对象整体通常按 4 字节对齐,尾部补 3 字节

2. 为什么空类也有大小

空类没有数据成员,但对象通常不能是 0 字节。原因是:

语言需要保证不同对象通常有不同地址,至少要能区分“这是两个不同实例”。

因此空类通常大小为 1。

struct Empty {};
static_assert(sizeof(Empty) == 1);

这是面试里最常见的回答,但更高分的补充是:

空类作基类时,可能出现“空基类优化”(EBO)

空类作为 基类 时,编译器通常可以不给它单独分配存储空间,这叫 Empty Base Optimization

struct Empty {};
struct X : Empty {
    int value;
};

static_assert(sizeof(X) == sizeof(int)); // 常见成立

也就是说:

  • 空类作为独立对象:通常至少 1 字节
  • 空类作为基类:可能被优化为 0 额外开销

C++20 的 [[no_unique_address]]

对于空成员对象,C++20 提供了 [[no_unique_address]],允许编译器做类似优化。

struct Empty {};

struct Y {
    [[no_unique_address]] Empty e;
    int value;
};

这个点不是必须答,但如果面试官继续追问“空对象一定占 1 字节吗”,这个补充很加分。


3. 为什么成员顺序会影响对象大小

成员顺序直接影响 padding 的位置和数量。

struct Bad {
    char  c1;   // 1
    int   i;    // 4
    char  c2;   // 1
};
// 常见 sizeof(Bad) == 12

struct Good {
    int   i;    // 4
    char  c1;   // 1
    char  c2;   // 1
};
// 常见 sizeof(Good) == 8

面试表述建议

相同成员、不同顺序,sizeof 可能不同。工程上如果对象数量非常大,比如百万级节点、缓存条目、消息结构体,合理调整成员顺序能显著降低内存占用,并提高缓存友好性。

选择原则

一般把 对齐要求高的成员放前面,小成员尽量聚合在一起。 但不要为了省几个字节牺牲可读性,只有在热点对象、批量对象、内存敏感系统中才值得刻意优化。


4. 虚函数为什么会影响对象大小

如果类有虚函数,编译器通常会给对象里放一个 虚表指针 vptr,指向该类的虚函数表 vtable,用于运行时多态分发。

struct Base {
    virtual void f() {}
    int x;
};

典型情况下对象布局可能类似:

// [vptr][x][padding]

因此:

  • 类一旦引入虚函数,对象通常会至少增加一个指针大小
  • 在 64 位系统上,常见是增加 8 字节左右,再加上可能的对齐填充

但要注意一个高频误区

C++ 标准并没有规定必须用 vptr/vtable 实现虚函数。

只是主流编译器几乎都这样做,所以面试时要说“通常”“大多数 ABI 实现中”。

虚析构函数也一样

虚析构函数本质上也是虚函数,所以也会让对象具备多态布局开销。


5. 继承如何影响对象布局

单继承

派生类对象通常由:

  • 基类子对象
  • 派生类新增成员

两部分组成。

struct Base {
    int x;
};

struct Derived : Base {
    int y;
};

// 常见布局:[Base::x][Derived::y]

这意味着派生类大小通常至少是:

基类子对象大小 + 派生类新增成员大小 + 可能的填充

多重继承

如果一个类继承多个基类,对象里通常会包含多个基类子对象。

struct A { int a; };
struct B { int b; };
struct C : A, B { int c; };

// 常见布局:[A子对象][B子对象][C::c]

这会带来两个后果:

  1. 对象更大
  2. 基类指针转换可能涉及地址调整,不一定是同一个起始地址

虚继承

虚继承是为了解决菱形继承中虚基类重复的问题,但代价是布局复杂度显著增加。

struct VB { int v; };
struct D1 : virtual VB { int d1; };
struct D2 : virtual VB { int d2; };
struct Final : D1, D2 { int f; };

虚继承通常意味着:

  • 虚基类只保留一份
  • 但对象里往往需要额外信息定位虚基类
  • 指针调整和布局都更复杂
  • 对象大小、访问成本、可读性通常都更差

工程实践建议

如果不是必须解决菱形继承共享同一份基类状态的问题,实际工程里一般尽量避免虚继承。多数业务代码更推荐接口继承 + 组合。


6. sizeof 到底算的是什么

sizeof(T)sizeof(obj) 算的是:

编译器为该类型对象本体分配的静态存储大小

它不包括:

  • 堆分配器额外元数据
  • 成员指针指向的堆内存
  • STL 容器内部动态扩容出去的空间
  • 虚函数表本身那张表的大小(对象里通常只放指针)
struct Node {
    int val;
    int* p;
};

sizeof(Node); // 只算 int + 指针 + padding
// 不算 p 指向的那块堆内存

这句话在面试中非常重要,因为很多候选人会把“对象大小”和“对象相关的总内存消耗”混为一谈。


7. 对齐为什么重要

性能角度

CPU 通常更擅长按自然边界访问数据。对齐后:

  • 读取次数可能更少
  • 总线访问更高效
  • 缓存行行为更稳定

兼容性角度

某些硬件架构对未对齐访问更敏感,可能:

  • 性能下降
  • 需要额外指令修正
  • 严重时触发异常

面试里别答太绝对

“为了性能”是对的,但不完整。更稳妥的说法是:

对齐主要是为了满足硬件访问约束并提升访问效率,不同平台影响程度不同,但现代 C++ 编译器默认都会按 ABI 规则做合理对齐。


8. #pragma pack / __attribute__((packed)) 能不能用

可以,但要谨慎。

作用

它们可以降低或取消对齐,减少 padding,从而压缩结构体大小。

#pragma pack(push, 1)
struct Packet {
    char c;
    int  i;
};
#pragma pack(pop)

风险

  • 可能导致未对齐访问,带来性能下降
  • 某些平台可能有兼容性问题
  • 直接把内存布局当协议格式,容易埋下可移植性问题

工程建议

通常只在这些场景使用:

  • 网络协议头
  • 磁盘文件格式
  • 硬件寄存器映射
  • 与外部二进制协议严格对齐的场景

普通业务对象、核心数据结构,不要为了省几个字节随便 pack(1)


9. 典型代码示例

示例 1:空类

struct Empty {};
static_assert(sizeof(Empty) == 1);

示例 2:成员顺序影响大小

struct S1 {
    char c;
    int  i;
    char d;
};
// 常见 sizeof(S1) == 12

struct S2 {
    int  i;
    char c;
    char d;
};
// 常见 sizeof(S2) == 8

示例 3:虚函数增加对象开销

struct V {
    int x;
    virtual void f() {}
};
// 64位下常见大于等于 16

示例 4:继承影响布局

struct Base {
    int x;
};

struct Derived : Base {
    char y;
};
// 大小 = Base子对象 + y + padding

示例 5:空基类优化

struct Empty {};
struct Holder : Empty {
    int x;
};

static_assert(sizeof(Holder) == sizeof(int)); // 常见成立

对比总结

概念是否计入 sizeof(对象)典型影响适用场景优点风险 / 缺点
非静态成员变量直接增加对象本体大小所有对象语义明确成员多时对象膨胀
静态成员变量不影响单个对象大小共享状态节省实例空间需要考虑全局状态管理
普通成员函数不进入对象本体行为定义不增加对象体积无法单独表达对象状态
指针成员只增加指针本身大小资源句柄、Pimpl、树/图结构解耦、可延迟分配不包含指向内存,易误判总占用
动态分配内存否(对象本体外)sizeof 分离大对象、可变长数据灵活堆开销、碎片、管理复杂
情况布局特点大小是否容易变大典型说明
普通类成员 + padding中等主要看成员顺序和对齐
含虚函数类通常多一个 vptr64位常多一个指针并伴随对齐
单继承基类子对象 + 派生类成员中等通常比较直观
多重继承多个基类子对象并列指针转换可能要调地址
虚继承共享虚基类 + 额外定位信息很容易变大解决菱形问题,但布局最复杂
方案适用场景优点缺点实践建议
调整成员顺序热点对象、海量对象减少 padding,成本低可读性可能变差首选优化手段
alignas 指定更高对齐SIMD、缓存优化、硬件接口可控对齐可能增大对象体积只在明确需要时使用
pack(1) 压缩布局协议、文件、寄存器映射节省空间,布局可控性能和可移植性风险仅用于二进制边界
组合替代复杂继承工程代码结构清晰,易维护设计上略繁琐大多数业务代码推荐

易错点

  • 以为 sizeof 就是“成员大小简单相加”。
  • 忽略了 尾部 padding,只看成员之间的空隙。
  • 认为成员声明顺序不影响对象大小,实际上影响很大。
  • 对象本体大小对象关联的总内存占用 混为一谈。
  • 以为有虚函数时,标准规定必须有 vptr,其实这只是主流实现方式。
  • 认为空类永远只占 1 字节,没有意识到 空基类优化 EBO
  • 误以为静态成员也算在对象里。
  • 认为 pack(1) 一定是优化,忽略了未对齐访问风险。
  • 拿某个平台的 sizeof 结果当成语言层面的绝对结论,忽略 ABI、编译器、平台差异。
  • 把“内存布局”和“序列化格式”画等号,直接 memcpy 跨平台传输对象。

记忆技巧

  • 四个关键词记忆对象大小:成员、对齐、填充、继承

  • 一旦看到虚函数,再补一个关键词:多态开销

  • 一句话口诀: “先看成员,再看对齐;有虚看指针,有继承看基类。”

  • 判断大小时按这个顺序想:

    1. 非静态成员有哪些?
    2. 最大对齐要求是多少?
    3. 成员之间会不会插 padding?
    4. 对象尾部需不需要补齐?
    5. 有没有虚函数?
    6. 有没有基类、多个基类、虚基类?

面试速答版

sizeof(对象) 不是成员大小简单相加,它主要由 非静态成员大小、对齐要求、编译器插入的填充字节、继承带来的基类子对象,以及虚函数带来的多态开销 决定。 空类通常大小是 1,不是因为它有数据,而是为了保证不同对象通常有不同地址。 成员顺序会影响大小,因为不同顺序会导致不同的 padding。 如果类有虚函数,主流实现里对象通常会多一个 vptr;如果有继承,尤其虚继承,布局会更复杂,也可能引入额外开销。 工程上如果要优化对象大小,优先做成员重排,其次再评估是否真的需要多态和复杂继承。


面试加分版

对象大小我一般从五个维度看:成员、对齐、填充、继承、多态

先说最基础的,sizeof 只统计对象本体,也就是非静态成员加上编译器为了布局插入的额外空间。静态成员和成员函数都不算在对象里。 然后是对齐。编译器会按 ABI 规则把成员放到合适边界上,所以对象大小经常大于成员大小之和。典型例子是 char + int + char,逻辑上是 6 字节,但实际常见是 12 字节,因为中间和尾部都会补 padding。 空类通常大小为 1,这是为了保证不同对象通常有不同地址;但如果空类作为基类,编译器常常会做空基类优化,不一定真的额外占空间。 再往上就是多态。如果类有虚函数,主流实现里对象里通常会有一个 vptr 指向虚表,所以对象一般会变大,64 位下常见增加一个指针大小并伴随对齐填充。 继承也会影响布局。单继承相对直观,就是基类子对象加派生类成员;多重继承会有多个基类子对象;虚继承为了共享虚基类,布局最复杂,往往还有额外定位开销。 所以工程上如果想优化对象大小,我通常优先做三件事:第一,调整成员顺序减少 padding;第二,确认是否真的需要虚函数;第三,避免不必要的复杂继承,尤其虚继承。 另外我会特别区分 sizeof 和总内存占用,比如对象里有指针,sizeof 只算指针本身,不算指向的堆内存,这在面试里是一个很容易被追问的点。