⚙️ C++ 深入详解

基于《C++ 基础知识完全指南》的深度扩展版:每个知识点在原文之上,补充底层原理图解、更多代码实战、错误排查调试、章末即时测验。展开知识卡片即自动打卡,进度保存在你的浏览器里,随时回来继续。

📚 10 个板块 · 45 知识点 📊 60+ 张原理图解 💻 130+ 代码示例 🧪 76 道章末自测 🔍 67 词条术语速查 💾 本地进度保存
0 / 45 知识点已学
🧪 测验平均分:尚未开始 🏆 已完成的章节测验:0 / 9 ♻️ 重置全部进度
🚀

第一章 · 入门与编译

编译型 vs 解释型、编译四阶段流水线、数据类型与内存格子、溢出现场、cin/cout 缓冲机制
6 知识点入门必读
→
🧮

第二章 · 运算符

整数除法陷阱、0.1+0.2≠0.3 的二进制真相、短路求值、前置后置时序图、位掩码权限实战
5 知识点高频坑区
→
🔀

第三章 · 流程控制

else-if 闸门、switch 跳转表原理、fall-through 事故现场、for 执行顺序图、continue 死循环陷阱
5 知识点图解丰富
→
📦

第四章 · 数组与字符串

数组内存布局与越界危害、vector 三指针结构与扩容搬家、string 的 SSO、C 字符串缓冲区溢出
4 知识点内存地基
→
⚙️

第五章 · 函数

声明 vs 定义、三种参数传递内存图、调用栈帧叠盘子、名字修饰解密、lambda 捕获的干粮与地址
5 知识点工程惯例
→
🔗

第六章 · 指针、引用与内存

指针纸条模型、p[i]≡*(p+i)、栈堆全景图、四大内存陷阱、RAII 与智能指针控制块、循环引用
6 知识点全书重镇 ⭐
→
🏛️

第七章 · 面向对象

封装与不变量、构造析构时间线、this 传参真相、运算符重载、继承与组合、vtable 全景图解
7 知识点面试重点
→
🧬

第八章 · 模板与 STL

模板实例化的编译期魔术、Stack<T> 实现、容器选型速查表、迭代器与算法组合拳、Stepanov 的故事
4 知识点现代主力
→
🧭

第九章 · 工程实践与进阶

异常栈展开图解、多文件编译链接全景、undefined reference 病理分析、后续学习路线图
3 知识点毕业通关
→
🧰

附录 · 术语速查表

67 个术语白话解释,支持实时搜索与分类筛选——遇到看不懂的词先来这查一眼
67 词条🔍 可搜索
→

📌 使用指引

  • 知识点默认折叠,点击卡片标题展开即自动记为「已学」(本地保存,换设备/清浏览器缓存会重置)
  • 灰色【说人话】是通俗比喻;📜 历史注解是「佐料」,看不懂可先跳过,不影响主线
  • 每章末尾有 8-10 道即时判分测验,全部作答后显示成绩与学习建议
  • 遇到看不懂的专有名词(如「编译期」「栈」「谓词」)→ 先去 附录·术语速查表 查一眼再回来继续
  • 建议按章节顺序学习,每段代码都亲手敲一遍——编程是练出来的,不是读出来的
🚀 CHAPTER 01 · 入门与编译

第一章 · 入门与编译

从「C++ 是什么」到「一段文字如何变成可运行的程序」——这一章打的是世界观地基。学完后你将明白:代码在机器眼里是什么、编译器替你做了哪些事、变量在内存里长什么样,以及为什么 C++ 的规矩处处透着「性能优先」的哲学。

📚 6 个知识点 💻 16+ 个代码示例 📊 9 张原理图解 🧪 8 道章末自测 ⏱️ 约 60-90 分钟

🎯 本章学习目标

  • 说清楚「编译型 / 解释型」「静态类型 / 动态类型」这两组概念的准确含义与实际后果
  • 亲手把 Hello World 拆成四个编译阶段逐一观察,看懂每个阶段产出的文件里有什么
  • 掌握全部基本数据类型的尺寸、取值范围与选择惯例,理解「溢出」和「未初始化」的真实危害
  • 分清 const / constexpr / 字面量 / auto 四个词,掌握 cin/cout 的缓冲机制与失败恢复
已学 0 / 6 个知识点
🌍

1. C++ 简介与发展史

编译型静态类型零开销抽象
✓ ▼

C++ 是一门编译型、静态类型的语言,诞生于 1979 年贝尔实验室,至今仍是游戏引擎(Unreal)、浏览器(Chrome)、数据库(MySQL)、操作系统组件与高频交易系统的主力语言。它能在几十年语言浪潮中屹立不倒,靠的是一条贯穿始终的设计哲学——零开销抽象:你可以用类、模板、智能指针这些高级工具组织代码,但只要你不使用某个功能,就不为它付出任何运行时代价。这条哲学的每一处细节(手动内存管理、无垃圾回收、编译期检查)都是它的直接产物。

🗣 【说人话】两个关键词到底什么意思:
编译型:你写的代码是给人看的「文字稿」,电脑只认识 0 和 1。编译型语言需要先把整份代码「翻译」成电脑能直接执行的文件再运行——好比把整本中文书提前翻译成英文出版,读者(CPU)读起来飞快。Python 那种「边看边翻」的叫解释型。
静态类型:每个变量「是什么类型」必须在写代码时就声明死,翻译官(编译器)会提前帮你检查有没有用错——大量错误在运行前就被抓住,而不是运行到一半才崩。

📊 图解一:编译型 vs 解释型——两条流水线

同一段代码的两种命运
📝 源代码
hello.cpp(人能读)
→
🔤 编译器 g++
整体翻译一次
→
⚙️ 机器码文件
hello.exe(CPU 直接读)
→
🚀 运行
飞快,可脱离源码分发
📝 源代码
hello.py
→
🔁 解释器
每次运行逐行翻译
→
🐢 运行
灵活但慢,需要解释器在场
上行:C/C++/Go/Rust · 下行:Python/JavaScript/Ruby

这张图解释了 C++ 世界里很多「祖传规矩」的来源:因为程序最终是一份独立的机器码文件,所以你需要「编译」这个步骤;因为翻译是一次性完成的,编译器必须在翻译时就知道每个变量的类型——这就是静态类型的由来;也正因如此,类型错误在编译期就暴露,而不是等到客户运行时才崩溃。

📊 图解二:标准演进时间线——语言的「版本号」

每约 3 年一版,C++11 是分水岭
1979
C with Classes
→
1983
得名 C++
→
1998
C++98 首个标准
→
2011
C++11 现代 C++ 元年
→
2014/17/20/23
持续演进
C++11 一口气引入 auto、lambda、智能指针、范围 for、nullptr——此后写法被称为「现代 C++」,与古典 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;          // 交还控制权,向系统报告"顺利跑完"
}
Hello, C++!
📜 历史注解:1979 年,丹麦计算机科学家 Bjarne Stroustrup 在贝尔实验室开始开发「C with Classes」(带类的 C),初衷是「像 Simula 那样优雅地组织程序,又像 C 一样快」。1983 年,同事 Rick Mascitti 为它取名 C++——用 C 语言的自增运算符 ++ 玩双关:「C 的增强版」。Bjarne 有句流传甚广的自嘲:「C 让你轻易崩掉自己的脚;C++ 让这件事变难了,但一旦崩掉,整条腿都没了。」趣闻:C++11 原定 200x 年完成、代号 C++0x——结果拖到 2011 年才发布,「0x」成了史上最著名的过期梗。
💡 本节要点:① 编译型 = 提前整体翻译,静态类型 = 编译期就锁定变量类型;② 零开销哲学是 C++ 一切「怪脾气」的根源;③ C++11 是现代 C++ 分水岭,本教程主线为 C++17;④ 选 C++ 的场景:极致性能、直接操作硬件、大型系统核心。
🔨

2. 第一个程序与编译流程

g++main 函数四阶段
✓ ▼

先把 Hello World 的每一行拆到极致——这一段 6 行代码浓缩了后面 44 个知识点里反复出现的角色。

🗣 【说人话】先认识三个角色:源代码(.cpp 文件)= 你写的、给人看的文字稿;编译器(如 g++)= 把文字稿翻译成机器指令的翻译官;可执行文件(如 hello)= 翻译产物,电脑能直接运行的文件(Windows 上就是 .exe)。

💻 逐行拆解:每一行都在干什么

// hello.cpp —— 你的第一个 C++ 程序
#include <iostream>    // ① 把"输入输出工具箱"的说明书抄进本文件
                        //    (cin/cout 的声明都在里面,没抄就用 = 编译报错)
int main() {            // ② 程序入口:操作系统从这里开始执行
                        //    全程序只能有一个 main;int 表示"结束时报告一个整数"
    std::cout << "Hello, C++!";  // ③ 把这句话送到屏幕:<< 表示"数据流向输出流"
    std::cout << "\n";           //    \n 是换行符(转义字符)
    return 0;           // ④ 向系统报告:"顺利跑完"(0=成功,非 0=出了问题)
}                       //    main 结束 = 程序结束
Hello, C++!
# 编译并运行(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)

📊 图解:一条编译命令背后的四个阶段

g++ 其实是个流水线车间:四个车间接力干活
📝 hello.cpp
源代码
→
① 预处理
把所有 #include 的文件
内容原样抄进来
#define 做文本替换
→
② 编译
把合并稿翻译成
汇编语言(接近机器
指令的中间语言)
→
③ 汇编
汇编 → 纯 0/1 机器码
装进目标文件 .o
(半成品零件)
→
④ 链接
装配工把你的零件
和标准库零件拼成
完整程序
→
🚀 hello
可执行文件
我们平时一条 g++ 命令 = 这四步连跑;实际上每一步都可以单独执行、单独观察

为什么要理解这四步?因为你未来 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++!
🔬 深入一层:hello.o(目标文件)里其实装着三样东西:① 你的函数编译成的机器码;② 符号表——一张「本文件提供哪些函数、需要外部哪些函数」的登记清单(main 是「提供」,cout 是「需要」);③ 调试信息。链接器的工作就是拿着所有零件的登记清单互相配对:谁的 main?cout 的实现在哪个库文件里?配不上的,就是 undefined reference。这套「独立编译、最后装配」的模型继承自 1970 年代的 C——为了大项目只重编译改动的文件。它的代价(头文件规矩、链接报错)你会在第九章再次遇到。
🐞 新手前三个必撞的报错
error: hello.cpp: No such file or directory
  1. 检查当前目录对不对:ls 看看 hello.cpp 在不在
  2. 检查文件名拼写(hello.cpp ≠ helloworld.cpp ≠ Hello.cpp,大小写敏感)
hello.cpp:5:12: error: expected ';' before 'return'
  1. 这是编译阶段的报错——语法问题。行号 5、列号 12 已经告诉你位置
  2. 注意:报错位置经常在「真正出错的那一行的下一行」——因为编译器读到那里才发现不对
  3. 中文输入法的全角分号「;」是同款报错的高频元凶,检查是否用了中文标点
undefined reference to `main'
  1. 这是链接阶段的报错——找不着 main 函数
  2. 常见原因:main 拼错成 mian / Main,或者把 main 写在了别的文件里却没一起编译
通用心法:读报错从第一行开始(后面的错误常是连锁反应);把第一行关键词复制去搜索引擎,是程序员的基本功。
💡 本节要点:① 一条 g++ = 预处理 → 编译 → 汇编 → 链接 四步接力;② 每步可单独执行观察(-E/-S/-c);③ 报错按阶段分类:语法错在编译期、undefined reference 在链接期;④ -Wall 常开,编译器是最严格的老师。
💬

3. 注释与代码结构

// 与 /* */#if 0
✓ ▼

注释是写给「未来的自己和同事」看的说明文字,编译器在预处理之前的词法阶段直接把它整段丢弃——所以注释里写什么都不影响程序,也不占任何运行时开销。C++ 有两种注释:单行注释 // 和多行注释 /* */。

// 单行注释:解释下面这行代码
int score = 95;        // 行尾注释:说明这一行

/* 多行注释:可以跨越多行
   常用于文件头说明或临时"屏蔽"一段代码
   这一行也是注释 */

/* ⚠️ 多行注释不能嵌套:
   外层 /* 内层 */  ← 编译器读到这里的 */ 就认为注释结束了!
   剩下的文字会被当成代码,产生莫名其妙的报错 */

📊 图解:嵌套注释为什么会「提前引爆」

编译器读注释的规则:见到 /* 开始,见到第一个 */ 就结束
/* 外层注释开始 ──────────────────┐ 这是注释内容 │ /* 内层开始(但编译器只认字面符号) │ 这里还是注释 │ */ ←←← 编译器:注释结束! │ 剩下的文字被当成代码 → 报错! │(永远等不到的结尾) */ ← 这成了悬在代码里的野符号
结论:想临时注释掉一段「本身带注释」的代码,用下面的 #if 0 技巧
// 临时屏蔽一段代码的正确姿势:#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++; 已经说清楚了)
  • // 定义一个变量   (噪音,纯废话)
  • // 这段代码是张三写的,有问题找他   (版本控制表示抗议)
🐞 注释引发的两类「灵异现象」
error: expected expression / stray '\357' in program
  1. 八成是注释里混入了中文标点或全角字符混出注释外(如全角分号 ; 被当代码)
  2. 检查注释符是不是被输入法吃成了全角://(全角斜杠)≠ //
  3. 「stray '\xxx'」= 源文件里出现了编译器不认识的字节,几乎总是中文标点/不可见字符
预防:写代码时切英文输入法,只让中文出现在字符串和注释里。
📜 历史注解:// 注释源自 1967 年的 BCPL 语言(C 语言的「曾祖父」)。C 语言长期只支持 /* */,// 直到 1999 年的 C99 标准才被 C 接纳;而 C++ 一出生就继承了它。于是出现有趣的倒挂:二十年前的 C++ 代码可以用 //,同期的 C 代码却不行。
💡 本节要点:① 两种注释:// 单行、/* */ 多行,编译器直接无视、零开销;② 多行注释不可嵌套——第一个 */ 就结束;③ 临时屏蔽带注释的代码块用 #if 0 ... #endif;④ 注释写「为什么」而不是「是什么」。
🔤

4. 变量与基本数据类型

int / double / char / bool溢出sizeof
✓ ▼
🗣 【说人话】变量是什么:内存就像一排带编号的储物柜。变量 = 你申请一个柜子、贴上名字标签、往里放东西。C++ 的规矩是:贴标签时必须写清楚「这个柜子只放整数 / 只放小数 / 只放一个字符」,之后放错类型,翻译官就报错——这就是「静态类型」在变量上的落地。

📊 图解:一个 int 在内存里的真实样子

int x = 42; —— 申请 4 个连号格子,放进 42 的二进制
变量名 x类型 int
0x100000101010
0x100100000000
0x100200000000
0x100300000000
4 个字节(32 个 0/1 开关)只用最低字节存了 42 = 00101010;一个 32 位 int 能表示约 42 亿个不同整数
char ch类型 char
0x200001000001
↖ char 只占 1 字节,01000001 = 65 = 字符 'A' 的编码

类型全家福:尺寸、范围与选择惯例

类型典型大小取值范围(约)字面量后缀什么时候用
bool1 字节true / false—真假开关(本质存 1/0)
char1 字节-128 ~ 127 或 0~255无单个字符;小整数
short2 字节±3.2 万无后缀,少用省内存的小整数(音频采样点)
int4 字节±21.4 亿默认整数默认选它
long long8 字节±9.2 × 1018100LL大数(时间戳纳秒、阶乘)
float4 字节~7 位有效数字3.14f省内存的小数(图形顶点坐标)
double8 字节~15-16 位有效数字小数默认小数默认选它
unsigned int4 字节0 ~ 42.9 亿42U位运算、天然非负的量
注意:以上大小是主流 64 位平台的典型值。标准只保证「至少」多大——精确尺寸用 sizeof 查,或用 int32_t/int64_t 这类定宽类型(头文件 <cstdint>)。

💻 代码实战: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;
}
4 8 1 -2147483648 4294967295

📊 图解:溢出 = 里程表绕圈

有符号 int 的取值是一个首尾相接的环
… -2147483647
-2147483648
最小值
0
2147483646
2147483647
最大值 INT_MAX
+1 溢出 → 回到 -2147483648
就像汽车里程表转到头会归零一样——这是「未定义行为」在大多数平台的典型表现(标准层面 UB,不可依赖)
🔬 深入一层:隐式转换的顺序规则(常见组合):① char/short 参与运算时先提升为 int(整型提升):'A' + 1 其实是 65 + 1;② int 与 double 运算时,int 先转 double:7 / 2.0 = 3.5;③ 赋值时右边的类型向左边「看齐」,塞不下就截断——这就是 int a = 3.14 丢掉小数的原理。记住方向:小 → 大安全自动转,大 → 小危险要显式(static_cast)。
🐞 未初始化变量:最阴险的 bug 制造机
// 两次运行输出不一样! 第一次运行输出:8362712 第二次运行输出:0 第三次运行输出:-1917364
  1. 未初始化的局部变量 = 没清空的旧柜子:里面是上一位「住户」留下的随机垃圾值
  2. 它不报错、不稳定复现——换台机器/换个编译选项,值就变,是「薛定谔的 bug」
  3. 某些编译器 debug 模式会好心填特定值(如 0xCCCCCCCC),导致「Debug 正常 Release 崩」的灵异现象
铁律:声明变量时永远顺手初始化(int x = 0; / int x{};)。开 -Wall 能抓住大部分未初始化使用。
📜 历史注解:bool 类型是 C++98 才引入的。更早的年代程序员用整数装真假(0=假,非 0=真),并用宏自定义 #define TRUE 1;C 语言直到 C99 才补上布尔类型。这个历史包袱解释了为什么很多老接口(如 isdigit())至今用 int 返回真假。
💡 本节要点:① 整数默认 int、小数默认 double、定宽需求用 int32_t/int64_t;② 溢出是 UB,unsigned 减法回绕是重灾区(循环里混用 signed/unsigned 是高频坑);③ {} 列表初始化禁止窄化,新代码首选;④ 永远初始化变量;⑤ sizeof 是编译期运算符。
🔒

5. 常量、字面量与 auto 类型推导

const / constexprauto字面量后缀
✓ ▼
🗣 【说人话】先分清三个词:常量 = 一旦定值就不许再改的变量(贴了封条的柜子);字面量 = 代码里直接写出来的值本身,如 42、3.14、"hello";编译期 vs 运行期 = 编译期是「程序还没跑、翻译官工作的阶段」,运行期是「程序真正执行阶段」——这组概念后面反复用到,务必刻进 DNA。

const vs constexpr:两种不同强度的「封条」

对比维度constconstexpr(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 运行期——两个常量的不同人生

时间轴:程序从"翻译"到"运行"
⏱️ 编译期
翻译官工作
→
⏱️ 运行期
程序真正执行
constexpr int SIZE = 100
编译期就锁定 100,
从此是"死了的"常量
→
使用 SIZE
数组长度、查表全 OK
const int limit = n * 2
编译期只有一个"承诺":
值还不知道
→
运行期用户输入 n
此刻 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;   只读引用(借来看,最常用的只读姿势)
🐞 auto 的两个经典坑
坑一:auto x = v; 悄悄发生了拷贝——大容器复制代价巨大
  1. 遍历大 vector 时写 for (auto x : v),每圈都复印一个元素
  2. 性能敏感代码里这是真实的事故:几万次深拷贝拖慢整个程序
修复:只读遍历一律写 const auto&;要修改写 auto&。
坑二:auto 推导出"代理类型"——vector<bool> 陷阱(进阶预告)
  1. vector<bool> 是特化版本,v[i] 返回的不是 bool& 而是一个代理对象
  2. auto b = v[0] 拿到的是代理的拷贝,某些操作下行为诡异
修复:现在只需记住:auto 会把引用"复印掉",想保引用要亲手写 &。
📜 历史注解:auto 在 C 语言里本来是个「存储类说明符」(几乎无人使用),C++11 回收这个废关键字、赋予「类型推导」新含义——C++ 历史上最成功的一次旧词新用。
💡 本节要点:① const = 只读承诺(值可运行期定),constexpr = 编译期锁定;② auto 是编译期推导,不是动态类型;③ 字面量后缀控制精确类型(f/L/U/LL);④ 遍历容器默认手写 const auto&。
⌨️

6. 输入输出与命名空间 std

cin / cout缓冲区命名空间
✓ ▼

C++ 的输入输出不进语言核心,而是标准库提供的「流(stream)」工具:std::cout 是标准输出流(屏幕),std::cin 是标准输入流(键盘)。用流之前要抄说明书:#include <iostream>。

📊 图解:iostream 家族与缓冲机制

「流」体系:万物皆是流
istream
输入流家族
cin
键盘
ifstream
读文件
ostream
输出流家族
cout / cerr
屏幕(缓冲/不缓冲)
ofstream
写文件
cerr 是"错误信息专用通道":不缓冲、立刻上屏(第九章异常处理会用到它)
为什么输出不是"立刻"到屏幕?——缓冲区
📝 你的输出语句
→
📦 缓冲区(草稿区)
先攒着
→ 攒满/换行/强制刷新 →
🖥️ 屏幕
std::endl = 换行 + 立刻刷缓冲;"\n" = 只换行不催促(更快)。程序崩溃时没刷出去的草稿会丢——所以调试时用 endl 保输出,正常代码用 "\n" 保速度。

💻 代码实战:输入输出的完整套路

#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;
}
请输入姓名和分数:Alice Chen 92 Alice Chen 的分数是 92 输入一句带空格的话:Hello C++ World 你说的是:Hello C++ World 3.14
🗣 【说人话】std:: 到底是什么:std 是 standard(标准)的缩写,是标准库所有工具的「姓氏」。写 std::cout 意思是「std 家的 cout」。为什么要有姓氏?防止重名打架:万一你自己或第三方库也造了个叫 cout 的东西,靠姓氏就能分清谁是谁。这套「按姓氏分组」的机制就叫命名空间。
// 嫌 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;
}
10 99.5
⚠️ using namespace std 的分寸:小练习文件里随便用,写起来爽;大项目/头文件里会让重名风险飙升(你写了个 count 变量,std::count 算法表示抗议)——专业代码避免,尤其永远不要在头文件里写它(会污染所有包含你的文件)。
🐞 经典灵异 bug:输入一个字母,程序"死循环"
请输入一个数字:abc 请输入一个数字:请输入一个数字:请输入一个数字:...(疯狂刷屏)
  1. 原因:cin >> score 遇到非数字输入 → cin 进入失败状态(failbit 置位)
  2. 失败后后续所有读取立即失效:非法字符留在缓冲区,每次循环都读它、每次都失败——死循环形成
  3. 修复三步曲:clear() 复位状态 → ignore() 清空残留 → 重读
修复代码:
if (!(std::cin >> score)) {
    std::cin.clear();                  // ① 解除失败状态
    std::cin.ignore(10000, '\n'); // ② 扔掉这行坏输入
    continue;                       // ③ 让用户重输
}
📜 历史注解:为什么输出用左移运算符 <<?这不是乱选——iostream 是运算符重载的官方示范:Bjarne 的核心愿景之一就是「让用户自定义类型能像内置类型一样自然输入输出」。cout << x 借用移位符的形象「把数据流向输出流」,比调用函数优雅得多(第七章第 35 节你将自己实现它)。
💡 本节要点:① 输出先进缓冲区,endl 强刷、"\n" 不刷;② cin >> 按空白断词,读整行用 getline;③ cin 失败后所有读取失效,clear + ignore 复活;④ 命名空间 = 防重名的姓氏机制,头文件里禁用 using namespace std。

📝 第一章 · 章末自测

8 道题检验本章学习成果。点击选项即时判分,每题附解析。全部作答后显示成绩与建议。

🧮 CHAPTER 02 · 运算符

第二章 · 运算符

运算符是 C++ 的「词汇表」——看起来最简单的一章,却埋着整数除法、浮点精度、未定义行为、短路求值四个改变程序命运的机关。学完本章,你能看懂一行代码「实际执行了什么」,而不是「看起来像什么」。

📚 5 个知识点 💻 14+ 个代码示例 📊 8 张原理图解 🧪 8 道章末自测 ⏱️ 约 45-60 分钟

🎯 本章学习目标

  • 彻底搞懂整数除法截断、浮点误差的根源,不再写出 0.1+0.2==0.3 这类必错代码
  • 掌握 = 与 == 的经典笔误、链式赋值的求值顺序
  • 理解短路求值的执行机制,用它写出安全的「先检查再使用」代码
  • 记住完整优先级表的骨架,理解为什么 j++ + ++j 是未定义行为
  • 学会用位运算实现权限标志位——读底层代码的硬通货
已学 0 / 5 个知识点
➕

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!)
3 1 -3 3.5 3.5 0
🗣 【说人话】「截断」不是四舍五入:整数除法的小数处理方式是直接砍掉小数部分(术语叫「截断」):7/2 得 3.5 然后砍成 3;-7/2 得 -3.5 砍成 -3(往 0 的方向砍)。想要四舍五入?先转小数除法,再 +0.5 后取整。

📊 图解:0.1 + 0.2 为什么不等于 0.3?

二进制无法精确表示 0.1,就像十进制写不尽 1/3
十进制的困境:1/3 = 0.33333...(永远写不完,只能截断存 0.3333) 二进制的困境:0.1 = 二进制 0.0001100110011001100...(同样写不完) 计算机存 double 时: 0.1 → 存成 0.1000000000000000055511151231257827… 0.2 → 存成 0.200000000000000011102230246251565… 相加 → 0.30000000000000004440892098500626… 0.3 → 存成 0.29999999999999998889776975374843… 0.1 + 0.2 的结果 ≠ 0.3 的存储值 ← 两位小数点后第 17 位分出了胜负 正确比较姿势:std::abs(x - y) < 1e-9 // 差的绝对值足够小就算相等
这是 IEEE 754 浮点标准的固有特性,Python/JS 一模一样——不是 C++ 的 bug,是二进制的宿命
🔬 深入一层:整数与浮点混合运算的提升规则
当 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 起标准规定)。
🐞 平均分为什么是 0?——整数除法事故现场
int total = 470; // 五门课总分 int n = 5; std::cout << total / n << "\n"; // 想要平均分 输出:94 ← 碰巧整除没事?再看这个: std::cout << (10 + 20 + 25) / 3 << "\n"; // 18?想要 18.33 输出:18 ← 小数部分静默丢失,成绩单全错
  1. 根源:整数 / 整数 = 整数,编译器认为这是你故意的(性能优先哲学:不偷偷帮你转小数,转小数是有代价的)
  2. 排查:凡是「结果比预期小了一截」的数值 bug,优先怀疑整数除法
修复:把任一操作数升级为 double —— static_cast<double>(total) / n,或先把累加器声明为 double。
📜 历史注解:「-7/2 等于 -3 还是 -4」早期由各编译器厂商自行决定(标准没管),直到 C99 才统一为向零截断,C++11 跟进——所以极老的代码在不同平台行为可能不同。而 0.1 + 0.2 != 0.3 是 IEEE 754 浮点标准的固有特性:二进制无法精确表示 0.1。金融/计量领域要么用整数存「分」,要么用专门的十进制库,绝不裸用 double。
💡 本节要点:① 整数/整数=整数(截断向零);② 想要小数:static_cast 或字面量 7.0;③ 浮点比较永远用「差的绝对值 < 容差」;④ % 只用于整数,负数取余符号随被除数;⑤ 混合运算勤加括号。
🟰

8. 赋值与复合赋值

= += -=链式赋值= vs ==
✓ ▼

赋值号 = 的含义是「把右边的值算出来,装进左边的变量」——不是数学上的「相等」。赋值本身也是表达式:算完还「是」一个值(就是刚赋的值),所以能连锁写。

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 的求值顺序

赋值表达式的结合方向:从右到左
a = b = c = 0
→
第①步
c = 0
c 变为 0,整个式子"是" 0
→
第②步
b = (上一步的0)
b 变为 0,式子"是" 0
→
第③步
a = 0
三个变量全为 0
右结合:先把最右边的值"接力"传到左边每一个变量
⚠️ = 与 == 笔误为什么如此致命:因为它不报错。if (level = 5) 的含义变成「先给 level 赋 5,再看 5 是不是非零(永远是)」——条件永远成立,逻辑静默出错。这类 bug 在代码审查里也很难肉眼发现,防御手段有三:① 编译加 -Wall(会对「赋值当条件」报警告);② 常量写左边 if (5 == level)(写错成 = 会立刻编译报错,人称「尤达条件句」);③ 现代习惯直接用 const 变量 + 显式比较。
🐞 编译器警告就是你唯一的救命稻草
$ g++ -Wall test.cpp test.cpp:6:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses] 6 | if (level = 5) { }
  1. 这行警告直译:「建议给"当真值用的赋值"加括号」——编译器在提醒你可能把 == 写成了 =
  2. 处理警告的正确姿势:理解并修复它,而不是加括号消音 if ((level = 5))(这等于把救命提示贴嘴上)
习惯:始终带 -Wall 编译;把警告当错误对待(进阶可加 -Werror)。
📜 历史注解:为对抗这个笔误,C 老兵流行把常量写左边:if (5 == level)——一旦手滑写成 = 会立刻编译报错。这被戏称为「尤达条件句」(像星战尤达大师的倒装语序:「5 等于 level 吗」)。现代编译器加 -Wall 会对赋值当条件报警告,这招渐渐退役,但读老代码你会认出它。
💡 本节要点:① = 是赋值、== 是比较,笔误不报错靠 -Wall 救命;② 赋值表达式有值,支持从右往左链式赋值;③ 复合赋值 += -= *= /= %= 是「运算+赋值」的缩写;④ 新代码用 const + 显式比较,尤达条件只用于读老代码。
⚖️

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 ✅ 正确写法
允许入场 1 0

📊 图解:短路求值——「结论已定,剩下不看」

&& 遇假即停;|| 遇真即停
A && B
→
先算 A
A 为 false?
直接返回 false
B 根本不执行!
A || B
→
先算 A
A 为 true?
直接返回 true
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) { /* 安全 */ }
🔬 深入一层:德摩根定律——化简复杂逻辑的瑞士军刀: !(A && B) ≡ !A || !B;!(A || B) ≡ !A && !B。口诀:取反穿过括号,与或互换。实战:条件「不是(已登录 且 是会员)」等价于「未登录 或 非会员」。当你写出三层嵌套的 !() 时,用德摩根摊平它,可读性立刻上升。另外注意 && || 是「逻辑与/或」(整体真假),而 & | 是「按位与/或」(二进制逐位算,见第 11 节)——写错不报错但语义完全不同,是打字时的隐形地雷。
💡 本节要点:① 区间判断必须写 x > 0 && x < 10,数学连写是错的;② 短路求值 = 安全写法的基石(先检查再使用);③ &&/|| 是逻辑运算,&/| 是位运算,别打错;④ 复杂取反用德摩根定律摊平。
🎯

10. 自增自减与优先级

++i / i++未定义行为优先级表
✓ ▼

++ 和 -- 让变量加 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 后置——两份「快递单」的区别

int a = i++; 与 int b = ++i; 的执行时序
int a = i++
后置版
→
① 先复印旧值 5
准备交货
→
② 再把 i 改成 6
→
交货 a = 5
i 最终 = 6
int b = ++i
前置版
→
① 先把 i 改成 7
→
② 复印新值 7
准备交货
→
交货 b = 7
i 最终 = 7

优先级速查:只需记住这几档骨架

优先级运算符记忆口诀
最高() [] . -> 后置 ++ --括号成员最亲
高前置 ++ -- ! ~ 一元 - * 解引用 & 取址 sizeof new/delete一元运算扎一堆
中* / % → + -乘除先于加减(数学惯例)
中低< > <= >= → == !=比较低于算术(所以 a+b > c 不用加括号)
低&& → ||逻辑垫底(高于三元和赋值)
最低? : → = += 系列 → ,赋值几乎是终点
实践建议:背不全没关系,拿不准就加括号——括号零成本,歧义高代价。唯一必须刻进 DNA 的:&& 优先于 ||,所以 a || b && c 是 a || (b && c)。
🐞 为什么 j++ + ++j 会被判为「未定义行为」?
int j = 0; std::cout << j++ + ++j; // GCC 输出 2?Clang 输出 1?MSVC 输出 3? // 都对——标准说"这是 UB,爱输出啥输出啥"
  1. 问题本质:一条语句里两次修改 j,而标准没规定两次修改的先后顺序
  2. 编译器可以自由选择求值顺序 → 每家结果不同 → 程序不可移植
  3. 更狠的是:标准允许编译器在这种情况下做出任何假设,可能引发连锁优化 bug
规矩:一条语句内一个变量只改一次(自增/自减/赋值都算改)。这类代码是区分「懂 C++」与「背 C++」的试金石。
📜 历史注解:为什么 C++ 程序员偏爱 ++i?对整数两者无差别;但对重载了 ++ 的对象(如 STL 迭代器),后置版必须先拷贝一份旧值再返回——多一次拷贝。这个习惯从模板时代延续至今。j++ + ++j 这类写法被标准明文列为未定义行为。
💡 本节要点:① 后置 = 先用旧值再加,前置 = 先加再用新值;② 单独一条 i++; 语句两者等价,此时习惯写 ++i;③ 一条语句多次修改同一变量 = UB;④ 拿不准优先级就加括号;⑤ 必记:&& 高于 ||。
🧮

11. 位运算

& | ^ ~ << >>位掩码硬件血统
✓ ▼
🗣 【说人话】先懂「二进制位」:电脑里所有数字最终都是一串 0/1 开关,每个开关叫一个比特。位运算 = 不看数字大小、直接对这些 0/1 开关做「与/或/翻转」操作。例如 12 的二进制是 1100,10 是 1010。

📊 图解:六个位运算符一次看懂

a = 0b1100 (12),b = 0b1010 (10),逐位操作
a & b
按位与:都是1才1
1&1=1
1&0=0
0&1=0
0&0=0
= 1000
十进制 8
a | b
按位或:有1就1
1|1=1
1|0=1
0|1=1
0|0=0
= 1110
十进制 14
a ^ b
异或:不同才1
1^1=0
1^0=1
0^1=1
0^0=0
= 0110
十进制 6
a << 2
左移2位
110000
= 48
末尾补俩0 = ×4
a >> 2
右移2位
11
= 3
砍掉末尾2位 = ÷4
~ 是按位取反(0变1、1变0,一元运算符);左移n位 = 乘2的n次方,右移 = 除2的n次方(无符号数)
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 → 编译错误
8 14 6 48 3

💻 实战:用 32 个开关位当 32 个「是/否」标志(权限系统)

一个 unsigned int = 32 个独立的开关
位31 …
bit4删除
bit3写入 ✓
bit2读取 ✓
bit1保留
bit0保留
flags = 0b1100:第2、3位亮 = 有「读取+写入」权限。1 个 int 存 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";
true false
🔬 深入一层:三个必会的位运算技巧
① 判断奇偶:n & 1 得 1 是奇数、0 是偶数(看最低位);
② 清最低位的 1:n & (n-1)——刷算法题的高频操作(统计 1 的个数);
③ 交换两个数(不用临时变量):a ^= b; b ^= a; a ^= b;——面试玩具,实战可读性差,知道即可。
另记住:位运算请配 unsigned 类型使用——带符号数的右移行为是历史遗留的「实现自定义」区(不同编译器可能补 0 或补符号位)。
📜 历史注解:位运算是 C 语言为写操作系统而生的本领——当年内存按 KB 计算,用一个 int 的 32 个 bit 当 32 个开关是极致节省。左移 = 乘 2 的幂更是 CPU 硬件原生支持的免费操作,编译器至今仍把 x * 4 优化成 x << 2。日常应用开发用得少,但读底层代码、写嵌入式、刷算法题是硬通货。
💡 本节要点:① 六个运算符:& 与、| 或、^ 异或、~ 取反、<< 左移、>> 右移;② 位运算优先级低,与 << 混用必须加括号;③ 位掩码三板斧:| 打开、& 查询、& ~ 关闭;④ 位运算配 unsigned;⑤ & 是逐位算,&& 是整体真假,完全不同的东西。

📝 第二章 · 章末自测

8 道题检验本章学习成果。特别注意第 1、3、5 题——它们是真实项目里踩过最多的事故点。

🔀 CHAPTER 03 · 流程控制

第三章 · 流程控制

程序不只是从上到下的直线——条件让它会「拐弯」,循环让它会「绕圈」。本章的关键不是背语法,而是建立「执行流」的脑内模型:每一行代码什么时候跑、跑几遍、什么时候跳过。switch 的贯穿陷阱和循环的边界错误,是本章两大事故高发区。

📚 5 个知识点 💻 13+ 个代码示例 📊 7 张原理图解 🧪 8 道章末自测 ⏱️ 约 45-60 分钟

🎯 本章学习目标

  • 掌握 if-else / switch 的完整语法与两大陷阱:悬垂 else 与 fall-through
  • 看懂编译器如何把 switch 优化成「跳转表」——理解它为什么只能接整数
  • 分清 while 与 do-while 的执行时机差异,杜绝「差一错误」
  • 掌握 for 三段的执行顺序图与现代 C++ 的范围 for 三种姿势
  • 会用 break/continue 控制循环,理解 goto 的历史争议与最后的正当用途
已学 0 / 5 个知识点
🔀

12. 条件语句 if-else

if / else if / else三元运算符C++17 if 初始化
✓ ▼

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 外面已经不存在了 —— 不污染外层作用域
优秀 奖金:9
🗣 【说人话】「作用域」是什么:花括号 {} 围出来的「领地」。变量只在领地内活着,出了领地就消失(柜子被收回)。上面 C++17 语法的意义:别让临时变量污染外层领地——bonus 只在这一个 if 里用,就让它生在这里、死在这里。

📊 图解:else if 链的「闸门式」执行流

92 分的旅程:命中即离场
grade >= 90 ?
92 ≥ 90
true →
输出「优秀」
命中!
后面全部跳过
≥75 / ≥60 / else
一个都不看
把最常见/最严格的条件放最前面,可以让大多数数据少走几步——这就是 else if 链的「闸门」设计
🔬 深入一层:悬垂 else(dangling else)——else 永远属于离它最近的、还没配对的 if:
if (a) if (b) f(); else g();
这个 else 属于内层 if(b),不是外层 if(a)!缩进会骗人,花括号不会。老老实实写 if (a) { if (b) f(); } else g(); 才能表达本意。这正是「永远写花括号」军规的又一论据。
⚠️ 花括号军规的真实血案:与 Python 用缩进不同,C++ 用花括号界定代码块。单行语句可以省略花括号,但强烈建议始终写上——苹果系统曾爆发著名安全漏洞(goto fail 漏洞):省略花括号的 if 下面后来被加了第二行,那行无条件执行,直接绕过了证书校验。省一对花括号,赔上整个系统的安全。
💡 本节要点:① else if 链命中即离场,条件排序影响效率与语义;② 三元运算符只能选值不能执行语句;③ C++17 if 初始化让临时变量不泄漏;④ else 配对最近的未配对 if;⑤ 花括号永远写——goto fail 是前车之鉴。
🎛️

13. switch 分支语句

case / breakfall-through 陷阱跳转表
✓ ▼

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 是「入口」,不是「隔间」

day=6 时从 case 6 进入,一路向下直到 break
switch (day) { case 1: ──── day==1 从这入 ┐ "周一"; break; │ 撞墙跳出 case 6: ──── day==6 从这入 ┐│ ↓入口 ││ case 7: (day==7 从这入) ││ "周末"; break; ←←←←←←←┘│ 撞墙跳出 default: ──── 都没中从这入 │ "工作日"; break; │ } 忘写 break 的 case = 没有墙的房间:执行会一路"滑"到下一个 case 继续跑 连写 case 6: case 7: 共享代码 = 刻意利用滑梯的合法技巧

📊 图解:编译器的小心思——switch 常被优化成「跳转表」

case 值连续时,if-else 链 vs 跳转表
if-else 链
day==1? ==2? ==3?...
最坏要问 7 次
VS
跳转表
编译器生成数组:[地址1,地址2,...地址7]
day 当下标,一次直达 → O(1)
这也解释了为什么 switch 只收整数:下标必须是整数;case 值必须是编译期常量;稀疏的 case(1, 9999, 123456)会被编译器降级回比较链
🐞 fall-through 事故现场:菜单多打了两行
输入 1,期望只打印 "开始游戏",实际输出: 开始游戏 加载存档 退出游戏 ← 程序直接退出了?!
  1. 检查 case 1 末尾是不是忘了 break —— 执行滑进了 case 2、case 3
  2. 编译器警告能抓住它:-Wimplicit-fallthrough(包含在 -Wall 的部分发行版里)
  3. C++17 起可以显式标注「我是故意的」:[[fallthrough]];
习惯:每个 case 写完先敲 break 再写逻辑;故意贯穿必须注释 + [[fallthrough]]。
📜 历史注解:贯穿特性继承自 BCPL 语言。C 之父 Dennis Ritchie 在《K&R》里直言「贯穿既有用又有害」——有用在多 case 共享代码,有害在忘写 break 时编译器默不作声。据考证各公开代码库中贯穿 bug 数以百计,C++17 最终发明 [[fallthrough]] 属性标注「我是故意的」。
💡 本节要点:① case 是入口不是隔间,break 是墙;② 连写 case 共享代码是合法技巧;③ switch 只收整数/枚举,case 值须是编译期常量;④ 故意贯穿写 [[fallthrough]];⑤ default 永远写上兜底。
🔁

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);   // ⚠️ 注意结尾的分号!这是唯一必须带分号的"语句块"
5 4 3 2 1 发射!

📊 图解:两种循环的执行流对比

while 是「先看路再迈步」,do-while 是「先迈一步再看路」
🔁 while(先检查)
  • ① 检查条件 → 真?执行循环体 → 回到 ①
  • 条件一开始就为假:循环体 0 次执行
  • 适合:「满足条件就一直做」——轮询、处理队列
🔂 do-while(后检查)
  • ① 执行循环体 → 检查条件 → 真?回到 ①
  • 无论条件真假:循环体至少执行 1 次
  • 适合:「至少要问一次」——菜单、重输提示
🔬 深入一层:差一错误(off-by-one)——循环最经典的 bug:while (count > 0) 打印 5 4 3 2 1;改成 while (count >= 0) 就多打一个 0;改成 while (count > 1) 就少打一个 1。写循环时永远口头推演三件事:初始值是什么、边界含不含、每圈变量怎么变。另外死循环写法 while (true) 内部用 break 退出,终端里程序卡死可用 Ctrl+C 强制中断。
🐞 死循环排查清单
程序输出刷屏停不下来 / 光标卡住不动(Ctrl+C 中断它)
  1. 循环变量忘了更新:while (count > 0) 里没有 count-- → 永远为真
  2. 更新方向反了:想倒数却写了 count++
  3. 浮点条件永不到达:while (x != 1.0) 里 x += 0.1 累加出 0.9999999...(第二章浮点坑!)
  4. 条件被循环体内的赋值破坏:第二章讲过 if (level = 5) 笔误,在 while 里同样致命
工具:怀疑死循环时 Ctrl+C 中断,看最后输出的值,反推哪个变量没按预期变化。
💡 本节要点:① while 先检查(0 次起步),do-while 后检查(至少 1 次);② do-while 结尾有分号;③ 菜单类「至少问一次」场景用 do-while;④ 写循环必推演:初始值、边界、每圈变化;⑤ 浮点数别当循环条件。
🎯

15. for 循环与范围 for

for(;;)范围 forconst auto&
✓ ▼

经典 for 把循环的三要素(起点、终点线、步伐)压缩在一行,分号隔开——一眼看全整个循环的「生命周期」。C++11 又补了范围 for:遍历容器时连下标都不用管。

📊 图解:for 三段的执行顺序(最容易搞错的知识点)

for (① int i = 0; ② i < 5; ④ i++) { ③ 循环体 }
① 初始化
只执行 1 次
i = 0
→
② 检查条件
i < 5 ?
假 → 直接跳出
真 →
③ 执行循环体
→
④ 步伐 i++
回到 ②
顺序 ①→②→③→④→②→③→④... 注意:④ 在③ 之后才执行——循环体里第一次见到的 i 是 0 而不是 1
// 经典 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++) { /* ... */ }
0 1 2 3 4 10 8 6 4 2

💻 范围 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 << " ";
}
10 20 30 20 40 60
⚠️ 值拷贝不修改的原理:姿势①的 n 是元素的复印件——改复印件不影响原件(第五章参数传递同款原理)。要修改必须加 & 拿本体。而姿势③的 const auto& 是「只看不改地借原件」:零拷贝 + 编译器兜底防误改,遍历大容器(string、vector<string>)性能差距可达数量级——把它变成肌肉记忆。
📜 历史注解:范围 for 是 C++11 补的课——此前遍历容器要手写迭代器循环,啰嗦至极。C++11 一口气补齐 auto、范围 for、lambda,因此有「古典 C++ / 现代 C++」之分,两者写法差异大到像两门语言。
💡 本节要点:① 执行顺序 ①②③④②③④,步伐在循环体之后;② 循环变量只活在 for 内;③ 范围 for 默认手写 const auto&(只读)或 auto&(要改);④ 值拷贝遍历大对象是性能杀手;⑤ 知道次数用 for、不知道次数用 while。
🚦

16. break、continue 与 goto

break / continuegoto 有害论
✓ ▼

循环体内三个「交通管制」: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 跳出
1 2 4 5 6 7 结束

📊 图解:continue 与 break 的行进路线

for 循环里遇到两个关键字的去向
for (①; ②; ④) { ③循环体: if (cond1) continue; ──┐ 跳过本圈剩余部分 if (cond2) break; ──┼──┐ 彻底离场 ...剩余代码... ←continue 跳过这 │ │ } ←break 跳过这 │ │ continue 去处 → 执行 ④ 步伐,回到 ② 检查 ┘ │ break 去处 → 循环之后的第一条语句 ────────────┘
关键区别:continue 还会回来(执行步伐 i++ 再检查条件);break 一去不回。while 循环里用 continue 要小心:它不经过步伐语句——若步伐写在循环体末尾,continue 会把它也跳过 → 死循环!
🐞 while + continue 的隐藏死循环
int i = 0; while (i < 10) { i++; // 步伐写在开头,安全 ✅ if (i % 2 == 0) continue; std::cout << i << " "; } // 但这样写就完了: int j = 0; while (j < 10) { if (j % 2 == 0) continue; // j=0 命中 continue j++; // ← 永远执行不到!j 永远是 0 std::cout << j << " "; // 死循环,CPU 直接拉满 }
  1. 根因:while 的步伐写在循环体里,continue 会跳过它——j 卡死在 0
  2. for 循环没这个问题:continue 之后仍然执行 for 头部的步伐 i++
规矩:while 循环的更新语句放在 continue 之前(或用 for 代替)。
📜 历史注解:1968 年,图灵奖得主 Edsger Dijkstra 发表著名信件《Go To Statement Considered Harmful》(goto 有害论),点燃结构化编程革命——有趣的是,这个响亮标题是编辑 Niklaus Wirth(Pascal 之父)加的,原题平淡无奇。争论结果:goto 从几乎所有新语言中消失(Python/Java/Rust 都没有),但 C 系语言为兼容老代码保留至今。Linux 内核有上万处 goto,几乎全用于集中式错误清理。给初学者的忠告:知道它存在,但别用它。
💡 本节要点:① break 离场、continue 跳圈(for 中仍执行步伐);② while 里 continue 可能跳过更新语句 → 死循环;③ 跳出多层嵌套:抽函数用 return,别用 goto;④ switch 里的 break 属于 switch,不影响外层循环。

📝 第三章 · 章末自测

8 道题覆盖本章全部陷阱点。第 2、4、6 题是真实工程事故的浓缩版。

📦 CHAPTER 04 · 数组与字符串

第四章 · 数组与字符串

「装一组东西」是编程的基本需求。C++ 给了你三代的工具:原生数组(1970 年代的老古董,看懂它=看懂内存)、vector/string(现代首选,自带长度、自动扩容)、C 字符串(历史包袱,操作系统接口仍离不开)。本章的内存布局图是全书最重要的一批图——第六章指针的地基就在这里。

📚 4 个知识点 💻 13+ 个代码示例 📊 8 张原理图解 🧪 8 道章末自测 ⏱️ 约 50-70 分钟

🎯 本章学习目标

  • 画出原生数组的内存布局图,解释「下标从 0 开始」的真实原因与越界的危害
  • 理解 vector 的内部三指针(begin/end/capacity)与倍增扩容的搬家过程
  • 掌握迭代器失效的场景与预防(reserve / 及时重新获取)
  • 熟练使用 string 的核心 API,了解它与 C 字符串的互转边界
  • 看懂 C 字符串的 '\0' 约定与缓冲区溢出——安全意识的起点
已学 0 / 4 个知识点
📊

17. 原生数组

int arr[5]内存布局数组退化
✓ ▼
🗣 【说人话】数组是什么:连号的一排格子——一个名字 + 编号(下标)取用任何一格。编号从 0 开始不是审美偏好,而是历史惯例:下标其实是「离第一格的偏移量」,第一格偏移 0。编译器拿到 arr[i] 就翻译成「首地址 + i×格子大小」,一步定位。

📊 图解:int scores[5] 的内存布局

五个 int 连号排列,总占地 5×4 = 20 字节
0x3000scores[0]
值 90
0x3004scores[1]
值 85
0x3008scores[2]
值 78
0x300Cscores[3]
值 92
0x3010scores[4]
值 88
0x3014scores[5]?!
越界!隔壁人家的地盘
地址每次 +4(int 占 4 字节)。scores[5] 越界读写的是 0x3014 —— 那里住着谁?不知道。这就是越界的恐怖:编译不报错、运行多半不报错,直接骚扰邻居数据(UB)
// 定长数组:格子数必须是"翻译阶段就确定"的常量
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 格)

📊 图解:二维数组的真实内存——也是一整排

int matrix[2][3] 在内存里按行平铺
row01
row02
row03
row14
row15
row16
matrix[1][2] = 首地址 + (1×3 + 2)×4 字节 = 第 6 格 = 6。「行×列数+列」一步换算,所谓二维只是书写习惯

⚠️ 数组退化:传参即丢长度

// ⚠️ 数组退化:数组一旦传给函数,函数收到的只是"第一格的地址"
void printArr(int arr[]) {              // 写着数组,实际收到的是指针(第六章讲)
    std::cout << sizeof(arr) / sizeof(arr[0]);   // 2(8÷4):长度信息丢了!
}                                        // sizeof(arr) 算的是"地址纸条"本身的大小

// 正确做法①:长度当第二个参数传
void printArr2(int arr[], int len) { /* 用 len 控制循环 */ }
// 正确做法②(现代):直接传 vector(下一节),自带长度
🐞 越界的两种结局——都比「报错」糟糕
结局一:段错误(Segfault)—— 运行到一半崩溃,至少你发现了 结局二:静默腐蚀 —— 程序"一切正常",隔壁变量悄悄被改, 三小时后在不相干的地方爆炸,你永远查不到真凶
  1. 越界写 = 直接往邻居柜子里塞东西:邻居可能是另一个变量、函数指针、甚至安全边界
  2. 历史上大量安全漏洞(缓冲区攻击)正是故意越界改写数据——这是 C/C++ 安全问题的头号来源
  3. 防御:能用 vector 就用 vector(at() 会抛异常);用下标前检查 i < len
心法:语言不替你查越界(性能优先哲学),查越界是你自己的责任。
📜 历史注解:「传参即退化」是 C 的著名妥协:1970 年代把整个数组拷贝进函数太贵,Dennis Ritchie 的方案是只传首地址。代价是数组从此不知道自己多长,由此诞生了无数越界 bug 与安全漏洞。教程态度很明确:学原生数组是为了看懂老代码和内存布局,写新代码请用 vector。
💡 本节要点:① 下标 = 偏移量,从 0 开始,最后下标 = 长度-1;② 越界是 UB:不报错比报错更可怕;③ sizeof 算长度只在本作用域有效,传参后失效;④ 二维数组内存按行平铺;⑤ 新代码用 vector / std::array。
📦

18. std::vector 动态数组

push_back / size / at三指针结构迭代器失效
✓ ▼

vector = 自动扩容、自带长度、可查越界的「升级版数组」。它是现代 C++ 容器的默认首选——理解它的内部结构,是理解一切 STL 容器的钥匙。

📊 图解:vector 的内部结构——三个指针管理一块仓库

size() = 已装几个;capacity() = 仓库总共几格
begin_ →v[0]
1
v[1]
2
v[2]
3
end_ →下一空位
size=3 到这
cap_ →空
capacity=4 到这
size() = end_ - begin_(3 格已用);capacity() = cap_ - begin_(4 格库存)。还有 1 格空余——再 push_back 一次不用搬家

📊 图解:扩容搬家——仓库满了怎么办

push_back 触发扩容:申请 2 倍新地盘 → 全员搬家 → 扔旧地盘
仓库满
size == capacity
(3格全满)
push_back(4) →
① 申请新仓库
约 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 不再触发搬家
3 0 100 100 2 3
🐞 迭代器/引用失效:引用了老地址的元素
std::vector<int> v = {1, 2, 3}; int& first = v[0]; // 引用 v[0] v.push_back(4); // 触发扩容搬家! first = 99; // ❌ 对着已回收的旧仓库地址写入 → UB
  1. 扩容(push_back 到满 / reserve / resize)→ 所有旧引用、指针、迭代器失效
  2. erase 删除元素 → 被删位置之后的迭代器失效
  3. 症状极不稳定:可能立刻崩、可能输出怪值、可能一直正常——典型 UB 三件套
修复:① 扩容后重新获取引用/迭代器;② 能预知规模就 reserve 提前留位;③ 「先改完结构、再用元素」不要交替进行。
🔬 深入一层:为什么是 2 倍扩容?倍增的经典之处:每次搬家总搬运量约为 n + n/2 + n/4 + ... ≈ 2n,均摊下来每次 push_back 只有一步的代价(术语叫均摊 O(1)——「平均算下来很快」)。对比「每次只多申请 1 格」的朴素方案:装 n 个元素要搬 1+2+...+n ≈ n²/2 次——数量级的差距。这是数据结构课程的必修经典,也是 Google 面试常客。
📜 历史注解:vector 的名字容易误导:数学里 vector 是向量,这里其实是「可变长数组」。它诞生于 1994 年 Stepanov 的 STL(第八章详述其故事)。与 Java ArrayList、Python list 是同族设计,但 C++ 版本把「搬家可见性」(迭代器失效)暴露给了程序员——因为没有垃圾回收器替你遮掩内存的生老病死。
💡 本节要点:① vector 三指针:begin/end/cap,size 与 capacity 是两个概念;② 扩容 = 2 倍搬家,旧引用全失效;③ 下标 operator[] 快但不查越界,at() 慢一点但安全;④ 预知规模 reserve 一次到位;⑤ 二维 grid 行列可运行时决定。
🔤

19. std::string 字符串

size / find / substrSSO 短串优化+ == 直接用
▼

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';                          // 按下标改单个字符
Hello World 11 下标:6 Hello 1

📊 图解:SSO——短字符串直接揣兜里,不进仓库

同一个 string 类型,两种存储形态(编译器自动切换)
短裤(≤15 字符左右):SSO 短串优化
  • 字符直接存在 string 对象内部的兜里
  • 零堆分配——创建/销毁飞快
  • "Hi" / "apple" 这类短串全程不碰仓库
长裤(超过阈值):去仓库租地盘
  • 内部改为「指针 → 堆上缓冲区」
  • 行为与 vector 类似:扩容搬家、引用失效
  • "Hello World..." 长文本走这条路
SSO = Small String Optimization。实际阈值 15/22 字节随实现不同。对你的意义:短 string 的拷贝/传参比想象中便宜;但从 string 取出的引用/指针在串变长后依然会失效

高频 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)遇空格不断词(第一章讲过)
🐞 find 判断的经典错误写法
if (s.find("World")) { ... } // ❌ 永远进不去! // find 找到时返回 6(下标),"World" 在开头时返回 0——0 当条件是 false! // 找不到时返回 npos(一个巨大的数)——非零,当条件反而是 true! if (s.find("xyz") != std::string::npos) { ... } // ❌ 逻辑反了:这是"找到了才进" if (s.find("xyz") == std::string::npos) { ... } // ✅ "没找到"才进这个分支
  1. npos 是 size_t 的最大值,转成 bool 是 true——「找不到」反而条件成立
  2. 正确姿势永远是与 npos 显式比较,别拿 find 的返回值直接当真假
肌肉记忆:find 的搭档永远是 == npos / != npos。
📜 历史注解:很难想象,1998 年之前的 C++ 没有标准字符串!早年各编译器厂商各造轮子(CString、String、ROPE...)互不兼容,跨平台苦不堪言,C++98 才一统为 std::string。它的 +、== 能直接用,靠的正是运算符重载(第七章第 35 节你将自己实现它)。
💡 本节要点:① string = 字符版 vector + 运算符糖衣;② find 判断必须和 npos 比较;③ SSO 让短串零堆分配;④ 数值互转:to_string / stoi / stod;⑤ 拼接优先 +=(+ 会产生临时串,循环里 += 更快)。
📜

20. C 风格字符串

'\0' 约定cstring缓冲区溢出
✓ ▼

在 string 诞生前,C 用字符数组装文字,规则:末尾必须有一个看不见的 '\0' 字符标记「到此为止」。今天写新代码几乎不需要它,但操作系统接口、第三方 C 库、文件格式至今都在用它——读懂它是程序员的成人礼。

📊 图解:"Hello" 实际占 6 格——最后一个看不见

char str[] = "Hello"; 的内存布局
0x4000'H'
0x4001'e'
0x4002'l'
0x4003'l'
0x4004'o'
0x4005'\0'
哨兵:到此为止
sizeof(str) = 6(连 '\0' 一起算);strlen(str) = 5(数到 '\0' 前停)。strcpy 拷贝 "Hello" 需要 6 格空间——少给 1 格就是缓冲区溢出
#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()
6 5 1
⚠️ strcmp 返回值最容易记反:返回 0 才是相等(不是 1)!返回正数表示第一串「大」、负数表示「小」。经典笔误:把 if (strcmp(a,b)) 当成「相等时进入」——实际完全相反(相等时返回 0 = false,进不去)。正确写法:if (strcmp(a,b) == 0)。
🐞 缓冲区溢出:1988 年蠕虫病毒到现在的心脏流血
char buf[8]; strcpy(buf, "Hello World!!!!"); // 16 个字符塞进 8 格仓库 // 多出来的 8 个字符直接砸进邻居内存 → 数据被改写 / 程序崩溃 / 被攻击者利用
  1. strcpy 不检查目标大小——性能优先的代价全由你承担
  2. 1988 年 Morris 蜕虫利用同款漏洞感染了当时 10% 的互联网;2014 年心脏流血(Heartbleed)同族
  3. 现代替代:strncpy / snprintf(带长度上限),或干脆用 std::string
军规:新代码用 string;必须用 C 接口时,永远用带长度限制的版本(strncpy/snprintf)。
📜 历史注解:「以 0 结尾」的约定可追溯到 1969 年 Ken Thompson 在 PDP-7 上的 B 语言,C 继承、C++ 再继承——于是全世界的操作系统接口、文件格式至今都在用它。为什么?因为它不用额外存长度:一个裸地址就能表达字符串,扫到 0 结束。代价:求长度要扫全串 O(n)、内容里不能有 '\0'、忘写 0 或格子不够就是漏洞。现代态度:日常用 string,char* 只在与 C 接口打交道时出现。
💡 本节要点:① 字面量末尾自带 '\0',sizeof 比 strlen 多 1;② == 比较的是地址,内容比较用 strcmp==0;③ 拷贝/拼接必须保证目标够大,用带长度版本;④ string↔char* 互转:直接赋值 / c_str();⑤ 新代码 string 优先。

📝 第四章 · 章末自测

8 道题聚焦内存布局与越界陷阱——本章的图请务必看懂再答。

⚙️ CHAPTER 05 · 函数

第五章 · 函数

函数是代码复用的第一块积木,也是「内存责任意识」的起点:递进去的是复印件还是原件?返回的是拷贝还是危险指针?本章的参数传递内存图与调用栈图,是理解 C++ 性能与安全的关键一跳。

📚 5 个知识点 💻 15+ 个代码示例 📊 7 张原理图解 🧪 8 道章末自测 ⏱️ 约 50-70 分钟

🎯 本章学习目标

  • 分清「声明=名片、定义=本人到场」,理解这套规矩的历史根源
  • 画出值/引用/指针三种传递的内存图,掌握 const 引用的工程惯例
  • 理解重载的名字修饰机制,看懂链接报错里的「外星文函数名」
  • 明白 inline 与宏的正邪对决,会用默认参数设计接口
  • 掌握 lambda 的捕获列表,能随手写出传给 sort 的谓词
已学 0 / 5 个知识点
🧩

21. 函数定义、声明与原型

声明 vs 定义单遍编译的遗产
✓ ▼
🗣 【说人话】函数是什么:把一段活儿打包命名的小作坊——递原料(参数)进去,加工完交货(返回值)。一次定义、随处调用。int 写在函数名前面,意思是「这个作坊交货时交一个整数」。你写 std::cout 时,就是在用别人(标准库作者)打包好的作坊。
// 函数声明(原型):只发"名片"——告诉编译器:有这个函数,收什么、交什么
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 = 交货并结束
}
8
🗣 【说人话】声明 vs 定义:声明 = 名片(「有这人,能干什么」);定义 = 本人到场(「真身在此,具体怎么做」)。名片可以到处发(声明允许重复),本人只能有一个(定义全程序仅一份,违者「redefinition」编译错误)。

📊 图解:为什么必须「先声明后使用」?

编译器读 main.cpp 是从上到下单遍扫描的
读到 add(3,5)
调用点
→
查名片夹
见过 add 的声明吗?
见过 →
生成调用代码
具体实现在哪?
那是链接器的事
没见过声明
→
error: 'add' was not declared in this scope
"我不认识这人,不敢让他干活"
编译器不往后看、只查已见过的名片——这套规矩后来演化成了头文件体系(第九章第 44 节)
🔬 深入一层:返回值的去向
① 有返回值的函数:return 表达式; 把值交回调用点,函数立即结束(一个函数可以有多个 return,但每次只走一个);
② 没有返回值:返回类型写 void,return 可省略或裸写 return;;
③ main 是特例:唯一可以不写 return 的函数——不写默认交 0(「顺利跑完」)。返回非 0 惯例上表示出问题,shell 里用 echo $? 能看到它。
📜 历史注解:为什么必须「先声明后使用」?因为 C 的祖师爷编译器是单遍扫描的:从上到下读一遍就生成代码,读到 add(3,5) 时若没见过 add 的名片,就不知道该收什么交什么,没法生成调用。K&R 时代的 C 甚至允许函数不声明参数类型(编译器闭眼猜),酿成无数 bug;C++ 从第一天就强制检查,C 到 C99 才跟上。
💡 本节要点:① 声明发名片(可重复)、定义本人到场(仅一份);② 声明放前面,调用放后面;③ 返回类型 void = 不交货;④ main 不写 return 默认交 0;⑤ 「not declared in this scope」= 编译器没见过名片。
📮

22. 参数传递方式

值 / 引用 / 指针const 引用惯例
▼
🗣 【说人话】先懂「拷贝」:= 拷贝 = 复印一份。值传递把复印件递给函数——改复印件不影响原件;引用传递直接递原件并附一个新名字——新名字改的就是本体。

📊 图解:三种传递方式在内存里的样子

调用 addTen(x) 时,函数窗口里收到的是什么
① 值传递 void f(int x)
窗口收到复印件
柜台多出一个新格子 x
改它 ≠ 改原件
② 引用传递 void f(int& x)
窗口收到"别名条"
不造新格子
x 就是原件本人
③ 指针传递 void f(int* 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& = 不复印 + 不许改,两全其美
(a=5, b=15, c=15)

📊 图解:函数调用的栈帧——柜台的一进一出

main 调用 addTenRef(b) 的瞬间:栈上的「叠盘子」
栈(前台柜台,盘子从上往下叠): ┌─────────────────────────────┐ │ addTenRef 的栈帧(刚叠上) │ ← 函数一到,自动分配 │ x(引用 → 指回 main 的 b) │ 参数格子、局部变量都住这 ├─────────────────────────────┤ │ main 的栈帧(在下面) │ │ a=5 b=5 c=5 │ └─────────────────────────────┘ 函数 return → 它的栈帧整层撤走(盘子拿掉),局部变量随之消亡 这就是"返回局部变量的地址"必翻车的原因——柜台已经清空,纸条指空柜台
每次函数调用 = 在栈上叠一个新盘子(栈帧);返回 = 拿走盘子。递归太深盘子叠太多 → 栈溢出(stack overflow,网站名字的由来)

选择口诀(工程速查表)

场景写法理由
小东西(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、能改指,灵活性换责任
📜 历史注解:为什么 Python 一个「赋值即引用」够用,C++ 要分这么细?因为 C++ 没有垃圾回收(内存自动保洁员),对象放哪、谁复印、谁销毁都要程序员把关——参数传递方式正是这套责任意识的起点。
💡 本节要点:① 值=复印件、引用=别名直达本体、指针=按地址上门;② 只读大对象一律 const 引用;③ 调用栈帧:函数到=叠盘、return=撤盘;④ 返回局部变量地址=悬垂指针(第六章重罚现场)。
♻️

23. 函数重载

同名不同参名字修饰
▼
🗣 【说人话】重载是什么:同一个函数名,根据递进来的东西不同自动派活——像一个叫 print 的总服务台:递整数走 1 号窗、递小数走 2 号窗、递文字走 3 号窗。C 语言做不到(同名函数直接冲突),这是 C++ 的早期革新。
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,编译器选择困难症发作
整数: 42 小数: 3.14 文字: abc

📊 图解:名字修饰——编译器怎么让三个 print 共存

机器码层面根本没有同名函数这回事
void print(int)
你写的
void print(double)
void print(string)
↓ 编译器把参数类型"编码"进函数名(名字修饰 name mangling)↓
_Z5printi
GCC 版全名
_Z5printd
d = double
_Z5printNSt7...
string 的全名长到夸张
调用点 print(42) 被编译器自动改写成 _Z5printi(42) —— 三个函数在机器层面名字各不相同,天然不打架
🔬 深入一层:名字修饰解释了两个「玄学现象」
① 链接报错里的外星文:undefined reference to `_Z5printi' —— 那串符号就是修饰名,学会用 c++filt 工具反解它(echo _Z5printi | c++filt → print(int));
② C++ 函数给 C 调用必须写 extern "C":告诉编译器「别改名字,按 C 的规矩来」——否则 C 那边找的是 print,你这边叫 _Z5printi,对不上号。不同编译器的修饰方案还不一样(MSVC 是另一套),所以不同编译器编译的库常常不能直接互链。
💡 本节要点:① 重载看参数(个数/类型/顺序),不看返回值;② 避免重载「转换路径打架」的参数组合(long vs double);③ 修饰名 = 链接报错外星文的真相;④ extern "C" = 取消改名,服务 C 接口。
⚡

24. 默认参数与内联函数

默认实参inline vs 宏
▼
// 默认参数:给参数一个"保底值"——调用时不传就用保底
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 函数宏的正邪对决

inline 的动机:干掉 C 时代的函数宏
❌ 宏 #define SQUARE(x) ((x)*(x))
  • 纯文本替换,不检查类型
  • 参数被原样抄两遍:SQUARE(i++) 展开成 ((i++)*(i++))——i 自增了两次,UB!
  • 没调试信息、没作用域,问题查到天亮
✅ inline int square(int x)
  • 真函数:类型检查齐全
  • 参数只求值一次:square(i++) 安全
  • 有作用域、可调试、可重载
宏的两大毒瘤:不检查类型 + 参数重复求值。教训延续至今:能用 inline 函数就永远别写函数宏
⚠️ 默认参数与重载可能撞车:同时有 f(int) 和 f(int, int=2) 时,调用 f(1) 编译器不知道该找谁——二义性编译错误。接口设计二选一。另外:默认值写一次就好——声明里写了,定义处再写会报「重复默认参数」错误(惯例:默认值写在头文件的声明里)。
📜 历史注解:C 时代为省调用开销流行函数宏——宏是预处理器的纯文本把戏,毒瘤无数。inline 函数既有宏的速度,又是真函数(类型安全、参数只算一次)。这场正邪对决以 inline 完胜告终,是 C++ 「不牺牲安全换性能」哲学的早期胜利。
💡 本节要点:① 默认参数从右往左连续给,声明里写一次;② 默认参数与重载可能二义性撞车;③ inline 是给编译器的建议,既有速度又保安全;④ 函数宏(带参数的 #define)在 C++ 里被判处死刑。
λ

25. lambda 表达式

C++11捕获列表 [ ]谓词
▼
🗣 【说人话】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
25 1 1

📊 图解:值捕获 vs 引用捕获——两种「带干粮」方式

lambda 是个能揣着「行李」的函数对象
📦 [threshold] 按值捕获
  • 出发时复印一份 threshold 带走
  • 原件后来改了?lambda 用的是自己那份复印件(旧值)
  • 安全:自带干粮,原件没了也不怕
  • 适合:异步/延迟执行、回调存起来稍后跑
🔗 [&threshold] 按引用捕获
  • 只带一张「地址条」,用时现取原件
  • 原件变了,lambda 看到新值;原件没了 = 悬垂 UB
  • 危险:lambda 存到异步任务里、函数返回后才执行 → 原柜台早清空
  • 适合:当场立刻用完就扔(如本线程的 sort 谓词)
🐞 悬垂引用捕获:lambda 存起来晚点用,炸了
std::function<bool(int)> makePred() { int threshold = 100; return [&threshold](int x) { return x > threshold; }; // ↑ 按引用捕获局部变量 } // ← 函数返回,threshold 的柜台被撤 —— 地址条悬空! auto pred = makePred(); pred(50); // ❌ UB:访问已消亡的 threshold
  1. 规则:lambda 的寿命超出被捕获变量的寿命时,引用捕获就是定时炸弹
  2. 爆炸场景:存进容器、注册回调、丢给线程、延迟任务
修复:凡是"存起来稍后跑"的 lambda,一律按值捕获 [=] 或显式列名;[&] 只给当场用完的 lambda。
📜 历史注解:lambda 是 C++11 三大明星特性之一(另两个是 auto 和智能指针),直接动机:STL 算法需要谓词——想给 sort 传比较规则,过去必须跑到几行外专门定义一个函数,把代码撕成两半。名字取自 Church 的 λ 演算(函数式编程的数学根基,1930 年代)。Bjarne 起初嫌它丑而抗拒,在社区强大呼声下才松口——他后来承认「用户永远是对的」。
💡 本节要点:① lambda = 就地一次性函数:[捕获](参数){体};② 主战场是当谓词传给 STL 算法;③ 值捕获带干粮(安全)、引用捕获带地址(高效但危险);④ 存起来晚点用的 lambda 禁用 [&];⑤ auto 接 lambda,类型让编译器推。

📝 第五章 · 章末自测

8 道题:第 1、2 题考内存模型,第 5、7 题是工程事故高频点。

🔗 CHAPTER 06 · 指针、引用与内存

第六章 · 指针、引用与内存

C++ 最核心也最「劝退」的一章——也是它强大的根源。学懂指针 = 学懂计算机内存的真实运作方式。本章每一张图都请反复看:指针不是玄学,是柜子编号;引用不是魔法,是终身外号;智能指针不是黑盒,是自动还钥匙的管家。

📚 6 个知识点 💻 17+ 个代码示例 📊 11 张原理图解 🧪 10 道章末自测 ⏱️ 约 90-120 分钟

🎯 本章学习目标

  • 用「柜子编号纸条」模型彻底理解 * 与 &,看懂任何指针表达式
  • 掌握 p[i] ≡ *(p+i) 的数组本质与指针算术的换算规则
  • 分清引用与指针的四大区别,理解引用「为运算符重载而生」的出身
  • 掌握栈 vs 堆的内存布局、new/delete 全流程与四大陷阱
  • 理解 RAII 思想与智能指针(unique/shared)的内部机制,从此告别手动 delete
已学 0 / 6 个知识点
📍

26. 指针基础

& 取地址 / * 解引用nullptr
▼
🗣 【说人话】先懂内存和地址:内存 = 电脑里一长排编了号的小柜子(每格 1 字节)。变量 = 占用一个或几个连号柜子;柜子的编号 = 地址。

那么指针是什么?一张写着柜子编号的小纸条。拿着纸条不等于拿到柜子里的东西——但顺着编号就能找到它。
&(取地址)=「告诉我这个变量占的柜子编号」;*(解引用)=「按纸条上的编号找到柜子,取出/放入东西」。

📊 图解:指针变量自己也是个变量

int x = 42; int* p = &x; —— 两块柜台,一张纸条
0x1000x
int · 值 42
0x5000p
int* · 存着"0x1000"
p 是 0x5000 号柜子里的一个变量,内容是另一块内存的编号 0x1000 —— 指针没有魔法,它就是「内容为地址的变量」
*p → 顺着纸条开门:取到 42 *p = 100 → 顺着纸条开门,塞进 100:x 变成 100 &x → 问 x 的编号:0x1000(和 p 里存的一致) p 的纸条也能复印:int* q = p; → q 也是 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) { /* ... */ }   // 使用前先验纸条,是好习惯
0x7ffd3a2c1f4c 42 100
🔬 深入一层:指针的指针 & void*
纸条本身也有编号,所以有「写着纸条编号的纸条」:int** pp = &p;(二级指针,*pp 取到 p,**pp 取到 x)——链表节点里常常见到。另有「万能白纸条」void* vp:只存地址、不记类型,不能直接解引用(编译器不知道柜子里放的是什么类型的东西)——必须先转回具体类型;malloc 的返回值就是它,C 风格代码的老朋友。日常代码里二级指针和 void* 都不算高频,但读库源码会碰到。
🐞 段错误(Segmentation Fault)——操作系统的安保拦截
Segmentation fault (core dumped)
  1. 含义:你的程序伸手摸了不属于它的内存地址(对空指针/野指针/已回收地址解引用)
  2. 操作系统给每个程序划了地盘,越界访问立刻被安保掐断——这是保护,不是惩罚(不然你会悄悄改坏别人的数据)
  3. 排查三板斧:① 崩溃行号(gdb 跑起来 gdb ./app → run → bt 看栈回溯);② 检查该行所有解引用的指针来源;③ 十有八九是 nullptr / 悬垂 / 越界三兄弟
预防:指针声明即初始化(没对象就 nullptr);用前判空;用智能指针从根上消灭手动管理。
📜 历史注解:指针的头号历史大坑是 NULL。C 时代 NULL 定义为 ((void*)0);C++ 出于类型安全禁止这种写法,只好把 NULL 定义成整数 0——于是灾难来了:有 void f(int) 和 void f(char*) 两个重载时,调用 f(NULL) 会命中 int 版本(NULL 就是 0!),程序员却以为自己传了空指针。这个坑坑了社区近三十年,直到 C++11 引入 nullptr(专门的空指针,不是整数)才根治。铁律:永远写 nullptr。
💡 本节要点:① 指针 = 内容为地址的变量,自己也在内存里;② & 问编号、* 按编号开门;③ 空指针用 nullptr,用前先验;④ 段错误 = 越界摸内存被系统拦截;⑤ 二级指针 = 纸条的纸条,void* = 不记类型的白条。
🔗

27. 指针与数组

*(p+1)退化本质是地址偏移
▼
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 字节)
// 编译器按指针类型自动换算 —— 这叫指针算术
10 20 40

📊 图解:p[i] ≡ *(p+i) —— C/C++ 数组的本质

arr[i] 的真面目:首地址 + i×4 字节,一步定位
0x3000arr[0]
10
p 指这
0x3004arr[1]
20
p+1
0x3008arr[2]
30
0x300Carr[3]
40
0x3010arr[4]
50
p + 1 → 地址 0x3000 + 1×sizeof(int) = 0x3004(前进一个元素,不是 1 字节!) *(p + 3) → 地址 0x300C 里的东西 = 40 = p[3] = arr[3] p+1 在 double* 上 → 前进 8 字节;在 char* 上 → 1 字节(按类型换算是编译器的事)
看穿了这一层,就明白:下标只是「地址偏移的书写习惯」。也因此越界无法被语言拦截——它只是「多偏移几格」的合法算术。vector 的 at() 才补上这道闸门
🔬 深入一层:数组退化(decay)的完整规则
数组名在几乎所有表达式里都退化成首元素指针:传参(第四章讲过)、赋给指针、参与运算。仅有的几个例外:sizeof(arr)(量整个数组大小)、&arr(取整个数组的地址,类型是 int(*)[5])。退化后长度信息彻底丢失——「数组不知道自己多长」的根源就在这。老代码里常见的「指针遍历」for(int* q = arr; q != arr + 5; ++q) 就是把终点线设为「首地址+5 格」,现在能用范围 for 就用范围 for。
⚠️ 指针算术的边界责任:语言不检查 p+100 是否还在数组范围内——偏移是「合法算术」,解引用越界地址才是 UB。给指针做算术前,心里必须有「这块地盘有几个格子」的答案;答不上来,就该用 vector 了。
📜 历史注解:理解了「p[i] ≡ *(p+i)」,就看穿了 C/C++ 数组本质:下标只是地址偏移的书写习惯。也因此越界无法被语言拦截(它只是「多偏移几格」的合法算术)——vector 的 at() 才补上这道闸门。
💡 本节要点:① arr[i] ≡ *(arr+i) ≡ *(p+i),下标是语法糖;② 指针±n 前进 n 个元素,字节数按类型换算;③ 数组名几乎总退化为指针,sizeof 是少数例外;④ 越界 = 合法算术 + 非法解引用,责任在你;⑤ 新代码用范围 for 替代指针遍历。
🏷️

28. 引用

int& r = x为运算符重载而生
▼
🗣 【说人话】引用是什么:给变量起的「外号」——叫外号和叫本名,指的都是同一个柜子。声明时必须立刻绑定(起外号得先有本人),且终身只忠于一个变量。
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)
                    // 引用一生只跟一个人
20 0x7ffd...(与 &x 相同)

引用 vs 指针:四连对比

对比维度引用 int& r指针 int* p
必须初始化?✅ 定义时立刻绑定❌ 可以先空着(nullptr)
可为空?❌ 没有「空引用」✅ nullptr 是常态
可换目标?❌ 终身一个✅ p = &other 随时改指
使用语法像普通变量(r = 5 直接用)处处要 * 和 ->
底层实现≈ 自动解引用的常量指针就是地址变量
适用场景参数传递、返回值、范围 for可空/可换/数组遍历/C 接口
🔬 深入一层:引用的底层真相——编译器实现引用的方式通常是「自动解引用的常量指针」:你写 r = 20,编译器生成 *ptr = 20。所以引用不是零成本的魔法,但也没有额外开销。三个推论:
① 引用没有「引用的引用」(r&& 不存在);
② sizeof(引用) = 本体的大小(不是指针的 8 字节);
③ 对引用取地址 = 对本体取地址——引用把自己藏得干干净净,这才是「别名」的完整含义。
📜 历史注解:为什么发明引用?不是为了替代指针,而是为了运算符重载。设想想让自定义类型支持链式写法 a = b = c——若用指针实现,语法就变成 &a = &b 这种反人类模样。1985 年前后的 C++ 手稿里 Bjarne 明确记录:「引用让用户自定义类型的运算符语法成为可能」。后来它顺带成为参数传递主力(第五章 const& 惯例)。一句话:指针是「能改指向的地址纸条」,引用是「终身外号」。
💡 本节要点:① 引用 = 终身外号:立即绑定、不可换绑、不可为空;② r = y 是赋值不是换绑;③ 底层 ≈ 自动解引用的常量指针,零额外成本;④ 参数传递首选引用系(要改用 &,只读用 const&);⑤ 需要空/换目标才用指针。
🧠

29. 动态内存 new / delete

new / delete[]栈 vs 堆
▼
🗣 【说人话】栈和堆是什么:程序有两块存放变量的地盘——
栈 = 前台柜台:函数的局部变量放这。函数一到自动分配、函数一走自动收回——快,但柜台很小(通常几 MB);
堆 = 大仓库:空间巨大,但要自己申请、自己归还,不还会「泄漏」(仓库被占满)。
new = 去仓库申请地盘;delete = 归还地盘。new 返回的是地址纸条(指针)。

📊 图解:程序的内存全景图

一个进程的虚拟内存布局(从低地址到高地址)
┌──────────────────────────────┐ 高地址 │ 📚 命令行参数 / 环境变量 │ ├──────────────────────────────┤ │ 🥞 栈(前台柜台)↓向下长 │ 局部变量、函数参数、返回地址 │ (几 MB,超了=栈溢出) │ 函数到=叠盘 / return=撤盘 │ ⋮ 空隙 ⋮ │ │ 🧲 未映射区(踩=段错误) │ │ ⋮ 空隙 ⋮ │ │ 🏭 堆(大仓库)↑向上长 │ new/malloc 申请 · delete/free 归还 │ (GB 级,但需自理生死) │ ← 你现在学的东西 ├──────────────────────────────┤ │ 📦 已初始化/未初始化数据 │ 全局变量、static 变量 ├──────────────────────────────┤ │ 📝 代码段(机器指令) │ 你的程序编译后的指令 └──────────────────────────────┘ 低地址 栈:自动生、自动死,快但小 —— 局部变量默认在这 堆:手动生、手动死,大但累 —— 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 巨型数组
🔬 深入一层:new 比你多做的两件事——相对 C 的 malloc/free 有质的飞跃:
① malloc 只给一块空地;new 还会自动执行对象的「出厂设置」(构造函数,第七章);
② delete 会先执行「收尾仪式」(析构函数)再还地——对象的一生由此完整。
这也是「不能用 free 对付 new」的根本原因:free 不会执行析构,对象占的其它资源(文件、子对象)全部漏掉。铁律:new 配 delete、new[] 配 delete[]、绝不混用 malloc/free。
🐞 delete 与 delete[] 配错的两种下场
int* arr = new int[100]; delete arr; // ❌ 单体版析构:只处理 arr[0],后面 99 个的收尾被跳过 // 多数平台对 int「碰巧没事」,但对类对象必出事——泄漏/UB
  1. new[] 出来的必须 delete[]:后者知道「这里有一串对象,逐个收尾」
  2. int 这类「平凡类型」常看不出症状——换 string 之类立刻泄漏,属「延迟爆炸型 bug」
  3. 现代解法:数组需求交给 vector,单个对象交给智能指针,裸 new[] 直接封杀
军规:现代 C++ 代码里几乎见不到裸 new/delete——容器 + 智能指针(下一节)接管一切。
📜 历史注解:现代建议:裸 new/delete 已属旧式写法,取代者是容器和智能指针(下一节)——但理解它们是理解后者的前提。就像学自动挡之前最好知道离合器是干嘛的。
💡 本节要点:① 栈小而快(自动管理),堆大而慢(手动管理);② new 返回指针,delete 立即归还;③ new[] 必配 delete[];④ new 会跑构造、delete 会跑析构——这是与 malloc 的本质区别;⑤ 裸 new 属旧式写法,新代码用容器+智能指针。
⚠️

30. 内存管理陷阱与 RAII 思想

悬垂指针 / 泄漏 / double freeRAII
▼

📊 图解:四大陷阱——每一张都是事故现场

陷阱① 悬垂指针:地盘已归还,纸条还攥着旧编号
int* p = new int(10);
纸条有效
→
delete p;
地盘归还仓库
→
*p = 5;
❌ 对已回收柜子开门
UB:可能崩 / 可能"正常"
陷阱② 重复归还
→
delete p; delete p;
同一地盘还两次 = UB
陷阱③ 内存泄漏
→
p = new int[100]; p = nullptr;
旧编号丢了 → 永远没人能还
陷阱④ 返回局部变量地址
→
int* bad() { int local=10; return &local; }
函数一返回柜台清空 → 悬垂指针
共同特征:不一定立刻崩溃,甚至可能一直「正常」——直到换编译器/平台/负载才爆发。这正是内存 bug 难查的原因
// 好习惯:还完立刻把纸条作废
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 的杰作
✅ RAII(C++):确定性归还
  • 作用域一结束,资源立刻归还
  • 析构顺序与构造严格相反,可预测
  • 异常路径上也不漏(栈展开时逐个析构)
🧹 GC(Java/Go):不定时打扫
  • 垃圾回收器「想起来才扫」,时机不定
  • 文件句柄、锁等非内存资源还得手动 close
  • 换来的便利:不会悬垂、不会忘还
📜 历史注解:手动管理之苦催生了 C++ 最重要的编程范式 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。对比 Java/C# 靠「保洁员」(垃圾回收)不定时打扫,RAII 是确定性的:作用域一结束资源立刻归还——这对锁、文件这类「必须准时还」的资源至关重要(GC 语言反而要靠 try-finally 手动保证)。
💡 本节要点:① 四大陷阱:悬垂、double free、泄漏、返回局部地址——全是 UB 家族;② delete 后立即置 nullptr,让错误尽早爆炸;③ RAII = 借还绑定对象生死,异常路径也不漏;④ 内存 bug 的共性:延迟爆炸、换环境才发作;⑤ 防御从「习惯」进化到「工具」:下一节智能指针。
🛡️

31. 智能指针

unique_ptr / shared_ptr引用计数循环引用
▼
🗣 【说人话】智能指针是什么:帮你自动 delete 的管家。把 new 得到的地址交给一个「栈上的管家对象」,管家离开作用域时自动归还资源——RAII 思想直接用在指针上。从此告别手写 delete。
#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)
42 10

📊 图解:shared_ptr 的控制块——人头怎么数

make_shared 一次造两样:对象本体 + 登记簿(控制块)
栈 · a
shared_ptr
→ 指向控制块
栈 · b
shared_ptr
→ 同一控制块
堆 · 控制块计数: 2
strong=2, weak=0
堆 · 对象10
被控制块指着
拷贝一个 shared_ptr = 计数 +1(原子操作);析构 = 计数 -1;减到 0 → 自动 delete 对象 → 控制块也回收。两个栈管家共管一份堆资产,账本只有一本

📊 图解:循环引用——数人头的死锁

A 攥着 B 的钥匙,B 攥着 A 的钥匙——谁都不肯先走
栈 · outerA
shared_ptr → A
堆 · A 对象
shared_ptr ptr → B
计数贡献:B 的账 +1
堆 · B 对象
shared_ptr ptr → A
计数贡献:A 的账 +1
outerA 离开作用域:A 的计数 2 → 1(B 还攥着,不减到 0,A 不销毁) A 不销毁 → A 里的 ptr 不析构 → B 的计数停在 1 → B 也不销毁 A 的账:B 投的 1 票;B 的账:A 投的 1 票 —— 永远到不了 0,两个对象双双泄漏 解法:把其中一条边换成 weak_ptr("只借用不计数"的观察者)—— weak_ptr 不加人头:A 销毁后 B 的票源消失,链条正常断开
经典场景:双向链表、父子节点互指、观察者模式。规则:所有权用 shared,回指用 weak
🔬 深入一层:① make_shared 比 shared+new 快:一次申请同时放下对象和控制块(两次申请合一,缓存也更友好);② unique_ptr 是零开销抽象:不比裸指针多占一个字节(没有控制块、没有计数),该用就放心用;③ weak_ptr 的正确姿势:if (auto sp = wp.lock()) —— lock() 尝试升级回 shared_ptr,对象已死则得空——「先确认还活着,再用」。
🐞 泄漏不用猜:工具替你盯
$ valgrind --leak-check=full ./app ==12345== HEAP SUMMARY: ==12345== definitely lost: 800 bytes in 1 blocks ==12345== Leak track: 0x4c2b1a0 → operator new → main (demo.cpp:12)
  1. Valgrind(Linux)/ AddressSanitizer(-fsanitize=address,三大平台通吃)能精确报告:泄漏的地址、大小、分配处的行号
  2. AddressSanitizer 用法:g++ -fsanitize=address -g app.cpp 编译后直接跑,越界/悬垂/UAF 一起抓
  3. CI 里挂上 sanitizer 跑测试,内存 bug 无处遁形
工作流:开发期 sanitizer 常开 > 提测前 valgrind 全量扫 > 发布前关掉(有 2x 左右性能税)。
📜 历史注解:C++98 的第一代智能指针 auto_ptr 有致命伤:为表达「独占」,它的复印操作会偷偷转移所有权(复印完原件就空了)——违反「复印就是复印」的直觉,放进容器直接翻车。社区吐槽十年,C++11 用 unique/shared 双轨方案替代并废除它(C++17 彻底移除)。auto_ptr 失败的根源是当年没有移动语义(「搬家」机制)——一个大特性失败倒逼出整套语言革新,是 C++ 演化史的经典案例。
💡 本节要点:① 默认 unique_ptr(零开销、所有权清晰),确需共享才 shared_ptr;② 创建统一用 make_unique/make_shared;③ 循环引用 = 双双泄漏,回指边用 weak_ptr;④ weak 用前 lock();⑤ sanitizer/valgrind 是内存 bug 的天敌,学会用工具。

📝 第六章 · 章末自测

10 道题——本章是全书重镇,多出的两道题送给指针与智能指针。看图再答题,事半功倍。

🏛️ CHAPTER 07 · 面向对象

第七章 · 面向对象

类 = 把数据和操作数据的方法打包成一个「自定义类型」。C++ 的面向对象是性能与抽象的平衡术:对象在内存里长什么样、虚函数表怎么工作、多态的代价是多少——本章全部画给你看。理解了 vtable 图,C++ 就对你没有秘密了。

📚 7 个知识点 💻 18+ 个代码示例 📊 10 张原理图解 🧪 10 道章末自测 ⏱️ 约 90-120 分钟

🎯 本章学习目标

  • 掌握 class 的封装体系:public/private/protected 与「不变量」设计
  • 理解构造/析构的完整生命周期与初始化列表的必要性
  • 看懂 this 指针的传参真相与 const 成员函数的「保证书」机制
  • 掌握运算符重载的规则与军规,亲手实现 operator<<
  • 画出 vtable 图解虚函数调用全过程,理解多态的成本与虚析构铁律
已学 0 / 7 个知识点
🏛️

32. struct 与 class

public / private封装不变量
▼
🗣 【说人话】类和对象是什么:类 = 设计图纸,对象 = 按图纸盖出的房子。图纸只有一张,房子可以盖很多栋——各住各的住户、各自的电表。第 33 节构造函数就是「交房时的装修流程」。
// 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 是后厨
🗣 【说人话】public/private 和「封装」:把成员设成 private,等于只留「营业窗口」(public 函数)、锁上后厨(数据)——像自动售货机:你按按钮(调函数),不必懂内部机械。这套做法叫封装。好处:窗口函数可以检查规矩(例如「分数必须在 0~100」),从根上挡住非法数据。

📊 图解:封装 = 售货机模型,不变量是铁律

private 数据被 public 窗口函数守卫
调用方
s.setScore(105)?
→
public 窗口
setScore 检查:
0 ≤ score ≤ 100?
合法 →
private 后厨
score 落账
非法 →
拒绝/报错
脏数据进不来
「分数永远在 0~100」这种必须永远成立的规矩,术语叫不变量(invariant)。封装的意义 = 让不变量不可能被绕过破坏
📜 历史注解:历史上先有 struct 后有 class:1979 年的「C with Classes」只允许给 struct 加成员函数(为兼容 C 结构体语法),后来才引入 class 与 private。Bjarne 本人后来承认:保留两个关键字纯粹是兼容包袱。今天社区惯例:纯数据聚合用 struct,有规矩要维护的类型用 class——这比语法差异(默认 public/private)更重要。
💡 本节要点:① struct 与 class 唯一语法差异:默认 public vs 默认 private;② 惯例:数据包 struct、有规矩 class;③ 封装 = 窗口守卫后厨,不变量永不失守;④ struct 也能有成员函数,class 也能全 public——惯例比语法更重要。
🏗️

33. 构造函数与析构函数

出厂设置初始化列表生命周期
▼
🗣 【说人话】两个「自动程序」:构造函数 = 对象出生时自动执行的「出厂设置」——名字与类相同、无返回类型;析构函数 = 对象销毁时自动执行的「收尾仪式」(关灯、还钥匙、delete 资源)——名字是 ~类名。RAII 的落点就在这。
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 析构(与构造顺序相反:后出生的先离开)
Alice 构造 Bob 构造 Bob 析构 Alice 析构

📊 图解:对象生命周期时间线——叠盘子定律

构造顺序 = 声明顺序;析构顺序 = 严格相反
时间 ──────────────────────────────────────────→ main 开始 ├─ a("Alice") 出生① ← 栈上第一块 │ └─ { 内层作用域 │ └─ b("Bob") 出生② ← 叠在 a 上面 │ } b 析构① ← 内层结束,上面的先走 main 结束 a 析构② ← 后构造的先析构(LIFO) 规矩1:成员的构造顺序 = 类里声明的顺序(不是初始化列表的书写顺序!) 规矩2:析构永远与构造完全相反 规矩3:中途 return / 抛异常 —— 已出生对象的析构照样逐个执行(RAII 的底气)

初始化列表 vs 构造函数体内赋值:两步 vs 一步

✅ 初始化列表 : name(n)
  • 成员出生时直接按 n 造(一步到位)
  • const 成员、引用成员只能这样初始化
  • 少一次默认构造 + 一次赋值,性能更优
⚠️ 函数体内 { name = n; }
  • 先用出厂默认值把 name 造出来,再改成 n(两步)
  • const/引用成员直接编译报错(它们不能先默认再造)
  • 大对象白白多付一次默认构造的代价
🔬 深入一层:构造函数重载与默认构造
① 构造函数可以重载多个(不同参数列表),按调用方式匹配——Student()(默认构造)、Student(string,int)(带参);
② 只要你写了任何构造函数,编译器就不再自动生成默认构造——此时 Student s; 会报错,需要自己补一个 Student() = default;;
③ C++11 的委托构造:Student() : Student("无名", 0) {}——让构造函数复用兄弟的实现;
④ 析构函数只有一个、无参数、无重载——收尾仪式没有花样。
💡 本节要点:① 构造=出厂设置、析构=收尾仪式,全自动执行;② 析构顺序与构造严格相反;③ 成员构造按声明顺序(不是列表书写顺序);④ 初始化列表一步到位,const/引用成员唯一选择;⑤ 写了构造函数就没了默认构造,需要时 = default 补上。
🤳

34. this 指针与 const 成员函数

return *thisconst 承诺
▼
🗣 【说人话】this 是什么:成员函数怎么知道「现在操作的是哪栋房子」?编译器的把戏:每个成员函数都悄悄多带一张「当前对象的地址纸条」,就叫 this。c.increment() 其实被翻译成 increment(&c)——c 的地址从后门递了进去。
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 没签保证书
3

📊 图解:this 的传参真相 & const 保证书体系

c.increment() 的展开形式
你写的
c.increment()
.increment()
编译器改写 →
increment(&c)
c 的地址悄悄进函数
成为 this
→
return *this
交回 c 本体 →
接着调下一次
📜 尾部 const = 保证书
  • int get() const 承诺「只看不改任何成员」
  • const 对象/引用只能调签了保证书的函数
  • 编译器强制执行承诺:函数体里改成员直接报错
🔒 为什么这套体系重要
  • 第 22 节的 const string& 参数只能调 const 成员
  • 「只读」成为编译器可检查的合同,而非口头约定
  • 这是 C++ 比多数语言更严密的只读保障
💡 本节要点:① this = 每个成员函数自带的「当前对象地址」;② return *this 支持链式调用;③ 尾部 const 签「不改成员」保证书;④ const 对象只能调 const 成员函数;⑤ 设计惯例:不改成员的 getter 一律加 const。
➕

35. 运算符重载

operator+operator<<C++ 的初心
▼
🗣 【说人话】运算符重载是什么:教编译器认识「你的类型遇到 + 号该怎么算」。写 a + b 时编译器自动翻译成你定义的函数调用 a.operator+(b)——让自定义类型用起来像内置类型一样自然。
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 和你之间只隔一个重载
4,6 (4,6)

📊 图解:operator<< 为什么必须写成独立函数

左操作数决定了谁是「东家」
a + b
左操作数是 Vec2
→ 可以做成 Vec2 的成员
VS
cout << c
左操作数是 ostream
→ ostream 是标准库的,
你不能跑进去加成员!
解法:写成全局函数 operator<<(ostream&, const Vec2&)——第一个参数接流,第二个接你的对象。返回 ostream& 支持链式 << << << 连写
⚠️ 重载军规与禁区:不可重载:::(作用域)、.(成员访问)、?:(三元)、sizeof。不能发明新运算符、不能改变优先级、不能改变操作数个数。Bjarne 立下的军规:重载应让语义自然——保留运算符的原有直觉,别发明新语义。判断标准:如果 a + b 的行为会让人惊讶,就改成普通函数名。
📜 历史注解:运算符重载是 Bjarne 设计 C++ 的核心动机之一——他要写矩阵、复数库,受够了 add(mul(a,b), c) 这种天书,想要 a*b + c。但社区很快玩脱了:有人给字符串减法定义「删子串」。你在用 C++ 的每一天都在用重载:string 的 +、vector 的 ==、cout 的 << 全是。
💡 本节要点:① a+b ≡ a.operator+(b),成员版;② 左操作数不是你的类型(<<)→ 写全局版;③ 返回 ostream& 支持链式输出;④ 军规:保留运算符直觉,不发明新语义;⑤ 四个运算符不可重载::: . ?: sizeof。
🌳

36. 继承

: public 基类protected组合 vs 继承
▼
🗣 【说人话】继承是什么:类的「家族继承」——子类自动继承基类的家产(成员和方法),还能添自己的新本事。术语提醒:基类 = 父类 = 被继承者;子类 = 派生类。
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();     // 自己的新本事
进食 汪汪(3岁)

三级访问控制全景表

访问修饰符类自己子类外部(main 等)
public✅✅✅
protected✅✅❌
private✅❌❌
protected 是为继承而设的中间档:家产传给子女(子类),但不对外公开。经验法则:成员默认 private,只把子类确实需要的提升为 protected。

📊 图解:组合 vs 继承——is-a 与 has-a 的分界线

OOP 设计的第一道判断题
✅ 继承:「狗 is a 动物」
  • class Dog : public Animal
  • 狗是一种动物——凡动物的属性行为,狗都成立
  • 子类能替换基类出现的地方(多态的前提)
✅ 组合:「汽车 has a 引擎」
  • class Car { Engine engine; }
  • 汽车有一个引擎——引擎不是一种汽车!
  • 把 Engine 作为成员装进来,用它的能力
把「有」当「是」来继承,是 OOP 新手第一大坑。判断口诀:能说「是一种」→ 继承;只能说「有一个」→ 组合。现代设计原则:优先组合、谨慎继承
🔬 深入一层:子类对象的构造顺序——造一只 Dog 的完整流程:① 先执行 基类 Animal 的构造(地基先行);② 再按声明顺序构造 Dog 自己的成员;③ 最后跑 Dog 构造函数体。析构严格相反:先 Dog 析构体、再成员、最后基类。若基类构造需要参数,子类初始化列表里写 Dog() : Animal(5), name("旺财") {}——「先给祖先交材料,再装修自己」。
💡 本节要点:① class 子类 : public 基类;② protected = 子类可见、外人不可见;③ private 连子类都不见;④ is-a 用继承、has-a 用组合,优先组合;⑤ 构造从基类到子类,析构完全相反。
🎭

37. 虚函数与多态

virtual / overridevtable 机制虚析构
▼
🗣 【说人话】虚函数解决什么问题:你拿着「动物牌遥控器」(基类指针)操作一只狗。按下「叫」按钮——执行狗的叫法还是动物默认的叫法?
普通函数:编译时按遥控器牌子定(静态绑定);虚函数:运行时看机器真身定(动态绑定)——这就是「多态」:同一个按钮,不同的机器各自表演。
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 对象的内存布局
┌──────────────────────┐ │ vptr(兜里的条) │──→ 指向 Dog 家的 vtable ├──────────────────────┤ │ Animal 的成员(age) │ ├──────────────────────┤ │ Dog 自己的成员 │ └──────────────────────┘ 每个对象多 8 字节(一个 vptr)
📇 Dog 家的 vtable(编译期生成,只此一份)
Dog vtable: [0] speak → Dog::speak 的地址 [1] ~dtor → Dog::~Dog 的地址 Animal vtable: [0] speak → Animal::speak 的地址 [1] ~dtor → Animal::~Animal 的地址 每个类一张表,全类共享
虚调用 a->speak() 的三步(运行时)
① 顺对象兜里的条
a->vptr
→
② 找到 Dog 家的表
(真身是 Dog)
→
③ 取 speak 槽位的真地址
跳过去执行 → 汪汪
狗和猫的表内容不同,所以同一个按钮各自表演。普通函数没有这套流程:编译期直接按指针类型定死地址(静态绑定,更快)
⚠️ 两个高频坑(面试必考):
① 基类析构必须 virtual——否则 delete 基类指针只执行基类收尾、跳过子类收尾,子类自己申请的资源全漏(UB)。规则:只要类有虚函数,析构就跟着写 virtual(或 = default 的虚析构)。
② 构造/析构函数里虚函数不多态——基类构造执行时对象「还只是基类」,vptr 还指着 Animal 家的表,虚调用解析到基类版本;析构期间同理(拆到哪层就用哪层的表)。
🔬 深入一层:多态的代价清单——① 每个对象 +8 字节(vptr);② 每次虚调用 +一次查表(间接跳转,还会阻止内联优化);③ CPU 分支预测在虚调用密集处可能变差。清晰可算的小代价换来「运行时决策」的灵活性——「不用多态就不付钱」正是零开销哲学的体现。override 关键字(C++11)则是保险丝:把「拼错函数名导致你以为在改写、其实新建了一个函数」这种静默 bug,变成编译错误。
📜 历史注解:虚表机制 1980 年代落地、沿用至今,几乎所有 C++ 编译器都用这个方案(标准其实没规定实现方式,但事实统一了)。有趣的是「c->speak() 该静态还是动态绑定」曾在标准委员会吵了十年——Bjarne 力主动态绑定默认关闭(非 virtual),理由正是零开销哲学:你只为使用的高级特性付费。
💡 本节要点:① virtual 标记「调用看真身」,实现运行时多态;② 机制:每类一张 vtable、每对象一个 vptr、调用三步查表;③ override 是改写保险丝,改写必写;④ 基类析构必须 virtual(delete 基类指针场景);⑤ 构造/析构中虚函数不多态;⑥ 代价:每对象 +8B、每调用 +一次查表。
📐

38. 抽象类与接口

纯虚函数 = 0接口设计
▼
🗣 【说人话】抽象类是什么:只有「规矩」没有「实现」的半成品图纸——规定子类「必须会算面积」,但自己不算。写法:函数后面加 = 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 —— 各自的正确版本
}
12.5664

三级函数设计惯例(虚函数语义光谱)

写法语义典型例子
纯虚 = 0必须由子类实现(基类只立规矩)Shape::area、Animal::speak
virtual 有实现可覆盖,但有默认实现(子类不改也能用)文档类的 save(默认存本地)
普通函数子类不该动(同签名隐藏而非覆盖)工具函数、不变流程
🔬 深入一层:接口 = C++ 风格的「合同」——全纯虚类就是 Java/C# 的 interface:class Drawable { virtual void draw() const = 0; virtual ~Drawable() = default; }。一个类可以同时实现多个这种接口(多继承接口 + 单继承实现,是 C++ 社区的标准姿势)。「面向接口编程」的威力:上层代码只依赖抽象(Shape*),下层随便换实现(Circle/Rect/Triangle...)——新增形状不用改一行上层代码,这正是多态交付的「开闭原则」红利。
📜 历史注解:Java 的 interface 关键字本质上就是「全纯虚类 + 全部 public」的语法糖。C++ 不设这个关键字,因为纯虚类可以更灵活:能混合实现代码、能有多重继承。设计哲学差异:Java 立法强制风格统一,C++ 给你零件自己组装。
💡 本节要点:① = 0 是纯虚函数,含纯虚的类是抽象类、不能实例化;② 子类必须实现所有纯虚,否则自己也是抽象类;③ 接口 = 全纯虚类,C++ 的多继承接口姿势;④ 三级惯例:纯虚=必须实现、虚=可选覆盖、普通=别动;⑤ 配合 unique_ptr<Shape> 容器实现「统一管理各种实现」。

📝 第七章 · 章末自测

10 道题——vtable、虚析构、初始化列表是三大必考点,工程与面试双料高频。

🧬 CHAPTER 08 · 模板与 STL

第八章 · 模板与 STL

模板让「一份代码服务所有类型」,STL 让「常用数据结构和算法不用自己写」。这是现代 C++ 真正的主力战场——你写的每一个 vector<int>、sort(v.begin(), v.end()) 背后,都是本章讲的机制。学完这章,你就站在了巨人的肩膀上。

📚 4 个知识点 💻 14+ 个代码示例 📊 6 张原理图解 🧪 8 道章末自测 ⏱️ 约 50-70 分钟

🎯 本章学习目标

  • 理解模板「实例化」的编译期魔术:编译器如何按需生成定制代码
  • 能自己写出泛型函数和 Stack<T> 类模板
  • 掌握 STL 容器选型表:什么时候用 vector/map/set/unordered_map
  • 会用迭代器 + 算法 + lambda 的组合拳,写出地道的现代 C++
已学 0 / 4 个知识点
🧬

39. 函数模板

template <typename T>实例化
▼
🗣 【说人话】模板是什么:带「类型占位符」的配方。T 是占位符:「这里的类型以后再定」。调用 myMax(3, 5) 时,编译器把 T 换成 int、现场生成一份 int 版本的真代码(这一步术语叫「实例化」);传 double 就再生成一份 double 版。于是:类型安全 + 每份代码都是专属定制(零运行时开销)。

📊 图解:实例化——编译器的「按单下厨」

你只写一份配方,编译器生成 N 份定制代码
📝 template<typename T>
T myMax(T a, T b)
配方(此时还不是代码,
是生成代码的图纸)
myMax(3,5) →
int 版真代码
T=int 实例化
myMax(2.5,1.8) →
double 版真代码
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?)
5 2.5 3
🔬 深入一层:模板的报错为什么「几屏红字」?——模板代码要等到实例化时才真正检查 T 支持哪些操作。myMax 里写了 a > b:对 int 没问题;哪天你传个没有 operator> 的自定义类型,错误在模板内部深处爆出来,展开后信息巨长。C++20 的 concepts(约束)就是为治理它而生:template <std::totally_ordered T> —— 把「T 必须支持什么」写在门口,报错短而准。现阶段的实用建议:读模板报错从第一行找 "required from...",定位到你调用它的那一行。
📜 历史注解:更传奇的是:人们后来发现模板在编译阶段就能做计算(分支、递归都行),是图灵完备的——「模板元编程」纯属意外发明,Bjarne 坦言这不是设计目标。整个 STL 建立在模板之上;而模板出了名的几屏红字报错也成了 C++ 最痛的体验,C++20 concepts 正是为治理它而生。
💡 本节要点:① 模板 = 带类型占位符的配方,不是代码本身;② 实例化在编译期按需生成定制代码(零运行时开销);③ T 推导冲突时显式写 myMax<double>(...);④ 模板报错从第一行 required from 往下读。
🧱

40. 类模板

Stack<T>尖括号填空
▼
#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");
2
🗣 【说人话】你早就在用类模板:vector<int>、string(内部即 basic_string<char>)、智能指针全是。尖括号里类型不同,生成的就是两个毫无关系的类——Stack<int> 与 Stack<string> 互不相干,就像两栋按同一张图纸、不同建材盖的房子。
🔬 深入一层:类模板的成员函数若在类外定义,每個定义都要带 template 头:template <typename T> void Stack<T>::push(const T& v) { ... }。另一个高频问题「模板为什么通常写在头文件里」:因为编译器要替调用方现场生成代码,必须看得见完整配方——这就是第九章头文件规矩的模板特例(C++20 模块正在试图改变这一切)。
💡 本节要点:① 类模板 = 图纸带占位符,尖括号填空实例化;② Stack<int> 和 Stack<string> 是两个独立的类;③ 模板类内借组合复用容器(vector<T> data);④ 模板代码通常放头文件(调用方要看得见配方)。
🗃️

41. STL 容器概览

vector / map / set选型表
▼
🗣 【说人话】STL 是什么:C++ 自带的「预制家具库」(Standard Template Library),三大件:容器(装东西的:vector/map/set)、算法(干活的:sort/find)、迭代器(连接两者的「搬运工」,下一节讲)。用它们 = 站在巨人肩膀上写代码。
#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 自动排序)
}
5 0 apple = 5 banana = 3

容器选型速查表(贴在工位的那种)

容器内部结构查找按下标什么时候用
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)❌中间频繁插删且持有稳定迭代器
经验法则:不知道选什么 → vector;要「键查值」→ unordered_map(要有序才 map);要去重 → unordered_set。90% 的业务代码用不到 list/deque。
⚠️ 高频坑:m["pear"] 会顺手插一个默认值!——下标访问 map 时,键不存在就悄悄插入并赋默认值 0。只想查询请用 m.count(key) 或 m.find(key)。这个坑的可怕之处:一次「无害的查询」就能把 map 撑大、把统计逻辑搞错。
📜 历史注解:STL 出自数学家 Alexander Stepanov 之手(1994 年前后在惠普实验室完成),最颠覆之处:它不是面向对象的。Stepanov 认为抽象的正确基础是数学(泛型算法 + 迭代器),而非类继承——他甚至公开说「OOP 在技术上是虚假的」。当时 C++ 委员会正把 OOP 当唯一正统,STL 的入选堪称「异端逆袭」,事实证明它成了 C++ 标准库的心脏,并深刻影响了 Java 泛型、Rust 等后来者。
💡 本节要点:① 三大件:容器装、算法干、迭代器连;② 选型:默认 vector、字典 unordered_map、去重 unordered_set;③ map 有序 O(logn)、unordered_map 哈希 O(1);④ m[] 会插默认值,纯查询用 count/find;⑤ STL 的灵魂是数学不是 OOP。
🔍

42. 迭代器与常用算法

begin / endsort / find / accumulate
▼
🗣 【说人话】迭代器是什么:指向容器中某个位置、并且能「往前走」的智能下标。begin() 指向第一个元素;end() 指向最后一个的「下一格」(终点线外一格——刻意设计,「走到 end」就是走完了的意思)。*it 取当前位置的元素、++it 前进一步。

📊 图解:begin() 与 end() —— 终点线外一格

v = {3, 1, 4, 1, 5},五格仓库 + 一格虚拟终点
begin() →3
1
4
1
5
end() →虚拟格
终点线外
find 找不到时返回 end()——「终点线本身」充当「没有」的信号,不需要报错。it != v.end() 是 STL 世界最常见的判断式
#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";
找到,下标 2 1 5
🔬 深入一层:为什么算法不直接收容器、要收一对迭代器?——这是 Stepanov 的神来之笔:解耦。sort 不关心你给它 vector 还是数组还是 deque,只要给一对「能走的位置」;而且迭代器对还能表达区间(只排前一半:sort(v.begin(), v.begin()+3))。一套算法配所有容器,代价为零。这套设计后来被所有语言效仿(Java Iterator、Python 迭代协议)。另记住:改动了容器(扩容/删除)后旧迭代器可能失效(第 18 节搬家警告在此同样适用),需要重新获取。
💡 本节要点:① 迭代器 = 智能下标:*it 取值、++it 前进;② end() 是终点线外一格,「返回 end」= 没找到;③ 算法收迭代器对而非容器——解耦 + 区间表达;④ 谓词 lambda 是算法的决策大脑;⑤ 容器结构变化后旧迭代器失效,重新获取。

📝 第八章 · 章末自测

8 道题:模板机制 3 题、容器选型 3 题、迭代器 2 题。

🧭 CHAPTER 09 · 工程实践与进阶

第九章 · 工程实践与进阶

最后一章解决「教程代码 → 真实项目」的跨越:程序出事了怎么优雅地善后?几千行代码怎么拆文件组织?学完这本教程往哪走?异常处理、多文件编译链接、学习路线图——毕业前的最后一课。

📚 3 个知识点 💻 10+ 个代码示例 📊 6 张原理图解 🧪 8 道章末自测 ⏱️ 约 45-60 分钟

🎯 本章学习目标

  • 掌握 try/catch/throw 的完整用法,理解栈展开与异常安全
  • 独立组织一个三文件(.h + 两个 .cpp)项目,独立排查 undefined reference
  • 看懂 include guard 的工作原理与头文件纪律
  • 获得一份可执行的后续学习路线(C++11/14 → 17/20 → 工程能力 → 方向选择)
已学 0 / 3 个知识点
🛡️

43. 异常处理 try / catch

throw / catch栈展开零成本异常
▼
🗣 【说人话】异常是什么:程序出事时扔出的「求救信号球」。throw = 扔球(附一句错误说明);try{} = 划定守场范围;catch = 接球手,按球的种类分工接住。球被接住后,程序就不用崩溃,可以善后继续。球自带的说明文本用 e.what() 读取。
#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 信号球
5 参数错误: 除数不能为零

📊 图解:栈展开——球一路向上飞,沿途自动打扫

main → a() → b() → c() 中扔球,谁接谁善后
调用栈(叠盘子): │ main() │ ← 有 try/catch 🧤 │ a() │ │ b() │ │ c() 💥 │ ← throw 炸弹在这里爆炸 c() 没有 catch → 球飞向 b(),c() 的栈帧立即撤走(局部对象析构✅) b() 没有 catch → 球飞向 a(),b() 的栈帧撤走(析构✅) a() 没有 catch → 球飞向 main() main() 的 catch 接住!→ a() 的栈帧也撤干净了 → 程序继续活着 若一路上没有任何接球手 → std::terminate → 程序崩溃退出 「沿途每个栈帧的局部对象都会被析构」——这就是 RAII 能在异常路径上 保证资源不泄漏的机制根源(第六章第 30 节的伏笔在此收回)
🔬 深入一层:零成本异常模型——C++ 采用「不扔球时零开销,扔球时代价昂贵」的实现:正常路径没有额外指令(对比错误码方案每次都要 if 判断);异常路径要走栈展开、查处理表,慢 2-3 个数量级。因此工程界分裂为异常派与错误码派(Google C++ 规范等性能敏感域禁用异常,C++23 引入 std::expected 给错误码派发了新武器)。初学建议:会接标准库的球即可,自己何时扔看团队规范。
🐞 catch 顺序反了 = 永远接不到具体异常
try { ... } catch (const std::exception& e) { /* 兜底在前 */ } catch (const std::invalid_argument& e) { /* ❌ 编译警告/错误:永远到不了这里 */ }
  1. catch 按声明顺序匹配,父类 std::exception 在前会把所有球提前接走(invalid_argument 也是它的子类)
  2. 规矩:具体在前、父类兜底在后;一律用 const 引用接(省拷贝、不切掉子类信息)
  3. 错误输出走 std::cerr(不缓冲、立刻上屏——第一章的伏笔)
记忆:接球手站位——细网在前,粗网在后。
📜 历史注解:C++ 异常史是「推倒重来」的活教材:C++98 设计了异常规范(void f() throw(std::bad_alloc) 声明函数会扔什么球),实践证明既难写对又损害优化——C++11 把它废弃、C++17 彻底删除,只留简化的 noexcept(承诺「我不扔球」)。
💡 本节要点:① throw 扔球、try 守场、catch 接球善后;② 栈展开沿途自动析构——RAII 的机制根基;③ catch 顺序:具体在前、父类兜底在后,一律 const 引用;④ 零成本模型:不扔零开销、扔了很贵;⑤ e.what() 读说明、cerr 输出错误。
📁

44. 头文件与多文件项目

.h / .cppinclude guardundefined reference
▼
🗣 【说人话】为什么拆文件:项目一大,全塞一个文件没法维护。标准做法:.h 头文件放「说明书」(声明:有什么),.cpp 文件放「实现」(怎么做)。使用者只需把说明书抄进自己的文件(#include),就能调用——至于具体怎么做,交给最后装配时拼接。
// ─── 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
Alice

📊 图解:多文件编译链接全景图

每个 .cpp 独立编译成零件,链接器最后总装
📝 main.cpp
#include "student.h"
→ 抄入说明书
独立编译 →
main.o
符号表:提供 main
需要 Student::Student
📝 student.cpp
#include "student.h"
独立编译 →
student.o
提供 Student::Student
(真身在此)
🔗 链接器总装
拿两份符号表配对:
main 需要的 ← student 提供
+ 标准库零件 → app ✅
配不上对?
undefined reference ❌
改了 student.cpp 只需重编它一个零件再总装——独立编译模型在大项目里省下的时间是 1970 年代至今的立身之本
🐞 两大经典报错的病理分析
① undefined reference to `Student::getName() const' ② undefined reference to `vtable for Student'
  1. ①最常见原因:忘了把 student.cpp 加进编译命令(g++ main.cpp -o app ❌)——零件没上流水线
  2. ①其他原因:函数只声明没实现;实现文件里的函数签名与声明不一致(少写 const 是高频)
  3. ②通常 = 类里声明了虚函数但析构/某个虚函数没实现——vtable 生成失败
  4. 修复通用套路:检查编译命令含所有 .cpp → 检查签名逐字一致 → 检查 .h 是否真的被 include
③ error: redefinition of 'Student'
  1. 同一份说明书被抄了两遍、类定义撞车——include guard 封条就是防这个的
  2. 或:头文件里放了普通函数的定义/全局变量定义(见下方纪律表)
心法:undefined reference 是链接期错(零件缺失/没配对);redefinition 是编译期错(抄重了)。先分清报错属于哪个阶段,排查方向就对了。

头文件纪律表(背下来少踩一半坑)

内容能放 .h 吗原因
函数声明 / 类定义✅ 头文件的本职编译器需要「知情书」做类型检查
inline 函数 / 模板✅ 可以允许重复定义(每个 .o 一份,链接器择一)
普通函数定义❌ 禁止两个 .cpp 都抄到 → 重定义
全局变量定义❌ 禁止同上;跨文件共享要用 extern(进阶)
#include 别的 .h✅ 尽量少能前置声明(class Student;)就别 include,编译更快
📜 历史注解:为什么 C++ 非要头文件,Python 一个 import 全搞定?回到第 2 节:编译 main.cpp 时根本看不见 student.cpp,编译器需要「知道 Student 长什么样」才能检查类型、生成代码——这份「知情书」就是头文件。C++20 推出 import module 试图终结这套 1970 年代机制,但生态迁移尚需多年——现阶段头文件仍是必修。
💡 本节要点:① .h 放声明(说明书)、.cpp 放实现(真身);② include guard 防重复抄写;③ 编译命令必须包含所有 .cpp;④ undefined reference=链接期(零件没配对),redefinition=编译期(抄重了);⑤ 头文件禁放普通函数/全局变量定义;⑥ "" 找当前目录、<> 找系统目录。
🗺️

45. 学习路线图

进阶方向书籍与工具
▼

掌握以上 45 个知识点,你已具备 C++ 的完整地基。下面这张地图回答「接下来去哪」:

📊 全景路线图

从「会语法」到「能干活」的四段旅程
📍 你在这里
45 知识点已通关
(本教程)
→
① 现代 C++ 必修
移动语义 · 右值引用
weak_ptr · 完美转发
→
② C++17/20 新特性
结构化绑定 · 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";
}
// 如果你能逐行讲出每个语法点出自哪一章——恭喜毕业 🎓
总字符数: 7

书籍与工具装备清单

阶段推荐定位
打地基《C++ Primer》(5th)百科全书式系统学习,配本教程查漏补缺
学惯用法《Effective C++》55 条军规,每条都是工程经验
进入现代 C++《Effective Modern C++》右值/移动/智能指针的正确姿势
理解底层《STL 源码剖析》看穿标准库的实现艺术
在线利器godbolt.orgCompiler Explorer:在线看代码对应的汇编
权威速查cppreference.com最权威的标准库手册,没有之一
📜 历史注解(收尾):回望这条时间线,C++ 的每个「怪脾气」都不是凭空而来:数组退化是 1970 年代省内存的妥协,头文件是独立编译的遗产,引用为运算符重载而生,nullptr 修补 NULL 三十年的坑,智能指针是 auto_ptr 失败后的重生,STL 是一位数学家对 OOP 的「叛逆」。Bjarne 给这门语言定下过一条始终未变的原则:「你不为你不使用的东西付费」。理解历史、理解哲学,C++ 就不再是「难学的语言」,而是一台你能亲手调校每一颗齿轮的精密机器。祝编码愉快。🎯
💡 本节要点:① 下一站:移动语义(现代 C++ 必修);② C++17/20 按需取用;③ 工程能力(CMake/gdb/单测/多线程)决定「能不能干活」;④ 书籍路线:Primer → Effective → 源码剖析;⑤ godbolt + cppreference 是两大在线神器。

📝 第九章 · 章末自测

8 道题——前 6 题考异常与多文件,最后 2 题带你总览全书。

🧰 APPENDIX · 附录

术语速查表

遇到看不懂的词,先来这里查一眼再回去继续学。支持实时搜索与分类筛选——比翻书快 10 倍。

📚 67 个词条 🗂️ 5 大分类 🔍 实时搜索
😢 没有匹配的词条——换个关键词试试?