⚙️ C++ 深入详解
基于《C++ 基础知识完全指南》的深度扩展版:每个知识点在原文之上,补充底层原理图解、更多代码实战、错误排查调试、章末即时测验。展开知识卡片即自动打卡,进度保存在你的浏览器里,随时回来继续。
第一章 · 入门与编译
第二章 · 运算符
第三章 · 流程控制
第四章 · 数组与字符串
第五章 · 函数
第六章 · 指针、引用与内存
第七章 · 面向对象
第八章 · 模板与 STL
第九章 · 工程实践与进阶
附录 · 术语速查表
📌 使用指引
- 知识点默认折叠,点击卡片标题展开即自动记为「已学」(本地保存,换设备/清浏览器缓存会重置)
- 灰色【说人话】是通俗比喻;📜 历史注解是「佐料」,看不懂可先跳过,不影响主线
- 每章末尾有 8-10 道即时判分测验,全部作答后显示成绩与学习建议
- 遇到看不懂的专有名词(如「编译期」「栈」「谓词」)→ 先去 附录·术语速查表 查一眼再回来继续
- 建议按章节顺序学习,每段代码都亲手敲一遍——编程是练出来的,不是读出来的
第一章 · 入门与编译
从「C++ 是什么」到「一段文字如何变成可运行的程序」——这一章打的是世界观地基。学完后你将明白:代码在机器眼里是什么、编译器替你做了哪些事、变量在内存里长什么样,以及为什么 C++ 的规矩处处透着「性能优先」的哲学。
🎯 本章学习目标
- 说清楚「编译型 / 解释型」「静态类型 / 动态类型」这两组概念的准确含义与实际后果
- 亲手把 Hello World 拆成四个编译阶段逐一观察,看懂每个阶段产出的文件里有什么
- 掌握全部基本数据类型的尺寸、取值范围与选择惯例,理解「溢出」和「未初始化」的真实危害
- 分清 const / constexpr / 字面量 / auto 四个词,掌握 cin/cout 的缓冲机制与失败恢复
1. C++ 简介与发展史
C++ 是一门编译型、静态类型的语言,诞生于 1979 年贝尔实验室,至今仍是游戏引擎(Unreal)、浏览器(Chrome)、数据库(MySQL)、操作系统组件与高频交易系统的主力语言。它能在几十年语言浪潮中屹立不倒,靠的是一条贯穿始终的设计哲学——零开销抽象:你可以用类、模板、智能指针这些高级工具组织代码,但只要你不使用某个功能,就不为它付出任何运行时代价。这条哲学的每一处细节(手动内存管理、无垃圾回收、编译期检查)都是它的直接产物。
编译型:你写的代码是给人看的「文字稿」,电脑只认识 0 和 1。编译型语言需要先把整份代码「翻译」成电脑能直接执行的文件再运行——好比把整本中文书提前翻译成英文出版,读者(CPU)读起来飞快。Python 那种「边看边翻」的叫解释型。
静态类型:每个变量「是什么类型」必须在写代码时就声明死,翻译官(编译器)会提前帮你检查有没有用错——大量错误在运行前就被抓住,而不是运行到一半才崩。
📊 图解一:编译型 vs 解释型——两条流水线
hello.cpp(人能读)
整体翻译一次
hello.exe(CPU 直接读)
飞快,可脱离源码分发
hello.py
每次运行逐行翻译
灵活但慢,需要解释器在场
这张图解释了 C++ 世界里很多「祖传规矩」的来源:因为程序最终是一份独立的机器码文件,所以你需要「编译」这个步骤;因为翻译是一次性完成的,编译器必须在翻译时就知道每个变量的类型——这就是静态类型的由来;也正因如此,类型错误在编译期就暴露,而不是等到客户运行时才崩溃。
📊 图解二:标准演进时间线——语言的「版本号」
C with Classes
得名 C++
C++98 首个标准
C++11 现代 C++ 元年
持续演进
把时间线记牢有实际意义:你看到的每一段教程代码,都隐含着「它是哪个年代的写法」。见到 auto 和 nullptr,说明至少是 C++11 之后的代码;见到裸 new/delete 满天飞,多半是 98 时代的遗产。本教程以 C++17 为主线(g++ -std=c++17),同时会标注老写法——因为真实工作中你一定会读到老代码。
💻 代码实战:C++ 都在哪儿干活
// 这些耳熟能详的软件,核心都是 C++ 写的:
// 游戏引擎 Unreal / Unity 核心渲染层
// 浏览器 Chrome(渲染引擎 Blink)与 Firefox
// 数据库 MySQL / MongoDB / Redis 的存储引擎
// 办公软件 Microsoft Office、WPS 的核心模块
// 高频交易系统:微秒级延迟要求,只有 C++ 能胜任
//
// 共同点:要么对「极致性能」有要求,要么需要「直接操作硬件」。
#include <iostream> // 这行代码本身就是 C++ 哲学的缩影:
// 你只抄一份"说明书"进来,不付任何运行代价
int main() { // 程序入口:操作系统从这里开始执行你的代码
std::cout << "Hello, C++!" << std::endl;
return 0; // 交还控制权,向系统报告"顺利跑完"
}
2. 第一个程序与编译流程
先把 Hello World 的每一行拆到极致——这一段 6 行代码浓缩了后面 44 个知识点里反复出现的角色。
💻 逐行拆解:每一行都在干什么
// hello.cpp —— 你的第一个 C++ 程序
#include <iostream> // ① 把"输入输出工具箱"的说明书抄进本文件
// (cin/cout 的声明都在里面,没抄就用 = 编译报错)
int main() { // ② 程序入口:操作系统从这里开始执行
// 全程序只能有一个 main;int 表示"结束时报告一个整数"
std::cout << "Hello, C++!"; // ③ 把这句话送到屏幕:<< 表示"数据流向输出流"
std::cout << "\n"; // \n 是换行符(转义字符)
return 0; // ④ 向系统报告:"顺利跑完"(0=成功,非 0=出了问题)
} // main 结束 = 程序结束
# 编译并运行(Linux / macOS / MinGW)
g++ -std=c++17 -Wall hello.cpp -o hello
./hello
# 命令拆解:
# -std=c++17 指定语言版本为 C++17
# -Wall 打开常用警告(Wall = W all,几乎所有警告)
# hello.cpp 输入:源文件
# -o hello 输出:生成的可执行文件叫 hello(不写默认叫 a.out)
📊 图解:一条编译命令背后的四个阶段
源代码
把所有 #include 的文件
内容原样抄进来
#define 做文本替换
把合并稿翻译成
汇编语言(接近机器
指令的中间语言)
汇编 → 纯 0/1 机器码
装进目标文件 .o
(半成品零件)
装配工把你的零件
和标准库零件拼成
完整程序
可执行文件
为什么要理解这四步?因为你未来 90% 的「玄学报错」都能定位到具体某个阶段:预处理阶段出错 → 你 include 的文件路径不对;编译阶段出错 → 你的代码语法/类型有问题(这是最常见的红色报错);链接阶段出错 → 报错信息长着 undefined reference 的面孔(第九章第 44 节详细收拾它)。把流水线刻进脑子,报错就不再玄学。
💻 眼见为实:把四个阶段拆开逐步观察
g++ -E hello.cpp -o hello.i # 1. 预处理:打开 hello.i 会看到
# <iostream> 的声明被整份抄了进来
# (文件暴涨到几万行,很正常!)
g++ -S hello.i -o hello.s # 2. 编译成汇编:打开 hello.s 能看到
# mov、call 等汇编指令(人还能勉强读)
g++ -c hello.s -o hello.o # 3. 汇编成机器码零件:hello.o 是二进制,
# 直接打开是乱码
g++ hello.o -o hello # 4. 链接:零件 + 标准库 → 完整程序
./hello # 运行 → Hello, C++!
undefined reference。这套「独立编译、最后装配」的模型继承自 1970 年代的 C——为了大项目只重编译改动的文件。它的代价(头文件规矩、链接报错)你会在第九章再次遇到。- 检查当前目录对不对:
ls看看 hello.cpp 在不在 - 检查文件名拼写(hello.cpp ≠ helloworld.cpp ≠ Hello.cpp,大小写敏感)
- 这是编译阶段的报错——语法问题。行号 5、列号 12 已经告诉你位置
- 注意:报错位置经常在「真正出错的那一行的下一行」——因为编译器读到那里才发现不对
- 中文输入法的全角分号「;」是同款报错的高频元凶,检查是否用了中文标点
- 这是链接阶段的报错——找不着 main 函数
- 常见原因:main 拼错成 mian / Main,或者把 main 写在了别的文件里却没一起编译
3. 注释与代码结构
注释是写给「未来的自己和同事」看的说明文字,编译器在预处理之前的词法阶段直接把它整段丢弃——所以注释里写什么都不影响程序,也不占任何运行时开销。C++ 有两种注释:单行注释 // 和多行注释 /* */。
// 单行注释:解释下面这行代码
int score = 95; // 行尾注释:说明这一行
/* 多行注释:可以跨越多行
常用于文件头说明或临时"屏蔽"一段代码
这一行也是注释 */
/* ⚠️ 多行注释不能嵌套:
外层 /* 内层 */ ← 编译器读到这里的 */ 就认为注释结束了!
剩下的文字会被当成代码,产生莫名其妙的报错 */
📊 图解:嵌套注释为什么会「提前引爆」
// 临时屏蔽一段代码的正确姿势:#if 0 ... #endif
// 预处理器看到 #if 0 就直接跳过中间所有内容——嵌套也不怕
#if 0
int price = 100; // 老价格逻辑
/* 历史说明注释 */
double rate = 0.8;
#endif
int main() {
int price = 120; // 新价格
return 0;
}
✅ 好注释:解释「为什么」
- // 这里必须加 1ms 延迟:传感器数据手册规定 ADC 转换最长 800us
- // 用位运算代替 %:热点路径, profiler 显示取余占 12%
- // 暂时禁用重试:与 issue #487 相关,等后端修复后恢复
❌ 坏注释:复述「是什么」
- // 把 score 加 1 (score++; 已经说清楚了)
- // 定义一个变量 (噪音,纯废话)
- // 这段代码是张三写的,有问题找他 (版本控制表示抗议)
- 八成是注释里混入了中文标点或全角字符混出注释外(如全角分号 ; 被当代码)
- 检查注释符是不是被输入法吃成了全角:
//(全角斜杠)≠// - 「stray '\xxx'」= 源文件里出现了编译器不认识的字节,几乎总是中文标点/不可见字符
4. 变量与基本数据类型
📊 图解:一个 int 在内存里的真实样子
类型全家福:尺寸、范围与选择惯例
| 类型 | 典型大小 | 取值范围(约) | 字面量后缀 | 什么时候用 |
|---|---|---|---|---|
bool | 1 字节 | true / false | — | 真假开关(本质存 1/0) |
char | 1 字节 | -128 ~ 127 或 0~255 | 无 | 单个字符;小整数 |
short | 2 字节 | ±3.2 万 | 无后缀,少用 | 省内存的小整数(音频采样点) |
int | 4 字节 | ±21.4 亿 | 默认 | 整数默认选它 |
long long | 8 字节 | ±9.2 × 1018 | 100LL | 大数(时间戳纳秒、阶乘) |
float | 4 字节 | ~7 位有效数字 | 3.14f | 省内存的小数(图形顶点坐标) |
double | 8 字节 | ~15-16 位有效数字 | 小数默认 | 小数默认选它 |
unsigned int | 4 字节 | 0 ~ 42.9 亿 | 42U | 位运算、天然非负的量 |
💻 代码实战:sizeof、初始化与溢出现场
#include <iostream>
#include <climits> // INT_MAX 等极限值住这里
int main() {
// ① sizeof:查看类型/变量占几个字节(它是编译期运算符,不是函数)
std::cout << sizeof(int) << " "; // 4
std::cout << sizeof(double) << " "; // 8
std::cout << sizeof(char) << "\n"; // 1
// ② 三种初始化写法
int a = 3.14; // ⚠️ 编译器放行但警告:小数被"截断"成 3(窄化转换)
int b(3.14); // 同上,也是 3(小括号初始化,构造函数味道)
int c{3.14}; // ❌ 直接编译报错!{} 列表初始化禁止窄化——现代推荐
// ③ 溢出现场:int 最大值 +1 会怎样?
int big = INT_MAX; // 2147483647
std::cout << big + 1 << "\n"; // -2147483648!绕一圈回到最小值
// ④ unsigned 的"回绕":0 减 1 变成超大正数
unsigned int u = 0;
std::cout << u - 1 << "\n"; // 4294967295(不是 -1!)
return 0;
}
📊 图解:溢出 = 里程表绕圈
最小值
最大值 INT_MAX
- 未初始化的局部变量 = 没清空的旧柜子:里面是上一位「住户」留下的随机垃圾值
- 它不报错、不稳定复现——换台机器/换个编译选项,值就变,是「薛定谔的 bug」
- 某些编译器 debug 模式会好心填特定值(如 0xCCCCCCCC),导致「Debug 正常 Release 崩」的灵异现象
5. 常量、字面量与 auto 类型推导
const vs constexpr:两种不同强度的「封条」
| 对比维度 | const | constexpr(C++11) |
|---|---|---|
| 承诺什么 | 「不许改」——只读承诺 | 「编译期就算得出值」——更强的封条 |
| 初始化时机 | 可以运行期才确定(如用户输入后) | 必须编译期就能算出来 |
| 能否当数组长度 | 看情况(编译期能确定的才行) | ✅ 永远可以:int arr[N] |
| 典型用途 | 函数参数只读承诺、成员只读 | 数组大小、查表、模板实参 |
const double PI = 3.14159; // 封条常量:运行期也不许改
// PI = 3.14; // ❌ 编译错误:常量不许赋值
int n;
std::cin >> n;
const int limit = n * 2; // ✅ 合法!const 的值可以运行期才定
// (n 是运行期输入的)
constexpr int SIZE = 100; // 更强的封条:翻译阶段就"算死"为 100
int arr[SIZE]; // ✅ 可以当数组长度(编译期已知)
// const int limit2 = n * 3;
// int arr2[limit2]; // ❌ 大概率报错:n*3 编译期算不出来
// (除非 n 也是 constexpr)
📊 图解:编译期 vs 运行期——两个常量的不同人生
翻译官工作
程序真正执行
编译期就锁定 100,
从此是"死了的"常量
数组长度、查表全 OK
编译期只有一个"承诺":
值还不知道
此刻 limit 才确定(只读)
auto:让编译器当抄写员
auto 不是「动态类型」——它只是让你少写类型名,类型在编译期照样锁死。编译器看着等号右边的类型,自动帮你把左边类型写好。它解决的是「STL 里有些类型名长到离谱(迭代器全名 40 多字符)」的实际痛点。
auto count = 10; // 编译器替你写成 int count = 10;
auto price = 19.99; // 推导为 double(小数字面量默认 double)
auto f = 3.14f; // float(后缀说了算)
auto flag = true; // bool
// count = "hello"; // ❌ 编译错误:auto 不是动态类型,定了就不能换
// 字面量后缀:给"写死的值"指定精确类型
auto a = 42; // int
auto b = 42U; // unsigned int(U 后缀)
auto c = 100L; // long
auto d = 100LL; // long long
auto e = 3.14L; // long double
// auto 的三种常见形态(第五章会深入):
// auto x = v; 值拷贝(复印一份)
// auto& x = v; 引用(本体别名)
// const auto& x = v; 只读引用(借来看,最常用的只读姿势)
- 遍历大 vector 时写
for (auto x : v),每圈都复印一个元素 - 性能敏感代码里这是真实的事故:几万次深拷贝拖慢整个程序
vector<bool>是特化版本,v[i]返回的不是 bool& 而是一个代理对象auto b = v[0]拿到的是代理的拷贝,某些操作下行为诡异
6. 输入输出与命名空间 std
C++ 的输入输出不进语言核心,而是标准库提供的「流(stream)」工具:std::cout 是标准输出流(屏幕),std::cin 是标准输入流(键盘)。用流之前要抄说明书:#include <iostream>。
📊 图解:iostream 家族与缓冲机制
输入流家族
键盘
读文件
输出流家族
屏幕(缓冲/不缓冲)
写文件
先攒着
💻 代码实战:输入输出的完整套路
#include <iostream>
#include <iomanip> // 精度控制工具箱
#include <string>
int main() {
std::string first, last;
int score;
std::cout << "请输入姓名和分数:";
std::cin >> first >> last >> score; // >> 以空格/回车断词,读三项
std::cout << first << " " << last
<< " 的分数是 " << score << "\n";
// 想读"带空格的一整行"?必须用 getline(>> 遇空格就断)
std::cin.ignore(); // 先吃掉残留的换行符(见下方坑)
std::string line;
std::cout << "输入一句带空格的话:";
std::getline(std::cin, line);
std::cout << "你说的是:" << line << "\n";
// 格式化:保留两位小数
std::cout << std::fixed << std::setprecision(2)
<< 3.14159 << "\n"; // 3.14
return 0;
}
// 嫌 std:: 写起来烦?两种"打招呼"方式:
using std::cout; // 方式一(推荐):只引入需要的名字
using namespace std; // 方式二(危险):本文件所有名字都默认找 std 家
// 也可以造自己的"姓氏":
namespace game {
int score = 0; // game 家的 score
void add(int n) { score += n; }
}
namespace ui {
double score = 99.5; // ui 家也有 score —— 靠姓氏区分,不打架
}
int main() {
game::add(10);
cout << game::score << "\n"; // 10
cout << ui::score << "\n"; // 99.5
return 0;
}
- 原因:
cin >> score遇到非数字输入 → cin 进入失败状态(failbit 置位) - 失败后后续所有读取立即失效:非法字符留在缓冲区,每次循环都读它、每次都失败——死循环形成
- 修复三步曲:clear() 复位状态 → ignore() 清空残留 → 重读
if (!(std::cin >> score)) {
std::cin.clear(); // ① 解除失败状态
std::cin.ignore(10000, '\n'); // ② 扔掉这行坏输入
continue; // ③ 让用户重输
}
第二章 · 运算符
运算符是 C++ 的「词汇表」——看起来最简单的一章,却埋着整数除法、浮点精度、未定义行为、短路求值四个改变程序命运的机关。学完本章,你能看懂一行代码「实际执行了什么」,而不是「看起来像什么」。
🎯 本章学习目标
- 彻底搞懂整数除法截断、浮点误差的根源,不再写出 0.1+0.2==0.3 这类必错代码
- 掌握 = 与 == 的经典笔误、链式赋值的求值顺序
- 理解短路求值的执行机制,用它写出安全的「先检查再使用」代码
- 记住完整优先级表的骨架,理解为什么 j++ + ++j 是未定义行为
- 学会用位运算实现权限标志位——读底层代码的硬通货
7. 算术运算符
六个算术运算符中,+ - * 朴实无华,真正的机关藏在 / 和 % 里——它们对整数和小数的行为完全不同,是新手 bug 率最高的位置之一。
int a = 7, b = 2;
std::cout << a / b << "\n"; // 3 ⚠️ 整数 ÷ 整数 = 整数:小数部分被直接扔掉!
std::cout << a % b << "\n"; // 1 取余数(% 两侧必须都是整数)
std::cout << -a / b << "\n"; // -3 小数部分"向零方向"截断(-3.5 → -3)
// 想要小数结果:至少一侧先变成小数类型
std::cout << 7.0 / b << "\n"; // 3.5(7.0 本身是小数字面量)
std::cout << static_cast<double>(a) / b << "\n"; // 3.5 正式写法:明确转换
// 浮点数精度经典坑(所有语言通病)
std::cout << (0.1 + 0.2 == 0.3) << "\n"; // 0(false!)
📊 图解:0.1 + 0.2 为什么不等于 0.3?
当 int 遇到 double,int 先「升级」为 double 再运算:
7 / 2 * 2.0 = 3 * 2.0 = 3.0(先算的 7/2 已经截断了!);而 7 * 2.0 / 2 = 14.0 / 2 = 7.0。同一组数字,运算顺序不同,结果天差地别——括号不要省。另外 % 对浮点数非法(想取余用 std::fmod),负数取余的符号跟随被除数:-7 % 2 = -1(C++11 起标准规定)。- 根源:整数 / 整数 = 整数,编译器认为这是你故意的(性能优先哲学:不偷偷帮你转小数,转小数是有代价的)
- 排查:凡是「结果比预期小了一截」的数值 bug,优先怀疑整数除法
8. 赋值与复合赋值
赋值号 = 的含义是「把右边的值算出来,装进左边的变量」——不是数学上的「相等」。赋值本身也是表达式:算完还「是」一个值(就是刚赋的值),所以能连锁写。
int x = 10;
x += 5; // 15 等价于 x = x + 5(复合赋值:运算+赋值一步完成)
x -= 3; // 12
x *= 2; // 24
x /= 4; // 6
x %= 4; // 2
// "赋值是表达式"的连锁用法:从右往左依次赋值
int a, b, c;
a = b = c = 0; // c=0 → b=0 → a=0,三个变量全为 0
// ⚠️ 最经典笔误:把 ==(比较)写成 =(赋值)
int level = 1;
if (level = 5) { } // ❌ 编译能过!实际是"把5赋给level",5非零=真 → 永远进分支
if (level == 5) { } // ✅ 本意:比较 level 是否等于 5
📊 图解:a = b = c = 0 的求值顺序
c = 0
c 变为 0,整个式子"是" 0
b = (上一步的0)
b 变为 0,式子"是" 0
a = 0
三个变量全为 0
-Wall(会对「赋值当条件」报警告);② 常量写左边 if (5 == level)(写错成 = 会立刻编译报错,人称「尤达条件句」);③ 现代习惯直接用 const 变量 + 显式比较。- 这行警告直译:「建议给"当真值用的赋值"加括号」——编译器在提醒你可能把 == 写成了 =
- 处理警告的正确姿势:理解并修复它,而不是加括号消音
if ((level = 5))(这等于把救命提示贴嘴上)
9. 比较与逻辑运算符
比较运算符 == != < > <= >= 产出一个 bool 值;逻辑运算符把它们组合起来:&&(并且)、||(或者)、!(取反)。C++ 的逻辑运算符有一条改变程序行为的特性——短路求值。
int age = 22;
bool has_ticket = true;
// &&(并且):两边都真才真;||(或者):一边真就真;!(取反):真假互换
if (age >= 18 && has_ticket) std::cout << "允许入场\n";
if (age < 12 || age >= 65) std::cout << "优惠票\n";
// ⚠️ 先说一个高频坑:数学写法 0 < x < 10 在 C++ 里不是"区间"!
int x = -5;
std::cout << (0 < x < 10) << "\n"; // 输出 1(true)?!
// 拆解:0 < x 为 false(0),然后 0 < 10 为 true —— 完全不是你想要的判断
std::cout << (x > 0 && x < 10) << "\n"; // 0 ✅ 正确写法
📊 图解:短路求值——「结论已定,剩下不看」
B 根本不执行!
B 根本不执行!
短路求值不是省时间的小聪明,而是安全写法的基石——「先检查、再使用」模式:
int* p = nullptr; // 空指针(第六章详解,先理解为"无效地址")
// 安全写法:左边判空失败后,右边的 *p 不会执行 → 不会崩溃
if (p != nullptr && *p > 0) {
std::cout << "指针有效且值为正\n";
}
// ❌ 危险写法(用 & 按位与):两边都会执行,*p 对空指针解引用 = 段错误
// if (p != nullptr & *p > 0) { ... } // 千万别把 && 写成 &
// 短路的另一个应用:除零保护
int denom = 0;
if (denom != 0 && 10 / denom > 2) { /* 安全 */ }
10. 自增自减与优先级
++ 和 -- 让变量加 1 或减 1。真正要分清的是前置和后置:同一个运算符,「先用后加」还是「先加后用」完全相反。
int i = 5;
int a = i++; // 后置++:先用旧值,再自增 → a=5,之后 i=6
int b = ++i; // 前置++:先自增,再用新值 → b=7,i=7
// ❌ 绝对不要写这种代码(同一条语句多次改同一个变量):
int j = 0;
int c = j++ + ++j; // 未定义行为(UB):不同编译器结果不同!
// ✅ 铁律:一条语句里,一个变量最多自增/自减一次
📊 图解:前置 vs 后置——两份「快递单」的区别
后置版
准备交货
i 最终 = 6
前置版
准备交货
i 最终 = 7
优先级速查:只需记住这几档骨架
| 优先级 | 运算符 | 记忆口诀 |
|---|---|---|
| 最高 | () [] . -> 后置 ++ -- | 括号成员最亲 |
| 高 | 前置 ++ -- ! ~ 一元 - * 解引用 & 取址 sizeof new/delete | 一元运算扎一堆 |
| 中 | * / % → + - | 乘除先于加减(数学惯例) |
| 中低 | < > <= >= → == != | 比较低于算术(所以 a+b > c 不用加括号) |
| 低 | && → || | 逻辑垫底(高于三元和赋值) |
| 最低 | ? : → = += 系列 → , | 赋值几乎是终点 |
&& 优先于 ||,所以 a || b && c 是 a || (b && c)。- 问题本质:一条语句里两次修改 j,而标准没规定两次修改的先后顺序
- 编译器可以自由选择求值顺序 → 每家结果不同 → 程序不可移植
- 更狠的是:标准允许编译器在这种情况下做出任何假设,可能引发连锁优化 bug
11. 位运算
📊 图解:六个位运算符一次看懂
按位与:都是1才1
十进制 8
按位或:有1就1
十进制 14
异或:不同才1
十进制 6
左移2位
末尾补俩0 = ×4
右移2位
砍掉末尾2位 = ÷4
unsigned int a = 0b1100; // 12(0b 开头 = 二进制字面量,C++14 起支持)
unsigned int b = 0b1010; // 10
std::cout << (a & b) << " " // 8
<< (a | b) << " " // 14
<< (a ^ b) << " " // 6
<< (a << 2) << " " // 48
<< (a >> 2) << "\n"; // 3
// ⚠️ 括号不能省!& 的优先级低于 <<(输出运算符)
// std::cout << a & b; // ❌ 被解析成 (std::cout << a) & b → 编译错误
💻 实战:用 32 个开关位当 32 个「是/否」标志(权限系统)
unsigned int READ = 1 << 2; // 0b0100 读取权限
unsigned int WRITE = 1 << 3; // 0b1000 写入权限
unsigned int DEL = 1 << 4; // 0b10000 删除权限
unsigned int flags = 0; // 初始:什么权限都没有
flags |= READ | WRITE; // 打开读取+写入(|= 负责点亮)
bool canRead = flags & READ; // 查某位是否亮着 → true
flags &= ~WRITE; // 关闭写入位(& ~ 负责熄灭)
flags ^= DEL; // 翻转删除位(原来关就开,原来开就关)
std::cout << std::boolalpha << canRead << " "
<< (bool)(flags & WRITE) << "\n";
① 判断奇偶:
n & 1 得 1 是奇数、0 是偶数(看最低位);② 清最低位的 1:
n & (n-1)——刷算法题的高频操作(统计 1 的个数);③ 交换两个数(不用临时变量):
a ^= b; b ^= a; a ^= b;——面试玩具,实战可读性差,知道即可。另记住:位运算请配 unsigned 类型使用——带符号数的右移行为是历史遗留的「实现自定义」区(不同编译器可能补 0 或补符号位)。
第三章 · 流程控制
程序不只是从上到下的直线——条件让它会「拐弯」,循环让它会「绕圈」。本章的关键不是背语法,而是建立「执行流」的脑内模型:每一行代码什么时候跑、跑几遍、什么时候跳过。switch 的贯穿陷阱和循环的边界错误,是本章两大事故高发区。
🎯 本章学习目标
- 掌握 if-else / switch 的完整语法与两大陷阱:悬垂 else 与 fall-through
- 看懂编译器如何把 switch 优化成「跳转表」——理解它为什么只能接整数
- 分清 while 与 do-while 的执行时机差异,杜绝「差一错误」
- 掌握 for 三段的执行顺序图与现代 C++ 的范围 for 三种姿势
- 会用 break/continue 控制循环,理解 goto 的历史争议与最后的正当用途
12. 条件语句 if-else
if-else 让程序「拐弯」:条件为真走一支,为假走另一支。else if 链从上往下依次检查,命中一个就跳过其余所有——这个特性常被用来做「分级判断」。
int grade = 92;
if (grade >= 90) { // 条件用小括号包住;代码块用花括号
std::cout << "优秀\n";
} else if (grade >= 75) { // 从上往下依次检查,命中一个就跳过其余
std::cout << "良好\n";
} else if (grade >= 60) {
std::cout << "及格\n";
} else {
std::cout << "不及格\n"; // 兜底分支
}
// 三元运算符:条件 ? 真时取这 : 假时取那(浓缩版 if-else,只能"选值")
int age = 20;
std::string status = (age >= 18) ? "成年" : "未成年";
// C++17 新语法:if 里先造变量再用(变量只活在 if/else 花括号内)
if (int bonus = grade / 10; bonus > 8) {
std::cout << "奖金:" << bonus << "\n";
}
// bonus 在 if 外面已经不存在了 —— 不污染外层作用域
📊 图解:else if 链的「闸门式」执行流
92 ≥ 90
命中!
≥75 / ≥60 / else
一个都不看
if (a) if (b) f(); else g();这个 else 属于内层 if(b),不是外层 if(a)!缩进会骗人,花括号不会。老老实实写
if (a) { if (b) f(); } else g(); 才能表达本意。这正是「永远写花括号」军规的又一论据。13. switch 分支语句
switch 适合对一个整数值做多路分派(星期几、菜单选项、状态码),比一长串 if-else 清晰。但它有一条与众不同的规矩:case 只是「入口标记」,不是隔间。
int day = 6;
switch (day) {
case 1:
std::cout << "周一\n";
break; // ⚠️ 忘写 break = "滑梯":滑进下一个 case 继续执行!
case 6: // 两个 case 故意连写不隔 break = 共享代码(合法技巧)
case 7:
std::cout << "周末\n";
break;
default: // 都不匹配时走这里(兜底,位置惯例在最后)
std::cout << "工作日\n";
break;
}
// ⚠️ switch 只接受整数或枚举:
// switch (3.14) {} // ❌ double 不行
// switch ("abc") {} // ❌ 字符串不行(string 比较请用 if-else 链)
📊 图解:case 是「入口」,不是「隔间」
📊 图解:编译器的小心思——switch 常被优化成「跳转表」
day==1? ==2? ==3?...
最坏要问 7 次
编译器生成数组:[地址1,地址2,...地址7]
day 当下标,一次直达 → O(1)
- 检查 case 1 末尾是不是忘了 break —— 执行滑进了 case 2、case 3
- 编译器警告能抓住它:
-Wimplicit-fallthrough(包含在 -Wall 的部分发行版里) - C++17 起可以显式标注「我是故意的」:
[[fallthrough]];
14. while 与 do-while
两种循环的本质区别只有一个:检查条件的时机。while 先检查再执行(可能一次都不跑);do-while 先执行再检查(保证至少跑一次)。
// while:先检查条件 → 再执行(条件一开始就假,循环体一次都不执行)
int count = 5;
while (count > 0) {
std::cout << count-- << " "; // 5 4 3 2 1
}
std::cout << "发射!\n";
// do-while:先执行一次 → 再检查(保证至少执行一次)
int choice;
do {
std::cout << "输入 0 退出:";
std::cin >> choice;
} while (choice != 0); // ⚠️ 注意结尾的分号!这是唯一必须带分号的"语句块"
📊 图解:两种循环的执行流对比
🔁 while(先检查)
- ① 检查条件 → 真?执行循环体 → 回到 ①
- 条件一开始就为假:循环体 0 次执行
- 适合:「满足条件就一直做」——轮询、处理队列
🔂 do-while(后检查)
- ① 执行循环体 → 检查条件 → 真?回到 ①
- 无论条件真假:循环体至少执行 1 次
- 适合:「至少要问一次」——菜单、重输提示
while (count > 0) 打印 5 4 3 2 1;改成 while (count >= 0) 就多打一个 0;改成 while (count > 1) 就少打一个 1。写循环时永远口头推演三件事:初始值是什么、边界含不含、每圈变量怎么变。另外死循环写法 while (true) 内部用 break 退出,终端里程序卡死可用 Ctrl+C 强制中断。- 循环变量忘了更新:while (count > 0) 里没有 count-- → 永远为真
- 更新方向反了:想倒数却写了 count++
- 浮点条件永不到达:while (x != 1.0) 里 x += 0.1 累加出 0.9999999...(第二章浮点坑!)
- 条件被循环体内的赋值破坏:第二章讲过 if (level = 5) 笔误,在 while 里同样致命
15. for 循环与范围 for
经典 for 把循环的三要素(起点、终点线、步伐)压缩在一行,分号隔开——一眼看全整个循环的「生命周期」。C++11 又补了范围 for:遍历容器时连下标都不用管。
📊 图解:for 三段的执行顺序(最容易搞错的知识点)
只执行 1 次
i = 0
i < 5 ?
假 → 直接跳出
回到 ②
// 经典 for:分号隔开三段 —— 起点; 终点线; 每圈结束后的步伐
for (int i = 0; i < 5; i++) {
std::cout << i << " "; // 0 1 2 3 4
}
// 倒数(起点10;没到0就继续;每圈减2)
for (int i = 10; i > 0; i -= 2) {
std::cout << i << " "; // 10 8 6 4 2
}
// i 只活在 for 的花括号里——两个 for 复用同一个名字不冲突
for (int i = 0; i < 3; i++) { /* ... */ }
for (int i = 0; i < 3; i++) { /* ... */ }
💻 范围 for:现代 C++ 遍历三姿势
#include <vector>
std::vector<int> nums{10, 20, 30};
// 姿势① 值拷贝:每圈把元素"复印"给 n(只读场景能用,但大对象复印很贵)
for (int n : nums) {
std::cout << n << " ";
}
// 姿势② 引用:拿到元素本体,修改才有效!
for (int& n : nums) {
n *= 2; // nums 变为 {20, 40, 60}
}
// 姿势③ const 引用:只读 + 免拷贝 + 免写类型 —— 最佳实践
for (const auto& n : nums) {
std::cout << n << " ";
}
16. break、continue 与 goto
循环体内三个「交通管制」:break 彻底离场、continue 跳过本圈、goto 无条件瞬移(现代代码几乎禁用)。
for (int i = 1; i <= 9; i++) {
if (i == 3) continue; // 跳过 3 这一圈,直接进入 i=4
if (i == 8) break; // 到 8 彻底退出循环
std::cout << i << " "; // 1 2 4 5 6 7
}
// goto:一次性跳出多层嵌套循环(少数正当用途)
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) {
if (i * j == 4) goto done; // break 只能跳一层,goto 直接跳到标签
}
done:
std::cout << "结束\n";
// 现代替代:把嵌套循环抽成函数,用 return 跳出
📊 图解:continue 与 break 的行进路线
- 根因:while 的步伐写在循环体里,continue 会跳过它——j 卡死在 0
- for 循环没这个问题:continue 之后仍然执行 for 头部的步伐 i++
第四章 · 数组与字符串
「装一组东西」是编程的基本需求。C++ 给了你三代的工具:原生数组(1970 年代的老古董,看懂它=看懂内存)、vector/string(现代首选,自带长度、自动扩容)、C 字符串(历史包袱,操作系统接口仍离不开)。本章的内存布局图是全书最重要的一批图——第六章指针的地基就在这里。
🎯 本章学习目标
- 画出原生数组的内存布局图,解释「下标从 0 开始」的真实原因与越界的危害
- 理解 vector 的内部三指针(begin/end/capacity)与倍增扩容的搬家过程
- 掌握迭代器失效的场景与预防(reserve / 及时重新获取)
- 熟练使用 string 的核心 API,了解它与 C 字符串的互转边界
- 看懂 C 字符串的 '\0' 约定与缓冲区溢出——安全意识的起点
17. 原生数组
📊 图解:int scores[5] 的内存布局
值 90
值 85
值 78
值 92
值 88
越界!隔壁人家的地盘
// 定长数组:格子数必须是"翻译阶段就确定"的常量
int scores[5] = {90, 85, 78, 92, 88};
int zeros[3] = {}; // 全部清零
std::cout << scores[0] << "\n"; // 90(第一格,下标 0)
std::cout << scores[4] << "\n"; // 88(5 个格子 → 最后下标是 4)
// ⚠️ 越界:编译不报错、运行"多半"不报错——直接读写隔壁格子(UB)
// scores[5] = 100; ← 可能崩溃,更可怕的是可能"正常"运行并悄悄破坏别的数据
// 数组不知道自己多长!本作用域内可以这样算:
int len = sizeof(scores) / sizeof(scores[0]); // 总字节数÷单格字节数 = 5
// 二维数组(数组的数组):内存里其实是"一排排连着铺"
int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};
std::cout << matrix[1][2]; // 6(第 2 排第 3 格)
📊 图解:二维数组的真实内存——也是一整排
⚠️ 数组退化:传参即丢长度
// ⚠️ 数组退化:数组一旦传给函数,函数收到的只是"第一格的地址"
void printArr(int arr[]) { // 写着数组,实际收到的是指针(第六章讲)
std::cout << sizeof(arr) / sizeof(arr[0]); // 2(8÷4):长度信息丢了!
} // sizeof(arr) 算的是"地址纸条"本身的大小
// 正确做法①:长度当第二个参数传
void printArr2(int arr[], int len) { /* 用 len 控制循环 */ }
// 正确做法②(现代):直接传 vector(下一节),自带长度
- 越界写 = 直接往邻居柜子里塞东西:邻居可能是另一个变量、函数指针、甚至安全边界
- 历史上大量安全漏洞(缓冲区攻击)正是故意越界改写数据——这是 C/C++ 安全问题的头号来源
- 防御:能用 vector 就用 vector(at() 会抛异常);用下标前检查 i < len
18. std::vector 动态数组
vector = 自动扩容、自带长度、可查越界的「升级版数组」。它是现代 C++ 容器的默认首选——理解它的内部结构,是理解一切 STL 容器的钥匙。
📊 图解:vector 的内部结构——三个指针管理一块仓库
1
2
3
size=3 到这
capacity=4 到这
📊 图解:扩容搬家——仓库满了怎么办
size == capacity
(3格全满)
约 2 倍大(6格)
旧 3 个元素
逐个拷到新家
旧内存归还
size=4, capacity=6
#include <vector>
std::vector<int> v = {1, 2, 3}; // 创建并初始化
v.push_back(4); // 末尾追加 → {1,2,3,4}
v.pop_back(); // 删除末尾 → {1,2,3}
std::cout << v.size() << "\n"; // 3(自带长度!)
std::cout << v.empty() << "\n"; // 0(false,不空)
v[0] = 100; // operator[]:快,但越界是 UB(和原生数组一样)
std::cout << v.at(0) << "\n"; // at():越界会抛出异常提醒你(安全)
for (int n : v) std::cout << n << " "; // 范围 for 直接遍历
// 二维 vector:行数列数都可以"运行时"才决定
int rows = 3, cols = 4; // 可以是变量!(原生数组做不到)
std::vector<std::vector<int>> grid(rows, std::vector<int>(cols, 0));
grid[1][2] = 5;
// 性能优化:预知规模就提前预留,避免反复搬家
std::vector<int> big;
big.reserve(100000); // 一次申请到位,之后 push_back 不再触发搬家
- 扩容(push_back 到满 / reserve / resize)→ 所有旧引用、指针、迭代器失效
- erase 删除元素 → 被删位置之后的迭代器失效
- 症状极不稳定:可能立刻崩、可能输出怪值、可能一直正常——典型 UB 三件套
19. std::string 字符串
▼std::string 是「专门装字符的 vector」+ 一层运算符重载的糖衣:+ 拼接、== 比较、+= 追加都能直接用。它是 C++ 与文字打交道时的默认武器。
#include <string>
std::string s = "Hello";
s += " World"; // 拼接:直接用 + 和 +=
std::cout << s << "\n"; // Hello World
std::cout << s.size() << "\n"; // 11(自带长度,不用扫到 \0)
// 查找:返回首次出现的下标;找不到返回 string::npos(一个特殊大值=没找到)
size_t pos = s.find("World"); // size_t:标准库的"下标专用类型",当数字看即可
if (pos != std::string::npos) {
std::cout << "下标:" << pos << "\n"; // 6
}
std::cout << s.substr(0, 5) << "\n"; // Hello(substr(起点, 个数))
// 比较:直接 ==(对比下一节 C 字符串的天壤之别)
std::cout << (s == "Hello World") << "\n"; // 1
// 数值互转(C++11)
std::string num = std::to_string(42); // "42"
int n = std::stoi("123"); // 123
double d = std::stod("3.14"); // 3.14
s[0] = 'h'; // 按下标改单个字符
📊 图解:SSO——短字符串直接揣兜里,不进仓库
短裤(≤15 字符左右):SSO 短串优化
- 字符直接存在 string 对象内部的兜里
- 零堆分配——创建/销毁飞快
- "Hi" / "apple" 这类短串全程不碰仓库
长裤(超过阈值):去仓库租地盘
- 内部改为「指针 → 堆上缓冲区」
- 行为与 vector 类似:扩容搬家、引用失效
- "Hello World..." 长文本走这条路
高频 API 速查表
| 操作 | 写法 | 说明 |
|---|---|---|
| 长度 | s.size() / s.length() | 两者完全等价(历史原因并存) |
| 拼接 | s += t / s + t | 返回新串(+ 不改变原串) |
| 查找 | s.find("x") | 找不到返回 string::npos,判断必须用它 |
| 截取 | s.substr(pos, len) | 从 pos 起取 len 个字符 |
| 删除 | s.erase(pos, len) | 从 pos 起删 len 个 |
| 判空 | s.empty() | 比 size()==0 更惯用 |
| 转数字 | stoi / stod / stol | 转不了会抛异常(第九章) |
| 数字转串 | to_string(42) | 所有数值类型通吃 |
| 读一整行 | getline(cin, s) | 遇空格不断词(第一章讲过) |
- npos 是 size_t 的最大值,转成 bool 是 true——「找不到」反而条件成立
- 正确姿势永远是与 npos 显式比较,别拿 find 的返回值直接当真假
20. C 风格字符串
在 string 诞生前,C 用字符数组装文字,规则:末尾必须有一个看不见的 '\0' 字符标记「到此为止」。今天写新代码几乎不需要它,但操作系统接口、第三方 C 库、文件格式至今都在用它——读懂它是程序员的成人礼。
📊 图解:"Hello" 实际占 6 格——最后一个看不见
哨兵:到此为止
#include <cstring> // strlen / strcmp / strcpy 住这里
char str[] = "Hello"; // 实际 6 格:H e l l o 和一个隐藏的 '\0'
std::cout << sizeof(str) << " " << strlen(str) << "\n"; // 6 5
// ⚠️ 比较不能直接用 ==(那是在比较两个"地址"!)
char a[] = "abc", b[] = "abc";
// a == b ← 结果 false:比较的是两段内存的地址,不是内容
std::cout << (strcmp(a, b) == 0) << "\n"; // 1 ✅ strcmp 返回 0 才是"相等"
// 拷贝/拼接:目标格子必须够大,否则越界(缓冲区溢出)
char buf[20];
strcpy(buf, a); // 拷贝(连 '\0' 一起,共 4 格)
strcat(buf, "def"); // 拼接 → "abcdef"(还需 7 格,20 格够用)
// 与 std::string 互转
std::string s = str; // char数组 → string:直接赋值
const char* cs = s.c_str(); // string → char数组:必须调 c_str()
if (strcmp(a,b)) 当成「相等时进入」——实际完全相反(相等时返回 0 = false,进不去)。正确写法:if (strcmp(a,b) == 0)。- strcpy 不检查目标大小——性能优先的代价全由你承担
- 1988 年 Morris 蜕虫利用同款漏洞感染了当时 10% 的互联网;2014 年心脏流血(Heartbleed)同族
- 现代替代:strncpy / snprintf(带长度上限),或干脆用 std::string
第五章 · 函数
函数是代码复用的第一块积木,也是「内存责任意识」的起点:递进去的是复印件还是原件?返回的是拷贝还是危险指针?本章的参数传递内存图与调用栈图,是理解 C++ 性能与安全的关键一跳。
🎯 本章学习目标
- 分清「声明=名片、定义=本人到场」,理解这套规矩的历史根源
- 画出值/引用/指针三种传递的内存图,掌握 const 引用的工程惯例
- 理解重载的名字修饰机制,看懂链接报错里的「外星文函数名」
- 明白 inline 与宏的正邪对决,会用默认参数设计接口
- 掌握 lambda 的捕获列表,能随手写出传给 sort 的谓词
21. 函数定义、声明与原型
// 函数声明(原型):只发"名片"——告诉编译器:有这个函数,收什么、交什么
int add(int a, int b); // 注意结尾分号!参数名可省略:int add(int, int);
int main() {
std::cout << add(3, 5) << "\n"; // main 在 add 前面也能调用(因为上面声明过)
return 0;
}
// 函数定义:真身在此——具体怎么干
int add(int a, int b) { // 花括号,无分号
return a + b; // return = 交货并结束
}
📊 图解:为什么必须「先声明后使用」?
调用点
见过 add 的声明吗?
具体实现在哪?
那是链接器的事
"我不认识这人,不敢让他干活"
① 有返回值的函数:
return 表达式; 把值交回调用点,函数立即结束(一个函数可以有多个 return,但每次只走一个);② 没有返回值:返回类型写
void,return 可省略或裸写 return;;③ main 是特例:唯一可以不写 return 的函数——不写默认交 0(「顺利跑完」)。返回非 0 惯例上表示出问题,shell 里用
echo $? 能看到它。22. 参数传递方式
▼📊 图解:三种传递方式在内存里的样子
窗口收到复印件
柜台多出一个新格子 x
改它 ≠ 改原件
窗口收到"别名条"
不造新格子
x 就是原件本人
窗口收到"地址纸条"
柜台多了个格子装地址
*x 才是按址取物
a = 5
x = 5(复印件)
x 直接指 a
x = 0x7ffd...(地址)
// ① 值传递:复印一份,函数改的是复印件
void addTen(int x) { x += 10; }
// ② 引用传递:原件的别名,直接改本体(& 出现在参数类型后面)
void addTenRef(int& x) { x += 10; }
// ③ 指针传递:递"地址条",函数按地址上门修改(第六章详解指针)
void addTenPtr(int* x) { *x += 10; }
int a = 5, b = 5, c = 5;
addTen(a); // a 仍是 5(改的是复印件)
addTenRef(b); // b 变 15 ✅
addTenPtr(&c); // c 变 15 ✅(注意调用时手动加 & 取地址)
// ④ 工程最重要的惯例:const 引用 —— "只看不改地借原件"
void printName(const std::string& name) { // 借来只读,不许改
std::cout << name << "\n";
}
// 为什么不直接值传递?——string 可能很大,复印一份很费;
// const& = 不复印 + 不许改,两全其美
📊 图解:函数调用的栈帧——柜台的一进一出
选择口诀(工程速查表)
| 场景 | 写法 | 理由 |
|---|---|---|
| 小东西(int/double/指针本身) | void f(int x) | 复印一张纸比递别名还便宜 |
| 要修改调用者的变量 | void f(int& x) | 引用直达本体 |
| 只读借大件(string/vector/对象) | void f(const std::string& s) | 免拷贝 + 防误改,现代 C++ 默认姿势 |
| 可空 / 可换目标 / 与 C 接口 | void f(int* p) | 指针能 nullptr、能改指,灵活性换责任 |
23. 函数重载
▼void print(int x) { std::cout << "整数: " << x << "\n"; }
void print(double x) { std::cout << "小数: " << x << "\n"; }
void print(std::string s) { std::cout << "文字: " << s << "\n"; }
print(42); // 自动选"整数版"
print(3.14); // 自动选"小数版"
print("abc"); // 自动选"文字版"
// ⚠️ 仅返回值类型不同,不构成重载:
// int f(int);
// double f(int); // ❌ 与上面冲突,编译报错 —— 派活时看的是"递进来的东西"
// ⚠️ 二义性陷阱:
void g(long x) { }
void g(double x) { }
// g(42); // ❌ 编译错误:42 能转 long 也能转 double,编译器选择困难症发作
📊 图解:名字修饰——编译器怎么让三个 print 共存
你写的
GCC 版全名
d = double
string 的全名长到夸张
① 链接报错里的外星文:undefined reference to `_Z5printi' —— 那串符号就是修饰名,学会用
c++filt 工具反解它(echo _Z5printi | c++filt → print(int));② C++ 函数给 C 调用必须写
extern "C":告诉编译器「别改名字,按 C 的规矩来」——否则 C 那边找的是 print,你这边叫 _Z5printi,对不上号。不同编译器的修饰方案还不一样(MSVC 是另一套),所以不同编译器编译的库常常不能直接互链。24. 默认参数与内联函数
▼// 默认参数:给参数一个"保底值"——调用时不传就用保底
double power(double base, int exp = 2); // ✅ 默认值必须靠右
// double f(int a = 1, int b); // ❌ 默认参数必须从右往左连续给
power(3); // 9 (exp 用保底 2)
power(3, 3); // 27(显式传 exp=3)
// inline:请求编译器把小函数的代码"直接抄进调用处",省掉跑腿开销
inline int square(int x) {
return x * x;
}
// 现代编译器很聪明,是否真抄由它定;inline 更多是链接层面的语义(进阶话题)
📊 图解:inline 函数 vs 函数宏的正邪对决
❌ 宏 #define SQUARE(x) ((x)*(x))
- 纯文本替换,不检查类型
- 参数被原样抄两遍:SQUARE(i++) 展开成 ((i++)*(i++))——i 自增了两次,UB!
- 没调试信息、没作用域,问题查到天亮
✅ inline int square(int x)
- 真函数:类型检查齐全
- 参数只求值一次:square(i++) 安全
- 有作用域、可调试、可重载
f(int) 和 f(int, int=2) 时,调用 f(1) 编译器不知道该找谁——二义性编译错误。接口设计二选一。另外:默认值写一次就好——声明里写了,定义处再写会报「重复默认参数」错误(惯例:默认值写在头文件的声明里)。25. lambda 表达式
▼[捕获](参数) { 函数体 }。什么时候用?当你需要一个「临时的判断标准」传给排序等工具时——这个临时函数有个专有名词叫谓词(一个回答「是/否」或「谁大谁小」的小函数)。#include <vector>
#include <algorithm>
// 基本形态
auto square = [](int x) { return x * x; };
std::cout << square(5) << "\n"; // 25
// 最大用武之地:给 sort 递一个"比较标准"(谓词)
std::vector<int> v{3, 1, 4, 1, 5};
std::sort(v.begin(), v.end(), [](int a, int b) {
return a > b; // 返回 true 表示 a 应排前面 → 降序
});
// v → {5, 4, 3, 1, 1}
// 捕获列表 []:决定 lambda 能不能"看见"外面的变量、怎么看见
int threshold = 3;
auto over = [threshold](int x) { return x > threshold; }; // 按值捕获:复印 3 进来
auto over2 = [&threshold](int x) { return x > threshold; }; // 按引用捕获:用原件本体
auto over3 = [=](int x) { return x > threshold; }; // 用到的外部变量全复印
auto over4 = [&](int x) { return x > threshold; }; // 全按引用借(小心悬垂)
std::cout << over(5) << " " << over2(5) << "\n"; // 1 1
📊 图解:值捕获 vs 引用捕获——两种「带干粮」方式
📦 [threshold] 按值捕获
- 出发时复印一份 threshold 带走
- 原件后来改了?lambda 用的是自己那份复印件(旧值)
- 安全:自带干粮,原件没了也不怕
- 适合:异步/延迟执行、回调存起来稍后跑
🔗 [&threshold] 按引用捕获
- 只带一张「地址条」,用时现取原件
- 原件变了,lambda 看到新值;原件没了 = 悬垂 UB
- 危险:lambda 存到异步任务里、函数返回后才执行 → 原柜台早清空
- 适合:当场立刻用完就扔(如本线程的 sort 谓词)
- 规则:lambda 的寿命超出被捕获变量的寿命时,引用捕获就是定时炸弹
- 爆炸场景:存进容器、注册回调、丢给线程、延迟任务
第六章 · 指针、引用与内存
C++ 最核心也最「劝退」的一章——也是它强大的根源。学懂指针 = 学懂计算机内存的真实运作方式。本章每一张图都请反复看:指针不是玄学,是柜子编号;引用不是魔法,是终身外号;智能指针不是黑盒,是自动还钥匙的管家。
🎯 本章学习目标
- 用「柜子编号纸条」模型彻底理解 * 与 &,看懂任何指针表达式
- 掌握 p[i] ≡ *(p+i) 的数组本质与指针算术的换算规则
- 分清引用与指针的四大区别,理解引用「为运算符重载而生」的出身
- 掌握栈 vs 堆的内存布局、new/delete 全流程与四大陷阱
- 理解 RAII 思想与智能指针(unique/shared)的内部机制,从此告别手动 delete
26. 指针基础
▼那么指针是什么?一张写着柜子编号的小纸条。拿着纸条不等于拿到柜子里的东西——但顺着编号就能找到它。
&(取地址)=「告诉我这个变量占的柜子编号」;*(解引用)=「按纸条上的编号找到柜子,取出/放入东西」。
📊 图解:指针变量自己也是个变量
int · 值 42
int* · 存着"0x1000"
int x = 42; // x 占了柜子,里面放 42
int* p = &x; // p 是纸条,上面写着 x 的柜子编号
std::cout << p << "\n"; // 0x7ffd...(编号本身,每次运行不同)
std::cout << *p << "\n"; // 42:顺着编号打开柜子取东西
*p = 100; // 顺着编号把 100 放进柜子(改的就是 x)
std::cout << x << "\n"; // 100
int* q = nullptr; // 空指针 = 白纸条:明确表示"哪都不指"
// *q = 1; // ❌ 对白纸条开门 = 程序崩溃(段错误:访问非法地址)
if (q != nullptr) { /* ... */ } // 使用前先验纸条,是好习惯
纸条本身也有编号,所以有「写着纸条编号的纸条」:
int** pp = &p;(二级指针,*pp 取到 p,**pp 取到 x)——链表节点里常常见到。另有「万能白纸条」void* vp:只存地址、不记类型,不能直接解引用(编译器不知道柜子里放的是什么类型的东西)——必须先转回具体类型;malloc 的返回值就是它,C 风格代码的老朋友。日常代码里二级指针和 void* 都不算高频,但读库源码会碰到。- 含义:你的程序伸手摸了不属于它的内存地址(对空指针/野指针/已回收地址解引用)
- 操作系统给每个程序划了地盘,越界访问立刻被安保掐断——这是保护,不是惩罚(不然你会悄悄改坏别人的数据)
- 排查三板斧:① 崩溃行号(gdb 跑起来
gdb ./app→run→bt看栈回溯);② 检查该行所有解引用的指针来源;③ 十有八九是 nullptr / 悬垂 / 越界三兄弟
27. 指针与数组
▼int arr[5] = {10, 20, 30, 40, 50};
int* p = arr; // 数组名在表达式里自动"变成"第一格的地址
std::cout << *p << "\n"; // 10 == arr[0]
std::cout << *(p+1) << "\n"; // 20 == arr[1]
// 惊人的事实:p[i] 就是 *(p+i) 的"语法糖"(同一种意思的更顺手写法)
std::cout << p[3] << "\n"; // 40
// 指针加 1 ≠ 地址加 1 字节!而是"前进一格元素"(int 指针 +1 = +4 字节)
// 编译器按指针类型自动换算 —— 这叫指针算术
📊 图解:p[i] ≡ *(p+i) —— C/C++ 数组的本质
10
p 指这
20
p+1
30
40
50
数组名在几乎所有表达式里都退化成首元素指针:传参(第四章讲过)、赋给指针、参与运算。仅有的几个例外:
sizeof(arr)(量整个数组大小)、&arr(取整个数组的地址,类型是 int(*)[5])。退化后长度信息彻底丢失——「数组不知道自己多长」的根源就在这。老代码里常见的「指针遍历」for(int* q = arr; q != arr + 5; ++q) 就是把终点线设为「首地址+5 格」,现在能用范围 for 就用范围 for。28. 引用
▼int x = 10;
int& r = x; // r 是 x 的外号(定义时必须立刻绑定)
r = 20; // 叫外号改的也是本体
std::cout << x << "\n"; // 20
std::cout << &r << "\n"; // 和 &x 相同:外号 = 本体
int y = 99;
// int& r2; // ❌ 外号不能空着(必须定义时绑定)
r = y; // ⚠️ 注意!这不是"换绑",而是 x = y(把 y 的值抄给 x)
// 引用一生只跟一个人
引用 vs 指针:四连对比
| 对比维度 | 引用 int& r | 指针 int* p |
|---|---|---|
| 必须初始化? | ✅ 定义时立刻绑定 | ❌ 可以先空着(nullptr) |
| 可为空? | ❌ 没有「空引用」 | ✅ nullptr 是常态 |
| 可换目标? | ❌ 终身一个 | ✅ p = &other 随时改指 |
| 使用语法 | 像普通变量(r = 5 直接用) | 处处要 * 和 -> |
| 底层实现 | ≈ 自动解引用的常量指针 | 就是地址变量 |
| 适用场景 | 参数传递、返回值、范围 for | 可空/可换/数组遍历/C 接口 |
r = 20,编译器生成 *ptr = 20。所以引用不是零成本的魔法,但也没有额外开销。三个推论:① 引用没有「引用的引用」(r&& 不存在);
② sizeof(引用) = 本体的大小(不是指针的 8 字节);
③ 对引用取地址 = 对本体取地址——引用把自己藏得干干净净,这才是「别名」的完整含义。
29. 动态内存 new / delete
▼栈 = 前台柜台:函数的局部变量放这。函数一到自动分配、函数一走自动收回——快,但柜台很小(通常几 MB);
堆 = 大仓库:空间巨大,但要自己申请、自己归还,不还会「泄漏」(仓库被占满)。
new = 去仓库申请地盘;delete = 归还地盘。new 返回的是地址纸条(指针)。
📊 图解:程序的内存全景图
// 申请单个变量:new 返回地址纸条
int* p = new int(42); // 仓库里造一个 int,初值 42
std::cout << *p << "\n"; // 42
delete p; // 用完必须手动归还!
// 申请一整片
int* arr = new int[100](); // () 表示全部清零
arr[0] = 1;
delete[] arr; // ⚠️ 数组必须用 delete[](配错 = UB)
// new 失败(仓库满了)会抛出 bad_alloc 异常(第九章详解异常)
// 大块分配建议先 reserve 或用容器,别裸 new 巨型数组
① malloc 只给一块空地;new 还会自动执行对象的「出厂设置」(构造函数,第七章);
② delete 会先执行「收尾仪式」(析构函数)再还地——对象的一生由此完整。
这也是「不能用 free 对付 new」的根本原因:free 不会执行析构,对象占的其它资源(文件、子对象)全部漏掉。铁律:new 配 delete、new[] 配 delete[]、绝不混用 malloc/free。
- new[] 出来的必须 delete[]:后者知道「这里有一串对象,逐个收尾」
- int 这类「平凡类型」常看不出症状——换 string 之类立刻泄漏,属「延迟爆炸型 bug」
- 现代解法:数组需求交给 vector,单个对象交给智能指针,裸 new[] 直接封杀
30. 内存管理陷阱与 RAII 思想
▼📊 图解:四大陷阱——每一张都是事故现场
纸条有效
地盘归还仓库
❌ 对已回收柜子开门
UB:可能崩 / 可能"正常"
同一地盘还两次 = UB
旧编号丢了 → 永远没人能还
函数一返回柜台清空 → 悬垂指针
// 好习惯:还完立刻把纸条作废
int* q = new int(5);
delete q;
q = nullptr; // ✅ 之后误用会立刻崩溃报错,而非悄悄出错
// (对 nullptr 解引用 = 立刻段错误,问题第一时间暴露)
// 陷阱④的正确写法:
// 要么按值返回(编译器有返回值优化,拷贝没你想的贵)
int good() { int local = 10; return local; } // ✅ 拷贝值回来
// 要么调用方先给地盘,函数往里填
void fill(int* out) { *out = 10; } // ✅ 地盘是调用者的
📊 图解:RAII——C++ 最重要的编程范式
构造函数
借资源:new / open / lock
随便用
(作用域内)
析构函数自动跑
还资源:delete / close / unlock
✅ RAII(C++):确定性归还
- 作用域一结束,资源立刻归还
- 析构顺序与构造严格相反,可预测
- 异常路径上也不漏(栈展开时逐个析构)
🧹 GC(Java/Go):不定时打扫
- 垃圾回收器「想起来才扫」,时机不定
- 文件句柄、锁等非内存资源还得手动 close
- 换来的便利:不会悬垂、不会忘还
31. 智能指针
▼#include <memory>
// ── unique_ptr:独占管家 —— 同一地盘只能有一个管家(推荐默认)──
auto p = std::make_unique<int>(42); // 创建并交给管家
std::cout << *p << "\n"; // 像普通指针一样用
// 离开作用域:自动 delete,一行不用写
// auto q = p; // ❌ 编译错误:独占,不许复印
auto q2 = std::move(p); // ✅ 只能"移交管家权"(p 从此失业为空)
// ── shared_ptr:共享管家 —— 多个管家共管,数人头 ──
auto a = std::make_shared<int>(10);
{
auto b = a; // 人头 1 → 2
std::cout << *b;
} // b 走了:人头 2 → 1,东西还在
// a 走了:人头 1 → 0 → 最后走的人负责关灯锁门(自动 delete)
📊 图解:shared_ptr 的控制块——人头怎么数
shared_ptr
→ 指向控制块
shared_ptr
→ 同一控制块
strong=2, weak=0
被控制块指着
📊 图解:循环引用——数人头的死锁
shared_ptr → A
shared_ptr ptr → B
计数贡献:B 的账 +1
shared_ptr ptr → A
计数贡献:A 的账 +1
if (auto sp = wp.lock()) —— lock() 尝试升级回 shared_ptr,对象已死则得空——「先确认还活着,再用」。- Valgrind(Linux)/ AddressSanitizer(-fsanitize=address,三大平台通吃)能精确报告:泄漏的地址、大小、分配处的行号
- AddressSanitizer 用法:g++ -fsanitize=address -g app.cpp 编译后直接跑,越界/悬垂/UAF 一起抓
- CI 里挂上 sanitizer 跑测试,内存 bug 无处遁形
第七章 · 面向对象
类 = 把数据和操作数据的方法打包成一个「自定义类型」。C++ 的面向对象是性能与抽象的平衡术:对象在内存里长什么样、虚函数表怎么工作、多态的代价是多少——本章全部画给你看。理解了 vtable 图,C++ 就对你没有秘密了。
🎯 本章学习目标
- 掌握 class 的封装体系:public/private/protected 与「不变量」设计
- 理解构造/析构的完整生命周期与初始化列表的必要性
- 看懂 this 指针的传参真相与 const 成员函数的「保证书」机制
- 掌握运算符重载的规则与军规,亲手实现 operator<<
- 画出 vtable 图解虚函数调用全过程,理解多态的成本与虚析构铁律
32. struct 与 class
▼// struct:成员默认 public(对所有人开放)—— 惯例用于"纯数据包"
struct Point {
double x, y; // 外部可直接访问
};
// class:成员默认 private(外人免进)—— 惯例用于"有行为、有规矩"的类型
class Student {
private: // (class 里可省略,默认就是 private)
std::string name; // 🔒 后厨:外部不可直接访问
int score;
public:
void setName(std::string n) { name = n; }
std::string getName() const { return name; }
};
Point pt{1.5, 2.5}; // struct:直接用成员
Student s;
s.setName("Alice"); // class:走窗口(成员函数)
// s.name = "Bob"; // ❌ 编译错误:private 是后厨
📊 图解:封装 = 售货机模型,不变量是铁律
s.setScore(105)?
setScore 检查:
0 ≤ score ≤ 100?
score 落账
拒绝/报错
脏数据进不来
33. 构造函数与析构函数
▼class Student {
public:
// 冒号后面的 name(n), score(s) 叫"初始化列表":
// 出生时直接按给定值造成员
Student(std::string n, int s) : name(n), score(s) {
std::cout << name << " 构造\n";
}
~Student() { // 析构:自动收尾
std::cout << name << " 析构\n";
}
private:
std::string name;
int score;
};
int main() {
Student a("Alice", 92); // 栈对象:作用域结束自动销毁
{
Student b("Bob", 85); // 内层的后出生
} // Bob 析构
return 0;
} // Alice 析构(与构造顺序相反:后出生的先离开)
📊 图解:对象生命周期时间线——叠盘子定律
初始化列表 vs 构造函数体内赋值:两步 vs 一步
✅ 初始化列表 : name(n)
- 成员出生时直接按 n 造(一步到位)
- const 成员、引用成员只能这样初始化
- 少一次默认构造 + 一次赋值,性能更优
⚠️ 函数体内 { name = n; }
- 先用出厂默认值把 name 造出来,再改成 n(两步)
- const/引用成员直接编译报错(它们不能先默认再造)
- 大对象白白多付一次默认构造的代价
① 构造函数可以重载多个(不同参数列表),按调用方式匹配——
Student()(默认构造)、Student(string,int)(带参);② 只要你写了任何构造函数,编译器就不再自动生成默认构造——此时
Student s; 会报错,需要自己补一个 Student() = default;;③ C++11 的委托构造:
Student() : Student("无名", 0) {}——让构造函数复用兄弟的实现;④ 析构函数只有一个、无参数、无重载——收尾仪式没有花样。
34. this 指针与 const 成员函数
▼class Counter {
public:
Counter() : count(0) {}
Counter& increment() {
++count; // 实际是 this->count,this 自动补全
return *this; // 返回"我自己"→ 支持连写 c.increment().increment()
}
int get() const { // 尾部 const:签保证书"只看不改"
return count;
}
private:
int count;
};
Counter c;
c.increment().increment().increment(); // 链式调用:3(this 的功劳)
std::cout << c.get() << "\n"; // 3
const Counter fixed; // const 对象:加了锁的房子
std::cout << fixed.get(); // ✅ 只能调"签过保证书"的 const 函数
// fixed.increment(); // ❌ 编译错误:increment 没签保证书
📊 图解:this 的传参真相 & const 保证书体系
c.increment()
.increment()
c 的地址悄悄进函数
成为 this
交回 c 本体 →
接着调下一次
📜 尾部 const = 保证书
- int get() const 承诺「只看不改任何成员」
- const 对象/引用只能调签了保证书的函数
- 编译器强制执行承诺:函数体里改成员直接报错
🔒 为什么这套体系重要
- 第 22 节的 const string& 参数只能调 const 成员
- 「只读」成为编译器可检查的合同,而非口头约定
- 这是 C++ 比多数语言更严密的只读保障
35. 运算符重载
▼class Vec2 {
public:
double x, y;
Vec2(double x, double y) : x(x), y(y) {}
// 定义"+":函数名固定写成 operator运算符
Vec2 operator+(const Vec2& o) const {
return Vec2(x + o.x, y + o.y);
}
bool operator==(const Vec2& o) const {
return x == o.x && y == o.y;
}
};
Vec2 a(1, 2), b(3, 4);
Vec2 c = a + b; // 编译器翻译成 a.operator+(b)
std::cout << c.x << "," << c.y << "\n"; // 4,6
// 重载 << 让自定义类型能直接 cout(注意:左边是输出流,所以要写成独立函数)
std::ostream& operator<<(std::ostream& os, const Vec2& v) {
return os << "(" << v.x << "," << v.y << ")";
}
std::cout << c << "\n"; // (4,6) —— cout 和你之间只隔一个重载
📊 图解:operator<< 为什么必须写成独立函数
左操作数是 Vec2
→ 可以做成 Vec2 的成员
左操作数是 ostream
→ ostream 是标准库的,
你不能跑进去加成员!
::(作用域)、.(成员访问)、?:(三元)、sizeof。不能发明新运算符、不能改变优先级、不能改变操作数个数。Bjarne 立下的军规:重载应让语义自然——保留运算符的原有直觉,别发明新语义。判断标准:如果 a + b 的行为会让人惊讶,就改成普通函数名。36. 继承
▼class Animal { // 基类(父类)
public:
void eat() { std::cout << "进食\n"; }
protected: // 中间档:子类看得见,外人看不见
int age = 3;
private: // 连子类都看不见
double dna = 0.0;
};
class Dog : public Animal { // 子类继承了 Animal
public:
void bark() {
std::cout << "汪汪(" << age << "岁)\n"; // ✅ protected 子类可用
// std::cout << dna; // ❌ private 子类也不行
}
};
Dog d;
d.eat(); // ✅ 继承来的能力
d.bark(); // 自己的新本事
三级访问控制全景表
| 访问修饰符 | 类自己 | 子类 | 外部(main 等) |
|---|---|---|---|
public | ✅ | ✅ | ✅ |
protected | ✅ | ✅ | ❌ |
private | ✅ | ❌ | ❌ |
📊 图解:组合 vs 继承——is-a 与 has-a 的分界线
✅ 继承:「狗 is a 动物」
- class Dog : public Animal
- 狗是一种动物——凡动物的属性行为,狗都成立
- 子类能替换基类出现的地方(多态的前提)
✅ 组合:「汽车 has a 引擎」
- class Car { Engine engine; }
- 汽车有一个引擎——引擎不是一种汽车!
- 把 Engine 作为成员装进来,用它的能力
Dog() : Animal(5), name("旺财") {}——「先给祖先交材料,再装修自己」。37. 虚函数与多态
▼普通函数:编译时按遥控器牌子定(静态绑定);虚函数:运行时看机器真身定(动态绑定)——这就是「多态」:同一个按钮,不同的机器各自表演。
class Animal {
public:
virtual void speak() { // virtual = 标记"子类可改写,调用看真身"
std::cout << "...\n";
}
virtual ~Animal() = default; // ⚠️ 虚析构!见下方警告
};
class Dog : public Animal {
public:
void speak() override { // override:让编译器核对你真的在改写
std::cout << "汪汪\n";
}
};
int main() {
Animal* a = new Dog(); // 基类指针指向子类对象
a->speak(); // 汪汪 ✅ 虚函数:按真身调用
// 若 speak 不是 virtual:输出 "..."(按指针类型定)
delete a;
}
📊 图解:vtable —— 虚函数的「功能地址登记表」
🐕 Dog 对象的内存布局
📇 Dog 家的 vtable(编译期生成,只此一份)
a->vptr
(真身是 Dog)
跳过去执行 → 汪汪
① 基类析构必须 virtual——否则 delete 基类指针只执行基类收尾、跳过子类收尾,子类自己申请的资源全漏(UB)。规则:只要类有虚函数,析构就跟着写 virtual(或 = default 的虚析构)。
② 构造/析构函数里虚函数不多态——基类构造执行时对象「还只是基类」,vptr 还指着 Animal 家的表,虚调用解析到基类版本;析构期间同理(拆到哪层就用哪层的表)。
38. 抽象类与接口
▼= 0(纯虚函数)。含纯虚函数的类不能造对象(半成品图纸不能盖房)。接口 = 全由纯虚函数组成的类:只规定「必须会什么」,不问「怎么做」。class Shape {
public:
virtual double area() const = 0; // "= 0":纯虚函数,本类不实现
virtual ~Shape() = default;
};
// Shape s; // ❌ 编译错误:抽象类不能实例化
class Circle : public Shape {
public:
Circle(double r) : r(r) {}
double area() const override { // 子类必须实现,否则子类也是抽象类
return 3.14159 * r * r;
}
private:
double r;
};
// 用途:用基类指针统一管理"各种形状"
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2));
for (const auto& s : shapes) {
std::cout << s->area() << "\n"; // 12.5664 —— 各自的正确版本
}
三级函数设计惯例(虚函数语义光谱)
| 写法 | 语义 | 典型例子 |
|---|---|---|
纯虚 = 0 | 必须由子类实现(基类只立规矩) | Shape::area、Animal::speak |
virtual 有实现 | 可覆盖,但有默认实现(子类不改也能用) | 文档类的 save(默认存本地) |
普通函数 | 子类不该动(同签名隐藏而非覆盖) | 工具函数、不变流程 |
第八章 · 模板与 STL
模板让「一份代码服务所有类型」,STL 让「常用数据结构和算法不用自己写」。这是现代 C++ 真正的主力战场——你写的每一个 vector<int>、sort(v.begin(), v.end()) 背后,都是本章讲的机制。学完这章,你就站在了巨人的肩膀上。
🎯 本章学习目标
- 理解模板「实例化」的编译期魔术:编译器如何按需生成定制代码
- 能自己写出泛型函数和 Stack<T> 类模板
- 掌握 STL 容器选型表:什么时候用 vector/map/set/unordered_map
- 会用迭代器 + 算法 + lambda 的组合拳,写出地道的现代 C++
39. 函数模板
▼📊 图解:实例化——编译器的「按单下厨」
T myMax(T a, T b)
配方(此时还不是代码,
是生成代码的图纸)
T=int 实例化
T=double 实例化
template <typename T> // T:类型占位符,名字随意
T myMax(T a, T b) {
return (a > b) ? a : b;
}
std::cout << myMax(3, 5) << "\n"; // 5 → T=int,生成 int 版
std::cout << myMax(2.5, 1.8) << "\n"; // 2.5 → T=double,生成 double 版
std::cout << myMax<double>(3, 2.5); // 3 显式指定:两种类型混用时手动选
// myMax(3, 2.5); // ❌ 编译报错:T 推导冲突(int? double?)
40. 类模板
▼#include <vector>
#include <string>
template <typename T>
class Stack { // 栈:只能从顶上放/取的容器(先放的后出 LIFO)
public:
void push(const T& v) { data.push_back(v); } // 放到顶上
void pop() { data.pop_back(); } // 拿走顶上的
T top() const { return data.back(); } // 看一眼顶上的
bool empty() const { return data.empty(); }
private:
std::vector<T> data; // 借 vector 存数据(组合优于重造)
};
Stack<int> si; // 用尖括号"填空"占位符 T
si.push(1); si.push(2);
std::cout << si.top() << "\n"; // 2
Stack<std::string> ss; // 同一个模板,另一份实例
ss.push("hello");
41. STL 容器概览
▼#include <vector>
#include <map>
#include <unordered_map>
#include <set>
std::vector<int> v{3, 1, 2}; // 动态数组(默认首选)
// map:字典 —— 按关键词查值。内部自动保持按 key 排好序
std::map<std::string, int> m;
m["apple"] = 5; // 按键存/改
m["banana"] = 3;
std::cout << m["apple"] << "\n"; // 5
std::cout << m.count("pear") << "\n"; // 0(count = 有没有这个键)
// unordered_map:也是字典,但内部用"哈希表"(按姓氏首字母直接翻到那页)
// 查找平均一步到位,代价:内容不按顺序排列
std::unordered_map<std::string, int> um;
um["apple"] = 5;
// set:集合 —— 自动去重 + 排序
std::set<int> s{1, 2, 2, 3}; // 实际存 {1, 2, 3}
// 遍历 map(C++17 结构化绑定:一次取出键和值)
for (const auto& [key, val] : m) {
std::cout << key << " = " << val << "\n"; // 按字母序输出(map 自动排序)
}
容器选型速查表(贴在工位的那种)
| 容器 | 内部结构 | 查找 | 按下标 | 什么时候用 |
|---|---|---|---|---|
vector | 动态数组 | O(n) | O(1) ✅ | 默认:顺序列表、下标访问 |
map | 红黑树(有序) | O(log n) | 按键 ✅ | 字典 + 需要按序遍历/范围查询 |
unordered_map | 哈希表 | O(1) 平均 | 按键 ✅ | 字典 + 只要快(绝大多数场景) |
set | 红黑树 | O(log n) | — | 去重 + 有序集合 |
unordered_set | 哈希表 | O(1) 平均 | — | 去重 + 只要快 |
deque | 分段数组 | O(n) | O(1) | 两头都要高效插删(队列) |
list | 双向链表 | O(n) | ❌ | 中间频繁插删且持有稳定迭代器 |
42. 迭代器与常用算法
▼📊 图解:begin() 与 end() —— 终点线外一格
终点线外
#include <vector>
#include <algorithm> // sort / find / count_if 住这里
#include <numeric> // accumulate 住这里
std::vector<int> v{3, 1, 4, 1, 5};
// 排序:升序
std::sort(v.begin(), v.end()); // {1,1,3,4,5}
// 降序:换一个比较标准(greater = "左边大于右边才算前")
std::sort(v.begin(), v.end(), std::greater<int>());
// 用 lambda 自定义:随你定义
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
// 查找:返回"找到的位置";没找到返回 end()(终点线外 = 没有)
auto it = std::find(v.begin(), v.end(), 4);
if (it != v.end()) {
std::cout << "找到,下标 " << (it - v.begin()) << "\n";
}
// 求和:从头加到尾,第 3 个参数是"起始值"(从 0 开始加)
int total = std::accumulate(v.begin(), v.end(), 0);
// 按条件计数:lambda 是"是/否"判断标准(谓词)
int big = std::count_if(v.begin(), v.end(), [](int n) { return n > 2; });
// 一次拿到最小和最大:返回一对位置,* 解引用取值
auto [mn, mx] = std::minmax_element(v.begin(), v.end());
std::cout << *mn << " " << *mx << "\n";
第九章 · 工程实践与进阶
最后一章解决「教程代码 → 真实项目」的跨越:程序出事了怎么优雅地善后?几千行代码怎么拆文件组织?学完这本教程往哪走?异常处理、多文件编译链接、学习路线图——毕业前的最后一课。
🎯 本章学习目标
- 掌握 try/catch/throw 的完整用法,理解栈展开与异常安全
- 独立组织一个三文件(.h + 两个 .cpp)项目,独立排查 undefined reference
- 看懂 include guard 的工作原理与头文件纪律
- 获得一份可执行的后续学习路线(C++11/14 → 17/20 → 工程能力 → 方向选择)
43. 异常处理 try / catch
▼#include <stdexcept>
#include <iostream>
double divide(double a, double b) {
if (b == 0) {
throw std::invalid_argument("除数不能为零"); // 扔信号球
}
return a / b;
}
int main() {
try {
std::cout << divide(10, 2) << "\n"; // 5
std::cout << divide(1, 0) << "\n"; // 这里扔球,下面的行不执行
std::cout << "这行不会执行\n";
}
catch (const std::invalid_argument& e) { // 具体异常在前
std::cerr << "参数错误: " << e.what() << "\n";
}
catch (const std::exception& e) { // "万类异常之父"兜底在后
std::cerr << "其他错误: " << e.what() << "\n";
}
}
// STL 自带异常:v.at(越界下标) 会扔 out_of_range 信号球
📊 图解:栈展开——球一路向上飞,沿途自动打扫
- catch 按声明顺序匹配,父类 std::exception 在前会把所有球提前接走(invalid_argument 也是它的子类)
- 规矩:具体在前、父类兜底在后;一律用 const 引用接(省拷贝、不切掉子类信息)
- 错误输出走 std::cerr(不缓冲、立刻上屏——第一章的伏笔)
44. 头文件与多文件项目
▼// ─── student.h ─── 说明书(对外公开的"名片夹")
#ifndef STUDENT_H // include guard(防重复抄写封条):已抄过就跳过
#define STUDENT_H
#include <string>
class Student {
public:
Student(std::string n, int s);
std::string getName() const;
private:
std::string name;
int score;
};
#endif // (主流编译器也支持一行版:#pragma once)
// ─── student.cpp ─── 实现
#include "student.h" // 双引号:从当前目录找(自己的说明书也要抄)
Student::Student(std::string n, int s) : name(n), score(s) {}
std::string Student::getName() const { // 类外实现:类名:: 前缀
return name;
}
// ─── main.cpp ─── 使用者
#include "student.h"
#include <iostream>
int main() {
Student s("Alice", 95);
std::cout << s.getName() << "\n";
}
// 编译:把所有 .cpp 一起交给编译 + 装配
// g++ main.cpp student.cpp -o app
📊 图解:多文件编译链接全景图
#include "student.h"
→ 抄入说明书
符号表:提供 main
需要 Student::Student
#include "student.h"
提供 Student::Student
(真身在此)
拿两份符号表配对:
main 需要的 ← student 提供
+ 标准库零件 → app ✅
undefined reference ❌
- ①最常见原因:忘了把 student.cpp 加进编译命令(g++ main.cpp -o app ❌)——零件没上流水线
- ①其他原因:函数只声明没实现;实现文件里的函数签名与声明不一致(少写 const 是高频)
- ②通常 = 类里声明了虚函数但析构/某个虚函数没实现——vtable 生成失败
- 修复通用套路:检查编译命令含所有 .cpp → 检查签名逐字一致 → 检查 .h 是否真的被 include
- 同一份说明书被抄了两遍、类定义撞车——include guard 封条就是防这个的
- 或:头文件里放了普通函数的定义/全局变量定义(见下方纪律表)
头文件纪律表(背下来少踩一半坑)
| 内容 | 能放 .h 吗 | 原因 |
|---|---|---|
| 函数声明 / 类定义 | ✅ 头文件的本职 | 编译器需要「知情书」做类型检查 |
| inline 函数 / 模板 | ✅ 可以 | 允许重复定义(每个 .o 一份,链接器择一) |
| 普通函数定义 | ❌ 禁止 | 两个 .cpp 都抄到 → 重定义 |
| 全局变量定义 | ❌ 禁止 | 同上;跨文件共享要用 extern(进阶) |
| #include 别的 .h | ✅ 尽量少 | 能前置声明(class Student;)就别 include,编译更快 |
45. 学习路线图
▼掌握以上 45 个知识点,你已具备 C++ 的完整地基。下面这张地图回答「接下来去哪」:
📊 全景路线图
45 知识点已通关
(本教程)
移动语义 · 右值引用
weak_ptr · 完美转发
结构化绑定 · optional
string_view · concepts
协程
CMake · gdb/lldb
GoogleTest 单测
thread/mutex 多线程
游戏 · 嵌入式 · 高频交易
音视频 · 高性能服务端
💻 一段预告未来的代码:你现在能看懂多少?
// 这段代码用到了本教程的:模板、智能指针、lambda、范围for、结构化绑定、多态
// ——外加两个"下一站"概念(移动语义 make_unique 内部、auto 结构化绑定)
#include <vector>
#include <memory>
#include <string>
#include <iostream>
int main() {
std::vector<std::unique_ptr<std::string>> bag; // 第六章 + 第八章
bag.push_back(std::make_unique<std::string>("现代")); // 第六章
bag.push_back(std::make_unique<std::string>("C++"));
int total = 0;
for (const auto& p : bag) { // 第三章 + 第五章
total += static_cast<int>(p->size()); // 第一章 + 第六章
}
std::cout << "总字符数: " << total << "\n";
}
// 如果你能逐行讲出每个语法点出自哪一章——恭喜毕业 🎓
书籍与工具装备清单
| 阶段 | 推荐 | 定位 |
|---|---|---|
| 打地基 | 《C++ Primer》(5th) | 百科全书式系统学习,配本教程查漏补缺 |
| 学惯用法 | 《Effective C++》 | 55 条军规,每条都是工程经验 |
| 进入现代 C++ | 《Effective Modern C++》 | 右值/移动/智能指针的正确姿势 |
| 理解底层 | 《STL 源码剖析》 | 看穿标准库的实现艺术 |
| 在线利器 | godbolt.org | Compiler Explorer:在线看代码对应的汇编 |
| 权威速查 | cppreference.com | 最权威的标准库手册,没有之一 |
术语速查表
遇到看不懂的词,先来这里查一眼再回去继续学。支持实时搜索与分类筛选——比翻书快 10 倍。