C++基础知识
一、编译内存相关
1. C++程序编译步骤
编译程序分为四个步骤:处理、编译、汇编和链接。每个步骤都会生成对应的文件
graph LR A[test.c] --> | 预处理 -E | B[test.i] B --> | 编译 -S | C[test.s] C --> | 汇编 -c | D[test.o] D --> | 链接 | E[可执行二进制文件]
1 | # 1. 预处理 → main.i |
1.1 常用相关选项(辅助调试与分析)
| 选项 | 作用 | 典型使用场景 |
|---|---|---|
-o <file> |
指定输出文件名 | 所有阶段通用,如 gcc -c main.c -o main.o |
-v |
显示编译器驱动过程(调用的 cc1、as、ld 等) | 调试工具链、查看默认包含路径 |
-### |
显示(但不执行)编译过程中调用的子命令 | 分析 GCC 内部行为 |
-save-temps |
保留中间文件(.i, .s, .o)在当前目录 |
学习编译流程、调试宏或汇编 |
-dM(配合 -E) |
仅输出所有宏定义(包括内置宏) | 查看平台/编译器预定义宏 例:gcc -E -dM - < /dev/null |
-fverbose-asm(配合 -S) |
生成带注释的汇编代码(含变量名、C 行号) | 阅读汇编更直观 |
-g |
生成调试信息(DWARF 格式),供 GDB 使用 | 调试程序崩溃、单步执行 |
-O0, -O1, -O2, -O3, -Os, -Ofast |
设置优化级别 | -O0 用于调试,-O2 用于发布 |
-Wall |
启用大量常见警告(如未使用变量、格式不匹配) | 提高代码健壮性(强烈推荐) |
-Wextra |
启用额外警告(如函数参数未用、隐式类型转换) | 更严格的代码审查 |
-Werror |
将所有警告视为错误 | CI/CD 中保证零警告 |
-I<dir> |
添加头文件搜索路径 | 使用第三方库时指定 include/ 目录 |
-isystem <dir> |
添加“系统头文件”路径(抑制警告) | 集成第三方库且不想看到其警告 |
-DNAME 或 -DNAME=value |
在命令行定义宏 | 条件编译开关,如 -DDEBUG |
-UNAME |
取消定义某个宏 | 覆盖默认宏行为 |
-L<dir> |
添加库文件(.a/.so)搜索路径 |
链接自定义或本地库 |
-l<name> |
链接 lib<name>.a 或 lib<name>.so |
如 -lm 链接数学库,-lpthread 链接线程库 |
-pthread |
启用 POSIX 线程支持(自动加 -lpthread + 定义 _REENTRANT) |
多线程程序编译 |
-std=<标准> |
指定 C/C++ 语言标准(如 c11, c++17) |
控制语言特性可用性 |
-fPIC |
生成位置无关代码(Position Independent Code) | 编译共享库(.so)必需 |
-M, -MM |
输出源文件的头文件依赖关系 | 自动生成 Makefile 依赖 |
-shared |
生成共享库(.so 文件) |
构建动态链接库 |
-static |
静态链接所有依赖库 | 生成可独立运行的可执行文件 |
1.2 预处理(Preprocessing)
对源代码进行文本级的替换和展开,为后续编译做准备。
主要任务:
- 处理
#include:将头文件内容原样插入**到当前文件中。 - 处理宏定义
#define:进行简单的文本替换(如#define PI 3.14)。 - 条件编译:根据
#if、#ifdef、#ifndef等指令,决定是否包含某段代码。 - 删除注释。
- 添加行号和文件名标记(用于调试和错误定位)。
输入:
.c或.cpp源文件
输出:
- 纯
C/C++代码(无宏、无#include),通常扩展名为.i(C)或.ii(C++)
运行预处理命令(GCC):
1 | g++ -E main.cpp -o main.ii |
1.3 编译(Compilation)
将预处理后的高级语言代码翻译成汇编语言(与目标平台相关的低级代码)。
主要任务:
- 词法分析(Tokenization)
- 语法分析(Parsing)
- 语义分析(类型检查、作用域等)
- 优化(如常量折叠、死代码消除等)
- 生成汇编代码
输入:
.i或.ii文件(预处理后的代码)
输出:
- 汇编语言文件,扩展名通常为
.s
运行预处理命令(GCC):
1 | g++ -S main.ii # 或直接 g++ -S main.cpp |
生成
main.s,内容类似(x86-64):
1
2
3
4
5
6
7 main:
pushq %rbp
movq %rsp, %rbp
movl $100, %esi # MAX 被替换为 100
leaq _ZSt4cout(%rip), %rdi
call _ZNSolsEi
...
1.4 汇编(Assembly)
将汇编语言代码转换为机器码(二进制目标代码)。
主要任务:
- 使用汇编器(assembler)将
.s文件翻译成 CPU 可直接执行的二进制指令。 - 生成目标文件(Object File),包含代码段、数据段、符号表、重定位信息等。
输入:
.s汇编文件
输出:
- 目标文件(Object File),扩展名通常为
.o(Linux/macOS)或.obj(Windows)
运行预处理命令(GCC):
1 | g++ -c main.s # 或 g++ -c main.cpp 直接生成 .o |
生成
main.o,这是一个不可直接运行的二进制文件,但包含机器码。可以用
objdump -d main.o查看其反汇编内容。
1.5 链接(Linking)
将多个目标文件(.o)和库文件(静态库 .a 或动态库 .so/.dll)合并成一个完整的可执行程序。
主要任务:
符号解析
(Symbol Resolution):
- 解决函数/变量的引用(如
printf定义在 libc 中)。
- 解决函数/变量的引用(如
重定位
(Relocation):
- 将各目标文件中的地址从“相对地址”调整为“最终运行时地址”。
合并段(如
.text,.data,.bss)。处理库依赖(静态 or 动态)。
输入:
- 一个或多个
.o文件 - 静态库(
.a)或动态库(.so,.dll)
输出:
- 可执行文件(如
a.out、myapp.exe)或共享库
运行预处理命令(GCC):
1 | # 假设有 main.o 和 utils.o |
1.6 全流程图解(以 GCC 为例)
graph LR A(main.cpp) B[预处理器] C(main.ii) D[编译器] E(main.s) F[汇编器] G(main.o) H[链接器] I(a.out -- 可执行文件) A --> B --> C C --> D --> E E --> F --> G G --> H --> I
1.7补充说明
1.7.1 分阶段执行
| 阶段 | GCC 命令 |
|---|---|
| 预处理 | g++ -E file.cpp -o file.ii |
| 编译 | g++ -S file.ii → file.s |
| 汇编 | g++ -c file.s → file.o |
| 链接 | g++ file.o -o program |
1.7.2 分阶段的优势
- 模块化编译:修改一个
.cpp文件只需重新编译它,再链接即可(加快构建速度)。 - 调试方便:可在不同阶段检查中间结果。
- 跨平台支持:同一份 C++ 代码可编译到不同架构(只需更换后端)。
1.7.3 静态链接 vs 动态链接
在链接阶段,链接器决定是把库代码“嵌入”(静态)还是“引用”(动态)。
| 静态链接(Static Linking) | 动态链接(Dynamic Linking) | |
|---|---|---|
| 定义 | 在编译时,将所需的库代码直接复制到最终生成的可执行文件中。 | 在编译时不将库代码嵌入可执行文件,而是在运行时由操作系统加载共享库。 |
| 结果 | 生成的可执行文件是自包含的,不依赖外部库文件(如 .dll 或 .so)。 |
可执行文件较小,但运行时需要对应的共享库文件存在。 |
| 典型文件扩展名 | Windows:.lib(静态库)Linux/macOS: .a(archive file) |
Windows:.dll(动态链接库) + .lib(导入库)Linux: .so(Shared Object)macOS: .dylib |
工作流程对比
| 步骤 | 静态链接 | 动态链接 |
|---|---|---|
| 编译阶段 | 编译源码为 .o 文件 |
同左 |
| 链接阶段 | 链接器将 .o 与 .a 库合并,生成完整可执行文件 |
链接器只记录依赖的共享库名称,不嵌入库代码 |
| 运行阶段 | 直接运行,无需额外库 | 操作系统加载器在启动时(或运行中)加载 .so/.dll |
注:动态链接可分为 加载时链接(程序启动时加载)和 运行时链接(通过
dlopen/LoadLibrary手动加载)。
优缺点对比
| 静态链接 | 动态链接 | |
|---|---|---|
| 优点 | 可移植性强:一个可执行文件即可运行,无需担心依赖缺失。 启动快:无需在运行时解析和加载外部库。 版本稳定:库代码已固化,不受系统上库版本变化影响。 |
节省磁盘和内存:多个程序共享同一份库文件(通过虚拟内存映射)。 便于更新:只需替换 .so/.dll 文件,无需重新编译主程序(前提是 ABI 兼容)。插件机制支持:可通过动态加载实现模块化、插件架构。 |
| 缺点 | 文件体积大:每个程序都包含一份库副本。 内存浪费:多个程序使用相同库时,各自占用独立内存。 更新困难:若库有安全漏洞,必须重新编译所有使用该库的程序。 |
依赖问题(“DLL Hell”):缺少库、版本不兼容会导致程序无法运行。 启动稍慢:需解析符号、重定位等。 安全性风险:恶意替换共享库可能导致安全问题(需签名或路径保护)。 |
选择推荐
| 场景 | 推荐方式 |
|---|---|
| 嵌入式系统、单文件分发工具 | 静态链接 |
| 桌面/服务器应用、需频繁更新库 | 动态链接 |
| 安全敏感环境(避免外部依赖) | 静态链接 |
| 资源受限(内存/磁盘)且多程序共用库 | 动态链接 |
2. C++内存管理
2.1 Linux 程序内存布局
1 | High Addresses (高地址) ↑ |
注意:地址从低到高向上增长,但 栈向下增长,堆向上增长
2.1.1 内核空间(kernel Space)
- 位置:
- 地址高于 4GB(x86_64 下用户空间 0~4G,内核空间 4G+)
- 用途:
- 操作系统内核运行
- 系统调用处理
- 内存管理、设备驱动等
- 注意:
- 用户程序不能直接访问内核空间
- 通过系统调用(如
malloc,open,read)间接交互
2.1.2 Environment & Arguments(环境变量与命令行参数)
内容:
- 环境变量(
PATH,HOME等) - 命令行参数(
argv[0], argv[1], ...)
- 环境变量(
位置:
- 位于栈上方,靠近高地址
访问方式:
1
2
3
4int main(int argc, char* argv[]) {
// argv 是命令行参数
// environ 是环境变量表(全局变量)
}
2.1.3 STACK(栈区)
用途:
- 函数调用栈
- 局部变量、函数参数、返回地址
特点:
- 向下增长(向低地址)
- 快速分配/释放(仅移动栈指针)
- 大小有限(通常几 MB),易溢出
示例:
1
2
3
4void func(int x) {
int local = x; // 栈上
double arr[1000]; // 栈上(可能导致栈溢出)
}栈上的对象在作用域结束时自动析构(RAII 的基础)。
2.1.4 共享区(Shared Memory)
- 内容:
- 动态链接库(
.so文件)映射 - 共享内存(
shm_open) - 其他内存映射(
mmap)
- 动态链接库(
- 示例:
libc.so,libstdc++.so,libpthread.so- 使用
dlopen()加载的插件
- 特性:
- 多个进程可共享
- 通过
ld-linux.so加载器实现
2.1.5 HEAP(堆区)
用途:
- 动态内存分配(
new,malloc) - 生命周期由程序员控制
- 动态内存分配(
特点:
- 向上增长(向高地址)
- 由
brk/sbrk系统调用管理(Linux) - 常见于
new int(10)、malloc(100)分配
示例:
1
2int* p = new int(10); // 在堆上
delete p; // 手动释放堆内存容易出现泄漏、野指针等问题。
2.1.6 BSS(未初始化全局区)
内容:
- 未显式初始化或初始化为 0 的全局/静态变量
属性:
- 运行时由操作系统自动清零
- 不占用可执行文件空间(只记录大小)
- 占用
.bss段
示例:
1
2int global_uninit; // 默认为 0 → .bss
static int zero = 0; // 显式为 0 → .bss
2.1.7 DATA(已初始化全局区)
内容:
- 全局变量 和 静态变量,且显式初始化为非零值
属性:
- 可读写
- 程序启动时从可执行文件加载初始值
- 占用
.data段
示例:
1
2int global_var = 100; // 在 .data
static int static_var = 50; // 在 .data
2.1.8 TEXT(代码区)
内容:
- 所有函数的机器码(
main,func等) - 字符串字面量(如
"Hello") const全局变量(若不可变)
- 所有函数的机器码(
属性:
- 只读(防止修改自身代码)
- 共享(多个进程可共享同一份代码)
- 通常映射自可执行文件(
.text段)
示例:
1
2const char* msg = "Hello"; // 存在 .text 或只读数据段
void func() { ... } // 机器码在此
2.1.9 Linux命令
在linux下size命令可以查看一个可执行二进制文件基本情况
1 | size test # test为可执行文件 |
2.2 栈和堆
2.2.1 基本概念
| 特性 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 分配方式 | 由编译器自动分配和释放 | 由程序员手动分配和释放(如 C 的 malloc/free,C++ 的 new/delete) |
| 内存管理 | 自动管理(函数调用/返回时自动入栈/出栈) | 手动管理(需显式申请和释放) |
| 存储内容 | 局部变量、函数参数、返回地址等 | 动态分配的对象、数组、大型数据结构等 |
| 生命周期 | 与作用域绑定(函数结束即销毁) | 从分配到显式释放(或程序结束) |
2.2.2 内存布局
1 | 高地址 |
注:具体布局因操作系统和编译器而异,但栈和堆通常相向增长。
2.2.3 关键区别
| 栈 | 堆 | |
|---|---|---|
| 分配速度 | 极快。只需移动栈指针(如 sub rsp, 8),硬件直接支持 |
较慢。需调用内存管理器(如 malloc 内部可能涉及系统调用、空闲链表查找、碎片整理等) |
| 大小限制 | 较小(通常几 MB,如 Linux 默认 8MB) (递归过深或大局部数组易导致 栈溢出(Stack Overflow)) |
较大(受限于虚拟内存,可达 GB 级) |
| 碎片问题 | 无碎片。先进后出(LIFO),内存连续释放。 | 可能产生内存碎片(频繁分配/释放不同大小块导致空闲内存不连续)。 |
| 线程安全性 | 每个线程有独立栈 → 天然线程安全。 | 所有线程共享 → 多线程访问需加锁(如 malloc 内部通常已加锁)。 |
| 初始化 | 不会自动初始化(C/C++ 中局部变量初始值为“垃圾值”)。 | malloc 不初始化;calloc 会清零;new 在 C++ 中会调用构造函数 |
2.2.4 常见误区
| 误区 | 正确理解 |
|---|---|
| “堆就是自由存储区,栈就是局部变量区” | 基本正确,但堆也可用于全局指针指向的动态对象 |
| “栈内存比堆内存快是因为在 CPU 寄存器里” | ❌ 栈仍在主存,快是因为分配逻辑简单 + 高速缓存友好 |
“C++ 的 new 一定在堆上” |
✅ 是的(除非重载 operator new 改变行为) |
| “栈变量不能返回指针” | ✅ 正确!返回栈变量地址会导致悬空指针(dangling pointer) |
2.2.5 如何选择
| 场景 | 推荐 |
|---|---|
| 小型、生命周期明确的数据(如循环计数器、临时变量) | 栈 |
| 大型数据、生命周期跨越函数调用、大小运行时确定 | 堆 |
| 需要多线程共享的数据 | 堆(配合同步机制) |
| 避免内存泄漏/简化管理 | 优先用栈;堆需 RAII(C++ 智能指针)或 GC(Java/Python) |
栈快小自动,堆慢大手动;
栈随作用域走,堆凭指针留。
2.2.6 例题:栈和堆的区别
- 申请方式:栈是系统自动分配,堆是程序员主动申请。
- 申请后系统响应:分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上。
- 栈在内存中是连续的一块空间(向低地址扩展)最大容量是系统预定好的,堆在内存中的空间(向高地址扩展)是不连续的。
- 申请效率:栈是有系统自动分配,申请效率高,但程序员无法控制;堆是由程序员主动申请,效率低,使用起来方便但是容易产生碎片。
- 存放的内容:栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制。
此题总结:
1、申请方式的不同。 栈由系统自动分配,而堆是人为申请开辟;
2、申请大小的不同。 栈获得的空间较小,而堆获得的空间较大;
3、申请效率的不同。 栈由系统自动分配,速度较快,而堆一般速度比较慢;
4、 存储的内容不同。
栈在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。 当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。
2.3 变量
在 C++ 中,变量根据其存储位置、生命周期、作用域、初始化方式和链接属性等不同维度,可以分为多种类型。理解这些“变量的区别”对掌握 C++ 的内存模型、程序结构和行为至关重要。
2.3.1 按 存储期(Storage Duration) 分类
自动存储期(Automatic Storage Duration)
代表:函数内的局部变量(未加
static)特点:
- 在进入作用域时创建,离开作用域时销毁。
- 存放在 栈(stack) 上。
- 未显式初始化 → 值为未定义(垃圾值)
示例:
1
2
3
4void func() {
int x; // 自动变量,未初始化
int y = 10; // 自动变量,已初始化
}
静态存储期(Static Storage Duration)
包括:
- 全局变量
static全局变量- 函数内的
static局部变量 - 类的
static成员变量
特点:
- 程序启动前分配,程序结束时销毁。
- 存放在 .data 或 .bss 段(与 C 相同)。
- 若未显式初始化 → 自动零初始化(zero-initialized)。
示例:
1
2
3
4
5
6
7int global_var; // 静态存储期,.bss,值=0
static int s_global = 5; // 静态存储期,.data
void func() {
static int count = 0; // 第一次调用时初始化,之后保持值
count++;
}
动态存储期(Dynamic Storage Duration)
代表:通过
new、malloc等在堆上分配的变量特点:
- 由程序员控制生命周期(
new/delete)。 - 存放在 堆(heap)。
new会调用构造函数;malloc不会。
- 由程序员控制生命周期(
示例:
1
2int* p = new int(42); // 动态分配,初始化为 42
delete p; // 必须手动释放
线程存储期(Thread Storage Duration,C++11 起)
使用
thread_local修饰每个线程有独立副本
示例:
1
thread_local int counter = 0; // 每个线程有自己的 counter
2.3.2 按 作用域(Scope) 分类
| 类型 | 作用域范围 |
|---|---|
| 局部变量 | 函数内部或复合语句 {} 内 |
| 全局变量 | 整个翻译单元(文件)可见 |
| 类成员变量 | 类内部定义,通过对象访问 |
| 命名空间变量 | 在命名空间中定义 |
2.3.3 按 链接属性(Linkage) 分类
| 类型 | 链接属性 | 说明 |
|---|---|---|
| 无链接(No linkage) | 如局部变量、lambda 捕获变量 | 仅在当前作用域可见 |
| 内部链接(Internal linkage) | 用 static 修饰的全局变量 / const 全局变量(C++ 特有) |
仅在当前编译单元(.cpp 文件)内可见 |
| 外部链接(External linkage) | 普通全局变量、函数、extern 变量 |
可被其他编译单元引用 |
C++ 中,**
const全局变量默认是内部链接**(与 C 不同!)
1
2 const int x = 10; // 内部链接(相当于 static const)
extern const int y = 20; // 外部链接
2.3.4 按 初始化方式 分类(C++ 特有)
| 初始化方式 | 示例 | 说明 |
|---|---|---|
| 默认初始化(Default Initialization) | int x; |
内置类型未初始化(垃圾值);类类型调用默认构造函数 |
| 值初始化(Value Initialization) | int x{}; 或 new int() |
内置类型初始化为 0;类类型调用默认构造或值初始化 |
| 直接初始化(Direct Initialization) | int x(5); |
直接调用构造函数或赋值 |
| 拷贝初始化(Copy Initialization) | int x = 5; |
语义上拷贝,但通常优化为直接初始化 |
| 列表初始化(List Initialization, C++11) | vector<int> v{1,2,3}; |
防止窄化转换,更安全 |
2.3.5 特殊变量类型
常量变量(
const)1
const int a = 10; // 不可修改,编译期常量(若初始化为字面量)
引用(Reference)
1
2int x = 5;
int& ref = x; // ref 是 x 的别名,不是新变量右值引用(Rvalue Reference, C++11)
1
int&& rref = 42; // 绑定到临时对象
类的静态成员变量
1
2
3
4
5class MyClass {
public:
static int count; // 声明
};
int MyClass::count = 0; // 定义(必须在类外)
2.3.6 总结对比表
| 变量类型 | 存储位置 | 生命周期 | 初始化 | 是否自动释放 | 典型用途 |
|---|---|---|---|---|---|
| 局部自动变量 | 栈 | 作用域内 | 否(垃圾值) | 是 | 临时计算 |
static 局部变量 |
静态区 | 整个程序运行期 | 仅首次 | 否 | 计数器、缓存 |
| 全局变量 | 静态区 | 整个程序运行期 | 零初始化 | 否 | 配置、共享状态 |
| 动态分配变量 | 堆 | 手动控制 | 可控 | 否(需 delete) |
大数据、多态对象 |
thread_local |
线程局部存储 | 线程生命周期 | 零初始化 | 否 | 线程私有状态 |
2.3.7 常见误区澄清
- ❌ “C++ 不再区分 .data 和 .bss” → 错误! C++ 依然使用这两个段。
- ❌ “
static变量都在堆上” → 错误!static变量在静态存储区(.data/.bss)。 - ✅ “局部变量一定在栈上” → 基本正确(除非编译器优化到寄存器,但语义仍是栈)。
- ✅ “
new出来的对象一定在堆上” → 正确(除非重载operator new)。
2.3.8 例题1:静态变量在代码执行的什么阶段进行初始化?
1 | static int value //静态变量初始化语句 |
对于C语言: 静态变量和全局变量均在编译期进行初始化,即初始化发生在任何代码执行之前。
对于C++: 静态变量和全局变量仅当首次被使用的时候才进行初始化。
助记: 如果你使用过C/C++你会发现,C语言要求在程序的最开头声明全部的变量,而C++则可以随时使用随时声明;这个规律和答案类似
2.3.9 例题2:全局变量定义在头文件中有什么问题?
如果在头文件中定义全局变量,当该头文件被多个文件 include 时,该头文件中的全局变量就会被定义多次,导致重复定义,因此不能再头文件中定义全局变量。
2.4 内存对齐
内存对齐(Memory Alignment) 是指数据在内存中存储时,其起始地址必须是某个特定数值(通常是2、4、8、16等)的整数倍。这种机制由硬件架构和编译器共同决定,目的是提高内存访问效率,甚至在某些平台上是强制要求(否则会引发硬件异常)。
2.4.1 为什么需要内存对齐
- 硬件访问效率
现代CPU通常以“字”(word)为单位读取内存(如32位系统一次读取4字节,64位系统一次读8字节)。如果一个变量跨越两个内存块边界(比如一个4字节int从地址3开始),CPU可能需要两次内存访问才能读取完整数据,效率低。
- 平台兼容性
某些架构(如ARM、SPARC)强制要求对齐访问,否则会触发总线错误(bus error)或未定义行为。x86/x64虽然允许非对齐访问,但性能较差。
2.4.2 基本对齐规则
C++标准规定:
- 每个类型都有一个自然对齐值(natural alignment),通常是其
sizeof的值(但不总是)。 - 结构体/类的对齐要求等于其最严格对齐成员的对齐值。
- 数组元素之间无额外填充,但整个数组的对齐仍满足元素类型的对齐要求。
示例:
1 | struct A { |
在32/64位系统上,sizeof(A) 通常是 8,因为:
c占1字节(地址0)- 编译器插入3字节填充(地址1~3)
i从地址4开始(4是4的倍数)- 总大小 = 1 + 3(填充)+ 4 = 8
2.4.3 对齐控制方式
alignof和alignas(C++11起)alignof(Type):获取类型的对齐要求(返回size_t)。alignas(N):指定变量或类型的最小对齐(N必须是2的幂,且 ≥ 自然对齐)
1
2
3
4
5
6
7
8
9
10
struct alignas(16) Vec4 {
float x, y, z, w;
};
int main() {
std::cout << "alignof(Vec4): " << alignof(Vec4) << "\n"; // 输出16
std::cout << "sizeof(Vec4): " << sizeof(Vec4) << "\n"; // 至少16(实际16)
}注意:
alignas只能增加对齐,不能减少。编译器扩展(如
#pragma pack)用于减小结构体内存占用(常用于网络协议、文件格式等需要精确布局的场景):
1
2
3
4
5
6
7
8
struct Packed {
char c;
int i;
};
// sizeof(Packed) == 5(无填充)⚠️ 风险:可能导致非对齐访问,影响性能或崩溃。
2.4.4 对齐与动态内存分配
new/malloc返回的指针至少满足alignof(std::max_align_t)的对齐(通常是8或16字节)。
C++17 引入了
aligned_alloc和 **std::aligned_alloc**(实际来自C11),以及更安全的 **std::allocate_aligned**(通过std::allocator或自定义分配器)。C++17 还引入了 **
std::aligned_storage**(C++23已弃用)和 **std::assume_aligned**(优化提示)。
示例:手动分配对齐内存(C++17前)
1 | void* ptr = aligned_alloc(32, 64); // 32字节对齐,分配64字节 |
C++17 起推荐使用:
1 |
|
2.4.5 常见误区
| 误区 | 正确理解 |
|---|---|
“所有类型都按 sizeof 对齐” |
不一定。例如 double 在32位Windows上 sizeof=8,但对齐可能是4(历史原因)。 |
| “结构体大小 = 成员大小之和” | 错!要考虑成员对齐和末尾填充。 |
“#pragma pack(1) 总是安全的” |
在x86上可能运行,但在ARM等平台可能崩溃。 |
2.4.6 实用建议
- 默认情况下信任编译器:不要随意修改对齐,除非有明确需求(如硬件寄存器映射、SIMD向量、跨平台数据交换)。
- 跨平台数据结构:若需固定布局(如网络包),使用
#pragma pack并配合静态断言验证大小。 - SIMD优化:AVX指令要求32字节对齐,可使用
alignas(32)。 - 调试工具:用
offsetof、alignof、sizeof验证布局;Valgrind/AddressSanitizer 可检测非对齐访问(部分平台)。
2.4.7 总结
| 概念 | 说明 |
|---|---|
| 对齐值(Alignment) | 数据起始地址必须是该值的整数倍 |
| 自然对齐 | 类型默认的对齐要求(通常 = sizeof,但受平台影响) |
| 填充(Padding) | 编译器插入的空字节,确保成员对齐 |
| 结构体总大小 | 必须是最严格对齐成员的整数倍(末尾也可能填充) |
| 控制手段 | alignas(增强)、#pragma pack(减弱) |
2.5 内存泄漏
内存泄漏(Memory Leak) 是指程序在运行过程中动态分配了内存(通常通过 new 或 malloc),但在使用完毕后未能正确释放,导致这部分内存无法被再次使用,直到程序结束。虽然现代操作系统会在进程退出时回收所有内存,但在长时间运行的程序(如服务器、嵌入式系统、桌面应用)中,内存泄漏会不断累积,最终耗尽可用内存,引发性能下降甚至崩溃。
2.5.1 内存泄漏的根本原因
C++没有自动垃圾回收机制(GC),动态内存的生命周期由程序员手动管理。
当出现一下情况时,就可能发生内存泄漏:
- 忘记调用
delete/delete[]/free - 提前返回或异常导致释放代码未执行
- 指针被覆盖或者丢失(”悬空指针”未释放)
- 循环引用(在使用智能指针时设计不当)
2.5.2 常见内存泄漏场景及示例
场景 1:忘记释放
1 | void leak1() { |
场景 2:异常安全问题
1 | void leak2() { |
场景 3:数组与单对象混淆
1 | void leak3() { |
场景 4:指针重新赋值导致原地址丢失
1 | void leak4() { |
场景 5:类成员未在析构函数中释放
1 | class BadClass { |
2.5.3 避免内存泄漏的方法
遵循 RAII 原则(Resource Acquisition Is Initialization)
将资源管理封装在对象的生命周期中:构造时获取,析构时释放。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class GoodClass {
int* data; // 指向动态分配的数组
public:
// 构造函数:获取资源(Acquire)
explicit GoodClass(size_t n) : length(n) {
if (n == 0) {
data = nullptr;
return;
}
data = new int[n]; // 分配内存 —— 资源获取即初始化
}
// 析构函数:释放资源(Release)
~GoodClass() {
delete[] data; // 自动释放,即使发生异常也会调用
data = nullptr; // 防止误用(习惯性置空)
}
};优先使用智能指针(C++11 起)
智能指针 用途 std::unique_ptr独占所有权,自动释放,无开销 std::shared_ptr共享所有权,引用计数,注意循环引用 std::weak_ptr配合 shared_ptr打破循环引用1
2
3
4void safe() {
auto p = std::make_unique<int>(42); // 自动 delete
auto arr = std::make_unique<int[]>(100); // 自动 delete[]
}使用容器代替原始指针
1
2
3// 不要:int* arr = new int[n];
// 要用:
std::vector<int> arr(n); // 自动管理内存确保异常安全:使用 RAII 或 try-catch
1
2
3
4void safe_with_exception() {
auto p = std::make_unique<int>(100);
risky_function(); // 即使抛异常,p 也会自动释放
}自定义类必须遵循“三/五法则”
如果类管理资源(如动态内存),必须显式定义:
- 析构函数(
~T()) - 拷贝构造函数(
T(const T&)) - 拷贝赋值运算符(
operator=) - (C++11 起)移动构造函数和移动赋值(可选但推荐)
否则默认的浅拷贝会导致重复释放或泄漏。
1
2
3
4
5
6
7
8
9
10class ResourceManager {
int* data;
public:
ResourceManager(size_t n) : data(new int[n]) {}
~ResourceManager() { delete[] data; }
// 禁止拷贝(或正确实现深拷贝)
ResourceManager(const ResourceManager&) = delete;
ResourceManager& operator=(const ResourceManager&) = delete;
};- 析构函数(
2.5.4 检擦内存泄漏
工具检测
- Linux/macOS:
valgrind --leak-check=full ./your_program - Windows (MSVC): 使用
_CrtDumpMemoryLeaks()+ 调试器
1
2
3
4
5
6
int main() {
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
// your code
}- AddressSanitizer (ASan): 编译时加
-fsanitize=address(GCC/Clang) - 静态分析工具: Clang Static Analyzer, PVS-Studio, PC-lint
- Linux/macOS:
日志或计数器(开发阶段)
1
2
3
4
5
6
7
8
9
10static size_t alloc_count = 0;
void* operator new(size_t size) {
++alloc_count;
return malloc(size);
}
void operator delete(void* p) noexcept {
if (p) --alloc_count;
free(p);
}
// 程序结束时检查 alloc_count 是否为 0
2.5.5 特殊注意事项
全局/静态对象的析构顺序不确定
可能导致“析构时访问已释放资源”,但一般不直接造成泄漏。
内存泄漏 ≠ 野指针
- 内存泄漏:内存没释放,但指针已丢失 → 浪费内存
- 野指针:指针指向已释放的内存 → 危险访问
“良性泄漏”?
有些程序(如短命命令行工具)即使有小泄漏也无妨,因为 OS 会回收。但绝不应养成坏习惯。
2.5.6 总结
| 关键点 | 说明 |
|---|---|
| 根源 | 手动内存管理 + 疏忽/异常/设计缺陷 |
| 核心原则 | RAII + 智能指针 + 容器 |
| 最佳实践 | 尽量避免裸 new/delete;用 make_unique/make_shared |
| 检测手段 | Valgrind、ASan、调试器工具 |
| 设计准则 | 遵循三/五法则,异常安全 |
💡 记住:在现代 C++ 中,如果你还在频繁写
delete,那你可能做错了。
通过合理使用 RAII 和标准库设施,绝大多数内存泄漏都可以在编译期或设计阶段避免。
2.6 三/五法则
“三/五法则”(Rule of Three / Rule of Five)是 C++ 中关于类如何正确管理资源(如动态内存、文件句柄、网络连接等)的一组核心设计准则。它的本质是:如果你需要自定义某个特殊成员函数,通常也需要自定义其他几个相关的函数,以保证资源安全和行为一致。
2.6.1 背景: C++ 的六个特殊成员函数
C++ 编译器会为每个类自动提供以下特殊成员函数(如果未显式定义):
| 函数 | 默认行为 |
|---|---|
| 默认构造函数 | T() |
| 析构函数 | ~T() — 若无自定义,不执行任何清理 |
| 拷贝构造函数 | T(const T&) — 浅拷贝(逐字节复制) |
| 拷贝赋值运算符 | T& operator=(const T&) — 浅拷贝 |
| 移动构造函数(C++11) | T(T&&) — 若未定义,可能被禁用或默认生成 |
| 移动赋值运算符(C++11) | T& operator=(T&&) — 同上 |
⚠️ 问题在于:默认的拷贝/移动操作是浅拷贝(shallow copy),即只复制指针值,不复制指针指向的数据。这在管理动态资源时会导致严重问题(如重复释放、悬空指针、内存泄漏)。
2.6.2 三法则(Rule of Three)
如果一个类需要显式定义以下三个函数中的任意一个,那么通常也需要定义另外两个:
- 析构函数(
~T()) - 拷贝构造函数(
T(const T&)) - 拷贝赋值运算符(
T& operator=(const T&))
如果你写了析构函数(说明你在手动释放资源,比如
delete[] data),
那么默认的浅拷贝会导致多个对象指向同一块内存 → 析构时多次释放 → 未定义行为(程序崩溃)
示例:违反三法则 → 危险!
1 | class BadString { |
正确做法:遵循三法则
1 | class GoodString { |
现在
s2 = s1会创建独立副本,析构时各自释放自己的内存,安全!
2.6.3 五法则(Rule of Five)—— C++11 起
C++11 引入了移动语义(Move Semantics),用于高效转移资源(避免不必要的深拷贝)。于是三法则扩展为五法则:
如果需要自定义以下任意一个,通常应全部定义这五个:
- 析构函数(
~T()) - 拷贝构造函数(
T(const T&)) - 拷贝赋值运算符(
T& operator=(const T&)) - 移动构造函数(
T(T&&)) - 移动赋值运算符(
T& operator=(T&&))
为什么需要移动操作?
- 提高性能:例如
std::vector扩容时,移动元素比拷贝快得多; - 对于不可拷贝的资源(如
std::unique_ptr),只能移动。
补充移动操作到 GoodString
1 | // 4. 移动构造函数 |
现在支持高效移动:
1
2 GoodString s1("Hello");
GoodString s2 = std::move(s1); // 不拷贝,直接转移指针
2.6.4 零法则(Rule of Zero)—— 现代 C++ 最佳实践
最好的资源管理方式,是根本不需要写析构函数、拷贝/移动操作!
通过使用RAII 类型(如 std::string, std::vector, std::unique_ptr)作为成员,让它们自动管理资源。
1 | class BestString { |
std::string内部已正确实现三/五法则;BestString继承了这些安全语义;- 代码简洁、无 bug、高性能。
Rule of Zero 是首选;只有在封装底层资源(如裸指针、C API 句柄)时,才考虑三/五法则。
2.6.5 总结:何时用哪个法则?
| 场景 | 推荐法则 | 说明 |
|---|---|---|
类只包含 int, std::string, std::vector 等 RAII 成员 |
零法则(Rule of Zero) | 什么都不写,用默认函数 |
类直接管理裸资源(如 new, 文件句柄) |
五法则(Rule of Five) | 显式定义全部5个函数(或禁用拷贝) |
| 兼容 C++98/03 旧代码 | 三法则(Rule of Three) | 只需处理拷贝和析构 |
核心思想
资源管理必须一致:
- 如果你能销毁资源(析构),你就必须知道如何复制它(拷贝);
- 如果你能复制,你也应该能移动它(C++11+);
- 最好的办法是让别人帮你管(RAII + 零法则)
2.7 智能指针
智能指针(Smart Pointers)是 C++11 引入的一组模板类,用于自动管理动态分配的内存(或其他资源),从而避免内存泄漏、悬空指针、重复释放等常见错误。它们通过 RAII(Resource Acquisition Is Initialization) 机制,在对象生命周期结束时自动释放所管理的资源。
C++ 标准库提供了三种主要智能指针:
std::unique_ptrstd::shared_ptrstd::weak_ptr
2.7.1 std::unique_ptr —— 独占所有权
核心特性
- 独占所有权:一个资源只能被一个
unique_ptr拥有。 - 不可复制(copy),但可移动(move)。
- 零开销:不引入额外内存或性能损耗(相比裸指针)。
- 自动调用
delete(或自定义删除器)释放资源。
基本用法
1 |
|
自定义删除器(用于管理非 new 分配的资源)
1 | // 管理 C 风格数组 |
注意事项
- 不能复制:
auto p2 = p1;编译错误。 - 适合“单一所有者”场景:如工厂函数返回值、局部资源管理、容器中存储动态对象。
2.7.2 std::shared_ptr —— 共享所有权**
核心特性
共享所有权:多个
shared_ptr可指向同一对象。使用引用计数(reference count)跟踪有多少个
shared_ptr共享该资源。当最后一个
shared_ptr被销毁时,自动释放资源。可复制、可赋值。
基本用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void use_shared(std::shared_ptr<int> sp) {
std::cout << "use count: " << sp.use_count() << "\n"; // 引用计数
}
int main() {
auto sp1 = std::make_shared<int>(100); // 推荐用 make_shared
std::cout << "count: " << sp1.use_count() << "\n"; // 1
{
auto sp2 = sp1; // 复制,引用计数变为 2
use_shared(sp1); // 传参(再+1,但函数内是3,退出后回2)
} // sp2 析构,计数回到 1
// sp1 析构时,计数归0 → delete int
}优势
- 更高效:一次内存分配同时创建控制块(引用计数)和对象;
- 异常安全:避免
new和shared_ptr构造之间抛异常导致泄漏。
1
2
3
4
5// 不推荐(可能泄漏):
std::shared_ptr<int> p(new int(42)); // 若 shared_ptr 构造失败,new 的内存泄漏
// 推荐:
auto p = std::make_shared<int>(42);注意事项
有性能开销:引用计数需原子操作(线程安全),且控制块占用额外内存。
不能管理数组(C++17 前):
1
2// C++17 起支持:
auto arr = std::make_shared<int[]>(10);警惕循环引用(见下文
weak_ptr)。
2.7.3 std::weak_ptr —— 观察共享对象(打破循环引用)
核心特性
不拥有资源,只是“观察”一个由
shared_ptr管理的对象。不影响引用计数。
用于解决
shared_ptr的循环引用问题。必须通过
.lock()转为shared_ptr才能安全访问对象。
循环引用问题示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25struct Node;
using NodePtr = std::shared_ptr<Node>;
struct Node {
std::string name;
NodePtr parent;
std::vector<NodePtr> children;
~Node() { std::cout << name << " destroyed\n"; }
};
int main() {
auto parent = std::make_shared<Node>();
parent->name = "Parent";
auto child = std::make_shared<Node>();
child->name = "Child";
parent->children.push_back(child);
child->parent = parent; // 循环引用!
// main 结束时:
// parent 引用计数=1(main 中),child 引用计数=1(parent->children 中)
// 互相持有 → 计数永不归零 → 内存泄漏!
}用
weak_ptr打破循环1
2
3
4
5
6struct Node {
std::string name;
std::weak_ptr<Node> parent; // 改为 weak_ptr
std::vector<NodePtr> children;
~Node() { std::cout << name << " destroyed\n"; }
};现在
child->parent不增加parent的引用计数,循环被打破,析构正常。安全访问
weak_ptr1
2
3
4
5
6
7void print_parent(const std::weak_ptr<Node>& wp) {
if (auto sp = wp.lock()) { // 尝试升级为 shared_ptr
std::cout << "Parent: " << sp->name << "\n";
} else {
std::cout << "Parent already destroyed\n";
}
}
2.7.4 对比总结
| 特性 | unique_ptr |
shared_ptr |
weak_ptr |
|---|---|---|---|
| 所有权 | 独占 | 共享 | 无(观察者) |
| 可复制 | ❌ | ✅ | ✅(但不增加引用计数) |
| 可移动 | ✅ | ✅ | ✅ |
| 引用计数 | 无 | 有(原子操作) | 依赖 shared_ptr 的计数 |
| 内存开销 | 无(同裸指针) | 额外控制块(≈16~32字节) | 同 shared_ptr 控制块 |
| 适用场景 | 单一所有者、性能敏感 | 多所有者、共享数据 | 打破循环引用、临时观察 |
2.7.5 最佳实践
- **优先使用
unique_ptr**:除非需要共享,否则不要用shared_ptr。 - **永远用
make_unique/make_shared**:更安全、更高效。 - **避免裸
new/delete**:让智能指针接管资源。 - 慎用
shared_ptr的原始指针:不要从shared_ptr提取.get()后长期保存。 - 用
weak_ptr解决循环引用:尤其在图、树、观察者等结构中。 - 不要将智能指针用于栈对象或全局对象:只用于堆分配资源
2.7.6 常见误区
| 误区 | 正确做法 |
|---|---|
shared_ptr<T>(new T[10]) 管理数组 |
C++17 前应使用 unique_ptr<T[]>;C++17 起可用 shared_ptr<T[]> |
从 shared_ptr 取 .get() 并 delete |
❌ 会导致 double-free;智能指针会自动 delete |
多个 shared_ptr 从同一个裸指针构造 |
❌ 会创建多个控制块 → double-free;必须从一个 shared_ptr 复制 |
在多线程中随意拷贝 shared_ptr |
✅ 引用计数是线程安全的,但对象访问仍需同步 |
2.7.7 总结
C++ 智能指针是现代 C++ 内存管理的基石:
- **
unique_ptr**:轻量、高效、独占 → 默认选择。 - **
shared_ptr**:共享、安全、有开销 → 按需使用。 - **
weak_ptr**:观察、不拥有所 → 解决循环引用。
智能指针不是“万能垃圾回收”,而是“确定性资源管理”。
它们让 C++ 在保持高性能的同时,极大提升了内存安全性。
2.8 深拷贝与浅拷贝
深拷贝(Deep Copy) 与 浅拷贝(Shallow Copy) 是对象复制时的两种不同策略,尤其在类管理动态资源(如堆内存、文件句柄等)时至关重要。理解它们的区别是避免内存错误(如重复释放、悬空指针、内存泄漏)的关键。
2.8.1 基本概念
- 浅拷贝(Shallow Copy)
- 定义:仅复制对象的成员变量的值,对于指针成员,只复制指针本身(即地址),而不复制指针所指向的数据。
- 结果:两个对象的指针成员指向同一块内存。
- C++ 默认行为:编译器自动生成的拷贝构造函数和拷贝赋值运算符执行的就是浅拷贝。
- 深拷贝(Deep Copy)
- 定义:不仅复制成员变量的值,对指针成员还会分配新的内存,并将原对象指针所指向的数据完整复制一份。
- 结果:两个对象各自拥有独立的内存副本,互不影响。
- 需要手动实现:程序员必须显式定义拷贝构造函数和拷贝赋值运算符
2.8.2 直观图示
假设有一个类包含一个 int* 成员:
1 | class MyClass { |
浅拷贝后:
1 | obj1.data ──→ [100] |
深拷贝后:
1 | obj1.data ──→ [100] |
2.8.3 代码示例:浅拷贝的问题
1 |
|
此时虽然能运行,但一旦添加析构函数,就会崩溃:
1 | ~BadString() { |
运行结果(典型错误):
1 | Allocated: Hello at 0x123456 |
这就是浅拷贝 + 析构函数 = 灾难。
2.8.4 正确做法:实现深拷贝(遵循三法则)
1 | class GoodString { |
测试:
1 | int main() { |
输出示例:
1 | String: World at 0x7ffee123 |
✅ 安全、无泄漏、无重复释放。
2.8.5 需要深拷贝场景
| 场景 | 是否需要深拷贝 |
|---|---|
类包含裸指针(T*)且指向堆内存 |
✅ 必须 |
类包含 std::vector, std::string 等 RAII 成员 |
❌ 不需要(它们内部已处理) |
| 类管理文件句柄、socket、锁等资源 | ✅ 需要(或禁用拷贝) |
| 对象仅用于移动(如返回临时值) | 可考虑移动语义替代 |
2.8.6 深拷贝 vs 移动语义(C++11+)
深拷贝成本高(需分配内存 + 复制数据)。C++11 引入移动语义,通过“转移资源”避免拷贝:
1 | // 移动构造函数(偷资源) |
1 | GoodString createString() { |
✅ 最佳实践:
- 若需共享 → 用
shared_ptr- 若需独占且可转移 → 实现移动语义
- 若必须拷贝 → 实现深拷贝
- 最好避免裸指针 → 用 RAII 容器(零法则)
2.8.7 总结对比表
| 特性 | 浅拷贝 | 深拷贝 |
|---|---|---|
| 复制内容 | 仅复制指针值(地址) | 复制指针 + 指向的数据 |
| 内存布局 | 多个对象共享同一块内存 | 每个对象有独立内存 |
| 默认行为 | 编译器自动生成 | 需手动实现 |
| 安全性 | 析构时易导致 double-free | 安全 |
| 性能 | 快(O(1)) | 慢(O(n),需分配+复制) |
| 适用场景 | 无资源管理的简单类 | 管理动态资源的类 |
2.8.8 结论
- 浅拷贝是默认行为,但对资源管理类是危险的;
- 只要类有析构函数(说明管理资源),就必须考虑是否需要深拷贝(三法则);
- 现代 C++ 更推荐:
- 使用
std::string、std::vector等 RAII 类型(避免裸指针); - 或使用智能指针(
unique_ptr,shared_ptr); - 这样可完全避免手动实现深拷贝(Rule of Zero)。
- 使用
“如果你写了
delete,你就很可能需要写深拷贝。”
但更好的方式是——**根本不要写delete**。
2.9 虚拟内存
在 C++ 编程中,“虚拟内存”(Virtual Memory)这个术语常被提及,但需要明确:虚拟内存是操作系统(OS)和硬件(MMU)提供的机制,不是 C++ 语言本身的特性。然而,C++ 程序运行在支持虚拟内存的系统上,其内存模型、指针行为、动态分配等都深受虚拟内存机制的影响。
下面从 操作系统视角 和 C++ 程序员视角 两个层面,详细解释虚拟内存及其对 C++ 的意义。
2.9.1 基本概念
核心定义
虚拟内存是一种内存管理技术,由操作系统和 CPU 的内存管理单元(MMU)协作实现,为每个进程提供一个独立、连续、私有的地址空间(称为“虚拟地址空间”),而实际物理内存(RAM)可以是不连续的,甚至部分数据可暂存于磁盘(swap/page file)。
主要目标
- 隔离性:进程 A 无法直接访问进程 B 的内存(安全/稳定)。
- 抽象性:程序看到的是连续地址(如 0x00000000 到 0x7fffffffffff),无需关心物理内存布局。
- 扩展性:可用内存 > 物理 RAM(通过磁盘交换)。
- 共享与保护:允许多个进程共享代码段(如 libc),同时设置读/写/执行权限。
2.9.2 工作方式
虚拟地址 → 物理地址(地址翻译)
- 每个进程有独立的页表(Page Table)。
- CPU 访问内存时,MMU 自动将虚拟地址(VA)通过页表转换为物理地址(PA)。
- 若页不在 RAM 中(page fault),OS 从磁盘加载后再继续执行。
1
2
3C++ 指针值(如 0x55aabbcc) → 虚拟地址
↓(MMU + 页表)
物理内存芯片上的真实位置 → 物理地址对 C++ 程序员而言,所有指针都是虚拟地址,你永远看不到物理地址。
内存分页(Paging)
- 虚拟内存和物理内存被划分为固定大小的页(通常 4KB)。
- 页表记录“虚拟页 ↔ 物理页帧”的映射关系。
- 未使用的页可被换出到磁盘(swap space)。
2.9.3 C++ 程序的虚拟地址空间布局
一个典型的 64 位 Linux 进程虚拟地址空间(简化):
1 | 高地址 |
注意:具体布局因 OS 和架构而异(Windows、macOS 类似但细节不同)。
2.9.4 虚拟内存对 C++ 编程的关键影响
指针是虚拟地址
1
2int* p = new int(42);
std::cout << p; // 输出的是虚拟地址(如 0x55d8a2b3e2a0)- 该地址只在当前进程有意义;
- 其他进程即使有相同数值的指针,也指向不同物理内存(或无效)。
动态内存分配依赖虚拟内存
new/malloc本质是向 OS 请求虚拟地址空间;- OS 通过
brk(堆扩展)或mmap(大块内存)分配虚拟页; - 物理内存直到首次写入才真正分配(按需分页,lazy allocation)。
空指针与非法地址
nullptr(0x0)通常被 OS 映射为不可访问区域;- 访问它会触发 segmentation fault(段错误);
- 其他非法虚拟地址(如未分配区域)同样会 crash。
内存保护机制
- 代码段(text)通常是 read-only + execute;
- 尝试写入会导致 SIGSEGV:
1
2char* str = "hello"; // 字符串字面量在只读段
str[0] = 'H'; // ❌ 段错误!共享内存与内存映射文件
- 通过
mmap(Linux)或CreateFileMapping(Windows),多个进程可共享同一物理内存页; - C++ 可通过指针直接读写文件内容(零拷贝):
1
2
3// 伪代码:将文件映射到虚拟地址空间
void* addr = mmap(file, size, PROT_READ | PROT_WRITE, MAP_SHARED, ...);
// 现在可通过指针像操作数组一样操作文件- 通过
2.9.5 常见武误区
| 误解 | 正确理解 |
|---|---|
| “C++ 有虚拟内存” | C++ 运行在支持虚拟内存的 OS 上,但语言本身不提供虚拟内存 |
| “虚拟内存 = swap” | swap 只是虚拟内存的一种后备存储,核心是地址空间抽象 |
| “指针地址是物理内存位置” | 所有用户态指针都是虚拟地址,物理地址由 MMU 隐藏 |
| “分配内存 = 占用物理 RAM” | 虚拟内存分配 ≠ 物理内存占用(直到实际读写) |
2.9.6 C++ 程序员关注点
虽然不直接操作虚拟内存,但需理解以下实践:
不要假设地址连续性
1
2int a, b;
// &a 和 &b 的地址关系不确定(可能不连续)大内存分配可能“成功”但实际不可用
new返回非空指针 ≠ 有足够物理内存(Linux 默认允许 over-commit);- 真正使用时可能被 OOM killer 杀死。
性能优化:利用局部性原理
- 虚拟内存以页为单位加载;
- 遍历数组比随机访问链表更快(缓存友好 + 页命中率高)。
调试内存错误
- 段错误(Segmentation Fault):访问了未映射/无权限的虚拟地址;
- 使用
valgrind、AddressSanitizer可检测非法内存访问。
2.9.7 扩展
| 技术 | 说明 |
|---|---|
| 内存映射 I/O | 将设备寄存器映射到虚拟地址,通过指针控制硬件(嵌入式/驱动) |
| huge pages | 使用 2MB/1GB 大页减少 TLB 缺失,提升高性能应用性能 |
| ASLR(地址空间布局随机化) | 安全机制,每次运行时代码/堆/栈基址随机化,防止攻击 |
| NUMA | 多 CPU 插槽系统中,虚拟地址到物理内存的距离影响性能 |
2.9.8 总结
| 关键点 | 说明 |
|---|---|
| 虚拟内存是 OS 机制 | C++ 程序运行在其之上 |
| 指针 = 虚拟地址 | 进程私有,由 MMU 翻译为物理地址 |
new/malloc 分配虚拟空间 |
物理内存按需分配 |
| 地址空间分段管理 | 代码、数据、堆、栈各司其职 |
| 影响 C++ 行为 | 内存保护、段错误、性能特性等 |
你操作的是“虚拟世界”的地址,操作系统和硬件为你屏蔽了物理内存的复杂性。
二、编程语言对比
1. C++11特性
1.1 auto 类型推导
作用:让编译器根据初始化表达式自动推导变量类型,简化复杂类型的书写。
1 | auto i = 42; // int |
优点:
- 避免冗长类型名(如迭代器、模板实例化)。
- 提高泛型编程的可读性。
注意:
auto推导的是值类型(会去除引用和顶层 const),若需保留引用或 const,应使用const auto&等形式。
1.2 decltype 类型推导
作用:返回表达式的“声明类型”(declaration type),不进行求值。
1 | int x = 0; |
用途:
用于模板元编程中获取表达式类型。
与
auto结合用于函数返回类型后置(trailing return type)1
2
3
4template<typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {
return a + b;
}
1.3 Lambda 表达式
作用:定义匿名函数对象(闭包),常用于 STL 算法、回调等场景。
基本语法:
1 | [capture](parameters) -> return_type { body } |
示例:
1 | auto f = [](int x) { return x * x; }; |
捕获方式:
[=]:按值捕获所有外部变量。[&]:按引用捕获所有外部变量。[a, &b]:混合捕获。
Lambda 在内部被编译为一个唯一的函数对象类(functor)。
1.4 范围 for 语句(Range-based for loop)
作用:简化容器或数组的遍历。
语法:
1 | for (declaration : container) { ... } |
示例:
1 | std::vector<int> v = {1, 2, 3}; |
要求容器支持 begin() 和 end()(可以是成员函数或 ADL 查找的自由函数)。
1.5 左值、右值、左值引用、右值引用
C++11 引入的右值引用(rvalue reference)是现代 C++ 性能优化的核心机制之一,它与左值/右值的概念紧密相关。要真正掌握移动语义、完美转发等高级特性,必须深入理解这些基础概念。
1.5.1 什么是“值”?——表达式的分类
在 C++ 中,“左值”和“右值”并不是变量的属性,而是表达式(expression)的属性。每个表达式在求值后,都会被归类为 左值(lvalue)、右值(rvalue),或者更细分为 C++11 中的五类
关键点:看一个东西是左值还是右值,要看它在代码中“怎么用”,而不是它本身是什么。
1.5.2 传统定义(C++98/03)
左值(lvalue)
有名字(identifiable),有内存地址,生命周期较长。
可以出现在赋值运算符
=的左边(故名 “left-value”)。例如:
1
2
3int x = 10; // x 是左值
int* p = &x; // 能取地址 → 是左值
*p = 20; // *p 是左值
右值(rvalue)
临时的、无名的,通常表示计算结果。
不能取地址,不能放在赋值左边。
例如:
1
2
3
442 // 字面量,右值
x + y // 表达式结果,右值
std::string("hi") // 临时对象,右值
func() // 返回非引用类型的函数调用,返回右值
注意:
"hello"是左值!因为字符串字面量在内存中有固定地址(类型是const char[6]),可取地址。
1.5.3 C++11 的精细化分类:五类表达式(Value Categories)
C++11 将表达式细分为五类,但核心仍是 左值 vs 右值 的二分:
| 类别 | 英文 | 特点 | 示例 |
|---|---|---|---|
| 左值 | lvalue | 有标识(identity),不可移动 | 变量名、解引用 *p |
| 将亡值 | xvalue (eXpiring value) | 有标识,可移动 | std::move(x)、static_cast<T&&>(x) |
| 纯右值 | prvalue (pure rvalue) | 无标识,可移动 | 字面量 42、临时对象、函数返回非引用 |
- 左值 + 将亡值 = 泛左值(glvalue)
- 将亡值 + 纯右值 = 右值(rvalue)
重点理解:将亡值(xvalue)
这是 C++11 新增的关键概念!它既有身份(可取地址),又表示资源即将被销毁或可被“窃取”。
1 | int x = 10; |
此时
x仍然是左值(名字还在),但std::move(x)这个表达式是 xvalue,表示“你可以把x的资源拿走”。
1.5.4 左值引用 vs 右值引用
左值引用(lvalue reference):
T&- 只能绑定到 左值。
- 经典用途:避免拷贝、实现修改传参。
1
2
3
4int a = 5;
int& ref = a; // OK:a 是左值
int& bad = 10; // ❌ 错误:10 是右值(prvalue)
const int& ok = 10; // ✅ 允许:const 左值引用可绑定右值(延长临时对象生命周期)特例:
const T&可以绑定右值,这是 C++98 就有的特性,用于避免不必要的拷贝。右值引用(rvalue reference):
T&&- 只能绑定到右值(包括 prvalue 和 xvalue)。
- C++11 新增,用于实现移动语义。
1
2
3
4int&& r1 = 42; // OK:42 是 prvalue
int x = 10;
int&& r2 = std::move(x); // OK:std::move(x) 是 xvalue
int&& r3 = x; // ❌ 错误:x 是左值!关键:右值引用本身是一个左值!
因为它有名字,可以取地址:1
2
3
4void foo(int&& x) {
// x 在这里是一个左值!尽管参数类型是右值引用
int* p = &x; // 合法
}所以,在函数内部若想继续传递“可移动”语义,需再次使用
std::move(x)。
1.5.5 引用折叠规则(Reference Collapsing)
在模板推导或 typedef 中可能出现 T& && 等情况,C++11 定义了引用折叠规则:
| 原始组合 | 折叠结果 |
|---|---|
T& & |
T& |
T& && |
T& |
T&& & |
T& |
T&& && |
T&& |
口诀:只要有一个
&,结果就是&;只有两个&&才是&&。这为完美转发(
std::forward)提供了基础。
1.5.6 为什么需要右值引用?——移动语义的意义
问题:传统拷贝代价高
1 | std::vector<std::string> createHugeVector(); |
解决方案:移动构造
1 | class MyString { |
当传入的是右值(临时对象),编译器优先调用移动构造而非拷贝构造,避免深拷贝,提升性能。
触发移动的关键:右值引用参数
1 | MyString s1 = "hello"; |
1.5.7 左值/右值判断速查表
| 表达式 | 类型 | 说明 |
|---|---|---|
变量名(如 x) |
lvalue | 有名字 |
字面量(如 42, 3.14) |
prvalue | 无名临时值 |
字符串字面量 "abc" |
lvalue | 有地址 |
函数返回非引用(T f()) |
prvalue | 临时对象 |
函数返回左值引用(T& f()) |
lvalue | 返回已有对象 |
*p |
lvalue | 解引用得到对象 |
a[i] |
lvalue | 数组/容器元素 |
x++ |
prvalue | 返回原值的副本 |
++x |
lvalue | 返回自增后的 x |
std::move(x) |
xvalue | 显式转为将亡值 |
static_cast<T&&>(x) |
xvalue | 等价于 std::move |
1.5.8 常见误区
误区1:“右值就是不能取地址的东西”
更准确:右值没有持久的内存地址,但
xvalue(如std::move(x))对应的对象其实有地址,只是语义上“即将消亡”。
误区2:“右值引用绑定的变量是右值”
错!右值引用变量本身是左值(因为它有名字)。要传递其“可移动”语义,必须用
std::move。
1 | void g(int&& x) { |
误区3:“所有临时对象都能移动”
不一定!如果类没有定义移动构造函数,编译器会回退到拷贝构造(如果可用)。
1.5.9 总结
| 概念 | 本质 | 用途 |
|---|---|---|
| 左值 | 有身份、持久的对象 | 被读写、取地址 |
| 右值 | 临时、即将销毁的对象 | 被“移动”而非拷贝 |
左值引用 T& |
绑定左值 | 避免拷贝、修改实参 |
右值引用 T&& |
绑定右值 | 实现移动语义、完美转发 |
1.6 移动语义(Move Semantics)
通过移动构造函数和移动赋值运算符,避免不必要的深拷贝,提升性能。
1 | class Buffer { |
1.7 标准库std::move()
作用:将左值“转换”为右值引用,以便调用移动构造函数或移动赋值运算符。
1 | std::string s1 = "hello"; |
本质:
1 | template<typename T> |
std::move不移动任何东西,只是类型转换!真正的移动由移动构造函数实现。
1.8 =delete 和 =default
=default显式要求编译器生成默认版本的特殊成员函数(即使用户已定义其他构造函数)
1
2
3
4
5class A {
public:
A(int x) { ... }
A() = default; // 仍可使用默认构造
};=delete禁用某个函数(包括隐式生成的或用户声明的)。
1
2
3
4
5class NonCopyable {
public:
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};也可用于普通函数,防止不期望的重载匹配:
1
void foo(double) = delete; // 禁止传 double,避免隐式转换
1.9 标准库std::function
作用:通用的多态函数包装器,可存储、复制、调用任何可调用对象(函数指针、lambda、bind 表达式、函数对象等)。
1 |
|
优点:
- 统一接口,便于作为参数传递或存储回调。
- 支持类型擦除(type erasure)。
代价:可能有轻微运行时开销(虚表或函数指针间接调用)。
1.10 智能指针
1.11 nullptr
替代 NULL 或 0 作为空指针常量,类型安全。
1 | void f(int); |
1.12 统一初始化(Uniform Initialization)与 std::initializer_list
1 | std::vector<int> v{1, 2, 3}; // 使用大括号初始化 |
1.13 const、constexpr与noexcept
1.13.1 const - 常量修饰符
const 是 C++ 中最基础的常量修饰符,表示”不可修改”。
1 | // 1. 修饰变量 |
使用场景:
- 定义常量:替代
#define - 函数参数:保证传入的参数不被修改
- 成员函数:表明函数不会修改对象状态
- 只读访问:提供只读接口
1.13.2 constexpr - 常量表达式
constexpr 是 C++11 引入的关键字,表示”可以在编译时计算的值或函数”。
基本用法:
1 | // 1. constexpr变量(必须在编译时确定) |
1.13.3 constexpr 与 const 的区别
1 | // const - 运行时也可能初始化 |
1.13.4 noexcept - 异常说明符
noexcept 是 C++11 引入的异常说明,表示”函数不会抛出异常”。
基本用法:
1 | // 1. 基本声明 |
noexcept 的重要性:
1 | // 1. 移动语义优化 |
1.13.5 三者的对比
| 特性 | const | constexpr | noexcept |
|---|---|---|---|
| 引入时间 | C语言时代 | C++11 | C++11 |
| 主要用途 | 定义不可修改的值 | 编译时计算 | 异常保证 |
| 作用阶段 | 运行时/编译时 | 编译时 | 运行时(优化) |
| 修饰对象 | 变量、函数、成员 | 变量、函数、构造函数 | 函数 |
| 编译时计算 | 不一定 | 是 | 否 |
| 优化价值 | 只读优化 | 编译时计算 | 异常处理优化 |
1.13.6 综合示例
1 |
|
这三个关键字共同体现了 C++ 对性能(编译时计算、移动语义)和安全性(常量性、异常保证)的重视。
1.14 线程支持(<thread>, <mutex>, <atomic>)
C++11 首次在标准库中提供原生多线程支持。
1 |
|
1.15 override 和 final
override:显式标记虚函数覆盖,提高安全性。final:禁止派生或重写
1 | struct Base { |
1.16 委托构造函数(Delegating Constructors)
一个构造函数调用同一类的另一个构造函数。
1 | class X { |
2. 常见编程语言对比
2.1 语言定位与设计哲学
| 语言 | 定位 | 设计哲学 |
|---|---|---|
| C | 系统级过程式语言 | “信任程序员”,贴近硬件,极致效率,无抽象开销 |
| C++ | 多范式(面向对象 + 泛型 + 过程式)系统语言 | “零成本抽象”:高级特性不带来运行时开销 |
| Java | 面向对象、跨平台应用语言 | “一次编写,到处运行”,强调安全性、可移植性、自动内存管理 |
| Python | 高级动态脚本语言 | “可读性至上”,快速开发,强调简洁与生产力 |
C/C++:追求性能与控制;
Java:平衡性能与安全;
Python:牺牲性能换开发效率
2.2 编译与执行模型
| 语言 | 编译/解释 | 执行方式 | 运行依赖 |
|---|---|---|---|
| C | 编译为机器码 | 直接由 CPU 执行 | 仅需操作系统 |
| C++ | 编译为机器码 | 直接由 CPU 执行 | 仅需操作系统(或标准库) |
| Java | 编译为字节码(.class) |
由 JVM(Java 虚拟机)解释/JIT 编译执行 | 必须安装 JVM |
| Python | 解释执行(或先编译为字节码 .pyc) |
由 Python 解释器(如 CPython)执行 | 必须安装 Python 解释器 |
C/C++ 是 AOT(Ahead-of-Time)编译,直接生成本地代码;
Java 是 JIT(Just-in-Time)编译(运行时优化);
Python 是 解释执行为主,速度最慢。
2.3 内存管理
| 语言 | 内存管理方式 | 是否有 GC | 手动控制 |
|---|---|---|---|
| C | 完全手动(malloc/free) |
❌ 无 | ✅ 必须手动管理 |
| C++ | 手动(new/delete)+ RAII + 智能指针 |
❌ 无(但可通过智能指针实现自动释放) | ✅ 支持精细控制 |
| Java | 自动垃圾回收(GC) | ✅ 有(如 G1、ZGC) | ❌ 不能直接释放对象 |
| Python | 引用计数 + 循环垃圾回收 | ✅ 有 | ❌ 一般不手动干预 |
C++ 的 RAII(Resource Acquisition Is Initialization) 机制(如
std::unique_ptr)实现了“确定性析构”,比 Java/Python 的 GC 更可控、低延迟
2.4 类型系统
| 语言 | 类型检查时机 | 类型是否静态 | 是否强类型 |
|---|---|---|---|
| C | 编译时 | ✅ 静态 | ⚠️ 弱类型(允许大量隐式转换、指针强制转换) |
| C++ | 编译时 | ✅ 静态 | ✅ 强类型(但保留 C 的弱类型兼容性) |
| Java | 编译时 + 运行时(泛型擦除后仍需类型检查) | ✅ 静态 | ✅ 强类型 |
| Python | 运行时 | ❌ 动态类型 | ✅ 强类型(但类型在运行时才确定) |
示例:
1
2 int x = 5;
x = "hello"; // 编译错误!C++ 不允许
1
2 x = 5
x = "hello" # 合法!Python 允许变量重绑定不同类型
2.5 面向对象支持
| 语言 | OOP 支持 | 多继承 | 接口/抽象 |
|---|---|---|---|
| C | ❌ 不支持(可用结构体+函数模拟) | — | — |
| C++ | ✅ 完整支持(类、继承、多态) | ✅ 支持(但易引发菱形问题) | 抽象基类(纯虚函数) |
| Java | ✅ 完整支持 | ❌ 不支持类多继承(但可多实现接口) | 接口(interface) |
| Python | ✅ 支持(一切皆对象) | ✅ 支持(通过 MRO 解决冲突) | 抽象基类(abc 模块) |
C++ 的多继承功能强大但复杂;
Java 用“单继承 + 多接口”简化设计;
Python 的 OOP 更灵活,但运行时才报错。
2.6 性能对比(典型场景)
| 语言 | 执行速度 | 内存占用 | 启动时间 |
|---|---|---|---|
| C | ⚡ 极快(接近硬件) | 极低 | 极快 |
| C++ | ⚡ 极快(可与 C 比肩) | 低(可控) | 快 |
| Java | 快(JIT 优化后接近 C++) | 中高(JVM 开销) | 较慢(需启动 JVM) |
| Python | 慢(解释执行) | 高(对象开销大) | 快(脚本启动快) |
实测参考(计算斐波那契):
- C/C++:1x(基准)
- Java:1.2x ~ 2x
- Python:10x ~ 100x 慢于 C++
但 Python 可通过
NumPy(C 实现)或Cython提升性能。
2.7 标准库与生态
| 语言 | 标准库特点 | 第三方生态 |
|---|---|---|
| C | 极简(stdio, stdlib, string 等) | 依赖 POSIX 或第三方(如 glib) |
| C++ | 强大(STL:容器、算法、智能指针、线程等) | 丰富(Boost、OpenCV、Qt 等) |
| Java | 非常全面(集合、并发、网络、IO、NIO 等) | 极其庞大(Spring、Hadoop、Android SDK) |
| Python | “电池已包含”(文件、网络、正则、JSON、HTTP 等) | 最活跃(NumPy、Pandas、TensorFlow、Django) |
Python 和 Java 的生态更适合快速构建大型应用;
C/C++ 更适合底层、嵌入式、游戏引擎、高频交易等场景。
2.8 典型应用场景
| 语言 | 典型用途 |
|---|---|
| C | 操作系统内核(Linux)、嵌入式、驱动、数据库引擎(SQLite) |
| C++ | 游戏引擎(Unreal)、浏览器(Chrome)、高频交易、图形渲染、AI 框架底层(PyTorch C++ API) |
| Java | 企业后端(银行系统)、Android 应用、大数据(Hadoop/Spark)、Web 服务(Spring Boot) |
| Python | 数据分析、机器学习、脚本自动化、Web 后端(Django/Flask)、科学计算 |
2.9 总结
| 维度 | C | C++ | Java | Python |
|---|---|---|---|---|
| 范式 | 过程式 | 多范式 | 面向对象 | 多范式(OOP + 函数式) |
| 性能 | ⚡ 最高 | ⚡ 最高 | 高 | 低 |
| 内存管理 | 手动 | 手动 + RAII | 自动 GC | 自动 GC |
| 类型系统 | 静态、弱 | 静态、强 | 静态、强 | 动态、强 |
| 编译/执行 | 编译 → 机器码 | 编译 → 机器码 | 编译 → 字节码 → JVM | 解释执行 |
| 开发效率 | 低 | 中 | 高 | ⚡ 极高 |
| 学习曲线 | 中 | ⚠️ 陡峭 | 中 | 平缓 |
| 适用层级 | 系统层 | 系统/应用层 | 应用层 | 应用/脚本层 |
- 选 C:当你需要极致控制硬件(如写 OS、驱动)。
- 选 C++:当你需要高性能 + 高级抽象(如游戏、引擎、基础设施)。
- 选 Java:当你需要跨平台、稳定、大型企业系统。
- 选 Python:当你追求快速原型、数据科学、自动化脚本。
现代开发中,四者常混合使用:
- Python 调用 C/C++ 扩展(如 NumPy)
- Java 通过 JNI 调用 C 代码
- C++ 项目嵌入 Python 脚本引擎