这个专栏假设你已经读完「C++ 基础」专栏,能够用 C++ 的类、RAII 和命名空间写出小型程序。现在我们不再按“语法点”来组织内容,而是从另一个角度来回答一个核心问题:
C++ 里的“对象”到底长什么样?
继承、多态、虚函数这些特性,在内存布局和调用流程层面究竟做了什么?
我们不会去做 ABI 级别的实现考古,而是以设计决策为主线,在刚好足够的实现细节上建立心智模型:
- 对象在内存中是怎样排布的;
- 不同的继承/多态写法背后,编译器帮你“偷偷”做了哪些事;
- 这些实现细节反过来如何影响接口设计与代码风格。
为了降低单篇的阅读压力,本专栏会把每个主题拆成粒度更小的几篇文章,你可以按章节渐进式地构建自己的对象模型心智图。
导读
-
- 解释"对象模型"这个词在本专栏里的具体含义,它和"面向对象编程"的区别,以及掌握它能帮你解决什么实际问题。
-
本专栏的阅读地图与工具准备 (🚧 计划中)
- 列出先修知识、推荐阅读顺序,以及会用到的工具(Compiler Explorer、GDB/LLDB、AddressSanitizer)的快速入门。
第一部分:对象的内存布局
这一部分回答一个核心问题:一个 C++ 对象在内存里到底长什么样?
-
对象就是一块内存:从 C struct 到 C++ class
- 从最简单的 POD struct 出发,理解"对象 = 一块带类型标签的内存"。顺带讲清
struct与class的唯一区别(默认访问权限),以及何时用 struct、何时用 class 的风格约定。 - 涉及语法:
struct/class定义、public/private/protected访问修饰符
- 从最简单的 POD struct 出发,理解"对象 = 一块带类型标签的内存"。顺带讲清
-
- 通过几个小例子,拆解成员顺序、对齐要求、填充字节如何共同决定对象大小。讲解
alignas和alignof在需要手动控制对齐(如 SIMD、缓存行)时的用法。 - 涉及语法:
sizeof、alignof、alignas、成员变量声明顺序
- 通过几个小例子,拆解成员顺序、对齐要求、填充字节如何共同决定对象大小。讲解
-
空类的 sizeof 为什么是 1?空基类优化与
[[no_unique_address]]- 解释空类大小的规则、空基类优化(EBO)的原理,以及 C++20
[[no_unique_address]]属性如何让空成员"不占空间"。 - 涉及语法:空类定义、
[[no_unique_address]]属性
- 解释空类大小的规则、空基类优化(EBO)的原理,以及 C++20
-
- 解释成员函数在 ABI 层面如何被实现成普通函数 + 隐式 this 参数。顺带讲清
const成员函数的含义(this 变成const T*)、返回*this实现链式调用的原理。 - 涉及语法:成员函数定义、
const成员函数、*this返回值
- 解释成员函数在 ABI 层面如何被实现成普通函数 + 隐式 this 参数。顺带讲清
-
- 区分静态成员与普通成员的差异,说明静态成员在内存中的存放位置。讲解静态成员变量的类外定义规则,以及 C++17
inline static简化写法。 - 涉及语法:
static成员变量/函数、inline static、类外定义
- 区分静态成员与普通成员的差异,说明静态成员在内存中的存放位置。讲解静态成员变量的类外定义规则,以及 C++17
第二部分:构造、析构与对象生命周期
这一部分回答:对象从"一块内存"变成"可用对象"的过程中,发生了什么?
-
- 区分"内存被分配"与"对象被构造"两个阶段,理解编译器插入构造/析构调用的规则。顺带讲清默认构造函数何时会被隐式生成、何时会被删除。
- 涉及语法:构造函数、析构函数、
= default、隐式生成规则
-
成员初始化顺序的坑:为什么初始化列表顺序不重要? (🚧 计划中)
- 解释成员初始化顺序由声明顺序决定的原因,以及违反这一规则可能导致的微妙 bug。讲解初始化列表语法、类内成员初始化器(C++11)的使用。
- 涉及语法:初始化列表
: member(value)、类内初始化器int x = 0;
-
- 介绍 C++11 委托构造函数(一个构造函数调用另一个)和
using Base::Base;继承构造函数,说明它们如何简化代码。 - 涉及语法:委托构造
: ClassName(args)、using Base::Base;
- 介绍 C++11 委托构造函数(一个构造函数调用另一个)和
-
placement new:在指定内存上构造对象 (🚧 计划中)
- 解释 placement new 的原理与使用场景(内存池、对象复用),以及为什么必须手动调用析构函数。
- 涉及语法:
new (ptr) T(args)、obj->~T()显式析构
第三部分:拷贝、移动与特殊成员函数
这一部分回答:编译器"偷偷"帮你生成的那些函数,到底在做什么?
-
拷贝构造与拷贝赋值:编译器默认做了什么? (🚧 计划中)
- 解释默认拷贝操作的"逐成员拷贝"行为,何时够用、何时会出问题(如裸指针成员导致的 double free)。讲解拷贝构造函数和拷贝赋值运算符的正确签名。
- 涉及语法:
T(const T&)、T& operator=(const T&)
-
- 用内存布局图解释深浅拷贝的区别。顺带讲解 copy-and-swap 惯用法:如何用 swap 优雅地实现异常安全的赋值。
- 涉及语法:
std::swap、以值传递参数的赋值运算符写法
-
移动语义:资源的所有权转移是怎么回事? (🚧 计划中)
- 从对象模型角度解释移动语义:移动后源对象的状态、为什么移动比拷贝更高效。讲解移动构造函数和移动赋值运算符的正确签名、
noexcept的重要性。 - 涉及语法:
T(T&&) noexcept、T& operator=(T&&) noexcept、右值引用&&
- 从对象模型角度解释移动语义:移动后源对象的状态、为什么移动比拷贝更高效。讲解移动构造函数和移动赋值运算符的正确签名、
-
std::move 不移动任何东西:一个常见误解 (🚧 计划中)
- 解释
std::move只是一个 cast(把左值转成右值引用),真正的"移动"发生在移动构造/赋值函数里。讲解何时该用std::move、何时不该用。 - 涉及语法:
std::move()、static_cast<T&&>()
- 解释
-
- 把三条规则放回历史背景里:从 Rule of 3 到 Rule of 5,再到 Rule of 0。讲清为什么它们总是和深浅拷贝、异常安全、移动语义绑在一起。
- 涉及语法:
= default、= delete、拷贝/移动构造与赋值、noexcept
第四部分:继承的内存布局
这一部分回答:继承时,基类和派生类在内存里是怎么"拼"在一起的?
-
派生类对象里藏着一个基类对象 (🚧 计划中)
- 从内存布局角度解释继承:派生类对象"包含"一个基类子对象,以及这如何影响指针转换。讲解继承的基本语法和
class Derived : public Base的含义。 - 涉及语法:继承声明
: public/protected/private Base
- 从内存布局角度解释继承:派生类对象"包含"一个基类子对象,以及这如何影响指针转换。讲解继承的基本语法和
-
public / protected / private 继承:不只是访问权限 (🚧 计划中)
- 解释三种继承方式对"is-a"关系和成员可见性的影响。讲解
protected访问权限的含义、private 继承作为"实现继承"的典型用法。 - 涉及语法:三种继承方式、
protected成员
- 解释三种继承方式对"is-a"关系和成员可见性的影响。讲解
-
using 声明:改变继承成员的访问权限 (🚧 计划中)
- 讲解如何用
using Base::member;在派生类中调整基类成员的访问级别,以及用using Base::Base;继承构造函数。 - 涉及语法:
using Base::member;、using Base::Base;
- 讲解如何用
-
继承 vs 组合:什么时候该用哪个? (🚧 计划中)
- 总结常见的误用继承场景(为了代码复用而滥用基类),给出一套判断准则:何时用继承(is-a)、何时用组合(has-a)。
-
多重继承的内存布局:指针转换时发生了什么? (🚧 计划中)
- 解释多重继承时对象如何包含多个基类子对象,以及向上转型时指针值变化的原因。
- 涉及语法:多重继承
: public Base1, public Base2
-
菱形继承与虚继承:解决问题的代价 (🚧 计划中)
- 用 iostream 为例说明菱形继承问题,介绍虚继承如何保证只有一份共享基类,以及带来的额外成本。
- 涉及语法:
virtual继承: virtual public Base
第五部分:虚函数与动态多态
这一部分回答:虚函数调用时,编译器和运行时各自做了什么?
-
virtual 关键字到底做了什么? (🚧 计划中)
- 解释 virtual 的作用:允许派生类重写、启用动态绑定、引入 vptr。对比虚函数与非虚函数在继承中的行为差异(静态绑定 vs 动态绑定)。
- 涉及语法:
virtual关键字、函数重写
-
纯虚函数与抽象类:定义接口的标准方式 (🚧 计划中)
- 解释
= 0的含义、抽象类不能实例化的原因。顺带提及纯虚函数可以有实现体(作为默认实现)的冷知识。 - 涉及语法:
virtual void foo() = 0;、抽象类
- 解释
-
虚函数表长什么样?vptr 与 vtable 图解 (🚧 计划中)
- 构建一幅"对象 + vptr + vtable"的概念图,解释单继承下虚函数表的结构。
-
p->foo()这一行代码背后发生了什么? (🚧 计划中)- 围绕
Base* p = new Derived; p->foo();,追踪从构造到调用的全过程:构造时写入 vptr、调用时查表跳转。
- 围绕
-
构造/析构函数里调虚函数:一个经典陷阱 (🚧 计划中)
- 解释为什么在构造/析构函数中调用虚函数不会得到预期的多态行为(vptr 尚未/已被"降级")。
-
对象切片:多态对象千万别按值传递 (🚧 计划中)
- 解释对象切片的成因(按值复制时只拷贝基类部分)、危害,以及如何在代码评审中识别这类问题。
-
虚析构函数:为什么接口类几乎都要写它? (🚧 计划中)
- 通过
delete base_ptr的例子展示非虚析构导致的资源泄漏。讲解virtual ~Base() = default;的写法,以及替代方案(protected 非虚析构)。 - 涉及语法:
virtual ~ClassName()、protected析构函数
- 通过
-
override 和 final:防止重写错误的两个关键字 (🚧 计划中)
- 介绍 C++11 的
override(显式标记重写,防止签名错误)和final(禁止进一步重写或继承),说明它们如何帮助编译器捕获常见错误。 - 涉及语法:
override、final(用于函数或类)
- 介绍 C++11 的
第六部分:RTTI 与类型转换
这一部分回答:在运行时如何"安全地"知道对象的真实类型?
-
C++ 的四种类型转换:何时用哪个? (🚧 计划中)
- 对比
static_cast、dynamic_cast、const_cast、reinterpret_cast的适用场景与安全性差异。 - 涉及语法:四种 cast 的语法与典型用法
- 对比
-
RTTI 是什么?typeid 和 type_info 入门 (🚧 计划中)
- 介绍运行时类型信息的概念、
typeid运算符的用法,以及type_info类的基本功能。 - 涉及语法:
typeid(expr)、type_info::name()、type_info::operator==()
- 介绍运行时类型信息的概念、
-
dynamic_cast:安全的向下转型 (🚧 计划中)
- 解释
dynamic_cast的工作原理(通过 RTTI 检查类型),与static_cast的区别,以及失败时的行为(指针返回 nullptr,引用抛std::bad_cast)。 - 涉及语法:
dynamic_cast<Derived*>(base_ptr)、dynamic_cast<Derived&>(base_ref)
- 解释
-
禁用 RTTI 会怎样?性能与替代方案 (🚧 计划中)
- 讨论
-fno-rtti的使用场景,禁用后的限制,以及替代方案(如虚函数返回类型 ID)。
- 讨论
第七部分:多态的替代方案与选型
这一部分回答:除了虚函数,还有哪些方式实现"多态"?该怎么选?
-
模板多态:编译期"长出"不同的实现 (🚧 计划中)
- 用策略模式的例子,展示如何用模板参数替代虚函数,实现零运行时开销的多态。对比模板与虚函数在编译时间、二进制体积、可读性上的取舍。
- 涉及语法:函数模板、类模板、模板参数
-
CRTP:奇异递归模板模式是什么? (🚧 计划中)
- 介绍 CRTP 的基本形式
class Derived : public Base<Derived>与典型用法(静态多态、mixin),解释它如何在编译期实现类似虚函数的行为。 - 涉及语法:CRTP 模式的模板写法
- 介绍 CRTP 的基本形式
-
std::function:可调用对象的类型擦除 (🚧 计划中)
- 分析
std::function的实现原理(内部通常包含虚函数或函数指针)、性能开销与使用场景。 - 涉及语法:
std::function<ReturnType(Args...)>
- 分析
-
std::variant vs 虚函数继承:封闭类型集合的选择 (🚧 计划中)
- 对比
std::variant与虚函数继承体系的优劣,介绍std::visit配合overloaded模式的使用。 - 涉及语法:
std::variant<T1, T2, ...>、std::visit、std::get<T>
- 对比
-
虚函数 vs 模板:一份实战选型清单 (🚧 计划中)
- 根据"是否跨动态库""是否需要插件式扩展""是否强调性能""类型集合是否封闭"等因素,给出可操作的选型建议。
-
虚函数的性能成本:没你想的那么大,也没那么小 (🚧 计划中)
- 从 CPU 微架构角度理解虚函数调用的代价:间接分支、指令缓存、分支预测。讨论编译器的去虚拟化(devirtualization)优化。
第八部分:对象模型与现代 C++ 特性
这一部分回答:现代 C++ 的新特性如何与对象模型交互?
-
Lambda 的真面目:编译器生成的匿名类 (🚧 计划中)
- 解释 lambda 表达式实际上是一个匿名类的实例,捕获列表如何影响闭包对象的大小与布局。讲解 lambda 的完整语法。
- 涉及语法:
[capture](params) -> ret { body }、捕获列表[=]/[&]/[this]/[x, &y]
-
值捕获 vs 引用捕获:从内存布局理解差异 (🚧 计划中)
- 分析不同捕获方式的实现(值捕获 = 成员变量拷贝,引用捕获 = 存指针/引用),以及
[=, this]、[*this](C++17 捕获当前对象副本)的语义差异。 - 涉及语法:
[*this](C++17)、mutablelambda
- 分析不同捕获方式的实现(值捕获 = 成员变量拷贝,引用捕获 = 存指针/引用),以及
-
泛型 Lambda:auto 参数背后是什么? (🚧 计划中)
- 解释泛型 lambda(
auto参数)实际上是闭包类带模板operator()的简写,以及 C++20 模板 lambda 的完整语法。 - 涉及语法:
[](auto x) {}、[]<typename T>(T x) {}(C++20)
- 解释泛型 lambda(
-
constexpr 对象:在编译期就"存在"的对象 (🚧 计划中)
- 解释
constexpr构造函数的要求,以及constexpr对象在对象模型中的特殊地位。讲解constexpr函数与consteval(C++20)的区别。 - 涉及语法:
constexpr、consteval(C++20)、constinit(C++20)
- 解释
-
聚合初始化与指定初始化器 (🚧 计划中)
- 解释聚合类型的定义(无自定义构造函数、无私有成员等)、花括号初始化的规则,以及 C++20 指定初始化器
{.x = 1, .y = 2}的语法。 - 涉及语法:聚合初始化
T{a, b}、指定初始化器{.member = value}
- 解释聚合类型的定义(无自定义构造函数、无私有成员等)、花括号初始化的规则,以及 C++20 指定初始化器
-
结构化绑定:优雅地"拆开"对象 (🚧 计划中)
- 介绍 C++17 结构化绑定的语法与原理,以及它与
std::tuple、std::pair、自定义类型的交互方式。 - 涉及语法:
auto [a, b, c] = expr;
- 介绍 C++17 结构化绑定的语法与原理,以及它与
附录:工具与实践
-
用 sizeof、offsetof、Compiler Explorer 验证对象布局 (🚧 计划中)
- 介绍如何用这些工具验证你对对象布局的理解。讲解
offsetof宏的用法与限制。 - 涉及语法:
sizeof、offsetof(Type, member)
- 介绍如何用这些工具验证你对对象布局的理解。讲解
-
用调试器查看 vptr 和 vtable 的内容 (🚧 计划中)
- GDB/LLDB 的内存查看命令实操,如何打印 vptr、vtable,验证你的心智模型。
-
对象模型相关的常见 UB:悬空引用、未初始化对象、生命周期越界 (🚧 计划中)
- 列举与对象模型强相关的 undefined behavior,说明它们在内存层面踩了哪些红线。
-
对象模型视角的代码评审检查清单 (🚧 计划中)
- 一份检查清单:如何识别切片、生命周期问题、虚析构遗漏、过度继承等常见错误。
读完本专栏后,你应该能:
- 画出 C++ 对象的大致内存布局心智图;
- 清楚地知道什么时候该用继承与虚函数,什么时候应优先考虑组合或模板;
- 理解拷贝、移动、特殊成员函数的生成规则,写出资源安全的代码;
- 在虚函数、模板多态、类型擦除之间做出合理选型;
- 在代码评审中用"对象模型"的语言讨论接口设计;
- 借助工具"看见"对象模型,从而更有底气地做出架构决策。