$ cat ~ / posts /c++ /oop2 3.3k Words ~ 12 Mins
cover.png
面向对象程序设计02

#面向对象程序设计02

exdoubled Lv5

对象

从面向对象分析的角度看,对象来自现实世界的抽象,例如一张桌子有长、宽、高、颜色,一份银行账户有户主、余额和存取款操作。程序不可能把现实中的桌子放进计算机,只能把它抽象成一组数据,再为这些数据定义可执行的操作

在 C++ 中,这组数据通常表现为 structclass 的非静态数据成员:

1
2
3
4
5
struct Table {
double length;
double width;
double height;
};

当声明一个对象时:

1
Table t{1.2, 0.8, 0.75};

内存中真正存在的是一块能容纳三个 double 的区域

Table 这个类型本身并不会作为一个“类型字段”存进 t 里面

类型信息主要由编译器在编译期使用:编译器知道 t.length 应该从对象起始地址偏移多少字节读取,知道读出来的 8 字节应该按 double 解释,也知道能对它做哪些操作

对象布局

对象的内存布局只包含非静态数据成员,不包含以下内容:

  • 成员函数
  • 静态成员函数
  • 静态数据成员

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <cmath>

class Point2 {
static inline int n_dimensions = 2;

double x_;
double y_;

public:
Point2(double x, double y) : x_{x}, y_{y} {}

double distance(Point2 p) const {
return std::sqrt((x_ - p.x_) * (x_ - p.x_) +
(y_ - p.y_) * (y_ - p.y_));
}
};

Point2 对象里真正按对象实例存放的只有 x_ 和 `y_``

`distance 和构造函数是代码,放在代码区;n_dimensions 是静态数据成员,不属于某一个对象实例,通常放在全局/静态数据区

创建 10000 个 Point2 对象,并不会复制 10000 份 distance 函数代码

每个对象只保存自己的数据,成员函数代码只有一份

调用成员函数时,编译器会额外传入当前对象地址,也就是隐含的 this 指针,让同一份函数代码知道自己正在操作哪个对象

声明顺序

1
2
3
4
struct Point {
double x_; // offset 0,占 8 字节
double y_; // offset 8,占 8 字节
};

在常见 64 位平台上,sizeof(Point) 是 16

成员按声明顺序排列,x_ 在前,y_ 在后

如果声明顺序改成 y_ 在前、x_ 在后,内存布局也会随之改变

structclass 在布局原则上没有本质差别

二者的语言差异是默认访问权限:struct 默认 publicclass 默认 private

无约束的数据聚合更适合用 struct,需要维护不变量的类型更适合用 class

例如平面点没有太多合法性约束,用 struct Point 很自然;日期必须保证月份、天数合法,就更适合用 class 隐藏数据并通过构造函数检查

为什么需要对齐

CPU 读取一个 N 字节对象时,最高效的情况通常是该对象起始地址是 N 的整数倍

例如 4 字节的 int 放在 4 的整数倍地址,8 字节的 double 放在 8 的整数倍地址

如果不对齐,可能出现几个问题:

  • CPU 需要拆成两次内存读取,再拼接结果
  • 某些架构上非对齐访问更慢
  • 在一些早期或严格对齐的架构上,非对齐访问可能直接触发硬件异常

因此,编译器会在对象布局中插入填充字节,让成员满足对齐要求

对齐和填充规则

  • 每个成员放在满足自身对齐要求的偏移处
  • 整个结构体大小必须是最大对齐数的整数倍

例子:

1
2
3
4
5
struct Padded {
char a; // 1 字节
int b; // 4 字节
char c; // 1 字节
};

直觉上 1 + 4 + 1 = 6,但常见平台上:

1
static_assert(sizeof(Padded) == 12);

布局推导如下:

1
2
3
4
5
offset 0: a,占 1 字节
offset 1-3: padding,填充 3 字节,让 b 从 4 的倍数开始
offset 4-7: b,占 4 字节
offset 8: c,占 1 字节
offset 9-11: padding,填充 3 字节,让总大小成为 4 的倍数

所以总大小是 12 字节。

换一个成员顺序:

1
2
3
4
5
struct Optimized {
int b; // offset 0,占 4 字节
char a; // offset 4,占 1 字节
char c; // offset 5,占 1 字节
};

此时常见平台上 sizeof(Optimized) == 8。同样三个成员,只是调整顺序,就少了 4 字节

sizeof 和 alignof

sizeof(T) 查询类型对象占多少字节,alignof(T) 查询类型的对齐要求:

1
2
3
4
5
6
7
8
9
10
11
static_assert(alignof(char) == 1);
static_assert(alignof(int) == 4);
static_assert(alignof(double) == 8);

struct Foo {
int i;
float f;
char c;
};

static_assert(alignof(Foo) == 4);

这里 Foo 的最大成员对齐要求是 4,所以 alignof(Foo) 是 4

sizeof(Foo) 不只是成员大小相加,还要包含成员之间和末尾可能出现的 padding

可以用 alignas 主动指定更强的对齐:

1
2
3
4
5
struct alignas(16) SimdVec {
float data[4];
};

static_assert(alignof(SimdVec) == 16);

典型场景包括 SIMD 指令要求 16 或 32 字节对齐,以及多线程中通过按缓存行对齐避免伪共享:

1
2
3
4
5
#include <atomic>

struct alignas(64) CacheLinePadded {
std::atomic<int> counter;
};

缓存友好的布局

现代 CPU 不是每次只从内存加载一个变量,而是按缓存行加载,常见缓存行大小是 64 字节

如果两个经常一起访问的字段离得很远,甚至跨越缓存行,就可能增加访存成本

缓存不友好的布局:

1
2
3
4
5
struct Bad {
int hot_a;
char cold_1[56];
int hot_b;
};

hot_ahot_b 经常一起访问,但中间隔了 56 字节,很可能落在不同缓存行

缓存友好的布局:

1
2
3
4
5
struct Good {
int hot_a;
int hot_b;
char cold_1[56];
};

把热字段放在一起,有利于一次缓存行加载同时拿到相关数据

这类优化在小程序里不明显,但当对象数量达到百万、千万甚至更多时,布局就会明显影响性能

栈、堆、静态区和代码区

C++ 程序运行时常见内存区域可以粗略分为:

  • 栈区:函数调用时自动分配栈帧,存放局部变量、参数、返回地址等,离开作用域自动释放
  • 堆区:由 new 或底层分配器在运行时分配,需要显式释放,或交给智能指针和 RAII 类型管理
  • 全局/静态区:全局变量和 static 变量所在区域,生命周期通常贯穿整个进程
  • 代码区:存放编译后的机器指令,也可能存放只读数据、字符串字面量、虚函数表等

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <print>
#include <string>

struct Widget {
std::string name{};

Widget(const std::string& n) : name{n} {
std::println("构造 {}", name);
}

~Widget() {
std::println("析构 {}", name);
}
};

void demo() {
Widget a{"a"}; // 栈上对象
Widget* b = new Widget{"b"}; // 堆上对象

delete b; // 手动销毁堆上对象
} // 离开作用域,a 自动析构

输出顺序是:

1
2
3
4
构造 a
构造 b
析构 b
析构 a

栈对象的生命周期绑定到作用域,使用方便,但不能把已经离开作用域的栈对象地址继续拿出去用

堆对象的生命周期更灵活,但必须明确谁负责释放,否则会泄漏;释放后继续访问则是悬空指针

生命周期的三个必要条件

一个对象合法存在,需要同时满足三个条件:

  • 内存已经分配
  • 构造函数已经完成
  • 析构函数尚未开始

典型错误:

1
2
3
4
5
6
7
8
9
10
11
#include <print>

int* dangling() {
int x = 42;
return &x;
}

int main() {
int* p = dangling();
std::println("{}", *p); // 未定义行为
}

dangling 返回时,x 的生命周期已经结束。那块栈内存可能暂时还没有被覆盖,所以程序“有时能输出 42”

但这不表示代码正确,只表示错误被暂时掩盖,未定义行为最危险的地方就在于它不一定立刻崩溃,而是可能在生产环境以低概率出现

开发阶段可以借助工具发现这类问题:

1
g++ -std=c++23 -Wall -Wextra -fsanitize=address main.cpp

AddressSanitizer 能在许多悬空指针、越界访问、use-after-free 发生时直接报错并指出位置。编译警告、Clang-Tidy、Valgrind 也都是排查内存问题的重要工具

值类型语义

值类型变量直接持有数据本身

赋值或传参时通常会复制出独立副本:

1
2
3
4
5
6
7
int a = 42;
int b = a;

b = 100;

std::println("{}", a); // 42
std::println("{}", b); // 100

ba 的副本,修改 b 不影响 a。C++ 中的基本类型、普通 struct、普通 class 对象默认都具有值语义

自定义类型也可以表现为值类型:

1
2
3
4
5
6
7
8
struct Point {
double x;
double y;
};

Point p1{1.0, 2.0};
Point p2 = p1;
p2.x = 10.0;

p2 修改后,p1 不变

这种语义清晰、局部、容易推理,适合小对象、不可变数据、独立状态

引用类型语义

引用类型变量不直接代表一份独立数据,而是指向或别名到已有对象:

1
2
3
4
5
int a = 42;
int& r = a;

r = 100;
std::println("{}", a); // 100

指针也具有引用语义:

1
2
3
4
5
int a = 42;
int* p = &a;

*p = 100;
std::println("{}", a); // 100

引用语义的优势是避免复制,可以共享状态;代价是生命周期和所有权更复杂

谁拥有对象?谁负责销毁?其他引用是否可能悬空?

值类型和引用类型的区别:

维度值类型引用类型
传递方式拷贝,得到独立副本指针或引用,共享同一对象
修改影响修改副本不影响原对象通过别名修改会影响原对象
生命周期随变量自动管理必须确认被引用对象仍然存在
适用场景小数据、独立状态、不可变值大对象、共享状态、多态对象

继承时的对象布局

单继承时,派生类对象中包含一个基类子对象:

1
2
3
4
5
6
7
struct Base {
int x;
};

struct Derived : Base {
int y;
};

布局:

1
2
3
Derived 对象:
offset 0: Base::x
offset 4: Derived::y

Derived 对象起始处就是它的 Base 子对象。因此:

1
2
Derived d;
Base* pb = &d; // 安全的向上转换

Derived*Base* 的向上转换是安全的,可以自动发生

相反,从 Base*Derived* 是向下转换,不一定安全

如果一个对象本来就只是 Base,硬把它当成 Derived,访问 Derived::y 就会跑到不属于这个对象的内存里,产生未定义行为

如果代码经常需要向下转换,往往意味着设计有问题

虚函数

普通成员函数不占对象空间,但虚函数会改变对象布局

虚函数要支持运行时多态:通过基类指针调用函数时,程序要根据对象的真实类型选择正确函数

例如:

1
2
3
4
struct Base {
virtual void foo();
int x;
};

在常见 64 位实现中,这类对象通常会多出一个虚函数表指针 vptr

1
2
3
4
5
Base 对象:
offset 0: vptr,8 字节
offset 8: x,4 字节
offset 12-15: padding,4 字节
总大小:16 字节

如果没有虚函数,只有一个 int xBase 可能只需要 4 字节;加入虚函数后,变成 16 字节

这个开销对少量对象不明显,但如果对象数量非常大,例如游戏粒子、ECS 组件、图形对象,就不能忽略

vtable 为每个含虚函数的类对应的一张函数指针表:

  • 表中每一项是某个虚函数实现的地址
  • 这张表由编译器生成,通常放在只读数据区域
  • 对象内部不保存整张表,只保存一个指向表的 vptr

调用虚函数时,大致过程是:

1
对象地址 -> 读取 vptr -> 找到 vtable -> 按虚函数编号取出函数地址 -> 跳转调用

相比普通函数直接跳转,虚函数调用多了间接访问

它带来的好处是运行时多态,代价是对象更大、调用路径更复杂、优化难度更高

this 指针

成员函数代码只有一份,但它能操作不同对象,靠的是隐含的 this 指针

当写:

1
obj.foo();

可以粗略理解成编译器生成了类似:

1
foo(&obj);

真实规则比这个更复杂

成员函数执行时知道当前对象的地址,因此可以通过偏移访问当前对象的数据成员

虚函数也是一样,找到要调用的函数地址后,还需要把当前对象地址传进去,否则函数不知道自己要操作哪个对象。

这解释了为什么成员函数不需要在每个对象里复制一份:对象保存数据,函数保存代码,调用时通过 this 把二者关联起来

$ discussion
# Comments
waline