#面向对象程序设计02
对象
从面向对象分析的角度看,对象来自现实世界的抽象,例如一张桌子有长、宽、高、颜色,一份银行账户有户主、余额和存取款操作。程序不可能把现实中的桌子放进计算机,只能把它抽象成一组数据,再为这些数据定义可执行的操作
在 C++ 中,这组数据通常表现为 struct 或 class 的非静态数据成员:
1 | struct Table { |
当声明一个对象时:
1 | Table t{1.2, 0.8, 0.75}; |
内存中真正存在的是一块能容纳三个 double 的区域
Table 这个类型本身并不会作为一个“类型字段”存进 t 里面
类型信息主要由编译器在编译期使用:编译器知道 t.length 应该从对象起始地址偏移多少字节读取,知道读出来的 8 字节应该按 double 解释,也知道能对它做哪些操作
对象布局
对象的内存布局只包含非静态数据成员,不包含以下内容:
- 成员函数
- 静态成员函数
- 静态数据成员
例如:
1 |
|
Point2 对象里真正按对象实例存放的只有 x_ 和 `y_``
`distance 和构造函数是代码,放在代码区;n_dimensions 是静态数据成员,不属于某一个对象实例,通常放在全局/静态数据区
创建 10000 个 Point2 对象,并不会复制 10000 份 distance 函数代码
每个对象只保存自己的数据,成员函数代码只有一份
调用成员函数时,编译器会额外传入当前对象地址,也就是隐含的 this 指针,让同一份函数代码知道自己正在操作哪个对象
声明顺序
1 | struct Point { |
在常见 64 位平台上,sizeof(Point) 是 16
成员按声明顺序排列,x_ 在前,y_ 在后
如果声明顺序改成 y_ 在前、x_ 在后,内存布局也会随之改变
struct 和 class 在布局原则上没有本质差别
二者的语言差异是默认访问权限:struct 默认 public,class 默认 private
无约束的数据聚合更适合用
struct,需要维护不变量的类型更适合用class例如平面点没有太多合法性约束,用
struct Point很自然;日期必须保证月份、天数合法,就更适合用class隐藏数据并通过构造函数检查
为什么需要对齐
CPU 读取一个 N 字节对象时,最高效的情况通常是该对象起始地址是 N 的整数倍
例如 4 字节的
int放在 4 的整数倍地址,8 字节的double放在 8 的整数倍地址如果不对齐,可能出现几个问题:
- CPU 需要拆成两次内存读取,再拼接结果
- 某些架构上非对齐访问更慢
- 在一些早期或严格对齐的架构上,非对齐访问可能直接触发硬件异常
因此,编译器会在对象布局中插入填充字节,让成员满足对齐要求
对齐和填充规则
- 每个成员放在满足自身对齐要求的偏移处
- 整个结构体大小必须是最大对齐数的整数倍
例子:
1 | struct Padded { |
直觉上 1 + 4 + 1 = 6,但常见平台上:
1 | static_assert(sizeof(Padded) == 12); |
布局推导如下:
1 | offset 0: a,占 1 字节 |
所以总大小是 12 字节。
换一个成员顺序:
1 | struct Optimized { |
此时常见平台上 sizeof(Optimized) == 8。同样三个成员,只是调整顺序,就少了 4 字节
sizeof 和 alignof
sizeof(T) 查询类型对象占多少字节,alignof(T) 查询类型的对齐要求:
1 | static_assert(alignof(char) == 1); |
这里 Foo 的最大成员对齐要求是 4,所以 alignof(Foo) 是 4
sizeof(Foo) 不只是成员大小相加,还要包含成员之间和末尾可能出现的 padding
可以用 alignas 主动指定更强的对齐:
1 | struct alignas(16) SimdVec { |
典型场景包括 SIMD 指令要求 16 或 32 字节对齐,以及多线程中通过按缓存行对齐避免伪共享:
1 |
|
缓存友好的布局
现代 CPU 不是每次只从内存加载一个变量,而是按缓存行加载,常见缓存行大小是 64 字节
如果两个经常一起访问的字段离得很远,甚至跨越缓存行,就可能增加访存成本
缓存不友好的布局:
1 | struct Bad { |
hot_a 和 hot_b 经常一起访问,但中间隔了 56 字节,很可能落在不同缓存行
缓存友好的布局:
1 | struct Good { |
把热字段放在一起,有利于一次缓存行加载同时拿到相关数据
这类优化在小程序里不明显,但当对象数量达到百万、千万甚至更多时,布局就会明显影响性能
栈、堆、静态区和代码区
C++ 程序运行时常见内存区域可以粗略分为:
- 栈区:函数调用时自动分配栈帧,存放局部变量、参数、返回地址等,离开作用域自动释放
- 堆区:由
new或底层分配器在运行时分配,需要显式释放,或交给智能指针和 RAII 类型管理 - 全局/静态区:全局变量和
static变量所在区域,生命周期通常贯穿整个进程 - 代码区:存放编译后的机器指令,也可能存放只读数据、字符串字面量、虚函数表等
示例:
1 |
|
输出顺序是:
1 | 构造 a |
栈对象的生命周期绑定到作用域,使用方便,但不能把已经离开作用域的栈对象地址继续拿出去用
堆对象的生命周期更灵活,但必须明确谁负责释放,否则会泄漏;释放后继续访问则是悬空指针
生命周期的三个必要条件
一个对象合法存在,需要同时满足三个条件:
- 内存已经分配
- 构造函数已经完成
- 析构函数尚未开始
典型错误:
1 |
|
dangling 返回时,x 的生命周期已经结束。那块栈内存可能暂时还没有被覆盖,所以程序“有时能输出 42”
但这不表示代码正确,只表示错误被暂时掩盖,未定义行为最危险的地方就在于它不一定立刻崩溃,而是可能在生产环境以低概率出现
开发阶段可以借助工具发现这类问题:
1 | g++ -std=c++23 -Wall -Wextra -fsanitize=address main.cpp |
AddressSanitizer 能在许多悬空指针、越界访问、use-after-free 发生时直接报错并指出位置。编译警告、Clang-Tidy、Valgrind 也都是排查内存问题的重要工具
值类型语义
值类型变量直接持有数据本身
赋值或传参时通常会复制出独立副本:
1 | int a = 42; |
b 是 a 的副本,修改 b 不影响 a。C++ 中的基本类型、普通 struct、普通 class 对象默认都具有值语义
自定义类型也可以表现为值类型:
1 | struct Point { |
p2 修改后,p1 不变
这种语义清晰、局部、容易推理,适合小对象、不可变数据、独立状态
引用类型语义
引用类型变量不直接代表一份独立数据,而是指向或别名到已有对象:
1 | int a = 42; |
指针也具有引用语义:
1 | int a = 42; |
引用语义的优势是避免复制,可以共享状态;代价是生命周期和所有权更复杂
谁拥有对象?谁负责销毁?其他引用是否可能悬空?
值类型和引用类型的区别:
| 维度 | 值类型 | 引用类型 |
|---|---|---|
| 传递方式 | 拷贝,得到独立副本 | 指针或引用,共享同一对象 |
| 修改影响 | 修改副本不影响原对象 | 通过别名修改会影响原对象 |
| 生命周期 | 随变量自动管理 | 必须确认被引用对象仍然存在 |
| 适用场景 | 小数据、独立状态、不可变值 | 大对象、共享状态、多态对象 |
继承时的对象布局
单继承时,派生类对象中包含一个基类子对象:
1 | struct Base { |
布局:
1 | Derived 对象: |
Derived 对象起始处就是它的 Base 子对象。因此:
1 | Derived d; |
从 Derived* 到 Base* 的向上转换是安全的,可以自动发生
相反,从 Base* 到 Derived* 是向下转换,不一定安全
如果一个对象本来就只是 Base,硬把它当成 Derived,访问 Derived::y 就会跑到不属于这个对象的内存里,产生未定义行为
如果代码经常需要向下转换,往往意味着设计有问题
虚函数
普通成员函数不占对象空间,但虚函数会改变对象布局
虚函数要支持运行时多态:通过基类指针调用函数时,程序要根据对象的真实类型选择正确函数
例如:
1 | struct Base { |
在常见 64 位实现中,这类对象通常会多出一个虚函数表指针 vptr:
1 | Base 对象: |
如果没有虚函数,只有一个 int x 的 Base 可能只需要 4 字节;加入虚函数后,变成 16 字节
这个开销对少量对象不明显,但如果对象数量非常大,例如游戏粒子、ECS 组件、图形对象,就不能忽略
vtable 为每个含虚函数的类对应的一张函数指针表:
- 表中每一项是某个虚函数实现的地址
- 这张表由编译器生成,通常放在只读数据区域
- 对象内部不保存整张表,只保存一个指向表的
vptr
调用虚函数时,大致过程是:
1 | 对象地址 -> 读取 vptr -> 找到 vtable -> 按虚函数编号取出函数地址 -> 跳转调用 |
相比普通函数直接跳转,虚函数调用多了间接访问
它带来的好处是运行时多态,代价是对象更大、调用路径更复杂、优化难度更高
this 指针
成员函数代码只有一份,但它能操作不同对象,靠的是隐含的 this 指针
当写:
1 | obj.foo(); |
可以粗略理解成编译器生成了类似:
1 | foo(&obj); |
真实规则比这个更复杂
成员函数执行时知道当前对象的地址,因此可以通过偏移访问当前对象的数据成员
虚函数也是一样,找到要调用的函数地址后,还需要把当前对象地址传进去,否则函数不知道自己要操作哪个对象。
这解释了为什么成员函数不需要在每个对象里复制一份:对象保存数据,函数保存代码,调用时通过 this 把二者关联起来