ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

【C++进阶系列 (六)】C++ 内存对象模型:从内存块到多态的底层之旅

【C++进阶系列 (六)】C++ 内存对象模型:从内存块到多态的底层之旅 ⭐️在这个怀疑的年代我们依然需要信仰。个人主页 YYYing.⭐️C编程系列专栏C编程系列系列上期内容【C进阶系列 (五)】一文带你搞懂什么叫做“泛型编程“系列下期内容【C进阶系列 (七)】C RTTI 深度剖析目录1. 为什么需要对象模型2. 微观基础对象表示、值表示、对齐与填充2.1 对象表示 vs 值表示2.2 sizeof 的规则2.3 对齐与填充2.4 位域与 union2.5 存储期与内存区域3. 成员谁占对象谁不占3.1 非静态数据成员占3.2 静态数据成员不占3.3 成员函数不占3.4 this 指针3.5 成员函数指针3.6 const 成员函数4. 多态的心脏vptr 与 vtable4.1 什么是 vptr 和 vtable4.2 构造与析构时 vptr 的变化4.3 虚析构4.4 纯虚与抽象类5. 继承的内存布局5.1 单继承5.2 多重继承5.3 虚继承与菱形继承5.4 对象切片6. RTTI 与动态转换7. 生命周期与特殊工具7.1 平凡复制、标准布局、POD7.2 memcpy 的条件7.3 placement new 与 std::launder7.4 new / delete 表达式 vs operator new7.5 严格别名8. 现代 C 补充9. 宏观哲学与边界10. 面试回答模板直接可用结语---⭐️封面自取⭐️---1. 为什么需要对象模型从 C 的struct到 C 的class我们多了封装、继承、多态。但 C 的设计哲学是零开销抽象你不用的东西不该付出代价你用的东西不该比手写更慢。所以 C 没有把所有东西都塞进对象。它做了一个分工数据放对象里行为成员函数放代码段多态信息放 vtable虚函数指针对象里只留一个“指针”虚表指针去找到 vtable。生动比喻对象像一套公寓。数据成员是房间里的家具成员函数是小区物业的服务不在你家里vtable 是小区公告栏告诉你“遇到事情该找谁”vptr 是你家门牌上贴的一个二维码扫一下就能跳到公告栏。2. 微观基础对象表示、值表示、对齐与填充2.1 对象表示 vs 值表示C 标准把对象的比特分成两层对象表示object representation对象占用的所有字节包括填充。值表示value representation真正决定值的那些比特。比如一个int占 4 字节但可能有些位是填充或无效位。memcpy拷贝的是对象表示不是值表示。2.2sizeof的规则空类大小是1因为每个对象必须有唯一地址。结构体大小是最大对齐的整数倍所以会有填充。成员按声明顺序排列但受访问控制和编译器影响标准布局类型保证顺序。示例struct Empty {}; struct A { int x; }; struct B { char c; int i; char d; }; struct C { virtual void f(); int x; };64 位 GCC 下sizeof(Empty) 1sizeof(A) 4sizeof(B) 121 3 填充 4 1 3 填充sizeof(C) 16vptr 8 int 4 4 填充2.3 对齐与填充对齐规则每个类型有alignof(T)。成员地址必须是其对齐的整数倍。结构体总大小必须是最大对齐的整数倍。struct Bad { char c; int i; char d; }; // 12 struct Good { int i; char c; char d; }; // 8我的思考调整成员顺序就能省内存这是后端开发里非常实用的细节。高并发服务器里一个连接对象省 4 字节十万连接就是 400KB。2.4 位域与 union位域int x : 3;布局由实现定义不能取地址。union大小是最大成员对齐是最大对齐。同一时间只有一个活跃成员。union U { int i; char c[4]; };2.5 存储期与内存区域自动存储期栈函数结束销毁。静态存储期全局/静态区程序结束销毁。动态存储期堆手动或智能指针管理。线程存储期thread_local。对象模型主要关心“对象在内存里怎么摆”但存储期决定了它活在哪。3. 成员谁占对象谁不占3.1 非静态数据成员占每个非静态数据成员都占对象空间按对齐规则摆放。3.2 静态数据成员不占静态成员属于类不属于对象存在静态存储区。struct S { static int count; int x; }; // sizeof(S) sizeof(int)3.3 成员函数不占非静态成员函数不占对象空间。它们编译成普通函数只是多了一个隐式this参数。struct S { void foo(); int x; }; // sizeof(S) sizeof(int)3.4this指针非静态成员函数调用时编译器偷偷传入this。在 x86-64 System V ABI 里this通常放在rdi寄存器。3.5 成员函数指针成员函数指针不是普通指针。它可能需要存储函数地址this 调整偏移多重继承时。在 Itanium ABI 下成员函数指针通常是 16 字节。3.6const成员函数const成员函数本质是this类型变成const T*不影响对象大小。4. 多态的心脏vptr 与 vtable4.1 什么是 vptr 和 vtablevptr对象里的一个隐藏指针指向该类的 vtable。vtable每个多态类一份放在只读数据段里面是虚函数地址、RTTI 信息、偏移量等。struct Base { virtual void f(); virtual void g(); int x; };64 位 GCC 下Base对象布局---------------- | vptr | 8 字节 ---------------- | x | 4 字节 ---------------- | padding | 4 字节 ----------------vtable 大致---------------- | offset-to-top | | type_info | | Base::f | | Base::g | ----------------4.2 构造与析构时 vptr 的变化构造顺序虚基类 → 基类 → 成员 → 自身。在基类构造期间vptr 指向基类 vtable构造完派生类后vptr 才指向派生类 vtable。析构反过来。所以struct Base { Base() { f(); } virtual void f() { cout Base; } }; struct Derived : Base { void f() override { cout Derived; } }; Derived d; // 输出 Base而不是 Derived面试重点构造/析构中调用虚函数不会多态到派生类。4.3 虚析构Base* p new Derived; delete p; // 如果 Base 析构不是 virtualDerived 析构不会被调用所以多态基类必须虚析构。4.4 纯虚与抽象类纯虚函数 0让类变成抽象类不能实例化。vtable 里对应槽可能是__cxa_pure_virtual调用会报错。5. 继承的内存布局5.1 单继承struct Base { virtual void f(); int a; }; struct Derived : Base { int b; };Derived 对象---------------- | Base 子对象 | | vptr | | a | ---------------- | b | ----------------Derived 的 vtable 覆盖f槽指向Derived::f。5.2 多重继承struct B1 { virtual void f(); int a; }; struct B2 { virtual void g(); int b; }; struct D : B1, B2 { int c; };D 对象---------------- | B1 子对象 | | vptr1 | | a | ---------------- | B2 子对象 | | vptr2 | | b | ---------------- | c | ----------------D*转B2*时地址要加上偏移。vtable 里可能有thunk调整this后再跳转。D d; B2* p2 d; // p2 指向 d 中的 B2 子对象地址与 d 不同5.3 虚继承与菱形继承struct V { int v; }; struct B1 : virtual V { int b1; }; struct B2 : virtual V { int b2; }; struct D : B1, B2 { int d; };D 对象里V只保留一份放在末尾。B1、B2 子对象里有vbptr虚基类表指针用来找到共享的V子对象。---------------- | B1 子对象 | | vbptr | | b1 | ---------------- | B2 子对象 | | vbptr | | b2 | ---------------- | d | ---------------- | V 子对象 | | v | ----------------我的思考虚继承解决了菱形继承的数据冗余但增加了间接访问和指针调整性能有代价。能不用就不用。5.4 对象切片void foo(Base b); Derived d; foo(d); // 只拷贝 Base 部分Derived 部分被切掉切片后vptr 变成 Base 的 vtable多态失效。6. RTTI 与动态转换typeid返回std::type_info多态类型运行时才能确定。dynamic_cast依赖 RTTI 和 vtable做运行时检查。vtable 里通常有一个槽指向type_info。Base* p new Derived; typeid(*p).name(); // Derived Derived* d dynamic_castDerived*(p); // 成功dynamic_cast可能返回调整后的指针因为多重继承下地址不同。对于RTTI更详细的内容将在我们下一篇中进行展开7. 生命周期与特殊工具7.1 平凡复制、标准布局、PODtrivially copyable可以用memcpy安全复制。standard layout布局有保证可以和 C 结构体互操作。PODC20 已弃用之前指平凡且标准布局。static_assert(std::is_trivially_copyable_vS);7.2memcpy的条件只有平凡复制类型才能用memcpy拷贝对象。有虚函数、有自定义拷贝构造、有虚基类通常不行。7.3placement new与std::launderalignas(T) char buf[sizeof(T)]; T* p new (buf) T; // 如果之后用旧指针访问可能需要 std::launderC17 的std::launder用于告诉编译器“这里已经是一个新对象了”避免优化导致的 UB。7.4new/delete表达式 vsoperator newnew T会调用operator new分配内存再调用构造函数。delete p先调用析构再调用operator delete。new[]可能多分配一块空间存元素个数cookie尤其元素有析构函数时。7.5 严格别名通过不相关类型的指针访问同一内存是 UB除了char*、unsigned char*、std::byte*。这就是为什么网络编程里常用char*做缓冲区。8. 现代 C 补充alignas/alignof手动控制对齐。[[no_unique_address]]C20空成员可优化不占额外空间。空基类优化EBO空基类不增加派生类大小。final/override帮助编译器优化和检查。默认参数静态绑定虚函数默认参数在编译期决定容易踩坑。ABI 兼容性GCC/Clang 用 Itanium ABIMSVC 用自己的。跨编译器二进制不兼容。9. 宏观哲学与边界C 对象模型是类型系统在内存上的投影。它让程序员用类、继承、多态写代码又让编译器在背后生成接近手写 C 的机器码。它的核心权衡是运行时多态vptr vtable一次间接跳转。静态多态模板编译期展开零运行时开销。布局控制标准布局、对齐、EBO让你能精确控制内存。代价ABI 依赖、对象切片、多重继承复杂度、严格别名陷阱。写博客时一定要强调标准不规定 vptr/vtable 的具体位置以上都是主流实现。面试时你可以说“在 Itanium ABI 下通常如此”这样既专业又严谨。10. 面试回答模板直接可用面试官问谈谈 C 对象模型。您好我会从四个层面回答。第一本质。C 对象模型是类型系统在内存上的投影。对象就是一块内存类型决定怎么解释它。对象里通常只存非静态数据成员和可能的 vptr成员函数、静态成员、vtable 都在对象外。第二布局。数据成员按对齐规则摆放会有填充。空类大小是 1。有虚函数的类会多一个 vptr64 位下通常 8 字节。调整成员顺序可以优化大小。第三多态。多态靠 vptr 指向 vtable。vtable 在只读数据段每个多态类一份存虚函数地址和 RTTI。构造时 vptr 从基类指向派生类析构反向所以构造/析构中调用虚函数不会多态到派生类。多态基类必须虚析构。第四继承。单继承基类子对象在前多重继承有多个 vptr指针转换要调整地址vtable 里可能有 thunk虚继承用虚基类表共享基类解决菱形继承但有性能代价。对象切片会丢失派生部分。最后边界。标准不规定 vptr/vtable依赖 ABI。写代码要注意平凡复制、标准布局、严格别名、对象生命周期。我的总结是C 对象模型用最小的运行时开销换来了强大的抽象能力但代价是程序员必须理解底层布局。结语简单来说C 内存对象模型 一块内存 类型解释。对象里通常只存非静态数据成员和可能的vptr虚表指针成员函数、静态成员、虚函数表vtable都在对象外面。我是YYYing后面还有更精彩的内容希望各位能多多关注支持一下主包。无限进步我们下次再见---⭐️封面自取⭐️---
返回列表