C++ 课程阅读笔记

这页整理的是 ICS 45C Final Review 的课程笔记,适合按周复习语法、程序结构、测试和解题思路。下面这条路径可以带你回到 Notes、Course Notes、课程主页,或定位到当前笔记。将鼠标悬停在右侧的 ≡ 图标上可以打开目录。

最后编辑于 2026 年 6 月 30 日

ICS 45C — C++ 编程 · 期末复习笔记

课程: ICS 45C(课程编号 35770),Prof. Raymond Klefstad · 覆盖范围: 第 1–10 周(整门课程) 编写依据: 109 道题的 《学习指南》(即覆盖范围清单)+ 全部每周课件(C Tour、Arrays、Classes、Strings、Dynamic Arrays、Linked Lists、Topics 6–9)+ Topic 10 期末复习课件。

使用说明: 下面各节按官方《学习指南》的 13 个主题组依次排列。每个答案都写成可直接应试的形式:一个精炼的定义、关键要点、Klefstad 幻灯片中他本人的代码、常见陷阱(⚠️)以及有助于理解之处的 Python 对比(🐍)。《学习指南》的题号会以 (Q34) 这样的形式标出,方便你回查对照。最有可能出现在考试中的内容用 💡 标记。


考试须知与策略(出自 Topic 10 课件)

  • 在考试开始之前仔细阅读题目说明。 知道你的指定座位(考前不久会发布到 EdStem)。携带带照片的证件(也会使用带照片的点名册)。
  • 手机完全收起来——把手机拿出来 = 零分 / “重罪级作弊”。不要佩戴遮挡眼睛的帽檐、连帽衫或太阳镜。
  • 在答题卡上涂黑你的考试版本(Exam Version),并写上你的姓名 + UCI NetID。
  • 应试策略: 仔细阅读每道题 → 再读一遍 → 把试卷册当作草稿纸用 → 作答 → 立刻把答案涂到答题卡上 → 相信你学过的东西,别把自己反复猜成错误答案 → 如果还有时间,最后再检查一遍。
  • 期末考试中可能会有代码评审(Code Review)。 教授明确说明了这一点(见文末的代码评审附录)。

目录

  1. C++ 程序与编程
  2. 变量与表达式
  3. 内存区域、对象生命周期与作用域
  4. 数组、字符串与链表
  5. 函数与参数
  6. 类与对象
  7. 继承与多态
  8. 模板
  9. lambda
  10. 异常
  11. 命名空间
  12. I/O 流、文件与 stringstream
  13. STL 容器、迭代器与算法

1. C++ 程序与编程 (Q1–8)

头文件(header file,.hpp)与源文件(source file,.cpp) — (Q1)

  • .hpp(头文件)= 接口。 它声明一个模块提供了什么:类声明、函数原型(prototype)、constexpr 常量。头文件被其他文件 #include。
  • .cpp(源文件)= 实现。 它定义每个函数/方法如何工作。.cpp 文件会被编译;它们 #include 自己所实现的 .hpp。
  • 把一个模块拆分成 .hpp + .cpp,可以让你把 #include 依赖下沉到 .cpp 中,这样能减少每次构建时需要重新编译的量(构建工具——make/cmake——会跟踪依赖关系)。

⚠️ 课程规则: 绝不要在 .hpp 头文件里写 using namespace std;。它会泄漏到每个包含该头文件的文件里。(在 .cpp 内部,本课程的幻灯片可以容忍它,但头文件必须保持干净。)

预处理器指令(preprocessor directives) — (Q2)

以 # 开头的行,由预处理器(preprocessor)处理(编译的第一步,在编译器之前):

  • #include <file> — 以文本方式粘贴进声明(例如,#include <iostream> 引入 I/O 声明)。
  • #define NAME value — 定义一个宏(macro)(文本替换)。
  • #ifdef / #ifndef … #endif — 条件编译(仅当某个名字被定义/未被定义时才编译这个代码块)。

深入条件编译 — #ifdef / #ifndef / #if / #else / #elif / #endif 💡

条件编译(conditional compilation)让预处理器在编译器看到代码之前就保留或删掉一整块代码,依据是某个宏是否被定义(或者一个编译期常量)。被跳过的代码根本不会被编译——就好像你把它删掉了一样。

指令 在什么情况下保留该代码块
#ifdef NAME NAME 已被 #define
#ifndef NAME NAME 未被定义
#if expr 常量整数表达式 expr 非零(可以用 defined(NAME))
#elif expr 前面的判断都失败且 expr 非零(else-if)
#else 前面所有判断都失败
#endif 关闭该代码块——每个 #if/#ifdef/#ifndef 都必须有一个配对的 #endif
#undef NAME (不是判断)移除一个已有的定义

这个名字从哪来? 要么来自代码里的 #define,要么来自编译器选项:g++ -DDEBUG ... 会定义 DEBUG;-DLEVEL=2 则把它定义为一个值。(本课程的构建就是用 -DNDEBUG 来关掉 assert 的。)

// 1) 包含守卫 —— 最常见的用法(见下面的 Q4)
#ifndef COMPLEX_HPP
#define COMPLEX_HPP
// ... 头文件主体:每个翻译单元最多编译一次 ...
#endif

// 2) 平台相关代码(预处理后只有一个分支会留下)
#ifdef _WIN32
    constexpr char SEP = '\\';     // Windows
#elif defined(__APPLE__)
    constexpr char SEP = '/';      // macOS
#else
    constexpr char SEP = '/';      // Linux / 其它
#endif

// 3) 仅调试时编译的代码,用 g++ -DDEBUG 打开
#ifdef DEBUG
    std::cerr << "x = " << x << '\n';   // 只存在于 debug 构建中
#endif

// 4) 判断一个“值”,而不只是“是否被定义”
#if LEVEL >= 2
    verbose_logging();
#endif

⚠️ 陷阱: #ifdef X 只问“X 存在吗?”——要根据它的值来分支,得用 #if X == 1。每个 #if/#ifdef/#ifndef 都必须有配对的 #endif(漏写会得到一个让人摸不着头脑的 “unterminated #if” 错误)。这些名字没有类型、也没有作用域(它们只是预处理器的纯文本)——这正是为什么现代 C++ 把预处理器留给包含守卫以及平台/构建配置,而把普通逻辑交给 constexpr + if constexpr。
🐍 Python 没有预处理器。最接近的近亲——if __debug__: 或 if sys.platform == "win32":——都在运行期执行,而且没被选中的分支照样会被编译;#ifdef 则是在编译之前就把代码移除。

宏,以及该用什么来代替它 — (Q3)

宏(macro)是一种预处理器文本替换(#define MAX 1024)。预处理器会在编译器运行之前盲目地替换文本,所以宏没有类型检查、也没有作用域(scope)。

  • 💡 在现代 C++ 中,用以下两样东西来代替宏:
    1. 用 constexpr(或 const)变量代替 #define 常量 → 有类型、有作用域、可调试。
    2. 用 inline 函数 / 函数模板(function template)代替类函数式的宏 → 经过类型检查,不会出现参数被意外重复求值的情况。

包含守卫(include guards) — (Q4)

防止同一个翻译单元中重复多次包含某个头文件(那会导致 “redefinition”(重复定义)错误)。

// Complex.hpp
#ifndef COMPLEX_HPP        // 如果尚未定义……
#define COMPLEX_HPP        // ……就定义它,然后……
class Complex { /* ... */ };   // ……只包含这个类体一次
#endif

(现代替代方案:在头文件顶部写 #pragma once。)

增量式开发与一次性(大爆炸式)开发 — (Q5)

增量式开发(incremental development) = 写一小块,测试它,然后再添加下一块。一次性(大爆炸式)开发(big-bang) = 把所有东西全部写完,然后再试着运行。

  • 增量式更胜一筹,因为 bug 被隔离在你刚添加的那一小块里,所以很容易找到。而用一次性开发时,来自许多地方的 bug 互相纠缠,“太难找了”。
  • Klefstad:“我只想先让它能跑起来” / “我之后再清理” 会导致那些太难找的错误。现在就清理干净。 如果一个函数超过 5 行,就重构它。测试每一小块。

编写与处理测试用例 — (Q6)

在你编写每个函数的同时就编写测试(测试驱动开发,Test-Driven Development),而不是事后再写。本课程针对一个方法的 TDD 循环(出自 String 那套幻灯片):

  1. 挑选一个公有方法。2. 写一个常规测试用例(nominal test case)。3. 识别/构想出一个辅助函数(helper)。4. 只使用辅助函数来编写该方法。5. 编写这些辅助函数。6. 编写并运行一个辅助函数测试器(helper tester)(在 student_gtests.cpp 中)。7. 为该方法运行常规测试器。8. 复查:它能不能更干净、命名更好、或者使用另一个辅助函数?然后再添加边界条件(boundary-condition)测试。
  • 使用 Google Test:EXPECT_EQ、EXPECT_NE。测试往往占了一半以上的工作量。

面向对象与过程式/函数式 — (Q7)

  • 过程式/函数式: 程序围绕函数来组织,这些函数对被动的数据进行操作。
  • 面向对象: 程序围绕对象来组织,这些对象把数据 + 作用于这些数据的函数(方法)捆绑在一起,并对构造、拷贝和析构加以控制,还能够使用继承(inheritance)和多态(polymorphism)。只有对象自己的方法才能触碰它的 private 部分。

魔法数字(magic numbers) — (Q8)

魔法数字(magic number)是代码中未加解释的字面量(1024、0.40)。它们损害可读性,而且修改起来容易出错。

  • 💡 修正方法: 用 constexpr 常量给它们起个名字。
constexpr int BUF_SIZE = 1024;          // 而不是  char buf[1024];
constexpr double QUIZ_WEIGHT = 0.40;    // 而不是  grade = quiz * 0.40;
return PI * r * r;                      // 而不是  return 3.14159 * r * r;

2. 变量与表达式 (Q9–23)

声明与定义(变量) — (Q9)

  • 声明(declaration)引入一个名字及其类型(“这个名字存在,这是它的类型”)。
  • 定义(definition)还会分配存储空间/提供函数体。
  • 对于一个普通的局部变量,int x; 既是声明也是定义。一个不是定义的声明要用 extern:
extern int g;   // 仅声明(g 在某个其它文件中定义)
int g = 5;      // 定义(分配存储空间,这里是在本文件中)

运算符优先级与加括号 — (Q10)

运算符按照固定的优先级顺序结合;括号可以覆盖它。从高到低(取自幻灯片的子集):++ -- (unary) + - (unary) → * / % → + - (binary) → = += -= *= /= %=。关键的访问运算符:解引用 *、下标 []、取地址 &、成员箭头 ->。

int j = 8 - k * i / 2 + k * 2;   // == 8 - ((k*i)/2) + (k*2)
*p->next      // 表示 *(p->next)   ( -> 比 * 结合得更紧 )
*a[i]         // 表示 *(a[i])      ( [] 比 * 结合得更紧 )
&a[0]         // 表示 &(a[0])

⚠️ 考试时如果拿不准,就显式地加上括号并逐步追踪。

operator precedence——值得背下来的那张表

承接 (Q10):那里的三个指针追踪例子只是简单的部分。考试里真正让人翻车的,是那些看上去毫不相关的运算符之间的相对顺序(<< 对 ?:、== 对 &)。把下面这张表从上到下背熟;这就足以解析 Klefstad 期末里出的任何表达式。

优先级表——从高到低(同一档内优先级相等,由结合性决定先后):

档位 运算符 结合性 读作
1(最高) () [] -> . 后缀 ++ -- 左结合(左→右) 调用/下标/成员/后置自增
2 前缀 ++ -- 一元 + - ! ~ 解引用 * 取地址 & (type) sizeof 右结合(右→左) 所有一元运算符
3 * / % 左结合(左→右) 乘除取模
4 + -(二元) 左结合(左→右) 加减
5 << >> 左结合(左→右) 移位/流插入-提取
6 < <= > >= 左结合(左→右) 关系
7 == != 左结合(左→右) 相等
8 & 左结合(左→右) 按位 AND
9 ^ 左结合(左→右) 按位 XOR
10 | 左结合(左→右) 按位 OR
11 && 左结合(左→右) 逻辑 AND
12 || 左结合(左→右) 逻辑 OR
13 ?: 右结合(右→左) 条件运算符(三元运算符)
14 = += -= *= /= %= <<= …… 右结合(右→左) 赋值
15(最低) , 左结合(左→右) 逗号

💡 你真正会用到的两类右结合档位是:一元档(2)、条件运算符 ?:(13)和赋值(14)。其余全部是左结合。

每学期都会出现的陷阱——各配一行考试风格的代码:

(a)⚠️ <<(第 5 档)高于 ?:(第 13 档)。所以流插入中的三元表达式必须加括号:

cout << a ? b : c;     // 解析为 (cout << a) ? b : c —— b/c 被丢弃;-Werror 会拒绝它
cout << (a ? b : c);   // 正确:插入 b 或 c

(b)⚠️ ==(第 7 档)高于按位 &(第 8 档)。经典的位测试 bug:

if (x & 1 == 0) { ... }    // 解析为  x & (1 == 0)  ==  x & 0  ==  0  —— 永远为 false!
if ((x & 1) == 0) { ... }  // 正确:测试最低位

(c)⚠️ ?:(第 13 档)高于 =(第 14 档),所以赋值结合得最松:

int m = a > b ? a : b;     // 先算 (a > b ? a : b),再赋值 —— 这没问题
m = cond ? x : y = 0;      // 解析为  m = (cond ? x : (y = 0))  —— 右结合,出人意料

(d)指针/访问链——前缀 *(第 2 档)低于 -> [](第 1 档),所以先做访问(延续 Q10 的例子):

*p->next      // == *(p->next)     先 ->,再解引用
*a[i]         // == *(a[i])        先下标,再解引用
&a[0]         // == &(a[0])        先下标,再取地址
++*p          // == ++(*p)         先解引用,再对所指对象前置自增
*p++          // == *(p++)         后置自增的是“指针”,解引用的是“旧指针”

(e)⚠️🐍 求值顺序:i = i++ + ++i; 在没有顺序点的情况下多次读写 i——这是未定义行为(undefined behavior)。优先级只告诉你它如何解析,不告诉你副作用的发生顺序。🐍 Python 按从左到右求值、语义明确,而且根本没有 ++/--;在 C++ 里这一行根本不被允许——绝对不要写。

手动追踪(永远先改写成完全加括号的形式,再求值):

// 1)  混合算术,给定 k = 3, i = 4
int j = 8 - k * i / 2 + k * 2;
//   第 1 步(第 3 档,左→右):8 - ((k * i) / 2) + (k * 2)
//   第 2 步代入:             8 - ((3 * 4) / 2) + (3 * 2) = 8 - 6 + 6
//   第 3 步(第 4 档,左→右):(8 - 6) + 6 = 8

// 2)  位运算 + 相等,给定 x = 6
bool even = x & 1 == 0;
//   == x & (1 == 0)   (第 7 档先于第 8 档)
//   == 6 & 0  == 0  -> false   (意图错了!)   修正:(x & 1) == 0  -> true

// 3)  逻辑 vs 关系 vs 移位
if (a < b == c > d) { ... }
//   == ((a < b) == (c > d))    (第 6 档先于第 7 档)—— 比较两个 bool
cout << x + 1 << " " << (n > 0 ? "+" : "-");
//   == (((cout << (x + 1)) << " ") << (n > 0 ? "+" : "-"))   —— 注意 ?: 上必须加的括号

💡 应试技巧: 你不必在压力下把整张表背得分毫不差——拿不准时,就显式加上括号并手动 trace 加括号后的形式。“正确且一目了然”胜过“聪明却写错”,而且 -Werror 不会因为你多写了括号而报错。

左值与右值 — (Q11)

  • 左值(l-value) = 具有身份/地址、可以出现在 = 左边的东西(一个有名字、可寻址的对象)。例如:变量 x、数组元素 v[i]、*p。
  • 右值(r-value) = 没有持久地址的临时值/值(只能在右边)。例如:字面量 10、a + b 的结果、按值返回的临时对象。
int x = 10;   // x 是左值,10 是右值
x = 5;        // OK:左值在左边
5 = x;        // ERROR:5 是右值
v[i] = v[i-1]*2;   // v[i] 是左值;v[i-1]*2 是右值

这一点对于返回 int&(一个左值,因此 v[0]=1 才能工作)的 operator[],以及移动语义所使用的右值引用 &&(第 6 节)都很重要。

引用参数 — (Q12)

用 & 声明的参数。函数接收的是实际对象本身(一个别名),而不是副本,因此它可以修改调用方的变量。

void increment(int & x) { x = x + 1; }
int z = 10; increment(z); increment(z);   // z 现在是 12

引用返回类型 — (Q13)

函数返回 T&——一个对已存在对象的引用,因此该调用可以被当作左值使用(你可以向它赋值)。经典用法:operator[]。

int & operator[](int index) { return buf[index]; }  // 返回对该元素的引用
v[0] = 1;   // 可行:v[0] 是一个左值

⚠️ 永远不要返回对局部变量的引用——它会在函数返回时消亡(悬空引用)。

引用变量 — (Q14)

对一个已存在对象的局部别名,在构造时就固定下来(它永远无法被“重新绑定”去引用别的东西)。

int i = 10;
int & r = i;   // r 是 i 的另一个名字
r = 70;        // 把 i 改为 70

引用与指针 — (Q15) 💡

引用 int& r 指针 int* p
持有 对某个对象的别名 一个地址
必须初始化吗? 是,在声明处 否(但未初始化 = 危险)
能重新绑定吗? 否 能(p = &other;)
能为空吗? 否 能(nullptr)
使用目标的语法 直接用 r *p(解引用)、p->m
int i = 10;
int * p = &i;   // 指针:& 给出 i 的地址
*p = 60;        // 间接修改 i
int & j = i;    // 引用:只设置一次,是 i 的别名
j = 70;         // 间接修改 i

🐍 Python 中的名字是指向对象的引用(最接近 C++ 的引用),但 Python 中没有任何类似裸指针或 */& 区分的东西。

⚠️ 在一行里声明多个指针——* 绑定到变量,而不是类型。

int* p1, p2, p3;     // 只有 p1 是指针;p2 和 p3 是普通的 int!
                     // (* 附着在声明符上,而不是附着在 'int' 上)
int x = 0;
p2 = &x;             // 错误:invalid conversion from 'int*' to 'int'(p2 是 int)

int *q1, *q2, *q3;   // 修复:给每个名字都加 *(最清楚的做法是一行只声明一个)

💡 对比 int q1, q2, q3;——那里 int 作用于三个变量,所以三个都是 int。只有 *(和 &)才会出这个意外:它们绑定到每一个单独的名字,而不是绑定到类型。

const 关键字 — (Q16)

const 意味着“不能被修改”。用于变量、参数(防止意外修改 + 启用 const& 的效率优势),以及成员函数(承诺不修改 *this)。

const int i = 10;            // i 不能被重新赋值
void print(const Coins & c)  // c 在 print 内部不能被修改;const& 避免了一次拷贝
{ c.quarters = 0; }          // ERROR 被编译器捕获

constexpr — (Q17)

constexpr = 一个可在编译期计算出来的值(或函数)。用它来表示真正的编译期常量和数组大小。

constexpr double PI = 3.1415926536;
constexpr int len = 5;
int a[len] = {0};   // OK:len 是一个编译期常量

constexpr 与 const (Q17 / 期末复习幻灯片): const = “初始化之后不会改变”(可能是一个运行期的值);constexpr = “在编译期就已知”(更强;可用于要求编译期常量的地方,比如栈上数组的边界)。

常量指针与指向常量的指针 — (Q18) 💡(非常常见的陷阱)

围绕 * 从右往左读:

int * const p = &i;        // 常量指针:p 不能改变,*p 可以
                           //   p = &j;   ERROR     *p = 5;  OK
const int * p = &i;        // 指向常量的指针:p 可以改变,*p 不能
                           //   p = &j;   OK        *p = 5;  ERROR
const int * const p = &i;  // 两者都固定:p 不能改变,*p 也不能改变

const_cast — (Q19)

从某个类型上移除(或添加)const/volatile。仅当你必须把一个逻辑上可修改的对象,通过一个错误地接受/返回 const 的 API 传递时才需要它。罕见,而且是一种代码坏味道——修改一个真正为 const 的对象属于未定义行为。

const int * cp = &x;
int * p = const_cast<int*>(cp);   // 去掉 const(仅当你知道 x 其实并非真正 const 时使用)

static_cast — (Q20) 💡

在相关类型之间进行的、正常的、在编译期检查的转换(数值转换、void*→T*、在继承层次中向上/向下转换而不做运行期检查)。

int i = c;                                // char -> int(隐式,扩宽)
char c = static_cast<char>(i);            // int -> char(收窄,必须显式)
int x = static_cast<int>(std::round(d));  // double -> int 并四舍五入

⚠️ 课程的构建使用 -Werror。 隐式的收窄转换(例如 double → int、int → char)会变成错误,因此你必须用 static_cast 把它们显式化。

The four C++ casts compared — static_cast / const_cast / dynamic_cast / reinterpret_cast 💡

C++ 用四种命名 cast取代了 C 中一刀切的 C 风格强制转换(C-style cast),每一种各司其职。选对 cast 既能表达意图,又能让编译器(compiler)拒绝误用。本节把 const_cast(Q19)和 static_cast(Q20)汇总到一起,并补上你还没见过的另外两种。

Cast 作用 何时检查? 典型场景 安全性
static_cast<T> 在相关类型间转换 编译期(无运行期检查) 数值转换、void*→T*、继承体系里 up/down 只要关系真实就安全;错误的向下转型(downcast)是未定义行为(undefined behavior)
const_cast<T> 加/去掉 const / volatile 编译期 强行把值传过一个错误地标了 const 的 API 去掉 const 后写入一个真正 const 的对象属于未定义行为——代码异味
dynamic_cast<T> 在多态类型(polymorphic type)体系里做带检查的向下转型 运行期(使用 RTTI) 安全地 Base* → Derived* 安全:会在运行期告诉你这次转换是否有效
reinterpret_cast<T> 重新解释原始比特位 编译期(无真正检查) 指针 ↔ 整数、底层/memcpy 风格代码 危险,本课程中几乎从不正确

static_cast — (Q20) 💡 相关类型间最常用的转换:数值转换、void*→T*,以及继承体系里 up/down 且无运行期检查。

char c  = static_cast<char>(i);            // int -> char(窄化,必须显式)
int  x  = static_cast<int>(std::round(d)); // double -> int 并取整
Base* b = static_cast<Base*>(derivedPtr);  // 向上转型:永远安全(Derived is-a Base)

⚠️ 本课程构建使用 -Werror。 隐式窄化转换(double → int、int → char)会变成错误,所以你必须用 static_cast 显式转换。static_cast 的向下转型能毫无怨言地通过编译,却不做任何运行期检查——如果对象其实并不是那个派生类型,你就会得到未定义行为。

const_cast — (Q19) 唯一能加上或去掉 const/volatile 的 cast。仅当你必须把一个逻辑上可修改的对象传过一个错误地接收 const 的 API 时才需要它。罕见且属于代码异味;通过指针写入一个真正 const 的对象是未定义行为。

const int* cp = &x;
int* p = const_cast<int*>(cp);   // 去掉 const(仅当你确知 x 并非真正 const 时)

dynamic_cast — 带检查的向下转型 💡 只能用于多态类型(polymorphic type)——基类必须至少有一个虚函数(在我们的体系里,虚析构函数 / draw() 已使 Shape 成为多态类型,见第 7 节)。用它在运行期安全地把 Base* 转成 Derived*:

  • 指针版:如果对象实际并非那个派生类型,返回 nullptr。
  • 引用版:无法返回空,所以失败时抛出 std::bad_cast。
  • 向上转型(upcast)(Derived* → Base*)本来就是隐式的——根本不需要 cast。
Shape* shapePtr = pic.at(i);                       // 静态类型 Shape*,动态类型未知
if (Circle* cp = dynamic_cast<Circle*>(shapePtr)) {
    cp->setRadius(5);            // 安全:仅当 shapePtr 真的指向 Circle 时才会进入
}                               // 不是 Circle -> dynamic_cast 得到 nullptr,跳过该分支

Shape& s = *shapePtr;
Circle& cr = dynamic_cast<Circle&>(s);  // 引用形式:若不是 Circle 则抛出 std::bad_cast

💡 如果你发现自己频繁动用 dynamic_cast,不如改用虚函数(virtual function)——让每个 Shape 通过动态分派来回答(第 7 节的多态要点),而不是去做类型判断。

reinterpret_cast — 原始比特位重新解释 ⚠️ 告诉编译器把这些比特当作另一个、互不相关的类型来看待(例如指针 ↔ 整数)。它不对值或表示做任何转换。本课程中几乎从不恰当,仅留给底层系统代码。

std::uintptr_t addr = reinterpret_cast<std::uintptr_t>(ptr); // 指针 -> 整数(罕见、底层)

⚠️ 绝不要用 C 风格强制转换(C-style cast)(T)x。 它会按“尽力而为”的顺序静默地依次尝试 static_cast、const_cast、reinterpret_cast(或它们的组合)——所以一个笔误就可能悄悄把你本想做的数值转换变成比特位重新解释,且没有任何诊断信息。四种命名 cast 表明了你的意图,会被恰当地检查,而且可被搜索(你可以在代码库里搜 reinterpret_cast 来审计危险代码)。请始终使用它们。

🐍 Python 没有编译期类型可供转换:int(x) / str(x) 是运行期转换,会构造新对象,而 isinstance(obj, Circle) 大致相当于一次 dynamic_cast 检查。它没有类似 const_cast(没有 const)或 reinterpret_cast(没有原始内存重新解释)的东西——这些只因 C++ 暴露了静态类型与内存才存在。

auto — (Q21)

auto 让编译器从变量的初始化表达式中推导出它的类型。对于冗长的迭代器类型和 lambda 非常好用。

auto fn = [](int i){ return i*i; };                 // 一个 lambda
for (auto x : {1,2,3,4,5}) cout << x << endl;        // x 被推导为 int
for (auto it = V.begin(); it != V.end(); ++it) ...   // 无需写出迭代器类型的全称

有符号整数与无符号整数 — (Q22)

  • 有符号(signed)可以保存负值和正值(实际上有一个比特用来编码符号)。
  • 无符号(unsigned)只保存非负值,使正值范围翻倍;它在下溢/上溢时会回绕(wrap around)。
  • ⚠️ 经典 bug:v.size() 是无符号的。for (int i = 0; i < v.size(); ++i) 混用了有符号/无符号;当 size()==0 时 v.size() - 1 会回绕成一个巨大的数。

示例——看它如何回绕:

unsigned u = 0;
u = u - 1;               // 4294967295(UINT_MAX,32 位)—— 无符号会回绕;它不是 -1

vector<int> v;           // 空容器;v.size() 的类型是 size_t(无符号)
auto n = v.size() - 1;   // 18446744073709551615(64 位 size_t 回绕!)—— 不是 -1

for (int i = 0; i < v.size(); ++i) { /* ... */ }   // int 与 size_t → -Wsign-compare(在课程的 -Werror 下会变成错误)

修复方式:

for (size_t i = 0; i < v.size(); ++i) { /* ... */ }   // A)用匹配的无符号类型
for (int x : v) { /* ... */ }                          // B)基于范围的 for —— 没有下标,就没有符号问题(推荐)
if (!v.empty())                                        // C)在对无符号的 size 做减法之前先判空
    for (size_t i = 0; i + 1 < v.size(); ++i) { /* ... */ }   // 用 +1 来比较;绝不要对空容器算 size()-1

💡 用 size_t 给容器做下标(或者干脆用基于范围的 for),并且绝不要在没确认非空之前就写 container.size() - 1——对空容器它会下溢成约 1.8×10¹⁹ 那么大的数。

条件运算符(三元运算符)?: — (Q23)

condition ? valueIfTrue : valueIfFalse——一个产生值的表达式(不像 if,if 是一条语句)。

int factorial(int n) { return n == 0 ? 1 : n * factorial(n - 1); }
int max = a > b ? a : b;

🐍 Python 的 x if cond else y 是同样的思路(注意词序不同)。


3. 内存区域、对象生命周期与作用域 (Q24–32)

对象的生命周期 (Lifetime of an object) —— (Q24) 💡

Klefstad:“每个对象都有生命周期:出生、存活、死亡。”("Every object has a life-time: born, lives, dies.")

  • 出生 = 分配 + 构造(先预留内存,然后运行构造函数)。
  • 存活 = 对象可以使用。
  • 死亡 = 析构 + 释放(先运行析构函数,然后回收内存)。

    ⚠️ 术语: 应该说析构(destruction),而不是 “deconstruction”。教授专门强调了这一点。

具体何时发生取决于内存区域:栈(stack)对象从其声明处一直存活到所在 { } 块的末尾;堆(heap)对象从 new 一直存活到 delete;静态/全局(static/global)对象在整个程序运行期间都存活。

未初始化基本类型的默认值 —— (Q25)

  • 你没有初始化的局部(栈)基本类型变量持有的是垃圾值(无论那里原本是什么比特)。int foo; // what happens here? — undefined value
  • 静态/全局基本类型变量默认会被零初始化。

按值传递 vs 按引用传递 (Pass-by-value vs pass-by-reference) —— (Q26,另见 Q40)

  • 按值传递(pass by value,又称 copy/value): 函数得到它自己的副本(对类类型使用拷贝构造函数构建);修改不会影响调用者。Klefstad 的类比:把 HW1 的 PDF 邮件发给你 —— 你拥有自己的一份副本。
  • 按引用传递(&): 函数得到调用者对象的别名;修改对调用者可见,且不会产生副本。类比:分享一个 Google Doc 的链接。
  • 按引用传递的两个理由: (1) 为了修改实际参数,以及 (2) 效率 —— 避免拷贝一个大对象。const & 是“既要效率又不修改”的惯用写法。

静态变量 vs 栈变量(都是函数的局部变量) —— (Q27)

两者都声明在函数内部,但是:

  • 栈局部变量在每次进入函数时创建,在函数返回时销毁(每次调用都是全新的)。
  • static 局部变量位于静态存储区:它只创建一次,在多次调用之间保留其值,并在整个程序运行期间都存活。
void f() {
    int s = 0;          // 每次调用都重置为 0
    static int t = 0;   // 只初始化一次;跨调用记住其值
    ++s; ++t;
}

栈变量 vs 堆变量 —— (Q28)

  • 栈: 自动管理(在声明处创建,在块末尾销毁)。大小必须是固定的;速度快。
  • 堆(自由存储区,free store): 用 new/delete 显式管理;一直存活到你 delete 它为止;大小可以在运行时决定。你负责释放它(否则就会泄漏)。

用于动态内存的 new / delete —— (Q29)

int * p = new int{42};   // 在堆上分配一个 int,用 42 构造
delete p;                // 析构 + 释放那一个对象

new 预留堆内存并构造对象;delete 析构并释放它。

new[] vs new(以及与之匹配的 delete) —— (Q30) 💡

int * a = new int[size];   // 分配一个由 'size' 个 int 组成的数组(size 可以是运行时的值)
delete[] a;                // 对数组必须使用 delete[]

int * p = new int{42};     // 分配单个 int
delete p;                  // 对单个对象必须使用普通的 delete

⚠️ new[] 与 delete[] 配对;new 与 delete 配对。 把它们混用是未定义行为。原因在于:数组分配会记录存在多少个元素,以便能为每个元素都运行析构函数;而 delete(不带方括号)只处理一个对象。

局部变量 vs 全局变量 —— (Q31)

  • 局部变量: 声明在某个块/函数内部;只在那里可见;位于栈上(如果标记为 static 则位于静态存储区)。
  • 全局变量: 声明在所有函数之外;在整个文件内可见(通过 extern 也可被其他文件可见);位于静态存储区,在整个程序运行期间都存活。不鼓励使用全局变量(隐式耦合)。

内存泄漏 (Memory leak) —— (Q32)

从未被 delete 且你已经丢失了指向它的最后一个指针的堆存储 —— “由于缺少 delete 而永远丢失的存储”。原因:

  • 忘记 delete/delete[]。
  • 在删除堆对象之前覆盖了指向它的唯一指针。
  • 一个在构造函数中分配内存、但没有(或有错误的)析构函数的类 → 每个对象都会泄漏(三法则,第 6 节)。

⚠️ 必须记住的指针陷阱(Klefstad 的 “Pointer Caveats”):

  • 未初始化指针 → int *p; *p = 0; ⇒ 段违例(Segmentation Violation / SIGSEGV / “seg fault”,段错误)。
  • 悬空引用(dangling reference) → 指向一个已死对象的指针;使用它是一个致命的、极难调试的错误。
  • 内存泄漏 → 缺少 delete。
  • 重复删除(redundant delete) → 把同一块存储删除了两次。
  • 💡 箴言:把动态分配限制在类的实现里(让类的构造函数/析构函数来管理堆 —— RAII)。

4. 数组、字符串与链表 (Q33–38)

链表 vs 数组——权衡取舍 (Q33) 💡

数组(Array) 链表(Linked list)
存储 一整块连续的内存 用指针串起来的独立节点对象
索引 A[i] O(1)(2–3 条指令) O(n)——必须沿着链表逐个走
大小 分配时固定;扩容 = 分配更大的块 + 拷贝(开销大) 可自由增长/收缩
在中间插入/删除 开销大(需要移动元素) 在你已经持有指针的位置很廉价
额外开销 每个元素没有额外开销 每个节点多一个 next 指针
  • 两者“都应当只作为类的实现来使用”。链表的节点用指针串接;nullptr 标记末尾;空链表满足 head == nullptr。

浅拷贝 vs 深拷贝——(Q34) 💡(重点考查——见附录 D)

  • 浅拷贝(shallow copy): 只拷贝指针值 → 两个对象指向同一块堆数组。灾难:两个析构函数都会对同一块内存执行 delete[](双重释放(double-free)),而且通过其中一个所做的改动会影响另一个。
  • 深拷贝(deep copy): 分配一块新的堆数组并拷贝各元素 → 两个对象彼此独立。
// 为持有 'int* buf' 的类编写的深拷贝构造函数:
Vector(const Vector & v) : maxLen(v.maxLen), buf(new int[v.maxLen]) {
    for (int i = 0; i < v.maxLen; ++i) buf[i] = v.buf[i];   // 逐个拷贝每个元素
}

🐍 浅拷贝 ≈ b = a(两个名字 → 同一个 list 对象);深拷贝 ≈ copy.deepcopy(a)。在 C++ 中,默认(编译器生成)的拷贝是逐成员的,对指针成员而言就是浅拷贝,这正是你必须自己编写拷贝的原因(三法则)。

C 风格字符串 vs std::string——(Q35) 💡

C 风格字符串(C-string) std::string
它是什么 一个原始的 char 数组,以空字符结尾的 '\0' 一个自行管理缓冲区的类
长度 strlen() 扫描到 '\0'(O(n));'\0' 之后的字符是垃圾值 .size() 已被存储(O(1))
操作 strcpy、strcat、strlen、strcmp、手写循环 =、+、+=、==、[]、.size()、迭代器
安全性 不做边界检查;内存由你管理 替你管理内存/增长
char s[12] = "Hello World";   // 11 个字符 + 1 个 '\0' = 12
char s2[]  = "Hello World";   // 大小被推导出来(12)
char *s4   = "Hello World";   // 指向字符串字面量的指针(只读!)
std::string t = "hello";      // 类;t.size() 是 O(1)

⚠️ 通过 char* 写入字符串字面量(s4[0]='M';)会导致段错误——字面量是只读的。

声明 buf,让它指向一个 2*N 大小的 int 数组,存入 N,然后删除——(Q36)

这个确切的模式在考试中会出现:

int * buf = new int[2 * N];   // 栈上的局部指针 -> 堆上 2*N 个 int 的数组
buf[2*N - 1] = N;             // 把 N 存入最后一个元素(下标 2*N - 1)
delete[] buf;                 // 数组分配 -> delete[]

为什么不能直接写 int buf[2*N];? 普通的 C 风格栈数组要求大小是一个编译期常量。当 N 是运行期的值时,2*N 在程序运行前是未知的,所以 int buf[2*N]; 是一个变长数组(variable-length array, VLA)——这是某些编译器的非标准扩展,并不是 ISO C++。符合标准的构建会拒绝它:

int N = readSize();   // 运行期的值
int buf[2*N];         // ❌ 错误:"ISO C++ forbids variable length array 'buf'"(-pedantic / -Wvla)
                      //    (普通的 g++ 会作为扩展悄悄放行,但不可移植)

即便在能编译的地方,栈数组在这里也是错误的工具:它在所在代码块结束时就消亡(无法活过函数、也无法被返回),无法扩容,而且 N 很大时还会栈溢出。这里要用堆(new int[2*N]),正是因为堆可以在运行期确定大小,并且一直存活到你 delete[] 它为止。

💡 只有当 N 是编译期常量时,int buf[2*N]; 才合法:constexpr int N = 5; int buf[2*N];——一个固定大小的栈数组(见 Q17 / Q28)。一旦大小是运行期的值,就必须用 new[]。

对 C 数组的 A[i] 会做边界检查吗?——(Q37) 💡

不会。 低层次的 C 数组只是一个基地址;A[i] 计算的是 *(A + i),不会检查 i 是否在范围内。如果 i 越界,你就会读/写数组之外的内存 → 未定义行为:垃圾值、损坏的数据,或者段错误。(这正是我们要把数组封装进一个带有受检 operator[] 的类里的原因。)

int a[5] = {0};
a[-1] = 0;     // “会发生什么?”——写到了数组之外;未定义行为
a[5] = 99;     // 同样越界

C 数组元素的默认值——(Q38)

与 Q25 同样的规则:一个未初始化的局部 C 数组里装的是垃圾值。要清零,就显式初始化:

int counts[26] = {0};   // 全部 26 个元素都置为 0
int a[len] = {0};       // 零初始化

链表要点(你为 String/Picture 作业实现的那个单链表 SLL)

💡 本课程中强制规定的成员名:data 和 next。(“我们都必须使用 data 和 next,这样我们说的才是同一种语言。”)

struct Node { char data; Node * next; };   // 节点

// 遍历(一趟走完!):
for (Node * p = head; p != nullptr; p = p->next)
    cout << p->data << ' ';

// 加到表头(push):  head = new Node{ c, head };
// 删除表头(pop)——必须回收被删除的节点:
char pop() {
    Node * tmp = head;        // 保存要删除的节点
    char ret = tmp->data;
    head = head->next;        // 解除链接
    delete tmp;               // 回收  (不要写成:delete head; head = head->next;  -> 悬空!)
    return ret;
}
// 析构函数——用一个临时指针释放整条链:
~CharList() {
    for (Node * p = head; p != nullptr; ) {
        Node * tmp = p; p = p->next; delete tmp;
    }
}

⚠️ 只走一趟。 除非必须,否则不要把链表走两遍。不要做多余的拷贝。也绝不要先 delete p; 然后再 p = p->next;——那会读取已被释放的内存(悬空引用)。

RAII(Resource Acquisition Is Initialization,资源获取即初始化)——这一切背后的设计原则:把每一项资源(堆内存)封装进一个类,让它的构造函数获取资源,析构函数释放资源,拷贝做深拷贝,移动转移所有权。这是一种简单、系统的防止泄漏的方法。要始终自问的内存所有权问题:谁拥有这块堆内存?(例如,String/Picture 拥有它,而不是单个的 Node/Shape。)

递归(链表那套幻灯片把它和链表配在一起讲)💡

递归(Recursion)是一个调用自身的函数——在结构上类似归纳证明。Klefstad 的递归三定律:(1)必须有一个基准情形(一个无需递归即可解答的终止情形);(2)每次调用必须改变状态并朝基准情形靠近;(3)函数必须调用自身(即递归情形)。

int sumN(int n)     { return n == 0 ? 0 : n + sumN(n - 1); }       // 基准:n == 0 -> 0
int factorial(int n){ return n == 0 ? 1 : n * factorial(n - 1); }  // 基准:0! == 1

链表天然契合递归——处理一个节点,再对 next 递归;基准情形是 nullptr:

int length(Node * L) { return L == nullptr ? 0 : 1 + length(L->next); }   // 基准:空链表

⚠️ 漏掉基准情形(或未能朝它靠近)→ 无限递归 → 调用栈溢出。每一次递归调用都会得到它自己的栈帧,带有它自己的参数/局部变量(调用栈正是这样实现递归的)。
🐍 和 Python 的递归是同一个思路,但 C++ 的默认递归深度要小得多,而且没有自动的大整数,所以深度递归可能让栈溢出,而很大的结果可能让 int 溢出。

C 风格数组退化 ⚠️(一个经典陷阱;与 Q33/Q37 相关联)

当一个 C 数组被传入函数时,它会退化成一个指向其首元素的普通指针——大小信息丢失了。这就是为什么写成 int a[] 的参数实际上是 int* a,以及为什么你必须单独传入长度。

void f(int a[], int n) {            // 'a' 实际上是 int* ——大小信息没了
    // sizeof(a) == 一个指针的大小(例如 8),而不是数组的字节数
    for (int i = 0; i < n; ++i) {}  // 必须使用传入的长度 n
    // for (auto x : a) {}          // 这里会出错:a 现在是指针,不是数组
}
int main() {
    int A[5] = {0};
    for (auto x : A) {}             // 这里没问题:在这个作用域里 A 仍是真正的数组
    f(A, 5);                        // A 在传入时退化为 int*
}

💡 这是 std::vector / std::array 更安全的一大原因——它们随身携带自己的大小,不会退化。


5. 函数与参数 (Q39–48)

函数声明 vs 定义 —— (Q39)

  • 声明(原型,prototype): 只有签名,以 ; 结尾。它告诉编译器函数的名字、参数类型和返回类型,以便对调用进行检查。double average(double x, double y);
  • 定义: 声明加上函数体 { ... } —— 即实际的实现。

    💡 函数必须在被调用之前声明(否则编译器会报“undeclared”错误),并且在链接之前定义(否则链接器会报“undefined”错误)。参见下面 Q42–Q44 的逻辑。

按值传递 vs 按引用传递 —— (Q40)

(与 Q26 相同。)值 = 一份你可以在本地修改的拷贝;引用(&)= 一个别名,让你能够修改调用方的对象,并避免拷贝。const& = 高效 + 只读。

std::swap 及其所需的参数方式 —— (Q41) 💡

交换两个值。它必须按引用接收参数——否则它交换的是拷贝,调用方的变量不会发生变化。

template <typename T>
void swap(T & a, T & b) { T tmp = a; a = b; b = tmp; }   // & 是必需的!
int x = 1, y = 2;  std::swap(x, y);   // 现在 x==2, y==1

在移动操作中被大量使用(交换 buf 指针,而不是拷贝整个数组)。std::swap 位于 <utility> 中。

函数重载 vs 覆盖 —— (Q43) 💡

  • 重载(overloading): 多个同名但参数列表不同的函数,在编译期根据实参来解析。(一种静态多态。)
  • 覆盖(overriding): 派生类重新定义它所继承的一个 virtual 函数,使用相同的签名,在运行期根据对象的实际类型来解析。(动态多态——见第 7 节。)
int  min(int a, int b);        // 重载 1
double min(double a, double b); // 重载 2 (同名,参数不同)

struct Base    { virtual void draw(); };
struct Derived : Base { void draw() override; };  // 覆盖(相同签名,virtual)

深入 virtual 覆盖——动态分派到底是怎么工作的 💡💡

🔗 这是 Q68(虚函数,第 7 节)和虚析构函数(Q69)的深入篇——讲分派机制和各种坑。

有了 virtual,通过基类指针或引用发出的调用会在运行期分派到与对象实际(动态)类型相匹配的覆盖版本——调用经由每个对象里一个隐藏的 vtable 指针完成(Q68)。没有 virtual 时,同样的调用会在编译期绑定到声明(静态)类型。

struct Shape  { virtual void draw() const { cout << "Shape\n"; } virtual ~Shape() = default; };
struct Circle : Shape { void draw() const override { cout << "Circle\n"; } };

Shape* s = new Circle;
s->draw();     // "Circle" — virtual:按对象的实际类型分派
               // 如果 draw() 不是 virtual:会打印 "Shape"(按声明类型 Shape* 绑定)
delete s;      // 需要 Shape 的虚析构函数(Q69),否则 Circle 的那部分会泄漏

到底什么才算一次覆盖(写上 override,编译器就会逐条检查):

  • 名字相同、参数列表相同、const/引用限定符相同;
  • 基类里的那个函数是 virtual 的;
  • 返回类型完全相同——或者是协变(covariant)的(基类返回 Base*/Base& 时,派生类可返回 Derived*/Derived&)。
  • 写上 override,签名对不上就会变成编译错误;写上 final 则禁止它被再次覆盖。
struct Base { virtual void f(int); virtual Base* clone() const; };
struct Good : Base {
    void  f(int)        override;   // ✅ 真正的覆盖
    Good* clone() const override;   // ✅ 允许协变返回类型
};
struct Oops : Base {
    void  f(double)     override;   // ❌ 错误:"marked 'override', but does not override"
};

⚠️ 考试里最容易踩的坑:

(1) 是名字隐藏,不是覆盖。 派生类里一个同名但签名不同的成员,会把基类中所有同名重载隐藏掉——它们从派生类的接口里消失:

struct B { void f(int) {} };
struct D : B { void f() {} };   // 隐藏了 B::f(int)
D d;
d.f(3);       // ❌ "no matching function for call to 'D::f(int)'" — 被隐藏了,不是重载
d.B::f(3);    // ✅ 加限定名调用(或在 D 里写  using B::f;  解除隐藏)

(2) 忘了写 virtual → 没有动态分派:基类指针的调用会用基类版本。(而且这时 override 根本编译不过——这正是你应该总是写 override 的原因。)

(3) 默认实参按静态类型绑定。 虚调用执行的是派生类的函数体,却取了基类的默认实参——所以绝不要在覆盖里改默认实参:

struct B { virtual void g(int x = 1) { cout << "B " << x; } };
struct D : B { void g(int x = 2) override { cout << "D " << x; } };
B* p = new D;
p->g();       // 打印 "D 1" — D 的函数体,却用了 B 的默认实参!(默认实参要保持一致)

(4) 对象切片(object slicing)。 把一个 Derived 按值拷贝进一个 Base,派生部分会被切掉;之后的虚调用只会用 Base。多态对象一律用引用或指针传递/保存:

D d;          // 上面那个覆盖了 g 的 D
B  b = d;     // 按值拷贝:把 D 的部分切掉了
b.g();        // "B 1" — b 是个 B,覆盖没了
B& r = d;     // 改用引用绑定
r.g();        // "D 1" — 通过引用,分派依然有效

🐍 在 Python 里每个方法本来就都是"虚"的(动态),既没有重载也没有切片;C++ 要你用 virtual 显式选择加入,还会用切片来"惩罚"对派生对象的按值拷贝。

🛡️ 如何堵死切片——两层防线。 (1) 如果基类是抽象的(含纯虚函数),那么 Base b = d; 和 vector<Base> 根本编译不过——error: cannot allocate an object of abstract type 'Base'。抽象性本身就禁止了按值拷贝,所以静默切片只在具体基类时才会咬人。(2) 对于具体的多态基类,把它的拷贝构造和拷贝赋值 =delete(或把拷贝构造设为 protected),这样误写的 Base b = d; 会变成硬错误 error: use of deleted function。这正是 HW6 在 Picture 里存 Shape*、从不按值保存/拷贝 Shape 的原因。🔗 参见 Q71(抽象类)和 Q73(vector<Shape*> 示例)。

重载运算符:顶层 vs 成员 —— (Q44) 💡(期末复习“operators”幻灯片)

  • 作为成员函数: 左操作数就是 this(隐式),所以你少声明一个参数。
  • 作为顶层(普通)函数: 你要显式声明所有操作数。
// 成员:左操作数是 *this
bool operator==(const Vector & v) { /* 将 this 与 v 进行比较 */ }

// 顶层:两个操作数都显式声明。operator<< 必须是顶层函数(左操作数是流):
ostream & operator<<(ostream & out, const Complex & c) { c.print(out); return out; }
istream & operator>>(istream & in, Complex & c)        { c.read(in);  return in;  }

哪些必须是成员: =、[]、()、-> 以及类型转换运算符必须是成员函数。<< 和 >> 不能是成员(它们的左操作数是流)——把它们做成顶层函数(通常是 friend)。像 == != < > + - 这样的对称运算符既可以是成员(带一个参数),也可以是顶层(带两个参数)。

完整示例——同一个类里两种运算符(附输出):

#include <iostream>
using namespace std;   // 在 .cpp 里可以这样写(但绝不要在 .hpp 里)

class Complex {
    double re, im;
public:
    Complex(double r = 0, double i = 0) : re(r), im(i) {}

    // 成员运算符:左操作数就是 *this,所以你少声明一个参数
    Complex  operator+ (const Complex& rhs) const { return Complex{re + rhs.re, im + rhs.im}; }
    bool     operator==(const Complex& rhs) const { return re == rhs.re && im == rhs.im; }
    Complex& operator+=(const Complex& rhs)       { re += rhs.re; im += rhs.im; return *this; }  // 返回 *this 以支持链式

    friend ostream& operator<<(ostream&, const Complex&);   // 让流运算符能读取私有的 re/im
};

// 顶层(friend):左操作数是流,所以它不能是成员函数
ostream& operator<<(ostream& out, const Complex& c) {
    return out << "(" << c.re << ", " << c.im << ")";
}

int main() {
    Complex a{1, 2}, b{3, -4};
    cout << (a + b)  << '\n';   // a.operator+(b)      -> (4, -2)
    cout << (a == b) << '\n';   // a.operator==(b)     -> 0   (false)
    a += b;                     // a.operator+=(b)
    cout << a        << '\n';   // operator<<(cout, a) -> (4, -2)
}

输出:

(4, -2)
0
(4, -2)

💡 a + b 实际上是 a.operator+(b)——成员版本的左操作数就是 a(*this),所以它只带一个显式参数。cout << a 是 operator<<(cout, a)——它的左操作数是流,所以必须是顶层函数(这里用 friend 让它能读私有的 re/im)。

内联函数 —— (Q45)

被标记为 inline 的函数是一个请求,希望编译器在调用处替换函数体,而不是进行真正的函数调用。好处:消除微小函数的调用开销(并且它是类函数宏(function-like macros)的现代、类型安全替代品)。在类体内部定义一个函数会使它隐式成为内联函数。

默认实参 —— (Q46)

在声明中给参数一个默认值;如果调用方省略了那个实参,就使用默认值。只需提供一个值即可覆盖它。

double toCentigrade(double f = 32.0) { return 5.0*(f-32.0)/9.0; }
toCentigrade();      // 使用 32.0
toCentigrade(6.0);   // 用 6.0 覆盖默认值

⚠️ 默认值写在声明上(在 .hpp 中),不要在 .cpp 的定义里重复写;并且只有尾部的参数才可以有默认值。

友元函数 —— (Q47) 💡

一个非成员函数(或另一个类),被某个类授予访问其 private/protected 成员的权限,在类的内部用 friend 声明。

Klefstad 的说法:“谁说我是你的朋友?——是我(授予友元关系的那个类),而不是你。” 友元获得读取该类私有部分的特权。

  • 为什么使用它:例如,operator<< 必须是顶层函数,但它需要读取私有数据 → 把它做成 friend。

constexpr 变量 / constexpr 函数 —— (Q48)

  • 一个 constexpr 变量是一个编译期常量 (Q17)。
  • 一个 constexpr 函数在给定编译期实参时可以在编译期求值(否则在运行期求值)。用它们来计算常量,而无需使用宏。

C++ 程序是如何构建的 ——(哪个错误来自哪个工具?)💡(期末复习“declarations vs definitions”幻灯片;链接错误与编译错误的区分、以及“重复定义”都是常见易错点)

阶段 处理内容 典型错误
预处理器(Preprocessor) #include、#define、#ifdef (文本错误)
编译器(Compiler) 每个源文件一遍;必须在调用前声明 “undeclared” 标识符;类型/语法错误;“redefinition”(同一文件)
链接器(Linker) 合并 .o 文件;被调用的函数必须已定义 “undefined reference”;“multiply defined”(同一符号在 2 个文件中被定义)
加载器(Loader) 将可执行文件加载到 RAM 中,调用 main() —
(运行期) — segmentation fault(坏指针/越界索引)

Which stage reports which error — worked examples 💡

第 5 节的表格告诉你每类错误是哪个工具抓到的;这里为每一类给出可实际运行触发的最小代码片段、负责报错的工具,以及你大致会看到的报错信息。用课程风格的构建来编译(g++ -std=c++20 -Wall -Werror ...)。

(1)未声明标识符(undeclared identifier)——编译器(compiler)(Q4) 在本翻译单元(translation unit)里还看不到任何声明(declaration)时就调用 foo():

int main() {
    foo();              // 这里用到它,但上方从未声明过
    return 0;
}
void foo() { }          // 声明/定义出现得太晚了

编译器:error: 'foo' was not declared in this scope。编译器是自上而下、一次只看一个文件的,所以名字必须先声明、后调用(在 main 上方放一个原型即可修复)。🐍 与 Python 不同——Python 里函数只要在运行期(run time)被调用时存在即可,而 C++ 在编译期就解析名字。

(2)类型/窄化错误(type / narrowing error)——编译器(在 -Werror 下变成错误)(Q20) 窄化转换是在花括号初始化(brace-initialization) { } 中被诊断出来的(用 = 的拷贝初始化不会触发窄化规则):

double d = 3.9;
int n{d};               // 窄化 double -> int
char c{300};            // 窄化 int -> char(300 放不下)

int n{d} 默认是一个警告,-Werror 会把它变成硬性错误(error: narrowing conversion of 'd' from 'double' to 'int');char c{300} 即使不加 -Werror 也本来就是错误。⚠️ 修复方法是把转换写显式:int n{static_cast<int>(d)};、char c{static_cast<char>(300)};。这是在编译期、在当前正在编译的那一个文件里被抓到的。

(3)语法错误(例如漏掉 ;)——编译器(compiler) 少一个符号就会让解析器“跑偏”,而且报错信息常常指向下一行:

struct Point { int x; int y; }   // 右花括号后漏了 ';'

int main() { Point p; return 0; }

编译器:error: expected ';' after struct definition。⚠️ 报错的那一行往往在真正出错处之后——遇到莫名其妙的报错,先看上一行是不是漏了 ;、} 或 )。

(4)同一翻译单元内的重复定义——编译器(compiler)(Q4——包含守卫存在的理由) 在一个文件里把同一个类型定义两次——最常见的就是把一个没有包含守卫的头文件 #include 了两次:

// shape.hpp — 没有包含守卫
struct Shape { int sides; };
// main.cpp
#include "shape.hpp"
#include "shape.hpp"   // 第二次包含 -> struct Shape 在这个文件里被定义了两次
int main() { return 0; }

编译器:error: redefinition of 'struct Shape'——在编译期、单个翻译单元内被抓到。✅ 修复:给头文件加包含守卫(#ifndef SHAPE_HPP / #define SHAPE_HPP / … / #endif)或 #pragma once,第二次 #include 就展开成空。
💡 与下面的(6)对比:重复定义(redefinition)是一个文件看到同一类型两次(编译器错误);而 “multiply defined” 是两个文件各自产生了同一个符号(链接器错误)。包含守卫能解决前者,解决不了后者。

(5)未定义引用(undefined reference)——链接器(linker)(Q4) 函数被声明并调用,所以编译器很满意——但始终没有提供定义(definition)(或者你忘了把定义它的那个 .cpp 一起编译/链接):

void foo();             // 仅声明——承诺它在某处存在
int main() {
    foo();              // 编译没问题:声明在作用域内
    return 0;
}
// 在被链接的所有 .o 文件里都没有 foo() 的定义

链接器:undefined reference to 'foo()'。⚠️ 现实中最经典的原因是命令行漏了某个文件——写成 g++ main.cpp 而不是 g++ main.cpp foo.cpp。编译器只需要声明;链接器需要定义。

(6)重复定义(multiply defined)——链接器(linker)(Q4) 把一个非 inline 的函数定义放进头文件,然后让两个 .cpp 各自 #include 这个头文件:

// util.hpp  — 一个真正的“定义”(带函数体),不是 inline
void foo() { }          // 每个 include 它的 .cpp 都会得到自己的一份副本
// a.cpp
#include "util.hpp"
// b.cpp
#include "util.hpp"

链接器(链接 a.o + b.o 时):multiple definition of 'foo()'。每个翻译单元都产出了自己的一份 foo,链接器拒绝替你挑一个。

⚠️ 常见误解——包含守卫(include guard)并不能解决这个问题。 包含守卫(#ifndef … #define … #endif)只能阻止同一个头文件在同一个翻译单元内被文本性地包含两次,从而防止类型/类被重复定义。它在跨 .cpp 文件之间毫无作用:a.cpp 和 b.cpp 是各自独立编译的,每个都从头跑一遍守卫,所以每个仍然会产出一份 foo() 的定义。真正的修复方法是:

  • 在头文件里把该函数标为 inline(这样链接器就被允许合并这些完全相同的副本),或
  • 在头文件里只留声明,把定义移到某一个 .cpp 里。

(7)段错误(segmentation fault)——运行期(run time)(Q4) 编译、链接都干净通过;只在执行时才崩:

int main() {
    int* p = nullptr;
    *p = 42;            // 解引用空指针 -> 未定义行为
    int a[3] = {};
    return a[1000000];  // 越界乱访问 -> 未定义行为
}

这里没有任何工具会警告你——这是未定义行为(undefined behavior),通常在运行期由操作系统抛出 Segmentation fault (core dumped)。

一句话总结: 编译器(compiler)是逐文件、需要声明;链接器(linker)是合并文件、需要定义。

声明 × 定义 × 使用——完整真值表 💡💡

把"undeclared 与 undefined reference"的全部纠结压成一张矩阵。声明 = 调用前能看到原型;定义 = 某个参与链接的 .o 里有函数体;使用 = 真的被调用了。(每一行都用 g++ -std=c++20 -Wall -Wextra 实测过。)

声明 定义 使用 编译 链接 会发生什么
✅ ✅ ✅ ✅ ✅ 正常——一切都能解析
✅ ✅ ❌ ✅ ✅ 声明定义了但没用——完全没问题,连警告都没有
✅ ❌ ✅ ✅ ❌ undefined reference——链接器去找函数体却找不到(大家问的就是这一行)
✅ ❌ ❌ ✅ ✅ 只声明、没函数体、也没人调——没人引用它,链接器根本不去找 → 不报错
❌ — ✅ ❌ — undeclared——编译期就拦下,根本到不了链接器
❌ ✅ ✅ 看情况 — 定义本身也是一种声明:定义在使用之前能编译并链接;定义在使用之后,调用处就是 undeclared

💡 把两行关键的对照着看:声明了但没定义只有在被使用时才会爆(第 3 行 → 链接器 undefined reference);如果没人调它(第 4 行),链接器没理由去找,于是链接干干净净。而一个从没声明过的名字(第 5 行)在编译期就死了——根本走不到"链接错误"那一步。最后一行解释了:为什么你能调用同一文件里更早定义的函数而不用单独写原型,但调用更晚才定义的函数就得在调用前加原型。


6. 类与对象 (Q49–65)

这是本课程的核心。锚定示例:Klefstad 的 Vector/IntList/String 类,它们拥有一个堆数组 int* buf。

类声明与类定义 — (Q49)

  • 类声明(class declaration)(在 .hpp 中)类似于函数原型(prototype):它列出成员以及方法签名。
  • 类定义(class definition)提供函数体。(成员函数通常在 .cpp 中用 ClassName::method 的形式定义。)
// Complex.hpp —— 声明
class Complex { float re, im; public:
    Complex(float r = 0, float i = 0);
    void print(ostream & out) const;
};
// Complex.cpp —— 定义
Complex::Complex(float r, float i) : re(r), im(i) {}
void Complex::print(ostream & out) const { out << "(" << re << "+" << im << "i)"; }

类与对象 — (Q50)

类(class)是蓝图(定义该类型的对象长什么样以及它们的行为)。对象(object)是从类创建出来的一个实际实例(instance),它拥有自己的一份数据成员副本以及自己的生命周期(lifetime)。

私有成员与公有成员 — (Q51)

  • public: = 接口——所有人都可以访问(“控制部件”:方向盘、踏板)。
  • private: = 实现——只有该类自己的成员函数(以及友元(friend))可以访问(“内部部件”:发动机、燃油喷射系统)。
  • 简单规则:数据成员设为私有(private);大多数方法(以及构造函数/析构函数)设为公有(public);辅助方法通常设为私有。

私有成员与保护成员 — (Q52)

两者对外部代码都不可访问。区别在于继承(inheritance):

  • private 成员对派生类(derived class)不可访问。
  • protected 成员对派生类是可访问的(但对外部代码仍然不可访问)。

    经验法则:把表示(representation)保持为私有(private),这样派生类就不会依赖它(你以后还能修改它)。只有当派生类确实需要直接访问时,才使用 protected。

成员是… 类自身 + 友元 派生类 外部代码
public ✅ ✅ ✅
protected ✅ ✅ ❌
private ✅ ❌ ❌

(所以 protected = private 再加上“对派生类也可见”。)

🔗 与 Q67 的“继承模式”表不同:这张讲“谁能访问”成员,那张讲“继承模式”如何给继承下来的成员重新标定访问级别。

构造函数及其作用 — (Q53)

一个特殊的成员函数(与类同名,没有返回类型),它构建并初始化 this 对象;在分配内存之后立即自动被调用。使用成员初始化列表(member initializer list)来构造数据成员:

Vector(int newLen) : maxLen{newLen}, buf{ new int[newLen] } { }   // 初始化列表构建各成员

拷贝构造函数及其三种用途 — (Q54) 💡

将一个新对象构造为某个已有对象的副本;其签名接受一个参数:对同一类类型的(const)引用。

Vector(const Vector & v) : maxLen(v.maxLen), buf(new int[v.maxLen]) {
    for (int i = 0; i < v.maxLen; ++i) buf[i] = v.buf[i];   // 深拷贝
}

会调用它的三种情形:

  1. 用一个已有对象进行初始化: Vector v1{v};
  2. 按值传递(pass by value): Vector foo(Vector v) —— 实参会被拷贝构造。
  3. 按值返回: return v; —— 返回值会被拷贝构造。

    ⚠️ 最佳实践:参数必须是 const&(这样你才能拷贝临时对象,并且不会意外修改源对象);拷贝构造函数应当没有副作用。

析构函数 — (Q55)

一个特殊成员(~ClassName(),没有参数、没有返回值),它在内存释放之前进行清理;当对象消亡时(栈对象在块结束时、堆对象在 delete 时)自动被调用。对于一个拥有堆内存的类,它会释放该内存:

~Vector() { delete[] buf; }

this 指针 — (Q56)

每个(非静态的)成员函数都有一个隐藏的指针参数 this,指向调用该方法的那个对象——就像 Python 中显式的 self。this 的成员可以直接访问(你既可以写 re,也可以写 this->re)。你还会在赋值运算符中写 return *this;。

void print(ostream & out) const { out << re; /* this 的 re */ }
Vector & operator=(const Vector & v) { /* ... */ return *this; }

struct 与 class — (Q57) 💡

唯一的区别是默认访问级别: struct 的成员默认为 public;class 的成员默认为 private。(默认的继承同样如此:struct 默认公有继承,class 默认私有继承。)其余一切都完全相同——两者都可以有构造函数、方法、访问说明符等等。

构造函数与析构函数(何时被调用)— (Q58)

  • 构造函数在诞生时运行,紧接在分配内存之后,用来建立对象。
  • 析构函数在消亡时运行,紧接在释放内存之前,用来清理。
  • 在一个块的范围内,对象按其构造的相反顺序被销毁。

const 成员函数与非 const 成员函数 — (Q59) 💡

一个声明为 const 的方法(该关键字放在参数列表之后)承诺不修改 this;在它内部,this 被当作 const,因此它不能改变数据成员,并且只能调用其他 const 方法。你可以在一个 const 对象上调用 const 方法;而非 const 方法不能在 const 对象上调用。

void print(ostream & out) const;   // const:不修改该对象
void append(int value);            // 非 const:修改该对象

⚠️ operator<< 接受 const T&,所以它所调用的 print 必须是 const 的。

构造函数与赋值运算符 — (Q60) 💡

  • 构造函数从原始内存中构建一个全新的对象(目标对象此前并不存在)。
  • 赋值运算符 = 用另一个对象去更新一个已经构造好的对象 → 它必须先清理掉自己原有的资源,然后拷贝,最后 return *this;。
Vector & operator=(const Vector & v) {
    if (this != &v) {              // 自赋值检查(用 IDENTITY 标识——见下文)
        maxLen = v.maxLen;
        delete[] buf;              // 释放旧数组(我们自身已经存在)
        buf = new int[v.maxLen];
        for (int i = 0; i < maxLen; ++i) buf[i] = v.buf[i];
    }
    return *this;                  // 使链式赋值 a = b = c; 成为可能
}

💡 自赋值(self-assignment)检查用的是标识(IDENTITY),即 this != &v,而不是值比较。 Klefstad 的幻灯片指出 if (*this != v) 是错误的——自赋值关乎是否为同一个对象(同一地址),而不是是否具有相等的值。比较值(operator!=)是错误的检查方式(而且可能是未定义的或代价高昂的)。它所要防范的危险是:在 a = a 时,delete[] buf 会把你正打算从中拷贝的那个数组给释放掉。

移动赋值与拷贝赋值 — (Q61) 💡

拷贝赋值(copy assignment)复制源对象(深拷贝)并保持其完好无损。移动赋值(move assignment)则窃取源对象的堆资源(它是一个即将消亡的临时对象),从而避免分配/拷贝。当右侧是一个右值(R-value)(一个临时对象,或者你用 std::move 包裹的东西)时,就会选择移动。

Vector & operator=(Vector && v) {          // && = 右值引用;注意:没有 const
    delete[] buf;                          // 释放我们的旧数组
    buf = v.buf;  maxLen = v.maxLen;        // 窃取 v 的数组
    v.buf = nullptr;                       // 关键:让 v 可以安全地被析构
    return *this;
}

移动构造函数与拷贝构造函数 — (Q62) 💡💡 (考试常考)

移动构造函数(move constructor)通过接管一个即将消亡的对象的资源而非拷贝它们来构建一个新对象。参数是一个右值引用 T&&(没有 const)。当从一个临时对象 / 一个被 std::move 的对象进行初始化时被调用。

Vector(Vector && v) : maxLen(v.maxLen), buf(v.buf) {   // 窃取该指针
    v.buf = nullptr;   // ***极其重要***,以避免重复删除(double-free,双重释放)错误!!!
}

💡 牢记:移动构造函数(以及移动赋值)必须设置 v.buf = nullptr。 被移动后的对象仍然会被调用其析构函数,而 delete[] nullptr 是安全的——但如果你让 v.buf 仍然指向你刚刚窃取的那个数组,那么两个析构函数都会去释放它 → 双重释放 / 崩溃。

拷贝与移动一览:

拷贝 移动
参数 const T& T&&(无 const)
源对象之后 保持不变 被清空(buf = nullptr)
工作量 分配 + 拷贝元素 窃取指针(开销小)
触发条件 左值(L-value)源 右值(R-value)/ std::move 源

完整示例——四个特殊成员各自在什么时候被调用(附输出)💡💡

一个拥有堆上 int* buf 的类,四个特殊成员都加了打印,让你清楚看到到底是哪一个被调用。std::move(来自 <utility>)把左值变成右值,于是会选中移动版本:

输出:

ctor
ctor
copy ctor
copy =
move ctor
move =

💡 触发的关键是右边是什么:源是左值(像 a 这样的具名对象)→ 选拷贝版本;源是右值(临时对象,或 std::move(a))→ 选移动版本。初始化一个新对象(Vec c = …)调用构造函数;给已存在的对象赋值(c = …)调用赋值运算符。拷贝 = 分配 + 深拷贝;移动 = 窃取指针 + 置空源对象。(末尾的析构函数不打印任何输出。)

静态与非静态数据成员 — (Q63)

  • 一个非静态数据成员是每个对象各有一份的(每个实例都有自己的一份)。
  • 一个 static 数据成员被该类的所有对象共享(总共只有一份);通过 ClassName::member 访问。

示例——一个共享的 count,每个对象各有一个 id(附输出):

#include <iostream>
using namespace std;

class Widget {
    int id;                 // 非静态:每个对象各有一份
    static int count;       // 静态:整个类只有一份,被共享
public:
    Widget() : id(++count) {}              // 给共享计数器 +1;并给这个对象自己的 id
    int myId() const { return id; }
    static int total() { return count; }   // 静态成员函数:没有 this
};

int Widget::count = 0;      // 在类外定义并初始化静态成员(只此一次,放在某个 .cpp)

int main() {
    Widget a, b, c;
    cout << a.myId() << " " << b.myId() << " " << c.myId() << '\n';  // 每个对象各自的 id
    cout << Widget::total() << '\n';                                  // 共享的计数,通过 ClassName::
    cout << "sizeof(Widget) = " << sizeof(Widget) << '\n';           // 静态成员不存在每个对象里
}

输出:

1 2 3
3
sizeof(Widget) = 4

💡 每个 Widget 都有自己的 id(1、2、3)——这就是非静态成员。而 count 只有一份、被所有对象共享(所以 total() 是 3)——这就是静态成员,用 Widget::total() 访问。注意 sizeof(Widget) == 4(只有那一个 int id):静态的 count 存在静态存储区里只有一份,不在每个对象内部。⚠️ 静态数据成员必须在类外定义一次(int Widget::count = 0;),否则链接器会报 "undefined reference"。

静态与非静态成员函数 — (Q64)

  • 一个非静态方法拥有一个 this 指针,并作用于某个特定的对象。
  • 一个 static 方法没有 this——它属于类,而不属于某个实例,并且只能接触静态成员 / 它自己的参数。在本课程中,String 的辅助函数都是 static 的(通用、不了解任何特定对象),通过 String::strcpy(...) 来调用。

默认构造函数 — (Q65) 💡

一个可以不带参数调用的构造函数(要么不接受任何参数,要么所有参数都有默认实参(default argument))。当你声明一个不带初始化器的对象时会用到它:

Vector v;       // 默认构造
Vector arr[10]; // 每个元素都被默认构造

⚠️ 两个陷阱: (1) Vector result; 会调用默认构造函数,但 Vector result(); 声明的是一个函数(“最令人头疼的解析”,most vexing parse)——Klefstad 的幻灯片标注了“注意没有括号!!!”。(2) 如果你声明了任何自己的构造函数,编译器就会停止生成隐式的默认构造函数——如果你仍然需要无参构造,就必须把它加回来(Vector() = default; 或者自己写一个)。

三法则 / 五法则 / 零法则——(把 Q54–Q62 串联起来)💡💡💡

三法则:如果你的类包含任何(裸)指针 / 拥有某种资源,你必须把这三个全部写出来:

  1. 拷贝构造函数(深拷贝)
  2. 拷贝赋值 operator=(释放旧资源、深拷贝、自赋值检查)
  3. 析构函数(释放资源)

五法则: 为了效率,再加上两个移动操作(移动构造函数 + 移动赋值)。

零法则(主题 7):如果你能避免定义这些特殊操作,就避免。 如果你的成员全都是行为良好的类型(std::string、std::vector、std::map、智能指针……),它们会自己管理自己 → 五个一个都不要写;编译器生成的默认版本就是正确的。

那些默认版本做了什么,以及为什么它们对裸指针是错的: 编译器生成的拷贝构造函数 / 拷贝赋值做的是逐成员(浅)拷贝,而默认析构函数不做任何特殊处理。对于一个 char* buf:

String::~String() { delete[] buf; }
String(const String & s) = default;   // 浅拷贝 -> 两个对象共享同一个 buf -> 双重释放(double free)!
String & operator=(const String & s) = default;   // 同样的灾难
// 而一个空的手写版本同样糟糕:
String(const String & s) { }          // buf 未初始化(垃圾值)-> 崩溃
  • = default 是请求编译器的版本;= delete 则完全移除一个函数(例如,unique_ptr 删除了它的拷贝操作,因此它不能被拷贝)。

7. 继承与多态 (Q66–73)

锚点示例:收集在一个 Picture 中的 Shape 层次结构(Circle、Rectangle、Square)(HW6)。

继承,以及它如何简化设计 —— (Q66)

派生(derived)类继承基类的全部成员,并且可以添加成员或覆盖(override)方法。这让你能够通过扩展一个已有类型来构建一个新类型,而无需复制/修改它的源代码,并且让同一套框架能够操作一整族类型。

class Shape { /* ... */ };
class Triangle : public Shape { /* ... */ };   // “Triangle 是一种 Shape”

要区分继承(“是一种”,is-a)与组合(“拥有/包含”,containment / “是……的一部分”)。一个 Engine 是 Car 的一部分 → 把它作为数据成员。一个 Car 是一种 Vehicle → 使用公有继承。

示例——父类的公用函数(继承的“复用”那一面):

#include <iostream>
using namespace std;

struct Account {
    double balance = 0;
    void deposit(double x) { balance += x; }   // 公用:只写一次,每种账户都继承它
    virtual double interest() const = 0;       // 这部分因账户类型而异
};
struct Savings  : Account { double interest() const override { return balance * 0.05; } };
struct Checking : Account { double interest() const override { return balance * 0.01; } };

int main() {
    Savings s; Checking c;
    s.deposit(1000);                 // Account::deposit —— 继承来的,没有重写
    c.deposit(1000);                 // 完全是同一个函数
    cout << s.interest() << '\n';    // Savings::interest
    cout << c.interest() << '\n';    // Checking::interest
}

输出:

50
10

💡 deposit() 在 Account 里只写一次,被 Savings 和 Checking 原样继承——这就是把继承当作代码复用(不用复制粘贴)。而 interest() 是 virtual 的,因类型而异——这就是多态。真实的基类大多两者兼有:公用的具体方法加上虚函数钩子。

基类与派生类 —— (Q67)

  • 基类(base,超类/父类):定义共有的数据与方法。
  • 派生类(derived,子类/孩子类):继承这些成员,可以添加更多成员,可以覆盖。一个派生类自身又可以作为更进一步的类的基类(Menu : public Window : public Screen)。

public 与 protected 与 private 继承(Topic 6 PPT 第 27 页)💡

继承模式控制基类的成员在派生类内部变成什么。public 继承是唯一能建模“is-a”的方式(也是多态层次结构唯一可用的方式);protected/private 继承表示“以……来实现”,而不是“is-a”。

基类成员 public 继承 protected 继承 private 继承
public public protected private
protected protected protected private
private 不可访问 不可访问 不可访问

把继承模式理解为一个上限(天花板):成员透过来时至多与该模式所允许的一样可访问,而无论何种模式,基类的 private 成员永远不能被派生类直接访问。在 Vector 之上通过 private 继承来构建一个 Stack 属于“复用”,而不是“is-a”—— 这种情况下不要使用公有继承(对一个栈做数组下标访问毫无意义)。

🔗 这张表和 Q52 那张不是一回事:那张讲的是某个访问级别的成员谁能访问(类自身 / 派生类 / 外部);这张讲的是继承下来的成员在每种继承模式下最终是什么访问级别。

虚函数 —— (Q68) 💡

一个被标记为 virtual 的成员函数使用动态绑定(dynamic binding):调用哪个版本是在运行时由对象的实际(动态)类型决定的,而不是由它声明的(静态)类型决定。正是这一点让多态能够通过基类指针/引用工作。

struct Base    { virtual int f(); int g(); };   // f 是虚函数,g 不是
struct Derived : Base { int f() override; };     // “一旦虚函数,永远虚函数”

风格:覆盖时写上 override(这样编译器会验证函数签名是否匹配)。在派生类中 virtual 是可选的,但为了清晰仍建议写上。

🔗 这里是定义。想知道到底调用哪个版本,见下面的 静态绑定与动态绑定;想了解 vtable 分派机制和覆盖的各种坑(名字隐藏、对象切片、默认实参绑定),见 Q43 的 《深入 virtual 覆盖》 小节。

静态绑定与动态绑定 ——(运行的是哪个版本?)💡

每当你通过基类指针或引用去用一个对象时,会同时存在两个“类型”:静态(声明)类型——你在声明里写的那个——以及动态(实际)类型——它实际指向的对象的真实类型。问题在于由哪一个来决定调用哪个版本的函数:

  • 非虚调用在编译期按静态(声明)类型确定。
  • 虚调用在运行期按动态(实际)类型确定——这就是多态。
#include <iostream>
using namespace std;

struct Animal {
    void name()          { cout << "Animal\n"; }   // 非虚函数
    virtual void sound() { cout << "...\n";    }   // 虚函数
};
struct Dog : Animal {
    void name()           { cout << "Dog\n";  }    // 隐藏了 Animal::name(不是覆盖)
    void sound() override { cout << "Woof\n"; }
};

int main() {
    Dog d;
    Animal& a = d;     // 静态(声明)类型 = Animal;动态(实际)类型 = Dog
    a.name();          // 非虚 -> 按“静态类型”选 -> "Animal"
    a.sound();         // 虚   -> 按“动态类型”选 -> "Woof"
}

输出:

Animal
Woof

💡 同一个对象 d、同一个引用 a——但 a.name() 打印 Animal,而 a.sound() 打印 Woof。唯一的区别就是 virtual:没有它,调用跟着声明类型(Animal)走;有了它,调用跟着实际类型(Dog)走。这正是为什么一个装着很多 Shape*/Shape& 的 Picture 调用 draw() 时,能得到每个真实形状各自的版本(异质的情形)。如果集合是同质的、或者你只想复用,用静态绑定就够了。

虚析构函数,以及为什么它重要 —— (Q69) 💡💡

当你通过基类指针 delete 一个派生对象时,除非基类的析构函数是 virtual,否则只会运行基类的析构函数 —— 于是派生部分会泄漏/析构不完全。规则: 任何含有虚函数的类(多态层次结构中的任何基类)都需要一个虚析构函数,并在层次结构的根部引入它。

class Shape {
public:
    virtual double perimeter() = 0;
    virtual ~Shape() = default;    // 必需,这样 'delete shapePtr' 才会按真实类型析构
};

(只有在永远不会有对象通过基类指针被销毁时,才可以省略它。)

虚函数与纯虚函数 —— (Q70) 💡

  • 一个虚函数(virtual)函数有定义;派生类可以覆盖它。
  • 一个纯虚函数(pure virtual)函数没有定义,并以 = 0 声明;派生类必须覆盖它才能成为具体(可实例化的)类。
virtual void rotate(int d);       // 虚函数:有(或将有)函数体
virtual void draw() = 0;          // 纯虚函数:没有函数体,必须被覆盖

抽象类 —— (Q71) 💡

一个含有一个或多个纯虚函数(= 0)的类是抽象类(abstract class)——它描述这一族类型能做什么,而不规定怎么做。你不能直接实例化它(Shape s; 会报 error: cannot declare variable 's' to be of abstract type 'Shape');你只能使用指向它的指针或引用(Shape* sp; Shape& sr;),它们再指向具体的派生类对象。

抽象类仍然是一个真正的类——它可以拥有:

  • 数据成员和构造函数(由派生类的构造函数来运行);
  • 带函数体的非纯(具体)方法(例如下面的 describe()),它们甚至可以调用纯虚方法;
  • 一个虚析构函数(而且应该有一个——见 Q69)。

它做不到的是作为一个独立对象存在——而且一个没有覆盖全部纯虚函数的派生类自身仍然是抽象类。

struct Shape {
    virtual double area() const = 0;   // 纯虚函数 -> Shape 是抽象类
    void describe() const;             // 仍然可以有具体(非纯)方法
    virtual ~Shape() = default;
};
// Shape s;                 // 错误:不能实例化一个抽象类
Shape* sp = new Circle(3);  // 可以:抽象基类只能通过指针/引用使用

🔗 完整可运行的多态示例——一个装着 Circle/Square 的 vector<Shape*> 并打印——见 Q73 末尾的完整示例。

纯虚析构函数 —— (Q72)

当一个类没有其他纯虚函数时,你可以把它的析构函数声明为纯虚的(virtual ~Shape() = 0;)来强制该类成为抽象类。注意:纯虚析构函数仍然需要一个定义(函数体),因为每个派生类的析构函数都会调用基类的析构函数。

class Base { public: virtual ~Base() = 0; };
Base::~Base() {}   // 仍然必须提供一个函数体

多态如何工作及其好处 —— (Q73) 💡

多态(polymorphism) = 一次调用实际触发的操作取决于对象的类型。

  • 静态多态通过重载(overloading)实现(编译时)。
  • 动态多态通过虚函数实现(运行时):一个基类指针/引用可以引用任何派生对象,而一次虚调用会分派到正确的覆盖版本。
void rotate_all(Shape* vec[], int size, int angle) {
    for (int i = 0; i < size; ++i)
        vec[i]->rotate(angle);   // 在运行时分派到 Circle::/Rectangle::/Square::rotate
}

好处 —— 可扩展的框架: 你可以添加一个新的 Shape 子类型(例如 Square),而无需修改 rotate_all 或 Picture。“Picture 对 Triangle 一无所知,它只知道 Shape。”

构造/析构顺序:先构造基类,再构造派生类的成员;析构则相反。派生类的构造函数会先调用基类的构造函数(如果你不指明某个构造函数,就调用默认的那个)。

完整示例——多态实战(附输出)💡💡

把第 7 节的整幅图景浓缩进一个可运行的程序:一个抽象的 Shape 接口、两个具体形状,以及只提到 Shape 的框架代码(printAll)——但每次调用都会运行真实类型的版本。

输出:

Circle area = 12.5664
Square area = 9

💡 关键在 sp->name() / sp->area() 这两行:通过 Shape*,由实际类型(Circle 或 Square)决定运行哪个版本——这就是多态。printAll 从不提及任何具体形状,所以要支持一种新形状(比如 Triangle),你只需新增一个类——框架代码原封不动。而正是 virtual ~Shape() 让 delete sp 能正确销毁真实对象(Q69)。


8. 模板(Templates)(Q74–77)

模板参数的种类 + 默认值 —— (Q74) 💡

模板参数列表用逗号分隔;其种类有:

  • typename T(类型参数,type parameter)—— 用于元素类型任意的容器。(class T 含义相同,只是更早的写法。)
  • 非类型参数(non-type parameter),例如 int N —— 用于数组大小或默认计数之类的场合。

它们可以带默认值,就像默认实参(default argument)一样:

template <typename charType = char, int capacity = 100>
class basic_string { /* ... */ };   // std::wstring 就是 charType = wchar_t 的 basic_string

模板函数(Template function)—— (Q75)

一个函数只写一次,却能用于任意类型;编译器会为每个用到的类型生成一个具体版本。

template <typename Type>
Type min(Type a, Type b) { return a < b ? a : b; }

模板类型 T 必须支持函数体中所用到的一切操作(这里是 operator<)。当泛型版本对某个类型而言不正确或效率低下时,你可以为该类型进行特化(specialize):

char * min(char * a, char * b) { return strcmp(a,b) < 0 ? a : b; }  // 对 C 风格字符串做特殊处理

模板的重载决议——优先级阶梯(以及坑)💡💡

当一次调用有多个版本都能匹配时,编译器选的是最佳匹配(best match),而不是“谁先写谁优先”——书写顺序不影响选中哪一个。 假设有三个候选共用名字 mymin:

#include <iostream>
#include <cstring>
using namespace std;
template<typename T> T  mymin(T a, T b)   { cout << "[generic T]\n";          return a < b ? a : b; }
template<typename T> T* mymin(T* a, T* b) { cout << "[pointer T*]\n";         return *a < *b ? a : b; }
char* mymin(char* a, char* b)             { cout << "[non-template char*]\n"; return strcmp(a,b) < 0 ? a : b; }

int main() {
    int x = 1, y = 2;               mymin(x, y);     // int   -> 只有泛型 T 能匹配
    int a = 1, b = 2;               mymin(&a, &b);   // int*  -> T* 胜过 T(更特化)
    char s1[] = "ab", s2[] = "cd";  mymin(s1, s2);   // char* -> 三个都匹配 -> 非模板胜出
}

输出:

[generic T]
[pointer T*]
[non-template char*]

优先级阶梯(在候选其它方面打平 / 都是精确匹配时才用到):(1) 非模板函数胜过任何模板;(2) 模板之间,更特化的那个胜出(T* 比 T 更特化——凡能匹配 T* 的也能匹配 T,反之不行);(3) 否则用泛型模板。把这三个定义的顺序随便调换,结果都一样。

⚠️ 坑 1 —— char* min(char*, char*) 是重载(overload),不是真正的特化。 Klefstad 的幻灯片管它叫 “specialize”,但按标准,真正的显式特化(explicit specialization)要写 template <>:

template <> char* min<char*>(char* a, char* b) { /* ... */ }   // 显式特化

应试记住:模板和普通函数一样好时,普通(非模板)函数胜出。而且普通重载更安全——显式特化必须在该类型第一次用到主模板之前声明,否则会悄悄用错版本。

⚠️ 坑 2 —— T min(T, T) 强制两个实参是同一个 T。 混合类型推导会失败:

char* cp; int* ip;
mymin(cp, ip);   // char* 和 int* -> 错误:deduced conflicting types for 'T' ('char*' and 'int*')

要接受两个互相独立的类型,就用两个类型参数:

template <typename T, typename U> auto mymin(T a, U b) { return a < b ? a : b; }
mymin(3, 4.5);   // 可以

实例化模板函数 —— (Q76)

只需调用它——编译器会从实参中推导(deduce)出类型,并实例化(instantiate)对应的版本:

int    x = min(10, 20);       // Type = int
double y = min(10.5, 20.8);   // Type = double

(你也可以显式强制指定:min<double>(10, 20.8);。)

实例化模板类 —— (Q77) 💡

在尖括号中提供类型实参;这就创建出了一个真正可用的类型:

Stack<double> dstack;          // 一个存放 double 的 Stack
using intStack = Stack<int>;   // 类型别名(优于旧式的 'typedef Stack<char> charStack;')

一个完整、可运行的实例化: “实例化”一个类模板,意思是编译器会为你用到的每个类型各自生成一个真正的、独立的类——Stack<int> 和 Stack<string> 是由同一个模板生成的两个不同的类。

#include <iostream>
#include <string>
using namespace std;

template <typename T>
class Stack {
    T buf[100];
    int len = 0;
public:
    void push(T x) { buf[len++] = x; }
    T    pop()     { return buf[--len]; }
    bool empty() const { return len == 0; }
};

int main() {
    using IntStack = Stack<int>;   // Stack<int> 的类型别名
    IntStack s;                    // 和 'Stack<int> s;' 完全一样
    s.push(1); s.push(2);
    cout << s.pop() << '\n';       // 2

    Stack<string> w;               // 或者直接实例化,不用别名
    w.push("hi"); w.push("yo");
    cout << w.pop() << '\n';       // yo
}

输出:

2
yo

⚠️ 在类外定义模板方法: 每个类外方法都必须加上模板参数列表前缀,并且用 ClassName<T>:: 进行限定。正因如此,模板的定义通常完全放在 .hpp 中(编译器需要完整的定义才能针对每个类型进行实例化)。

template <typename T>
void Stack<T>::push(T x) { buf[len++] = x; }

函数解析顺序(重载 + 模板,简化版):先匹配非模板函数;若恰好有一个,就用它;否则尝试模板实例化;若仍没有,则再次尝试非模板函数并允许类型转换。(const 匹配:非 const 形参不能绑定 const 实参;const& 形参两者皆可绑定,对字面量会创建一个临时对象;普通的 & 形参需要一个有效的左值(l-value)——swap(10,20) 是错误的。)


9. lambda (Q78–80)

什么是 lambda —— (Q78) 💡

一种匿名的局部函数对象(anonymous local function object),你可以内联定义它并传给 STL 算法。形式:[capture](params){ body }(或者没有参数时写 []{})。

auto fn = [](int i){ return i * i; };   // 存到 'auto' 里
cout << fn(4) << endl;                  // 16
([](string s){ cout << "hello " << s << endl; })("world");   // 定义后立即调用:(fn)(arg)

lambda 在作为算法实参时最为出彩:

transform(V.begin(), V.end(), V.begin(), [](int i){ return i+1; });   // 给每个元素加 1
for_each(istream_iterator<string>(in), istream_iterator<string>(),
         [&](string & s){ ++M[s]; });   // 单词计数:按引用捕获 M

lambda 捕获(capture)—— (Q79)

[ ] 列出 lambda 函数体内部可以使用的、来自外围作用域(enclosing scope)的变量,以及它如何抓取这些变量(按引用还是按拷贝)。如果不进行捕获,lambda 就看不到局部变量。

捕获模式 —— (Q80) 💡

捕获写法 含义
[] 不捕获任何东西
[&] 按引用捕获所有用到的变量
[=] 按拷贝捕获所有用到的变量
[M] 只按拷贝捕获 M(其它都不捕获)
[=, &M] M 按引用,其余一切按拷贝
[&, M] M 按拷贝,其余一切按引用

⚠️ 按引用(&)会看到/影响那个活着的变量(例如 ++M[s] 会更新真正的 map)。按拷贝(=)则在一份私有快照上操作——改动不会传播到外部,而且按拷贝捕获一个大对象代价很高。如果 lambda 的存活时间超过被捕获的变量,按引用捕获就很危险。

⚠️ 顺序只在一个地方有影响——默认捕获符(= 或 &)必须放在第一个。 [=, &x] 和 [&, x] 都没问题;[&x, =] 是语法错误(expected identifier before '=')。而且在默认捕获之后再单独列某个变量时,必须用相反的模式——[=, x] 会被拒绝(error: explicit by-copy capture of 'x' redundant with by-copy capture default),得写成 [=, &x]。(把同一个变量列两次,[x, x],也是错误。)

注(主题 7 “零法则”):lambda + 标准容器正是你为什么常常一个特殊成员函数都不需要写的原因。


10. 异常(Exceptions)(Q81–85)

用 try-catch 处理异常 — (Q81) 💡

把有风险的代码放进 try 块中;在一个或多个 catch 块中处理问题。如果 try 内部(或它调用的某个函数)throw(抛出)了异常,控制流会跳转到一个匹配的 catch,跳过 try 中剩余的部分。

try {
    int i = v[10];          // 可能抛出 out_of_range
} catch (const std::out_of_range & e) {
    cerr << "bad index: " << e.what() << endl;
} catch (...) {             // 捕获一切
    cerr << "some other error" << endl;
}

异常是什么,以及为什么 — (Q82)

异常(exception) 是一种信号,表示发生了运行时错误(run-time error)(例如,索引越界,或对空栈调用 pop())。它是 return 的一种非局部替代方案:它让你能够从调用链深处把错误报告给栈上很远处的处理程序,“但应当谨慎使用。”(仅用于同步(synchronous)错误——而不是 GUI 事件/中断/信号。)

实际被抛出的是什么 — (Q83) 💡

一个对象。 你可以抛出任何值,但惯用做法是抛出一个异常类(exception class)的对象(通常来自标准层次结构),用于携带关于该错误的信息。

char & operator[](int index) {
    if (!in_bounds(index)) throw std::string("index out of bounds");  // 抛出一个对象
    return buf[index];
}

用 stringstream 在运行时拼出错误消息 —— 把实时的值(容量、下标……)拼进去,再 throw 出得到的 string:

#include <iostream>
#include <sstream>
#include <string>
using namespace std;

struct Stack {
    int data[100]; int top = 0; int cap = 100;
    int pop() {
        if (top <= 0) {
            stringstream ss;
            ss << "pop() on empty stack of capacity " << cap;   // 用运行时的值拼出消息
            throw ss.str();                                     // 抛出得到的 std::string
        }
        return data[--top];
    }
};

int main() {
    Stack s;
    try { s.pop(); }                      // 空栈 -> 抛出
    catch (const string& msg) {           // 接住抛出的字符串
        cout << "caught: " << msg << '\n';
    }
}

输出:

caught: pop() on empty stack of capacity 100

💡 ss.str() 把流进 ss 的全部内容作为一个 std::string 返回,所以抛出的消息能带上实时数据(这里是 cap),而纯字符串字面量做不到。(本课程抛的是 std::string;标准库则抛 std::exception 的子类,其 what() 携带文本。)

C++ 如何把 catch 与 throw 匹配起来 — (Q84) 💡

被抛出对象的类型会按顺序,自上而下地与每个 catch 子句进行匹配;第一个兼容的处理程序会被执行。针对基类(base)类型的 catch 也会捕获派生类(derived)的异常(因此顺序应为:具体 → 通用;catch(...) 放在最后)。匹配是按类型进行的——这里没有像函数实参匹配那样的隐式数值转换。

示例——基类处理程序能捕获派生类异常,所以顺序要“具体 → 通用”(附输出):

#include <iostream>
#include <stdexcept>   // out_of_range、exception
using namespace std;

int main() {
    try {
        throw out_of_range("index 5 is out of range");   // out_of_range : logic_error : exception
    }
    catch (const out_of_range& e) {     // 最具体的放最前 -> 执行这一个
        cout << "out_of_range: " << e.what() << '\n';
    }
    catch (const exception& e) {        // 基类:能捕获任何 std 异常(包括 out_of_range)
        cout << "exception: " << e.what() << '\n';
    }
    catch (...) {                       // 捕获一切:最后兜底,拿不到错误信息
        cout << "unknown error\n";
    }
}

输出:

out_of_range: index 5 is out of range

⚠️ 基类处理程序要放在具体的那些之后。 因为 catch (const exception&) 也能接住 out_of_range,把它写在前面会让具体处理程序永远到不了——g++ 甚至会警告:exception of type 'std::out_of_range' will be caught by earlier handler。永远按具体 → 通用排列,catch (...) 放最后。
💡 e.what() 是 std::exception 的一个 virtual 成员,返回 const char*——一段简短、人类可读的错误描述。对标准异常来说,它就是你传给构造函数的那条消息(这里是 "index 5 is out of range");你自己的异常类可以覆盖 what() 来返回自定义文本。

追踪一段 throw/catch 程序 — (Q85)

要能够预测输出。要点:try 内部 throw 之后的代码会被跳过;随着栈展开到处理程序,局部栈对象会被展开(它们的析构函数会运行);在匹配的 catch 执行完毕之后,执行会从整个 try/catch 之后继续。

标准异常层次结构(根:std::exception)💡

  • 由语言/运行时抛出:bad_alloc(new 失败)、bad_cast(向引用进行的 dynamic_cast 失败)、bad_typeid、ios::failure(I/O 错误)。
  • logic_error(可由程序员预防):domain_error、invalid_argument、length_error(例如,向一个字符串追加过多字符)、out_of_range(错误的 operator[] 索引)。
  • runtime_error(只在运行时才能检测到):range_error、overflow_error、underflow_error。

11. 命名空间(Namespaces)(Q86–90)

命名空间是什么 + 为什么 —— (Q86) 💡

一个用于将相关标识符分组的命名作用域(named scope),使得不同库中的名字不会发生冲突。所有 C++ 标准库的名字都位于命名空间 std 中。(在本课程里,你把自己的链表辅助代码放进 namespace list,并以 list::Node、list::copy() 的形式使用。)

创建并使用命名空间 —— (Q87)

namespace mySpace {
    class Stack;
    void myStackFunction(Stack s);
}
mySpace::Stack stk;          // 限定名用法
mySpace::myStackFunction(stk);

命名空间是累积的/开放的(cumulative / open):你可以在另一个文件里重新打开它并添加更多内容——这些片段会合并到一起。

namespace mySpace { void another(); }   // 把 'another' 添加到 mySpace

打开命名空间使其名字直接可见 —— (Q88)

三种“引入名字”的层级:

mySpace::Stack stk;          // 1) 完全限定(不导入)
using mySpace::Stack;        // 2) using 声明:只导入一个名字
Stack stk;
using namespace mySpace;     // 3) using 指令:打开整个命名空间
using namespace std;         //    (所有名字都直接可见)

这三种到底各自做了什么:

  • 完全限定(mySpace::Stack):完全不导入——每次都写 命名空间::名字。永远不会有歧义;在头文件(.hpp)里就该这么用。
  • using 声明(using mySpace::Stack;):把某一个具体的名字引入当前作用域,之后单写 Stack 就表示 mySpace::Stack。
  • using 指令(using namespace mySpace;):打开整个命名空间——里面所有名字一下子都直接可见(方便,但可能引入命名冲突——见 Q90)。
#include <iostream>
using namespace std;

namespace audio {
    void play()  { cout << "audio::play\n"; }
    int  volume = 7;
}

int main() {
    audio::play();              // 1) 完全限定 —— 不需要导入
    using audio::volume;        // 2) using 声明:只导入这一个名字
    cout << volume << '\n';     //    现在单写 'volume' 就是 audio::volume
    using namespace audio;      // 3) using 指令:打开整个命名空间
    play();                     //    现在 audio 里所有名字都直接可见
}

输出:

audio::play
7
audio::play

💡 按安全程度优先选:完全限定 > using 声明 > using 指令。 限定写法永远不会让你意外;using 声明只导入一个名字;using 指令把整个命名空间打开,是最可能造成歧义的——这也是为什么头文件里禁止写 using namespace std;(Q90)。

命名空间别名 —— (Q89)

为一个很长的命名空间起一个简短的本地名字:

namespace fs = std::filesystem;   // 现在可以写 fs::path 而不必写 std::filesystem::path

取舍:使用命名空间 vs 不使用 —— (Q90) 💡

  • 优点: 避免名字冲突;组织大型库;让两个库可以复用同样简单的名字。
  • 打开一个命名空间(using namespace std;)的缺点: 会拉入每一个名字,这可能重新引入歧义并掩盖 bug。

    ⚠️ 课程规则: 永远不要在 .hpp 中写 using namespace std;(它会强制每个包含该头文件的文件都打开这个命名空间)。在 .cpp 文件中优先使用限定名(std::cout)或者窄范围的 using std::cout; 声明。


12. I/O 流、文件与 stringstream (Q91–94)

流上的插入器与提取器 —— (Q91) 💡

  • << 是插入器(inserter) —— 把数据发送到输出流。它是一个左结合的二元运算符,所以可以链式调用:cout << a << b << endl; 会被解析为 ((cout << a) << b) << endl。
  • >> 是提取器(extractor) —— 从输入流读取数据;对字符串而言,它一次读取一个以空白分隔的单词,并返回该流,而流在到达文件末尾时为 false。这正是读取循环这一惯用法能奏效的原因:
string word;
while (in >> word) { out << word << endl; }   // 在 EOF 处停止
  • getline(in, line) 读取一整行(直到 '\n')。

C++ 流的两个例子 —— (Q92)

以下任选其二:cin/cout/cerr(终端)、ifstream/ofstream(文件)、istringstream/ostringstream/stringstream(内存中的字符串)。流可以是终端、文件、字符串、套接字……并且共享同一套 <</>> 接口。

#include <fstream>
ifstream in("input.txt");
ofstream out("output.txt");
string line;
while (getline(in, line)) out << line << endl;
in.close(); out.close();

iomanip 格式化(附示例)—— (Q93) 💡

<iomanip> 操纵符(manipulator)控制字段宽度、对齐方式、精度等。摘自期末复习幻灯片:endl、ends、left、right、setw(n)。

#include <iomanip>
cout << setw(8) << right << 42  << '\n';   // 宽度 8,右对齐:"      42"
cout << setw(8) << left  << "hi" << "|\n"; // 在宽度 8 内左对齐:"hi      |"
cout << fixed << setprecision(2) << 3.14159 << '\n';   // "3.14"

⚠️ setw(n) 仅作用于下一个项;对齐/精度标志是粘性的(保持设置直到被改变)。

更多 <iomanip> / 流操纵符(stream manipulator)—— (Q93) 💡

Q93 已经展示了 setw、left/right、setprecision 和 fixed。下面补全其余的日常工具箱,外加考题最爱考的一次性(one-shot)与粘滞(sticky)规则。

其余常用操纵符(除特别说明外均在 <iomanip> 里;<< 的顺序很重要):

操纵符 作用
setfill(c) 设置补齐到 setw 字段宽度(field width)时用的填充字符(fill character)(默认 ' ')。
left / right / internal setw 内的对齐(alignment):补在右侧 / 补在左侧 / 补在符号与数字之间(例如 - 42)。在 <iostream> 里。
setprecision(n) 位数:在默认模式下是总有效位数,在 fixed/scientific 下是小数点后的位数。
fixed / scientific / defaultfloat 浮点格式:3.14 / 3.14e+00 / 通用(defaultfloat 重置回默认)。在 <iostream> 里。
hex / oct / dec 整数进制(16 / 8 / 10)。在 <iostream> 里。
showbase / noshowbase 对整数显示进制前缀(0x、0)。
boolalpha / noboolalpha 把布尔值打印为 true/false,而非 1/0。
uppercase / nouppercase 在十六进制(0XFF)和科学计数(1.5E+10)中使用大写字母。
showpos / noshowpos 给非负数打印前导 +。
#include <iostream>
#include <iomanip>
std::cout << std::boolalpha << (1 < 2) << '\n';            // true   (不是 1)
std::cout << std::hex << std::showbase << 255 << '\n';     // 0xff
std::cout << std::uppercase << std::hex << 255 << '\n';    // 0XFF
std::cout << std::dec << std::nouppercase << std::noshowbase; // 先撤掉粘滞的 hex/uppercase/base
std::cout << std::showpos << 42 << '\n';                   // +42
std::cout << std::noshowpos;                               // 重置回普通十进制

⚠️ 注意:上面每一行都写到同一个 std::cout,所以粘滞标志会层层累积。若不加那行复位,std::cout << std::showpos << 42 会打印 0X2A(hex + uppercase + 进制前缀仍生效),而不是 +42——正是下面讲的粘滞陷阱。

⚠️ 一次性(one-shot)与粘滞(sticky)——最常考的陷阱:

  • setw(n)(以及 width())是一次性的:它只作用于紧接着插入的下一项,之后就重置为 0。
  • 其余的——setfill、left/right/internal、setprecision、fixed/scientific、hex/oct/dec、boolalpha、showpos、uppercase……——都是粘滞的:一直生效,直到被你改变。
std::cout << std::setfill('0') << std::setw(4) << 7 << '\n';  // 0007
std::cout << 7 << '\n';                                       // 7   (setw 已失效;setfill 仍设着,但没有宽度可补齐)

⚠️ endl 与 '\n'——另一个经典考点:

  • '\n' 只写一个换行符。
  • std::endl 写一个换行符并刷新(flush)缓冲区(强制把输出立刻送出)。
  • 刷新代价高昂,所以在热点循环里狂用 endl 会变慢——优先用 '\n',让流自己择机刷新(或在你真正需要时调用一次 std::flush)。
for (int i = 0; i < 1'000'000; ++i)
    std::cout << i << '\n';     // 快:带缓冲
    // std::cout << i << std::endl;  // 慢:刷新了一百万次

把一列右对齐(呼应 Q93)——setfill + 粘滞对齐,setw 每行重新施加:

int vals[] = {7, 42, 1234};
for (int v : vals)
    std::cout << std::setw(6) << v << '\n';   // 用空格补齐(默认)
//      7
//     42
//   1234

std::cout << std::setfill('0') << std::right; // 粘滞:只设一次
for (int v : vals)
    std::cout << std::setw(6) << v << '\n';   // 每行重新施加 setw(一次性)
// 000007
// 000042
// 001234

💡 要记住的套路:粘滞状态(setfill、对齐)在循环前设一次;一次性的 setw(n) 要每行重新发出。

stringstream(把一行解析成带类型的各个部分)—— (Q94) 💡💡(你在 HW8 的 compute_grades 中用到了它)

stringstream 是一个以字符串为底层支撑的流 —— 你可以用 >> 从中提取带类型的值,就像从 cin 中提取一样。标准的模式是先 getline 一整行,然后把它包进一个 stringstream 来解析各个字段。这样会把解析限定在那一行内(它不会意外读到下一行去)。

#include <sstream>
string line = "Alice 95 88 77";
istringstream ss(line);
string name; int q1, q2, q3;
ss >> name >> q1 >> q2 >> q3;   // name="Alice", q1=95, q2=88, q3=77

🐍 类似于 Python 中的 line.split(),但它能感知类型(它会把 "95" 直接转换成 int)。


13. STL 容器、迭代器与算法 (Q95–109)

STL 由三个可组合的部件构成(很大程度上源自 Alexander Stepanov 的设计):容器(container)负责存放东西,算法(algorithm)负责处理东西,而迭代器(iterator)把任意算法连接到任意容器。此外还有函数对象/lambda(让算法更灵活)以及标准异常。

STL 容器(STL container)— (Q95)

一个存放一组对象集合的类模板(class template)。两大家族:

  • 序列容器(sequence containers)(有序):array、vector(动态数组)、list(双向链表)、forward_list(单向链表)、deque(两端都能 push/pop)。
  • 关联容器(associative containers)(有序排序):set、multiset、map、multimap,以及对应的 unordered_(哈希)版本。
vector<int> v;  for (int i=0;i<100;++i) v.push_back(i);   // 动态数组

STL 算法(STL algorithm)— (Q96)

一个处理某个区间(range)的函数模板(function template)(复制、查找、排序、变换、累加……)。它能作用于任意容器,因为它面向的是迭代器,而非直接面向容器。

#include <algorithm>
#include <ranges>      // ranges:: 版本(C++20)

// 经典形式 —— 传入一对 [first, last) 迭代器来标定区间:
sort(V.begin(), V.end());                 // (first, last):begin() 是 first,end() 是“最后一个的后一个”
auto pos = find(V.begin(), V.end(), 3);   // (first, last, value) -> 指向那个 3 的迭代器(找不到则为 end())

// C++20 ranges 形式 —— 直接传整个容器,不用 begin()/end():
ranges::sort(V);                          // (range)
auto pos2 = ranges::find(V, 3);           // (range, value)

容器迭代器(container iterator)— (Q97)

一个指向容器内部的对象(就像数组指针),让你能读取并向前推进遍历元素。每个容器都提供 C.begin()(第一个)和 C.end()(最后一个的后一个)。

for (vector<int>::iterator p = V.begin(); p != V.end(); ++p)
    cout << *p << endl;   // *p 读取当前元素

迭代器操作: *(当前元素)、->(当前元素的成员)、++(下一个)、--(上一个,若支持)、==/!=、=。

容器、算法与迭代器之间的关系 — (Q98) 💡

迭代器是粘合剂:算法是针对迭代器接口编写的,因此同一个算法可以运行在 vector、list、set 等容器上。这正是 STL 之所以“可组合”的原因——N 个容器 + M 个算法,通过同一套迭代器抽象,就能在全部 N×M 种组合中工作。

基于范围的 for 循环(range-based for loop)— (Q99) 💡

无需显式迭代器即可遍历容器的每一个元素:

for (auto e : V)        cout << e;       // e 是每个元素的一份拷贝(COPY)
for (auto const & E : V) cout << E;       // E 是 const 引用(不拷贝)——读取时首选
for (auto & E : V)       E += 1;          // 引用——让你能修改元素
for (auto x : {1,2,3,4,5}) cout << x;     // 对初始化列表同样有效

🐍 这就是 C++ 版的 for x in V:。

std::string 容器(含示例)— (Q100)

一个字符序列,支持随机访问与丰富的操作(=、+、+=、==、[]、.size()、迭代器):

string s = "hello";
s[0] = 'H';
for (char c : s) cout << c;   // Hello

std::vector(含示例)— (Q101) 💡

一个动态数组——通过 operator[] 实现快速随机访问,在尾部(push_back、pop_back、back)以摊还 O(1) 的复杂度快速追加/删除。

vector<int> v(100, 0);   // 100 个元素,全部为 0
v.push_back(7);
cout << v[0] << " " << v.back() << " " << v.size();

🐍 相当于 C++ 里的 Python list。

⚠️ 构造时 () 与 {} 的区别——它们可能不是一回事。 用 {} 时,如果类型有 initializer_list 构造函数(每个 STL 容器都有),花括号会优先选它,于是这些值变成元素:

#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> a(100);    // (100):100 个 int,全为 0
    vector<int> b{100};    // {100}:只有 1 个 int,值是 100   <- 意外之处
    vector<int> c(3, 7);   // (3,7):3 个 int,全为 7
    vector<int> d{3, 7};   // {3,7}:2 个 int,3 和 7

    cout << "a(100): size=" << a.size() << '\n';
    cout << "b{100}: size=" << b.size() << "  first=" << b[0] << '\n';
    cout << "c(3,7): size=" << c.size() << "  {" << c[0] << "," << c[1] << "," << c[2] << "}\n";
    cout << "d{3,7}: size=" << d.size() << "  {" << d[0] << "," << d[1] << "}\n";
}

输出:

a(100): size=100
b{100}: size=1  first=100
c(3,7): size=3  {7,7,7}
d{3,7}: size=2  {3,7}

💡 规则:(...) 永远调用普通构造函数(这里是计数构造);{...} 在存在 initializer_list 构造函数时优先选它,把内容当作元素。对没有 initializer_list 构造的类型,两种写法一致。{} 还会禁止窄化(Q20)——所以它通常是更安全的默认选择,除了带大小的容器,那时你一般想用 ()。

std::map(含示例)— (Q102) 💡

一个有序排序的关联容器,存放 pair<key, value>,可用任意键类型索引;键唯一;insert/find/erase 都是 O(log N);按键的顺序迭代。

map<string,int> A;
A["Bill"] = 33; A["George"] = 29; A["Al"] = 27;
for (auto const & [name, age] : A)        // C++17 结构化绑定(“解构式 for”)
    cout << name << " is " << age << " "; // Al is 27 Bill is 33 George is 29 (按键排序)

⚠️ 对 map 使用 operator[] 时,如果键不存在,会悄悄插入一个默认值的条目——这是个经典陷阱。如果你只是想查找,请使用 .find()。
🐍 像 Python 的 dict,但始终按键保持排序。

读取 map 的 key/value 的两种写法——并排放:

#include <iostream>
#include <map>
#include <string>
using namespace std;

int main() {
    map<string,int> m = {{"Al", 27}, {"Bill", 33}};

    for (const auto& [key, val] : m)               // 写法 1:结构化绑定(C++17)
        cout << key << '=' << val << ' ';
    cout << '\n';

    for (const auto& p : m)                         // 写法 2:.first / .second
        cout << p.first << '=' << p.second << ' ';  // 每个元素是 pair<const string, int>
    cout << '\n';
}

输出:

Al=27 Bill=33
Al=27 Bill=33

💡 map 的元素是一个 pair<const Key, Value>,所以 [key, val] 只是把这个 pair 解包:key 等价于 p.first、val 等价于 p.second。(用迭代器则是 it->first / it->second。)键上的 const 意味着你可以改 val / .second,但永远不能改键。

std::set(含示例)— (Q103)

一个由唯一值构成的有序排序集合(元素本身就是它自己的键);insert/find/erase 为 O(log N)。multiset 允许重复值。

set<char> S;
for (char c='A'; c<='Z'; ++c) S.insert(c);
for (char c='Z'; c>='A'; --c) S.insert(c);   // 重复值被忽略
for (auto E : S) cout << E;                  // ABC...Z,每个仅一次
if (S.find('A') != S.end()) cout << "A is in there";

🐍 像 Python 的 set,但有序排序。

std::pair(含示例)— (Q104)

存放两个值,类型可以不同,通过 .first 和 .second 访问。map 的元素就是 pair。

pair<string,int> p{"Al", 27};
cout << p.first << " " << p.second;   // Al 27

Map 与 set 的对比 — (Q105) 💡

两者都是有序排序、键唯一的关联容器。区别在于:set 只存储键(值就是键);map 存储键→值的 pair,按键(.first)排序,而值(.second)随之一同携带。

常用容器操作——速查表 💡

日常真正会用到的成员函数(各容器的单独示例见 Q100–Q104):

你想做什么 写法 复杂度 容器
在末尾添加 c.push_back(x)、c.emplace_back(args) 摊还 O(1) vector deque list
从末尾删除 c.pop_back() O(1) vector deque list
在头部添加 / 删除 c.push_front(x) / c.pop_front() O(1) deque list
第一个 / 最后一个元素 c.front() / c.back() O(1) vector deque list
大小 / 是否为空 / 清空 c.size()、c.empty()、c.clear() O(1);clear() 为 O(n) 全部
元素访问(不检查 / 检查) v[i] / v.at(i) O(1) vector deque string
按位置 / 区间删除 c.erase(it)、c.erase(first, last) O(n);list 每节点 O(1) vector deque list string
插入 / 查找 / 计数 / 是否含 / 删除 c.insert(x)、c.find(x)、c.count(x)、c.contains(x)(C++20)、c.erase(x) O(log n);unordered_ 平均 O(1) set map
按键访问 m[k](不存在就插入!)、m.at(k) O(log n) map
适配器 stack:push / pop / top;queue:push / pop / front / back O(1) stack queue
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v;
    v.push_back(10);                 // [10]
    v.push_back(20);                 // [10, 20]
    v.emplace_back(30);              // [10, 20, 30](原地构造)
    cout << v.size() << " " << v.front() << " " << v.back() << '\n';   // 3 10 30
    v.pop_back();                    // [10, 20]
    v[0] = 99;                       // 不检查地写入 -> [99, 20]
    cout << v.at(1) << " " << (v.empty() ? "empty" : "filled") << '\n'; // 20 filled
    v.clear();                       // []
    cout << v.size() << '\n';        // 0
}

输出:

3 10 30
20 filled
0

⚠️ v[i] 和 v.at(i) 在下标越界时不同:[] 不做检查(未定义行为),at() 会抛 std::out_of_range。而 map 的 m[k] 在 k 不存在时会插入一个默认项——只想判断在不在就用 m.contains(k)(C++20,返回 bool),或老写法 m.find(k) != m.end() / m.count(k)(Q102)。(string::contains 要到 C++23 才有。)

遍历容器——有哪些循环写法 💡

同一份数据,几种写法(基于范围的 for 见 Q99,迭代器循环见 Q97):

#include <iostream>
#include <vector>
#include <map>
#include <string>
using namespace std;

int main() {
    vector<int> v = {10, 20, 30};

    for (size_t i = 0; i < v.size(); ++i) cout << v[i] << ' ';          // 1) 下标循环(vector/deque/string)
    cout << '\n';
    for (auto it = v.begin(); it != v.end(); ++it) cout << *it << ' ';  // 2) 迭代器循环(任何容器)
    cout << '\n';
    for (auto x : v) cout << x << ' ';                                  // 3) 基于范围的 for,按值(拷贝)
    cout << '\n';
    for (const auto& x : v) cout << x << ' ';                           // 4) 按 const& (只读,不拷贝)
    cout << '\n';
    for (auto& x : v) x += 1;                                           // 5) 按 & (就地修改)
    for (const auto& x : v) cout << x << ' ';
    cout << '\n';
    for (auto it = v.rbegin(); it != v.rend(); ++it) cout << *it << ' '; // 6) 反向迭代器
    cout << '\n';

    map<string,int> m = {{"a",1},{"b",2}};
    for (const auto& [key, val] : m) cout << key << '=' << val << ' ';  // 7) 结构化绑定(map/pair)
    cout << '\n';
}

输出:

10 20 30
10 20 30
10 20 30
10 20 30
11 21 31
31 21 11
a=1 b=2

💡 只读用 for (const auto& x : v)(不拷贝),修改用 for (auto& x : v);普通的 for (auto x : v) 会拷贝每个元素。下标循环只能用于随机访问容器(vector/deque/string);迭代器循环对每种容器都适用。rbegin()/rend() 倒着遍历,结构化绑定 [key, val] 用来解包 map/pair。

遍历中插入 / 删除——迭代器失效 💡💡

在循环中途修改容器,可能让你手里的迭代器失效;正确做法是用 erase/insert 返回的那个迭代器重新落座(reseat)——绝不要对一个已被 erase 的迭代器做 ++:

// 错误:erase 让 `it` 失效,接下来的 ++it 是未定义行为
for (auto it = v.begin(); it != v.end(); ++it)
    if (*it % 2 == 0) v.erase(it);          // it 成了悬空迭代器(-D_GLIBCXX_DEBUG 构建会在此处 abort)

// 正确:erase 返回指向下一个元素的迭代器;只有"没删"时才前进
for (auto it = v.begin(); it != v.end(); )  // 注意:循环头里没有 ++it
    if (*it % 2 == 0) it = v.erase(it);     // 用返回值给 it 重新落座
    else              ++it;

对 vector,比手写循环更地道的是 erase–remove 惯用法(或 C++20 的一行版):

#include <algorithm>   // remove_if
v.erase(remove_if(v.begin(), v.end(), [](int x){ return x % 2 == 0; }), v.end());
std::erase_if(v, [](int x){ return x % 2 == 0; });   // C++20:效果相同,一行搞定

输出 —— 手写 erase 循环、erase–remove 惯用法、erase_if 三者都把 {1, 2, 3, 4, 5, 6} 变成:

1 3 5

⚠️ 不同容器的失效规则不一样:

  • vector / deque / string —— erase/insert 会让删除/插入点及其之后的所有迭代器和引用失效;触发扩容的 push_back/insert 会让全部迭代器失效。永远用返回值重新落座。
  • list —— 只有被删元素的迭代器失效;insert 谁都不影响。中间廉价增删正是选 list 的全部理由。
  • set / map / unordered_*(基于节点)—— erase 只让被删的那个迭代器失效,且自 C++11 起也返回下一个,所以同样的 it = c.erase(it) 循环在这里也成立。

容器复杂度——操作表里没有的那部分 💡

有两条跨容器的事实是上面那张操作表读不出来的(随机访问、尾/头部插入、查找已经在它的复杂度列里了):

操作 vector deque list set / map unordered_set/map
在中间插入/删除(已持有迭代器) O(n) O(n) O(1) — —
迭代顺序 插入顺序 插入顺序 插入顺序 按键排序 未指定

💡 根据“什么必须快”来选择:需要随机访问 → vector;大量头部操作 → deque;在已持有迭代器时廉价地在中间插入/删除 → list;有序的键查找 → set/map;最快的无序查找 → unordered_*。

std::transform(含示例)— (Q106) 💡

对源区间的每个元素应用一个函数,并把结果写入目的地。

// 经典形式:transform(first, last, dest, op)
//   first,last = 源区间;dest = 结果写到哪里;op = 对每个元素施加的函数
transform(V.begin(), V.end(), V.begin(), [](int i){ return i+1; });   // dest == 源 -> 原地:每个 +1
transform(begin(L1), end(L1), back_inserter(L2), square);             // L2 = L1 的各元素平方

// C++20 ranges 形式:transform(range, dest, op)
ranges::transform(V, V.begin(), [](int i){ return i+1; });            // 原地
ranges::transform(L1, back_inserter(L2), square);                     // L2 = L1 的各元素平方

std::accumulate(含示例)— (Q107) 💡

把一个区间归约为单个值(默认为求和),从一个初始值开始。位于 <numeric> 中。

// accumulate(first, last, init [, op])
//   first,last = 区间;init = 起始值,并且它决定结果类型;op = 合并方式(默认是 +)
int total = accumulate(V.begin(), V.end(), 0);                       // 求和,从 0 开始
int prod  = accumulate(V.begin(), V.end(), 1, [](int a, int b){ return a*b; });  // 求积,从 1 开始

⚠️ 课程提示(HW8): accumulate 没有 ranges:: 版本——即使你对其它算法使用了 ranges:: 版本,也要用经典的迭代器对语法(accumulate(begin, end, init))来调用它。

⚠️ init 这个实参还决定了结果类型:对一个 vector<double>,accumulate(d.begin(), d.end(), 0) 会累加进一个 int(被截断!)——传 0.0 才能得到 double。

std::copy 与 std::move — (Q108) 💡

  • copy(first, last, dest) 把一个区间复制到目的迭代器。把它和插入迭代器(insert iterator)搭配使用,目的地就能随之增长:
// copy(first, last, dest):first,last = 源区间;dest = 输出迭代器
copy(L.begin(), L.end(), back_inserter(V));   // back_inserter -> 为每个元素调用 V.push_back()
copy(istream_iterator<string>(in), istream_iterator<string>(),   // 读取单词直到 EOF……
     back_inserter(V));                                          // ……存入 V
unique_copy(V.begin(), V.end(), ostream_iterator<string>(out, "\n"));  // 写出,跳过相邻的重复项
ranges::copy(L, back_inserter(V));            // C++20 ranges 形式:copy(range, dest)
  • std::move(这个函数) 把一个左值(L-value)转换成右值引用(R-value reference),从而发生移动(move)(窃取资源)而非拷贝:b = std::move(a);——此后 a 处于一个有效但空的状态。不要把这个移动转换(cast)与 memmove/复制混淆。
  • std::move(这个算法) 是同名的另一个东西:它把一整个区间移动赋值到目标处(每个元素都被掏空),例如 ranges::move(src, back_inserter(dst));。形状和 copy 一样,只是它移动而非拷贝。

std::for_each 与 std::copy_if 💡

  • for_each(first, last, fn) 对每个元素都运行 fn——为的是它的副作用(打印、修改)。给 lambda 一个 T& 参数,它就能原地修改元素。
  • copy_if(first, last, dest, pred) 只拷贝那些让 pred(x) 为真的元素(相当于 copy + filter 合成一步)。
#include <iostream>
#include <vector>
#include <iterator>   // back_inserter, ostream_iterator
#include <algorithm>  // for_each, copy_if, ranges::*
#include <ranges>
using namespace std;

int main() {
    vector<int> v = {1, 2, 3, 4, 5, 6};

    // for_each:对每个元素都运行一个函数(这里:打印)。
    cout << "print:  ";
    for_each(v.begin(), v.end(), [](int n){ cout << n << ' '; });
    cout << '\n';

    // for_each 的 lambda 取 T& 参数,就能原地修改每个元素:
    for_each(v.begin(), v.end(), [](int& n){ n *= 10; });    // 每个 *= 10
    cout << "x10:    ";
    ranges::for_each(v, [](int n){ cout << n << ' '; });     // ranges 形式:直接传整个容器
    cout << '\n';

    // copy_if:只拷贝通过谓词的元素。
    vector<int> picked;
    copy_if(v.begin(), v.end(), back_inserter(picked),
            [](int n){ return n % 20 == 0; });               // 留下 20 的倍数
    cout << "mult20: ";
    ranges::copy(picked, ostream_iterator<int>(cout, " "));  // 用 ranges::copy 打印
    cout << '\n';
}

输出:

print:  1 2 3 4 5 6 
x10:    10 20 30 40 50 60 
mult20: 20 40 60 

💡 这四个算法在经典形式里都接收一对迭代器,在 ranges:: 形式里接收整个区间。带 _if 的一族(copy_if、find_if、count_if、remove_if)接收一个谓词(predicate)(返回 bool);transform/for_each 接收一个操作(operation)(干活 / 返回新值)。

iterator 与 const_iterator 的对比 — (Q109)

  • 普通的 iterator 让你能读取并修改它所指向的元素(*it = x;)。
  • const_iterator 让你只能读取(*it 是 const)——在迭代 const 容器时、或当你不打算改动元素时使用(cbegin()/cend() 给出 const 迭代器)。

迭代器类别(哪种容器提供哪种)💡

类别 能…… 容器
输入/输出(input / output) 对序列读或写一次,向前 流迭代器
前向(forward) 读+写,用 ++ 移动 forward_list;你 HW9 的 SetList 迭代器
双向(bidirectional) ++ 以及 -- list、set、map、multiset、multimap
随机访问(random access) 双向 + []、+n vector、deque、string;你 HW9 的 MapArray 迭代器
连续(contiguous,C++17) 随机访问 + 物理上相邻 array、vector、string

声明迭代器的类别——iterator_category 与 iterator_traits 💡

自定义迭代器用五个成员 typedef 来声明自己属于上面哪一类,这样 STL 算法才能挑选最优实现。其中最关键的是 iterator_category,把它设为一个标签类型(tag type):

struct MyIter {
    using iterator_category = std::forward_iterator_tag;   // <- 类别标签
    using value_type        = int;
    using difference_type   = std::ptrdiff_t;
    using pointer           = int*;
    using reference         = int&;
    // ... 外加这个类别所要求的 operator*、operator++、operator== 等
};

这些标签由弱到强:input_iterator_tag / output_iterator_tag → forward_iterator_tag → bidirectional_iterator_tag → random_access_iterator_tag → contiguous_iterator_tag(C++20)。用 std::iterator_traits<It>::iterator_category 读回来(库里的迭代器也会报告自己的——vector<int>::iterator 是 random_access_iterator_tag,list<int>::iterator 是 bidirectional_iterator_tag)。

💡 为什么重要:算法会根据标签分派(dispatch)。std::distance(a, b) 和 std::advance(it, n) 对随机访问迭代器是 O(1)(直接 b - a / it += n),对前向/双向迭代器是 O(n)(只能一步步走)。⚠️ 把类别声明得比迭代器实际支持的更强是个 bug——算法会去调用你根本没写的操作(+= n、[]、--)。

按迭代器类别编写 operator++ / operator-- 💡 (HW9)

前置/后置的“外壳”对每种类别都是固定的——只有“步进”(那一行带注释的代码)会变化:

// 前置自增(PRE-increment):执行一步,按引用返回 *this
Iter& operator++() { /* 向前一步 */ return *this; }

// 后置自增(POST-increment):保存旧值,复用前置自增,返回那份旧的拷贝
Iter  operator++(int) { Iter tmp = *this; ++(*this); return tmp; }
  • 那个 int 参数是一个未使用的哑元(dummy),只用来区分后置与前置。
  • 后置自增永远都是“保存一份拷贝 + 调用前置自增”——这一点跨所有类别都不会变。各类别之间不同的,是前置自增内部的那个步进。
  • -- 形状完全相同;它的步进是“向后走一步”。在循环中优先用 ++it 而不是 it++(后置会多产生一份拷贝)。

① 前向(forward)——单链表(你的 SetList)。只有 ++;没有 --(无法往回走)。

// 成员:ListNode* current;
Iter& operator++()    { current = current->next; return *this; }   // 步进 = 跟随 `next`
Iter  operator++(int) { Iter tmp = *this; ++(*this); return tmp; }
// 没有 operator-- :前向不需要它,而且单链表也做不到

② 双向(bidirectional)——双向链表/树(真正的 std::set / std::map)。++ 和 -- 都有,一次走一个节点。

// 成员:Node* current;   (每个节点同时拥有 prev 和 next)
Iter& operator++()    { current = current->next; return *this; }   // 向前步进 = `next`
Iter  operator++(int) { Iter t = *this; ++(*this); return t; }
Iter& operator--()    { current = current->prev; return *this; }   // 向后步进 = `prev`  ← 这是双向相比前向多出来的部分
Iter  operator--(int) { Iter t = *this; --(*this); return t; }
// 仍然没有 +n :要往回走 3 步,就调用 -- 三次

③ 随机访问(random-access)——连续内存(你的 MapArray;成员是一个裸指针)。++/-- 是指针算术,并且你还能进行跳跃。

// 成员:value_type* current;   (value_type = std::pair<const Key, Value>)
Iter& operator++()    { ++current; return *this; }   // 步进 = 指针 ++(地址 + sizeof(elem))
Iter  operator++(int) { Iter t = *this; ++current; return t; }
Iter& operator--()    { --current; return *this; }
Iter  operator--(int) { Iter t = *this; --current; return t; }

随机访问额外多出的部分——+= -= + - 这一族(外加 []、各种比较、以及迭代器距离)——这正是让它成为“随机访问”的原因:

using difference_type = std::ptrdiff_t;

// 原地跳跃(按引用返回 *this)
Iter& operator+=(difference_type n) { current += n; return *this; }
Iter& operator-=(difference_type n) { current -= n; return *this; }

// 跳跃并产生一个新的迭代器(按值返回)
Iter  operator+(difference_type n) const { return Iter{ current + n }; }   // it + n
Iter  operator-(difference_type n) const { return Iter{ current - n }; }   // it - n

// 两个迭代器之间的距离(DISTANCE)——返回一个数字,而不是一个迭代器
difference_type operator-(const Iter& o) const { return current - o.current; }   // it2 - it1

// 下标:  it[k]  ==  *(it + k)
reference operator[](difference_type k) const { return current[k]; }

// 排序比较——前向/双向只有 == 和 != ;随机访问追加了这些:
bool operator<(const Iter& o) const  { return current <  o.current; }
bool operator>(const Iter& o) const  { return current >  o.current; }
bool operator<=(const Iter& o) const { return current <= o.current; }
bool operator>=(const Iter& o) const { return current >= o.current; }

💡 能力按类别层层累加。 前向提供 ++ == != * ->;双向追加 --;随机访问追加 += -= + -、it2 - it1、[] 以及 < > <= >=。所有这些在 MapArray 中几乎是免费的,恰恰因为 current 是一个裸指针,所以 current += n 已经完成了正确的地址算术。(为了完整的对称性,n + it 以自由函数 Iter operator+(difference_type n, const Iter& it) 的形式提供,因为成员形式只处理 it + n。)
⚠️ 与第 9 周代码的对比: Klefstad 的 HW9 SetList/MapArray 把前置自增按值返回(iterator operator++(),与他幻灯片第 12 页一致)。上面的 Iter& 形式是真正的 std:: 迭代器所采用的标准形式——两者都能编译,因为两者都 return *this,但 Iter& 才是规范的、无拷贝的形式。

迭代器适配器(adapters)(在算法示例中频繁使用)💡

  • 插入迭代器(Insert iterators): back_inserter(c) → c.push_back;front_inserter(c) → c.push_front;inserter(c, pos) → c.insert(唯一一个能用于 set/map 的)。
  • 流迭代器(Stream iterators): istream_iterator<T>(in) 用 >> 读取 T(默认构造的 istream_iterator<T>() = EOF 末尾标记);ostream_iterator<T>(out, "sep") 用 << 写出,在每一个元素之后都追加分隔符(包括最后一个)。
  • 反向迭代器(Reverse iterators): c.rbegin()/c.rend() 向后遍历。

C++20 Ranges 与视图(view)(主题 9)💡

两个相关的 C++20 特性,都住在 <ranges> 里。

(1)ranges:: 算法——直接传整个容器。 一个范围(range)就是一对 begin()..end(),而每个容器本身就已经是一个范围。所以算法的 ranges:: 版本可以直接接收容器——不用再写 .begin()/.end():

ranges::sort(v);                 // 取代  sort(v.begin(), v.end())
auto it = ranges::find(v, 42);   // 取代  find(v.begin(), v.end(), 42)

(2)视图(views)——用 | 拼出来的惰性流水线。 一个视图(view)(位于 std::ranges::views)是一个轻量的范围:你把一个数据源通过 filter、transform 这类适配器用“管道”串起来就得到它。从左往右读:v | filter(...) | transform(...) 的意思是“拿 v,留下通过过滤的元素,再把每个留下来的元素做变换。” 结果本身又是一个范围,所以可以直接用 range-for 遍历它。

关键就在惰性(lazy)这个词:搭出流水线这个动作本身什么都不计算。filter/transform 只在你迭代时才运行,每次只处理一个元素,而且只处理你真正访问到的那些元素。

#include <iostream>
#include <vector>
#include <ranges>     // std::ranges 与 std::ranges::views(C++20)
#include <algorithm>  // ranges::sort
using namespace std;
namespace rv = std::ranges::views;   // 简写别名:rv:: 就是 views::

int main() {
    vector<int> v = {5, 2, 8, 1, 4, 7, 6, 3};

    ranges::sort(v);                       // (1)整容器算法
    cout << "sorted: ";
    for (int x : v) cout << x << ' ';
    cout << '\n';

    // (2)搭一条流水线:留下偶数,再平方。此时什么都还没算。
    auto pipeline = v | rv::filter([](int n){ return n % 2 == 0; })
                      | rv::transform([](int n){ return n * n; });
    cout << "even squares: ";
    for (int x : pipeline) cout << x << ' ';   // 计算发生在这里,按需进行
    cout << '\n';

    // (3)take(3) 提前停止——第 4 个偶数从头到尾都没被平方过。
    cout << "first 3 even squares: ";
    for (int x : v | rv::filter([](int n){ return n % 2 == 0; })
                   | rv::transform([](int n){ return n * n; })
                   | rv::take(3))
        cout << x << ' ';
    cout << '\n';
}

输出:

sorted: 1 2 3 4 5 6 7 8 
even squares: 4 16 36 64 
first 3 even squares: 4 16 36 

想亲眼看到这种惰性?在 transform 里塞一句打印,看它到底什么时候触发:

vector<int> v = {1, 2, 3, 4};
auto squares = v | rv::transform([](int n){
    cout << "  [computing " << n << '*' << n << "]\n";   // 标记“计算发生”的时刻
    return n * n;
});
cout << "view built (nothing computed yet)\n";
for (int x : squares) cout << "got " << x << '\n';

输出:

view built (nothing computed yet)
  [computing 1*1]
got 1
  [computing 2*2]
got 4
  [computing 3*3]
got 9
  [computing 4*4]
got 16

💡 注意顺序:view built 先打印,然后每个平方值都是在它自己那行 got 之前才被算出来——计算是和循环交错进行的,绝不会提前一次性算完。这正是“惰性 / 按需”的含义,也正因如此,上面的 take(3) 才能让第 4 个偶数从头到尾都不被平方。

适配器 作用
filter(pred) 只保留 pred(x) 为真的元素
transform(f) 把每个元素 x 替换成 f(x)
take(n) / drop(n) 保留前 n 个 / 跳过前 n 个
reverse 从尾到头遍历

智能指针(smart pointer)(主题 9)——针对单个对象的 RAII 💡

  • unique_ptr<T>——唯一所有者;当它消亡时删除对象;拷贝被删除(只能移动,通过 std::move)。auto p = std::make_unique<T>(args);
  • shared_ptr<T>——共享所有权;当最后一个 shared_ptr 消亡时对象被删除(引用计数)。auto sp = std::make_shared<T>(args); 创建一个智能指针的两种写法——以及为什么第一种不推荐:
// ❌ 不推荐——裸 new:类型名要写两遍,而且不是异常安全的。
unique_ptr<Widget> a{new Widget(1)};   // 能用,但啰嗦
shared_ptr<Widget> b{new Widget(2)};   // 还会强制做第二次堆分配

// ✅ 推荐——make_xxx:类型只写一次,看不到 `new`。
auto a = make_unique<Widget>(1);
auto b = make_shared<Widget>(2);       // 对象 + 控制块只分配一次

用推荐写法跑一遍完整程序:

#include <iostream>
#include <memory>      // unique_ptr, shared_ptr, make_unique, make_shared
using namespace std;

struct Widget {
    int id;
    Widget(int i) : id(i) { cout << "ctor " << id << '\n'; }
    ~Widget()             { cout << "dtor " << id << '\n'; }
};

int main() {
    auto u = make_unique<Widget>(1);     // unique_ptr<Widget>,唯一所有者
    cout << "u->id = " << u->id << '\n'; // 像裸指针一样用
    auto u2 = std::move(u);              // 转移所有权;u 现在为空
    cout << "u is " << (u ? "set" : "null") << '\n';

    auto s = make_shared<Widget>(2);     // shared_ptr<Widget>,use_count = 1
    auto s2 = s;                         // 拷贝 -> 共享所有权,use_count = 2
    cout << "use_count = " << s.use_count() << '\n';
}   // u2 释放 Widget 1;Widget 2 在最后一个 shared_ptr 消亡时释放

输出:

ctor 1
u->id = 1
u is null
ctor 2
use_count = 2
dtor 2
dtor 1

💡 为什么用 make_xxx 而不是 unique_ptr<T>(new T(...))?(1) 异常安全——万一别的实参求值时抛异常也不会泄漏;(2) 类型名只写一次(不用 new T);(3) make_shared 把对象和它的控制块用一次分配搞定。用法和普通指针一样:p->成员、*p、p.get() 取裸指针、p.reset() 提前释放。

常见错误——拷贝 unique_ptr 无法通过编译:

auto u = make_unique<Widget>(1);
auto bad = u;             // ❌ 试图拷贝——unique_ptr 没有拷贝构造函数
auto ok  = std::move(u);  // ✅ 改用移动:所有权转移,u 变为空

编译器会直接拦住你——这是编译期错误,不是运行时才暴露的隐患(模板噪音已省略):

error: use of deleted function
       'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)'
note: declared here
  unique_ptr(const unique_ptr&) = delete;

💡 unique_ptr 是唯一所有者,拷贝它会造出两个所有者、都去 delete 同一个对象。C++ 把拷贝构造函数删除掉,就是把这种错误变成编译错误,而不是运行时的 double-free。shared_ptr 正相反——拷贝是允许的,只是把引用计数加一。

⚠️ HW9 陷阱(来自主题 9 的幻灯片): 如果一个链表节点的 next 是一个 unique_ptr,那么迭代器无法别名(alias)这个链表——所以对于 SetList,你的 next 必须使用一个裸 ListNode*,而不是 unique_ptr。

unique_ptr<T> shared_ptr<T>
所有者 恰好 1 个 1 到多个(引用计数)
可拷贝? ❌(拷贝 = delete) ✅(增加计数)
可移动? ✅(转移所有权) ✅
开销 除 T 外没有额外开销 一个控制块(计数 + 删除器)
何时释放对象 当 unique_ptr 消亡时 当最后一个 shared_ptr 消亡时(计数 → 0)
用什么创建 std::make_unique<T>(args) std::make_shared<T>(args)

💡 默认使用 unique_ptr(更便宜、所有权明确);只有在所有权确实是共享的时候才用 shared_ptr。优先使用 make_unique/make_shared 而不是裸 new(异常安全,无需重复书写类型名)。这就是 RAII 的现代面貌:当成员是智能指针(或 std::vector/std::string)时,三法则坍缩成了零法则——你一个特殊函数都不用写。


附录 A —— Klefstad 的格言(请牢记于心)

下面这些是教授在课件里反复讲到的"金句"——正是那类会出现在概念题里的"智慧":

  • "能力越大,责任越大。"("With great power comes great responsibility.") —— 谈的是动态分配:new/delete 赋予你能力,但你必须自己管理它。
  • "智慧之言:把动态分配限制在类的实现内部。"("Wisdom: restrict dynamic allocation to class implementations.") —— 把裸 new/delete 包进一个类里(RAII);不要让它们散落在 main 各处。
  • "我只想先让它跑起来" / "我之后再来收拾"("I just want to get it to work" / "I'll clean it up later") → 会导致那种极难找出的 bug。现在就把它收拾干净。 如果一个函数超过 5 行,就重构它。逐块测试。
  • "所有代码都有 bug,但只有可读的代码才能被修复。"("All code has bugs, but only readable code can be fixed.")(Code Review 课件)—— 可读性就是正确性的保险。
  • 代码评审(code review)最初的目的:"逼迫开发者写出其他开发者能看懂的代码。"("force developers to write code that other developers understand.")
  • 命名: "我们都必须使用 data 和 next,这样我们才是在讲同一种语言。"("We all must use data and next so we are speaking the same language.")(链表节点的成员)
  • 友元: "谁说我是你的朋友?—— 是我说的,不是你说的。"("Who says I am your friend? — me, not you.")(是类来授予 friend 身份)
  • 那张"Remember"幻灯片(期末总结):
    • 三块内存区域: 静态存储区(data segment)、栈(stack)、自由存储区(堆,free store / heap)。
    • 每个对象都有生命周期(出生 → 存活 → 死亡;是 destruction(析构),不是 "deconstruction")。
    • 按引用传递有两个用途:(1)修改实际的实参,(2)效率(避免一次拷贝)。
    • "如果你不会用它,……"("If you don't use it, …") —— 这是一个开放式提示,关于不要声明/分配你并不需要的东西(零法则的精神):不会 delete 的东西就别 new;本可以用 const& 传递的东西就别按值传递。

附录 B —— 快速对照表

指针与引用对比

指针(pointer)T* 引用(reference)T&
可重新绑定 是 否(初始化时即固定)
可为空 是(nullptr) 否
必须初始化 否 是
使用目标 *p、p->m 直接用 r

struct 与 class 对比

唯一的区别在于默认访问权限:struct → 公有(public);class → 私有(private)。(默认继承方式也是如此。)

拷贝与移动对比(特殊成员函数)

拷贝构造函数 / 拷贝 = 移动构造函数 / 移动 =
参数 const T& T&&
效果 深拷贝(deep copy);源对象不变 窃取资源;源对象被清空
操作后源对象 完好无损 buf = nullptr(必需)
触发条件 左值(L-value)源 右值(R-value)/ std::move

三法则 / 五法则 / 零法则

  • 三法则: 拥有一个裸指针(raw pointer)→ 编写拷贝构造函数 + 拷贝 = + 析构函数。
  • 五法则: 此外还要编写移动构造函数 + 移动 =。
  • 零法则: 使用 std::vector/string/map/智能指针(smart pointer)→ 这些一个都不用写。

指针周围的 const 摆放位置(从右往左读)

声明 p 可重新绑定? *p 可写?
int * p 是 是
int * const p 否 是
const int * p 是 否
const int * const p 否 否

容器速查表

容器 顺序 查找 备注
vector 插入顺序 [] O(1) 动态数组;尾部操作快(≈ Python list)
set 已排序 O(log N) 值唯一(≈ 已排序的 Python set)
map 按键排序 O(log N) 键唯一,pair<K,V>(≈ 已排序的 dict);[] 会插入元素!
list 插入顺序 O(n) 双向链表;双向迭代器

错误由哪个编译阶段报告?

undeclared → 编译器(compiler)。undefined reference / multiply defined → 链接器(linker)。segmentation fault → 运行时。

指针/引用追踪实例(期末复习的热门题——请手动追踪这些)

int i = 10;
int * p = &i;       // p -> i
int ** pp = &p;     // pp -> p
int * & rp = p;     // rp 是 p 的别名(一个指向指针的引用)
i  = 20;            // i = 20
*p = 30;            // i = 30  (通过指针修改)
*rp = 50;           // *rp == *p,所以 i = 50
// 最终:i == 50

// const 版本——哪些操作是允许的?
const int i = 10;
const int * const p = &i;   // 不能重新绑定 p,也不能写 *p
const int & r = i;          // i 的只读别名
// p = &j;  错误    *p = 5;  错误    r = 5;  错误
// 向链表头部插入元素(仅需一行!):
head = new Node{'C', head};   // 新节点的 next = 旧的 head,然后 head 指向新节点

附录 C —— 代码评审(可能出现在期末考试中)

教授指出,关于代码评审(Code Reviews)的题目可能会出现在期末考试中。 所考查的能力是阅读并在脑中执行代码,从而(1)找出 bug,以及(2)改善可读性(readability)。

如何写出一篇好的评审(要求的格式):

line X [bug]: The buggy code does {outcome} because {specific wrong code}.
              Instead it should {desired outcome}, so use {correct code}.
line Y [readability]: {unclear code}. Instead, {clearer code}.
  • 要具体且有实质内容,写明行号与确切的修改方案;“这段代码看起来很复杂”不会得到任何分数。
  • 把关于同一个问题的所有句子归在一起;以行号开头。
  • 先在脑中追踪执行(用纸,而不是 PythonTutor);之后你才可以在编译器/PythonTutor 中验证。
  • 态度要友好、尊重;避免用含糊的“This will……”作为句子开头——要明确指出代码做了什么。

反复出现的例子是一个链表(linked list)的 remove(),其中带有诸如 return temp; 这样的 bug(它只返回了第一个节点,因此删除头节点会失败——应该写成 return L;),以及令人困惑的命名(temp2→new_next,temp3→node_to_delete)。要做好准备去阅读 SLL 指针代码,并找出悬空指针(dangling pointer)、错误的返回值以及内存泄漏(memory leak)。


附录 D —— 期末考试的优先复习主题

下面是这门考试价值最高的几簇主题——它们能整齐地对应到上面的各个章节。优先复习这些:

  1. 追踪控制流 (最大的一簇:循环、if/嵌套 if、条件表达式、switch、布尔条件)。 Topic 10 的讲义明确列出了迭代(带 3 个字段的 for / 基于范围的 for / while / do-while)、switch/case/default/break 结构,以及混合算术运算与 i++ 对比 ++i。练习手工追踪小程序,并写出精确的输出。→ 关联第 2 节。
  2. 三法则与默认拷贝语义 (深拷贝、默认拷贝构造函数、默认拷贝赋值)。 要准确理解为什么编译器生成的拷贝是浅拷贝,以及它如何导致 double-free(双重释放)。→ 第 6 节(三法则)、第 4 节(浅拷贝对比深拷贝)。
  3. 代码审查式追踪 (深拷贝、动态数组扩容、构造函数初始化、数组查找)。 重新阅读第 6 节中的 IntList::increaseArrayTo、拷贝构造函数和 indexOf 代码,并能在其中找出 bug。→ 附录 C + 第 6 节。
  4. 数组与 C 风格字符串的追踪 (数组边界、栈数组初始化、C 风格字符串遍历)。 没有边界检查(第 4 节 Q37);未初始化 → 垃圾值 (Q25/Q38);五种 C 风格字符串遍历惯用法(哪些能用、哪些不能用)。→ 第 4 节。
  5. 下标运算符 [] (重载下标运算)。 它返回 int&(一个左值),以及哪个类做边界检查。→ 第 6 节 Q44/Q59、第 2 节 Q13。
  6. 构建流水线与声明 (函数声明、编译错误对比链接错误、重复定义)。 哪个阶段捕获哪种错误;包含守卫能防止“重复定义”。→ 第 5 节表格 + 第 1 节 Q4。

这六个题目簇是考试中分值最集中的地方。如果你手工追踪大约 10 个短程序,并能从零写出一个正确的三法则类,你就已经覆盖了价值最高的内容。


🎯 走进考场前最重要的 12 件事

  1. 对象生命周期(object lifetime): 出生(分配 + 构造)→ 存活 →死亡(析构+ 释放)。栈(stack)= 块作用域;堆(heap)= new……delete;静态(static)= 整个程序。
  2. 三大内存区域(memory area): 静态/数据段、栈、自由存储区(堆)。
  3. new↔delete、new[]↔delete[]——绝不要把它们配错。
  4. 三法则(Rule of Three): 只要有裸指针成员 ⇒ 就要写拷贝构造函数 + 拷贝 = + 析构函数;默认的拷贝是浅拷贝(shallow copy)→ 双重释放(double-free)。
  5. 移动构造函数 / 移动 = 必须把源对象的指针置为 nullptr(被移动的对象仍会被析构 →否则就会双重释放)。
  6. 自赋值(self-assignment)检查的是同一性(IDENTITY): if (this != &v),绝不要写成 if (*this != v)。
  7. 浅拷贝 vs 深拷贝(shallow vs deep copy)——以及为什么深拷贝和析构函数密不可分。
  8. 引用 vs 指针(reference vs pointer),以及 const 的摆放位置(常量指针 vs 指向常量的指针)。
  9. 对庞大的只读对象用 const& 传递(讲求效率);用 & 传递以便修改;按值传递以获得一份独立的拷贝。
  10. 虚函数(virtual function)+ 虚析构函数(virtual destructor) 提供运行期分派;带有纯虚函数(pure virtual function)的类是抽象类(abstract class)(无法实例化)。通过基类指针 delete 需要一个虚析构函数。
  11. STL = 容器 + 迭代器 + 算法;vector/set/map/pair;基于范围的 for 循环;transform/accumulate/copy/find/sort;map::operator[] 会悄悄插入元素。
  12. 构建流水线(build pipeline): 编译器(compiler)捕获未声明(undeclared)+ 类型错误;链接器(linker)捕获未定义(undefined)/重复定义(multiply defined);糟糕的指针/索引会在运行期触发段错误(seg-fault)。包含守卫(include guard) 可防止重复定义。

在代码题上要遵守的课程约定:成员名用 data/next;.hpp 里不要写 using namespace std;;窄化转换要用 static_cast(构建使用了 -Werror);它叫析构(destruction),而不是 “deconstruction”。

保持自信,保持专注,全力以赴。期末考试好运!🍀

快捷键[ 上一篇] 下一篇L 切换语言