C++ / 2026-08-01
CS106L 第 17 讲(选修):C++ 冰山之下
从词法、语法、生命周期、I/O 与 ABI 多个层次理解 C++ 的反直觉行为。
本课程重构自 CS106L Spring 2026 Lecture 17: C++ Iceberg。
本节课要解决的核心问题
到目前为止,我们已经学会了许多现代 C++ 工具:
- 运算符重载;
- 模板与类型推导;
- 移动语义;
- 对象生命周期;
- RAII;
- 智能指针;
- 标准库容器;
- 输入输出流;
- C++ 项目的编译与链接。
这些知识让我们能够写出正常工作的程序。但当代码逐渐复杂后,我们可能遇到一些看起来不符合直觉的现象:
while (x --> 0)
这里真的存在一个 --> 运算符吗?
else if (...)
else if 真的是一种独立的语法结构吗?
for (int value : getCollection().getRef())
这段范围 for 循环看起来正常,为什么却可能访问已经销毁的对象?
std::cout << "Hello World!\n";
return 0;
这么普通的程序,为什么可能在输出失败之后仍然返回成功?
还有一个更深的问题:
为什么标准库里一些已经知道如何改进的设计,却迟迟无法修改?
这些现象并不是互不相关的冷知识。它们分别来自 C++ 的不同层次:
我们写下的源代码
↓
词法分析:代码会被切成哪些记号?
↓
语法分析:这些记号会组成什么结构?
↓
类型与对象生命周期:表达式引用的对象还活着吗?
↓
标准库:操作会立即完成,还是被暂存?
↓
操作系统:真正的读写是否成功?
↓
二进制接口:已经编译好的程序还能否与新库协作?
本节课真正要建立的能力不是记住几个奇怪案例,而是:
当 C++ 程序的行为不符合直觉时,知道应该到哪一层寻找原因。
学习本节所需的前置知识
阅读本节课程前,只需要具备以下基础:
- 变量、表达式与运算符;
if、while和for;- 普通函数;
- 类与成员函数;
- 引用;
std::vector;std::cout;- 构造函数和析构函数的基本概念。
本节会再次用到以下内容,但会在使用前重新解释:
- 后置自减;
- 运算符优先级;
- 运算符重载;
- 临时对象;
- 对象生命周期;
- 悬空引用;
- 缓冲区;
- 编译与链接。
从上一节课留下的问题开始
上一节课学习 RAII 和智能指针时,我们建立了一条重要原则:
一个对象只能在自己的生命周期内被安全使用。
例如:
{
std::vector<int> numbers{10, 20, 30};
} // numbers 在这里被销毁
离开大括号后,numbers 已经不存在。无论原来存放数据的内存中还残留着什么字节,都不能继续把那些字节当作原来的 std::vector 使用。
但只知道这条原则还不够,因为对象的生命周期有时被隐藏在表达式中:
for (int value : getCollection().getRef()) {
// ...
}
这里没有明显的大括号告诉我们临时对象在哪里销毁。为了判断代码是否安全,我们必须理解编译器怎样解析表达式,以及临时对象究竟活到什么时候。
这正是“C++ 冰山”想表达的核心:我们平时看到的语法只是海面上的一小部分。
第一层:代码看起来像什么,不等于它被怎样切分
--> 真的是一个运算符吗?
先观察下面的程序:
#include <iostream>
int main() {
int x = 10;
while (x --> 0) {
std::cout << x << ' ';
}
std::cout << "\nfinal x = " << x << '\n';
}
使用下面的命令编译:
g++ -std=c++20 main.cpp -o main
程序输出:
9 8 7 6 5 4 3 2 1 0
final x = -1
从外观看,x --> 0 很像在表达:
x goes to 0
也就是“让 x 一路走到 0”。
但 C++ 中并不存在 --> 运算符。
编译器首先会把代码切成记号
在分析语法之前,编译器首先进行词法分析(lexical analysis)。
词法分析会把源代码切分为一个个记号(token)。
表达式:
x --> 0
被切分为:
x -- > 0
也就是:
(x--) > 0
这里包含两个已经存在的运算符:
--:后置自减运算符;>:大于运算符。
空格写成下面任何一种形式,都不会改变这次切分:
x --> 0
x-- > 0
x -- > 0
它们都会被理解为:
(x--) > 0
后置自减究竟返回什么
后置自减:
x--
会完成两件事:
- 产生
x修改前的旧值; - 将
x减少 1。
这两个动作属于同一个表达式,但比较操作使用的是旧值。
假设:
int x = 3;
bool result = (x-- > 0);
执行过程是:
执行之前:
x = 3
计算 x--:
表达式产生旧值 3
随后 x 变成 2
进行比较:
3 > 0
结果为 true
执行之后:
x = 2
result = true
因此:
while (x-- > 0)
不是“先减一,再判断减完后的值”,而是:
先取出旧值用于比较,同时把变量减一。
完整跟踪 while (x --> 0)
初始状态:
x = 10
第一次判断:
x-- 产生旧值 10
x 变成 9
比较 10 > 0,结果为 true
进入循环,输出当前 x,也就是 9
第二次判断:
x-- 产生旧值 9
x 变成 8
比较 9 > 0,结果为 true
进入循环,输出 8
后续过程相同。
当 x 为 1 时:
x-- 产生旧值 1
x 变成 0
比较 1 > 0,结果为 true
进入循环,输出 0
下一次判断:
x-- 产生旧值 0
x 变成 -1
比较 0 > 0,结果为 false
循环结束
因此最终:
输出:9 8 7 6 5 4 3 2 1 0
x = -1
为什么不应该把它当作正常写法
这段代码在语法和行为上都是合法的,但可读性较差。
读者很容易误以为:
-->
是一个特殊运算符。
实际项目中更推荐明确写成:
while (x-- > 0) {
std::cout << x << ' ';
}
甚至把修改与判断拆开:
while (x > 0) {
--x;
std::cout << x << ' ';
}
后者的执行顺序更容易观察:
先判断 x 是否大于 0
再把 x 减一
最后输出
代码能够通过编译,只说明它符合语言规则,不代表它具有良好的可读性。
练习:跟踪 x --> 1
预测下面程序的输出,并写出最终的 x。
#include <iostream>
int main() {
int x = 3;
while (x --> 1) {
std::cout << x << ' ';
}
std::cout << "\nfinal x = " << x << '\n';
}
答案与解释
程序输出:
2 1
final x = 0
表达式被解析为:
while ((x--) > 1)
第一次判断:
旧值 3 与 1 比较:3 > 1,为 true
x 变成 2
输出 2
第二次判断:
旧值 2 与 1 比较:2 > 1,为 true
x 变成 1
输出 1
第三次判断:
旧值 1 与 1 比较:1 > 1,为 false
x 变成 0
循环结束
容易误判的地方是认为先执行 --,再使用新值比较。那是前置自减:
--x
而题目使用的是后置自减:
x--
第二层:else if 并不是一种独立语法
我们平时怎样理解 else if
下面是最常见的条件分支:
if (score >= 90) {
std::cout << "A\n";
} else if (score >= 80) {
std::cout << "B\n";
} else {
std::cout << "C\n";
}
从排版上看,好像 C++ 提供了三种结构:
if
else if
else
但从语法上说,C++ 只有:
if
else
所谓的 else if,其实是:
一个
else,后面恰好跟着另一个if语句。
else 后面需要的是“一条语句”
可以把 if 的结构暂时写成:
if (条件)
一条语句
else
一条语句
这里的“一条语句”可以是很多东西。
它可以是函数调用:
if (condition)
doFirstThing();
else
doSecondThing();
它可以是 while 循环:
if (condition)
doFirstThing();
else
while (anotherCondition)
doSecondThing();
它也可以是另一个 if:
if (condition)
doFirstThing();
else
if (anotherCondition)
doSecondThing();
最后一种正是平时写作:
if (condition) {
doFirstThing();
} else if (anotherCondition) {
doSecondThing();
}
的结构。
课件中的 else while
下面的程序完全合法:
#include <iostream>
int main() {
int i = 3;
if (false) {
std::cout << "hello\n";
} else while (i > 0) {
std::cout << i << '\n';
--i;
}
}
输出:
3
2
1
它的结构是:
if (false)
复合语句
else
while 语句
可以画成:
if
├── 条件:false
├── 条件成立时:输出 hello
└── else 后的一条语句
└── while (i > 0)
├── 输出 i
└── i 减一
由于 if 条件为 false,程序执行 else 后面的那一条 while 语句。
大括号为什么也是“一条语句”
下面的大括号整体称为复合语句(compound statement):
{
statement1;
statement2;
statement3;
}
虽然内部可以包含很多语句,但整个大括号结构在外层看来是一条语句。
因此:
if (condition) {
statement1;
statement2;
}
仍然满足:
if (条件)
一条语句
只是这一条语句内部又包含了多条语句。
“悬空的 else”会连接到谁
考虑下面的代码:
if (hasAccount)
if (hasPermission)
std::cout << "allowed\n";
else
std::cout << "denied\n";
这里的 else 属于哪一个 if?
C++ 的规则是:
else会与前面距离最近、尚未拥有else的if配对。
因此上面的代码等价于:
if (hasAccount) {
if (hasPermission) {
std::cout << "allowed\n";
} else {
std::cout << "denied\n";
}
}
而不是:
if (hasAccount) {
if (hasPermission) {
std::cout << "allowed\n";
}
} else {
std::cout << "denied\n";
}
这两个程序的逻辑完全不同。
因此,即使 C++ 允许省略大括号,也推荐在存在嵌套时明确写出大括号。
else if 的真实结构
下面的代码:
if (score >= 90) {
std::cout << "A\n";
} else if (score >= 80) {
std::cout << "B\n";
} else {
std::cout << "C\n";
}
真实结构是:
第一个 if
├── 条件:score >= 90
├── 成立:输出 A
└── else
└── 第二个 if
├── 条件:score >= 80
├── 成立:输出 B
└── else
└── 输出 C
将它完全展开,可以写成:
if (score >= 90) {
std::cout << "A\n";
} else {
if (score >= 80) {
std::cout << "B\n";
} else {
std::cout << "C\n";
}
}
两种写法含义相同。第一种只是排版更紧凑。
练习:判断 else 属于谁
下面程序中,当:
hasAccount = false
hasPermission = false
时会输出什么?
#include <iostream>
int main() {
bool hasAccount = false;
bool hasPermission = false;
if (hasAccount)
if (hasPermission)
std::cout << "allowed\n";
else
std::cout << "denied\n";
}
答案与解释
程序不会输出任何内容。
else 与最近的内层 if (hasPermission) 配对,真实结构为:
if (hasAccount) {
if (hasPermission) {
std::cout << "allowed\n";
} else {
std::cout << "denied\n";
}
}
由于 hasAccount 为 false,整个内层 if 都不会执行。
常见误判是认为 else 属于外层的 if (hasAccount),从而预测会输出:
denied
要让 else 明确属于外层 if,需要写成:
if (hasAccount) {
if (hasPermission) {
std::cout << "allowed\n";
}
} else {
std::cout << "denied\n";
}
从语法进入标准库:为什么 iostream 会设计成现在这样
前两个例子说明:
- 源代码的视觉外观不一定等于记号划分;
- 常见的排版形式不一定对应独立的语法结构。
但即使我们已经理解了语法,仍然会遇到另一个问题:
为什么 C++ 要使用
<<和>>完成输入输出?
例如:
std::cout << value;
std::cin >> value;
<< 原本是左移运算符,>> 原本是右移运算符。它们为什么会出现在输入输出中?
答案来自 C++ 输入输出库的设计目标。
第三层:iostream 是一次充满取舍的设计
旧方法解决了什么,又缺少什么
C 语言经常使用:
printf("%d\n", value);
printf 很有效,也很简洁。但它的类型信息主要写在格式字符串中:
"%d"
编译器看到的函数接口大致是:
printf(const char* format, ...);
后面的参数数量和类型并没有完整体现在普通函数参数类型中。
例如:
printf("%d\n", "hello");
%d 要求对应一个整数,但实际传入的是字符串指针。现代编译器通常可以对字面格式字符串给出警告,但这种接口本身并没有通过参数类型完整表达要求。类型不匹配可能导致未定义行为。
C++ 输入输出流的设计目标包括:
- 类型安全(type safety);
- 简洁;
- 可扩展;
- 高效;
- 能够支持用户自定义类型。
因此产生了:
std::cout << value;
为什么选择 <<
std::cout 是一个输出流对象。
表达式:
std::cout << value
会根据 value 的类型选择对应的 operator<<。
例如:
int number = 42;
double price = 3.5;
std::string name = "Alice";
std::cout << number;
std::cout << price;
std::cout << name;
三个表达式外形相同,但编译器根据右侧参数类型选择不同的重载。
从直觉上可以把:
std::cout << value;
理解为:
把 value 送入 cout 代表的输出流
课件介绍的历史背景是:设计者希望它在视觉上接近 Unix 中“数据流动”的概念,曾考虑过 =, <, > 等符号,最终形成了以 << 和 >> 为核心的流式接口。
为什么可以连续写很多个 <<
观察:
std::cout << "answer = " << value << '\n';
它不是一次同时处理所有内容,而是从左向右连续执行:
((std::cout << "answer = ") << value) << '\n';
每次输出操作都会返回原来的流对象引用:
std::cout << "answer = "
↓
返回 std::cout 本身
↓
继续 << value
↓
再次返回 std::cout 本身
↓
继续 << '\n'
概念上,输出运算符的形式类似:
std::ostream& operator<<(std::ostream& out, SomeType value);
返回类型是:
std::ostream&
也就是输出流引用,因此后面还能继续连接新的 <<。
用户自定义类型也可以输出
假设我们有一个点类型:
struct Point {
int x;
int y;
};
我们希望可以这样输出:
Point point{3, 4};
std::cout << point << '\n';
可以重载输出运算符:
#include <iostream>
struct Point {
int x;
int y;
};
std::ostream& operator<<(std::ostream& out, const Point& point) {
out << '(' << point.x << ", " << point.y << ')';
return out;
}
int main() {
Point point{3, 4};
std::cout << "point = " << point << '\n';
}
输出:
point = (3, 4)
参数设计如下:
std::ostream& out
使用非常量引用,因为输出操作需要修改流对象的内部状态。
const Point& point
使用常量引用:
- 避免复制
Point; - 保证输出操作不会修改它。
返回:
return out;
让调用者可以继续链式输出:
std::cout << point << '\n';
这就是 iostream 的可扩展性。
操纵器:输出格式也可以流入流中
课件特别提到了操纵器(manipulator)。
例如:
std::dec
std::hex
std::oct
它们用于修改整数的进制输出状态。
完整示例:
#include <iomanip>
#include <iostream>
int main() {
int value = 1234;
std::cout << std::dec << value << '\n';
std::cout << std::hex << value << '\n';
std::cout << std::oct << value << '\n';
std::cout << std::dec << value << '\n';
}
输出:
1234
4d2
2322
1234
这些操纵器不会修改 value 本身。
执行:
std::cout << std::hex;
之后:
value 仍然是整数 1234
std::cout 的输出状态变为十六进制
接下来把整数送入这个流时,才会以十六进制显示。
流状态会持续存在
这是初学者容易忽略的地方。
#include <iomanip>
#include <iostream>
int main() {
int first = 255;
int second = 100;
std::cout << std::hex << first << '\n';
std::cout << second << '\n';
}
输出:
ff
64
第二次输出仍然是十六进制,因为 std::hex 改变的是流状态,而不是只影响紧随其后的一个整数。
要恢复十进制,需要明确写:
std::cout << std::dec;
更完整的写法:
std::cout << std::hex << first << '\n';
std::cout << std::dec << second << '\n';
那么,iostream 到底好不好
课件使用了具有争议性的标题:
iostream is bad?
更准确的理解不是“iostream 完全错误”,而是:
它成功解决了一些非常困难的问题,同时也付出了复杂度方面的代价。
它的优势包括:
- 输出操作与参数类型结合;
- 支持用户自定义类型;
- 可以链式调用;
- 可以保存格式状态;
- 不需要为每一种类型设计新的格式字符。
它的不足包括:
- 内部接口较复杂;
- 格式状态会持续,容易影响后续输出;
- 本地化和缓冲区接口较难理解;
- 一些成员函数命名对于普通使用者并不直观;
- 输入失败、输出失败和状态位需要额外检查;
- 某些使用场景下性能和易用性并不理想。
课件列举了诸如:
getloc / imbue
uflow / underflow
snextc / sbumpc / sgetc / sgetn
pbase / pptr / epptr
这样的底层接口名称。这些主要与本地化和流缓冲区实现有关,普通初学者不需要立即掌握。它们表达的是:
简单的
std::cout << value背后,存在一套相当复杂的抽象体系。
这与冰山主题完全一致。
练习:流状态会影响哪一次输出
预测下面程序的输出:
#include <iomanip>
#include <iostream>
int main() {
int a = 16;
int b = 16;
int c = 16;
std::cout << std::hex << a << ' ';
std::cout << b << ' ';
std::cout << std::dec << c << '\n';
}
答案与解释
输出:
10 10 16
第一条输出:
std::cout << std::hex << a
将流切换为十六进制,因此 a 输出为:
10
第二条输出没有恢复十进制:
std::cout << b
所以 b 仍然使用十六进制,输出:
10
第三条输出先执行:
std::dec
将流恢复为十进制,因此 c 输出为:
16
常见误判是认为 std::hex 只影响紧随其后的 a。实际上,它会修改流的持续状态。
第四层:范围 for 循环隐藏了对象生命周期
表面上很自然的循环
范围 for 循环(range-based for loop)让我们可以这样遍历容器:
std::vector<int> numbers{10, 20, 30};
for (int value : numbers) {
std::cout << value << '\n';
}
它看起来表达了:
依次取出 numbers 中的每个元素
但为了找到每个元素,编译器需要保存被遍历的对象,并获取它的起始和结束迭代器。
概念上,C++20 中的范围 for 可以近似展开为:
{
auto&& range = numbers;
auto begin = range.begin();
auto end = range.end();
for (; begin != end; ++begin) {
int value = *begin;
std::cout << value << '\n';
}
}
真实标准规则比这段简化代码更完整,但这个展开能够帮助我们观察对象生命周期。
为什么直接遍历临时容器通常是安全的
考虑:
std::vector<int> makeNumbers() {
return {10, 20, 30};
}
int main() {
for (int value : makeNumbers()) {
std::cout << value << '\n';
}
}
makeNumbers() 返回一个临时 std::vector<int>。
范围 for 内部会把这个临时对象绑定到类似:
auto&& range = makeNumbers();
的隐藏变量上。
在这种情况下,顶层临时容器的生命周期会延长到整个循环结束。
状态可以表示为:
makeNumbers()
↓
创建临时 vector
↓
隐藏变量 range 绑定到该 vector
↓
临时 vector 保持存活
↓
循环遍历完毕
↓
临时 vector 销毁
因此:
for (int value : makeNumbers())
可以安全工作。
问题出现在“临时对象内部的引用”
现在设计一个集合类:
#include <utility>
#include <vector>
class Collection {
public:
explicit Collection(std::vector<int> values)
: values_(std::move(values)) {}
const std::vector<int>& getRef() const {
return values_;
}
private:
std::vector<int> values_;
};
getRef() 返回成员 values_ 的常量引用。
再定义:
Collection getCollection() {
return Collection({10, 20, 30});
}
然后写:
for (int value : getCollection().getRef()) {
// ...
}
这段代码在 C++20 中存在生命周期问题。
一步一步观察对象发生了什么
首先执行:
getCollection()
它创建一个临时 Collection:
临时 Collection
┌───────────────────────────┐
│ values_ │
│ ┌────┬────┬────┐ │
│ │ 10 │ 20 │ 30 │ │
│ └────┴────┴────┘ │
└───────────────────────────┘
接着调用:
.getRef()
它返回对成员 values_ 的引用:
返回的引用
│
▼
临时 Collection.values_
问题在于,范围表达式最终得到的不是完整的临时 Collection,而是它内部成员的引用。
在 C++20 下,循环开始前可能发生:
1. 创建临时 Collection
2. getRef() 返回 values_ 的引用
3. 临时 Collection 销毁
4. values_ 随之销毁
5. 循环试图通过引用访问原来的 values_
此时引用变成悬空引用(dangling reference)。
为什么“直接返回临时对象”与“返回临时对象内部的引用”不同
安全形式:
for (int value : getCollection())
前提是 Collection 自身可以被遍历。
隐藏变量直接绑定到顶层临时对象:
range
│
▼
临时 Collection
临时对象的生命周期被延长。
危险形式:
for (int value : getCollection().getRef())
隐藏变量绑定的是成员引用:
range
│
▼
临时 Collection.values_
但拥有这个成员的临时 Collection 可能已经销毁。
核心区别是:
安全:保存了拥有资源的对象
危险:只保存了对象内部的一条引用
为什么错误代码有时“看起来能工作”
课件特别提醒:临时对象销毁后,那块内存中的字节不一定立即被其他数据覆盖。
因此程序可能偶尔仍然输出:
10
20
30
但这不代表代码安全。
对象销毁后:
std::vector的生命周期已经结束;- 它管理的存储可能已经释放;
- 原来的引用不再指向一个有效对象;
- 继续访问属于未定义行为(undefined behavior)。
“内存里的旧数据暂时还在”只是一种偶发现象。
下面两件事必须区分:
某些字节暂时没有变化
和:
这些字节仍然构成一个可以合法访问的 C++ 对象
前者并不能推出后者。
安全修复一:先保存拥有者
Collection collection = getCollection();
for (int value : collection.getRef()) {
std::cout << value << '\n';
}
执行期间:
collection 是当前作用域中的局部对象
↓
collection 在整个循环中保持存活
↓
getRef() 返回 collection.values_ 的引用
↓
循环结束后 collection 才销毁
这是最容易理解的修复方式。
安全修复二:使用范围 for 的初始化语句
C++20 支持在范围 for 前面加入初始化语句:
for (auto owner = getCollection(); int value : owner.getRef()) {
std::cout << value << '\n';
}
这里:
auto owner = getCollection();
先创建一个有名字的拥有者对象。
随后:
int value : owner.getRef()
遍历它内部的容器。
owner 的作用域覆盖整个循环:
进入循环
↓
创建 owner
↓
取得 owner 内部的引用
↓
执行所有迭代
↓
循环结束
↓
销毁 owner
这是一种非常适合该问题的 C++20 写法。
一个可编译的安全示例
#include <iostream>
#include <utility>
#include <vector>
class Collection {
public:
explicit Collection(std::vector<int> values)
: values_(std::move(values)) {}
auto begin() {
return values_.begin();
}
auto end() {
return values_.end();
}
auto begin() const {
return values_.begin();
}
auto end() const {
return values_.end();
}
const std::vector<int>& getRef() const {
return values_;
}
private:
std::vector<int> values_;
};
Collection getCollection() {
return Collection({10, 20, 30});
}
int main() {
std::cout << "直接遍历临时 Collection:\n";
for (int value : getCollection()) {
std::cout << value << ' ';
}
std::cout << "\n保存拥有者后遍历成员引用:\n";
for (auto owner = getCollection(); int value : owner.getRef()) {
std::cout << value << ' ';
}
std::cout << '\n';
}
输出:
直接遍历临时 Collection:
10 20 30
保存拥有者后遍历成员引用:
10 20 30
第一段安全,是因为范围 for 直接保存顶层临时 Collection。
第二段安全,是因为我们显式创建了 owner。
应避免在 C++20 中直接写:
for (int value : getCollection().getRef()) {
// 不要依赖这种写法
}
C++23 扩展了部分范围表达式中临时对象的生命周期规则,但实际项目仍然应优先让资源拥有者的生命周期清晰可见,尤其是在需要兼容 C++20 或不同编译环境时。
练习:哪一种遍历方式最稳妥
已知:
class Database {
public:
const std::vector<int>& records() const;
private:
std::vector<int> records_;
};
Database openDatabase();
以下写法中,哪一种最适合 C++20?
A:
for (int record : openDatabase().records()) {
process(record);
}
B:
Database database = openDatabase();
for (int record : database.records()) {
process(record);
}
C:
for (auto database = openDatabase();
int record : database.records()) {
process(record);
}
答案与解释
B 和 C 都是安全、清晰的写法。
B 显式创建局部拥有者:
Database database = openDatabase();
因此 database 在循环期间保持存活。
C 使用 C++20 范围 for 初始化语句:
auto database = openDatabase();
这个变量也会在整个循环期间保持存活。
A 在 C++20 中依赖临时 Database 内部成员引用,可能形成悬空引用。
初学者容易认为:
openDatabase().records()
既然出现在 for 的范围位置,所有相关对象就一定会自动活到循环结束。问题在于,范围最终得到的是成员引用,而不一定是拥有成员的顶层临时对象。
第五层:成功写入缓冲区,不代表成功写入设备
范围 for 的问题来自对象生命周期。
接下来,我们把观察范围继续向下移动:从 C++ 对象进入操作系统输入输出。
最普通的程序之一是:
#include <iostream>
int main() {
std::cout << "Hello World!\n";
}
通常我们认为:
程序运行
↓
文字成功输出
↓
程序结束
但输出操作可能经过缓冲区,因此真正的过程更像:
程序把文字交给 C++ 流
↓
文字暂存在用户空间缓冲区
↓
缓冲区在稍后被刷新
↓
操作系统尝试写入目标设备
↓
写入可能成功,也可能失败
什么是缓冲区
缓冲区(buffer)是一块临时存储区域。
如果每输出一个字符都立刻请求操作系统写入设备,调用成本可能很高。
因此标准库通常先收集一批数据:
程序连续输出字符
↓
字符进入缓冲区
↓
缓冲区积累到一定程度
↓
一次性提交给操作系统
这可以减少系统调用次数。
但它也带来一个结果:
operator<<返回时,数据可能只进入了缓冲区,还没有真正写到最终目的地。
/dev/full 用来测试写入失败
在 Linux 中,/dev/full 是一个特殊设备。
对它进行写入时,会报告“设备没有剩余空间”。
课件展示了类似命令:
echo "Hello World!" > /dev/full
Shell 能够观察到写入失败,并返回非零退出状态。
但一个没有检查输出状态的 C 或 C++ 程序,可能出现:
1. 把 Hello World 写入库缓冲区
2. 输出函数暂时认为操作成功
3. 程序准备结束
4. 标准库刷新缓冲区
5. 操作系统报告写入失败
6. 程序没有检查失败状态
7. 程序仍然以状态码 0 退出
错误确实发生了,只是程序忽略了它。
为什么换行不一定立即刷新
下面的代码:
std::cout << "Hello World!\n";
输出了换行符,但换行符不保证在所有环境中立即刷新 C++ 输出流。
终端上的行为和重定向到文件后的行为也可能不同:
./main
与:
./main > output.txt
可能使用不同的缓冲策略。
如果程序必须在某个时间点确认数据已经提交,应明确刷新:
std::cout.flush();
或者:
std::cout << std::flush;
std::endl 会输出换行并刷新:
std::cout << "Hello World!" << std::endl;
但即使已经刷新,程序仍然必须检查刷新是否成功。只刷新、不检查错误,并没有真正解决问题。
正确检查输出是否失败
#include <iostream>
int main() {
std::cout << "Hello World!\n";
std::cout.flush();
if (!std::cout) {
std::cerr << "写入标准输出失败\n";
return 1;
}
return 0;
}
编译:
g++ -std=c++20 main.cpp -o main
普通运行:
./main
预期输出:
Hello World!
退出状态为 0。
在 Linux 中测试失败:
./main > /dev/full
echo $?
程序会检测到输出失败,并返回 1。
错误信息使用:
std::cerr
它没有被重定向到 /dev/full 时,仍然可以显示在终端中。
为什么必须先 flush() 再检查
考虑:
std::cout << "Hello World!\n";
if (!std::cout) {
return 1;
}
当执行 if (!std::cout) 时,数据可能还停留在缓冲区中。
此时:
缓冲区接收数据成功
真正的设备写入尚未发生
std::cout 暂时仍处于正常状态
程序检查完之后才在退出过程中刷新缓冲区。真正的错误发生得太晚,程序已经决定返回 0。
因此检查顺序应为:
写入数据
↓
明确刷新或关闭
↓
检查流状态
↓
决定是否返回成功
文件输出还要检查关闭阶段
文件写入同样可能在关闭时才暴露错误。
#include <fstream>
#include <iostream>
#include <string>
bool saveMessage(const std::string& path) {
std::ofstream out(path);
if (!out) {
std::cerr << "无法打开文件\n";
return false;
}
out << "Hello World!\n";
out.close();
if (!out) {
std::cerr << "写入或关闭文件失败\n";
return false;
}
return true;
}
int main() {
if (!saveMessage("message.txt")) {
return 1;
}
return 0;
}
这里显式调用:
out.close();
并不是因为 RAII 无法关闭文件。即使不调用,std::ofstream 的析构函数也会自动关闭文件。
我们显式关闭是为了:
在函数返回之前观察关闭过程中是否发生了写入错误。
析构函数适合自动清理资源,但析构函数无法方便地把“最终写入失败”作为普通返回值交给调用者。
因此:
RAII 负责保证资源被清理
显式 flush/close + 状态检查负责确认操作成功
这两件事并不冲突。
练习:为什么检查得太早
下面程序能否可靠检测最终写入失败?
#include <iostream>
int main() {
std::cout << "important data\n";
if (!std::cout) {
return 1;
}
return 0;
}
答案与解释
不能可靠检测。
当检查:
if (!std::cout)
时,数据可能只进入了输出缓冲区,还没有真正提交给操作系统。
之后程序退出,标准库才刷新缓冲区。如果这时写入失败,程序已经选择返回 0。
更可靠的写法是:
#include <iostream>
int main() {
std::cout << "important data\n";
std::cout.flush();
if (!std::cout) {
std::cerr << "output failed\n";
return 1;
}
return 0;
}
常见误判是认为:
operator<<
返回后,数据一定已经到达文件或设备。实际上,它只保证输出操作被交给了流;流可以使用缓冲。
第六层:为什么已经知道如何改进,却不能直接修改
到目前为止,我们研究的都是单个程序:
- 表达式怎样解析;
- 语句怎样嵌套;
- 对象什么时候销毁;
- 输出什么时候真正发生。
但现代程序通常不会把所有代码一次性编译成一个文件。
我们会使用:
- 标准库;
- 动态链接库;
- 第三方库;
- 操作系统提供的接口;
- 已经编译完成的二进制文件。
这就带出了冰山更深的一层:应用二进制接口(Application Binary Interface,ABI)。
第七层:ABI 是已经编译好的代码之间的契约
API 与 ABI 有什么不同
应用程序接口(Application Programming Interface,API)主要描述源代码怎样使用某个库。
例如:
int add(int left, int right);
调用者知道:
- 函数名是
add; - 接受两个
int; - 返回一个
int。
这是源代码层面的接口。
ABI 描述的是代码编译后,二进制层面怎样协作。
它可能包含:
- 参数放在哪些寄存器或内存位置;
- 返回值怎样传递;
- 类型在内存中怎样布局;
- 函数名怎样编码;
- 异常怎样跨函数传播;
- 系统调用怎样进行;
- 对象的大小和对齐方式;
- 虚函数表怎样组织。
可以简单对比:
| 层次 | 主要面对什么 | 示例 |
|---|---|---|
| API | 源代码 | 函数名、参数类型、类的公开成员 |
| ABI | 编译后的机器代码 | 参数传递、对象布局、符号名称、异常机制 |
为什么需要 ABI
假设程序和库分别编译:
main.cpp
↓ 编译
main.o
library.cpp
↓ 编译
library.o 或 libexample.so
最后链接:
main.o + library
↓
可执行程序
调用库函数时,双方必须对底层规则达成一致。
例如,调用:
int result = add(10, 20);
程序和库必须共同知道:
10 放在哪里?
20 放在哪里?
返回值放在哪里?
函数在二进制中叫什么名字?
调用完成后由谁清理相关数据?
这些规则不是每次函数调用时现场协商的,而是由 ABI 预先规定。
ABI 不兼容会发生什么
假设旧版本库中的类型布局是:
Widget
┌──────────────┐
│ int width │
│ int height │
└──────────────┘
某个已经编译好的程序认为:
width 位于偏移 0
height 位于偏移 4
sizeof(Widget) 为 8
新版本库把它改成:
Widget
┌──────────────┐
│ bool visible │
│ int width │
│ int height │
└──────────────┘
成员偏移和对象大小都可能发生变化。
旧程序仍按照旧布局访问:
旧程序认为偏移 0 是 width
新库认为偏移 0 是 visible
即使源代码中的类名没有变化,旧二进制程序也可能读取错误位置。
这就是 ABI 破坏(ABI break)。
“重新编译就好了”为什么不总是可行
对于自己的小项目,升级库后全部重新编译通常并不困难。
但大型生态中可能存在:
- 已经发布、无法重新编译的软件;
- 只有二进制文件、没有源代码的程序;
- 数量庞大的操作系统软件包;
- 多个不同团队维护的库;
- 插件与宿主程序;
- 需要长期稳定运行的商业系统。
一个基础标准库的 ABI 变化,可能要求整个软件生态重新编译。
因此库设计者经常面对两种目标的冲突:
目标一:改进设计、性能和功能
目标二:旧程序不重新编译也能继续运行
两者有时无法同时满足。
课件列出的标准库困境
课件用“break ABI to save C++”这个具有争议性的口号,表达一种观点:
长期保持 ABI 稳定虽然保护了旧程序,但也可能阻碍标准库采用更好的实现。
课件列出的案例包括:
- 让关联容器大幅加速;
- 改进
std::regex的性能; - 为正则表达式加入 UTF-8 支持;
- 让
std::unique_ptr在某些调用约定下像原始指针一样通过寄存器传递; - 降低异常处理的实现成本。
这些案例背后的共同困难是:
更好的新实现
↓
可能改变对象布局、调用方式或异常机制
↓
改变 ABI
↓
已经编译好的旧程序可能无法继续使用新库
为什么 unique_ptr 只有一个指针,却不一定像指针一样传递
一个常见的 std::unique_ptr<T> 对象在简单情况下可能只保存一个指针。
从大小上看,它可能接近:
T*
但它不是普通指针。
它具有:
- 析构函数;
- 移动构造;
- 所有权语义;
- 可能自定义的删除器。
某些 ABI 会根据一个类型是否具有非平凡析构函数、复制或移动行为,决定它应当怎样传递。
因此即使:
sizeof(unique_ptr<T>) == sizeof(T*)
也不能自动推出:
unique_ptr<T> 与 T* 使用完全相同的参数传递方式
对象大小只是 ABI 的一部分。
“保持 ABI”与“永远不改 ABI”不是同一件事
ABI 稳定具有实际价值:
- 用户可以升级库而不重新编译全部程序;
- 操作系统更新风险更低;
- 第三方插件更容易保持兼容;
- 已发布的软件可以继续运行。
但过度强调永久兼容也可能带来成本:
- 早期设计错误被长期保留;
- 更高效的数据布局难以采用;
- 新功能不得不绕过旧结构;
- 实现越来越复杂。
因此这是一个工程取舍,而不是简单的是非题。
更准确的问题是:
一次 ABI 破坏带来的长期收益,是否足以抵消整个生态重新编译和迁移的成本?
不同平台、标准库实现和项目可能得出不同答案。
练习:API 兼容还是 ABI 兼容
某个库原来提供:
class User {
public:
int id() const;
private:
int id_;
};
新版本改为:
class User {
public:
int id() const;
private:
bool active_;
int id_;
};
公开函数仍然是:
int id() const;
请判断:
- 源代码 API 是否可能保持兼容?
- ABI 是否一定保持兼容?
答案与解释
源代码 API 可能保持兼容。
调用者仍然可以写:
User user;
int value = user.id();
公开成员函数名称和参数没有变化。
但 ABI 不一定兼容。
增加成员:
bool active_;
可能改变:
User的大小;id_的内存偏移;- 对齐方式;
- 函数接收或返回
User时的底层传递规则。
已经按照旧布局编译的程序,可能无法与新库正确协作。
常见误判是认为“公开函数没变”就代表完全兼容。公开源代码接口只属于 API 层,而对象布局属于 ABI 层。
冰山图中的其他标签应该怎样理解
课件中的冰山图还列出了大量 C++ 现象,例如:
0[arr]
#define private public
inline does not mean inline
C++ is not a superset of C
most vexing parse
<iosfwd>
spaceship operator
digraphs
vector<bool> is broken
unary minus with unsigned operand
std::move does not move
std::remove does not remove
rvalue references are lvalues
T&& is not always an rvalue reference
constexpr does not mean what you think it means
function try blocks
operator,()
templates are Turing complete
std::optional is a monad
heap and stack don't exist
C++0x is a hexadecimal name
abominable function types
C++ active issues
Godbolt is a real person
这些标签主要是冰山式的索引和幽默标题,并不代表本讲逐项教授了它们。
它们共同表达三个事实。
第一,C++ 中很多名称只是方便理解的简称:
std::move 不负责移动对象
else if 不是独立语法
inline 不保证函数一定被内联
第二,很多表面行为取决于更底层的规则:
表达式类别
对象生命周期
模板推导
预处理
调用约定
编译器实现
第三,越深入 C++,越需要区分:
日常教学中的直觉
语言标准规定的正式语义
编译器常见的实现方式
特定平台的 ABI 规则
本节展开的六个案例,正好覆盖了这几类差异。
综合练习:编写一个不会隐藏生命周期和输出错误的报告程序
任务背景
我们需要:
- 创建一个临时数字集合;
- 通过返回引用访问集合内部的
std::vector; - 使用范围
for遍历; - 将每个数字分别以十进制、十六进制和八进制写入文件;
- 正确检测文件打开、写入和关闭错误;
- 在 Linux 中能够使用
/dev/full测试失败路径。
预期正常输出文件:
dec=10, hex=a, oct=12
dec=20, hex=14, oct=24
dec=30, hex=1e, oct=36
需要完成的接口
class NumberCollection {
public:
explicit NumberCollection(std::vector<int> values);
const std::vector<int>& values() const;
private:
std::vector<int> values_;
};
NumberCollection makeNumbers();
bool writeReport(const std::string& path);
注意以下问题:
makeNumbers()返回的是临时对象;values()返回的是临时对象内部成员的引用;- 不能直接依赖:
for (int value : makeNumbers().values())
- 文件错误可能在
close()时才出现; std::hex和std::oct会修改流状态。
参考实现
#include <fstream>
#include <iomanip>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class NumberCollection {
public:
explicit NumberCollection(std::vector<int> values)
: values_(std::move(values)) {}
const std::vector<int>& values() const {
return values_;
}
private:
std::vector<int> values_;
};
NumberCollection makeNumbers() {
return NumberCollection({10, 20, 30});
}
bool writeReport(const std::string& path) {
std::ofstream out(path);
if (!out) {
std::cerr << "无法打开输出文件:" << path << '\n';
return false;
}
for (auto owner = makeNumbers(); int value : owner.values()) {
out << "dec=" << std::dec << value
<< ", hex=" << std::hex << value
<< ", oct=" << std::oct << value
<< '\n';
}
out.close();
if (!out) {
std::cerr << "写入或关闭文件时失败:" << path << '\n';
return false;
}
return true;
}
int main(int argc, char* argv[]) {
std::string path = "report.txt";
if (argc >= 2) {
path = argv[1];
}
if (!writeReport(path)) {
return 1;
}
std::cout << "报告已写入:" << path << '\n';
return 0;
}
编译:
g++ -std=c++20 -Wall -Wextra -pedantic main.cpp -o main
普通运行:
./main
终端输出:
报告已写入:report.txt
文件内容:
dec=10, hex=a, oct=12
dec=20, hex=14, oct=24
dec=30, hex=1e, oct=36
在 Linux 中测试写入失败:
./main /dev/full
echo $?
预期看到类似:
写入或关闭文件时失败:/dev/full
1
参考实现的执行过程
第一步:进入 main
std::string path = "report.txt";
创建局部字符串:
path = "report.txt"
如果命令行提供了路径:
if (argc >= 2) {
path = argv[1];
}
则使用用户给出的路径。
第二步:调用 writeReport
writeReport(path)
参数类型是:
const std::string&
没有复制整个字符串,函数通过常量引用读取 path。
第三步:打开输出文件
std::ofstream out(path);
创建局部输出文件流 out。
如果打开失败:
if (!out)
条件成立,函数返回 false。
此时 out 离开作用域,析构函数自动清理资源。
第四步:创建集合拥有者
for (auto owner = makeNumbers(); int value : owner.values())
首先执行初始化语句:
auto owner = makeNumbers();
makeNumbers() 创建一个 NumberCollection,其中保存:
owner
┌────────────────────────────┐
│ values_ │
│ ┌────┬────┬────┐ │
│ │ 10 │ 20 │ 30 │ │
│ └────┴────┴────┘ │
└────────────────────────────┘
owner 会一直存活到整个循环结束。
第五步:取得成员引用
owner.values()
返回:
const std::vector<int>&
引用指向:
owner.values_
因为 owner 仍然存活,所以引用有效。
第六步:逐个取得元素
每次循环:
int value
会复制当前整数。
第一次:
value = 10
第二次:
value = 20
第三次:
value = 30
整数复制成本很低,这里没有必要使用引用。
第七步:修改输出格式状态
对于 value = 10:
out << "dec=" << std::dec << value
输出:
dec=10
接着:
<< ", hex=" << std::hex << value
流切换到十六进制:
, hex=a
接着:
<< ", oct=" << std::oct << value
流切换到八进制:
, oct=12
下一次迭代重新执行:
std::dec
所以每行都会从十进制开始,不会受到上一行最后一个 std::oct 的影响。
第八步:显式关闭并检查
out.close();
关闭文件时,尚未写出的缓冲数据会被提交给操作系统。
然后:
if (!out)
检查包括关闭阶段在内的输出操作是否失败。
如果目标是 /dev/full,打开文件本身可能成功,但写入或关闭时会失败。
第九步:对象销毁顺序
writeReport 正常返回前:
循环结束
↓
owner 销毁
↓
owner.values_ 自动销毁
↓
函数返回
↓
out 离开作用域并销毁
由于已经显式调用 close(),out 的析构函数只需完成剩余清理。
没有需要手动 delete 的资源。
模拟 Kahoot:快速检查
问题一
下面的表达式会被怎样解析?
x --> 0
A. x 使用 --> 运算符走向 0
B. (x--) > 0
C. x - (->0)
D. 编译错误
答案与解释
答案是 B。
词法分析会识别:
x -- > 0
C++ 中没有 --> 运算符。
A 把代码的视觉外观误认为正式语法。
C 中的 -> 需要左侧是指针或重载该运算符的对象,且这里并不是这种记号划分。
D 错误,因为 (x--) > 0 是合法表达式。
问题二
else if 在 C++ 中是什么?
A. 一个独立关键字
B. 一个不可拆分的语法结构
C. else 后面跟着一条 if 语句
D. switch 的简写
答案与解释
答案是 C。
C++ 的关键字分别是:
else
if
不存在名为 else if 的单一关键字。
A 和 B 都把常见排版形式误认为独立语法。
D 与条件分支的真实结构无关。
问题三
执行:
std::cout << std::hex << 16 << ' ' << 16;
会输出什么?
A. 16 16
B. 10 16
C. 10 10
D. 编译错误
答案与解释
答案是 C。
std::hex 修改输出流状态,后续整数都以十六进制输出,直到使用:
std::dec
恢复十进制。
A 忽略了格式状态。
B 错误地认为 std::hex 只影响第一个整数。
D 错误,因为这是合法的流操纵器用法。
问题四
在 C++20 中,下面哪种写法最可能产生生命周期问题?
A:
for (int value : makeVector())
B:
for (int value : namedVector)
C:
for (int value : makeCollection().getRef())
D:
for (auto owner = makeCollection();
int value : owner.getRef())
答案与解释
答案是 C。
C 保存的是临时拥有者内部成员的引用,拥有者可能在循环开始前销毁。
A 直接遍历顶层临时容器,其生命周期会延长到循环结束。
B 遍历有名字的容器,只要该变量仍在作用域内就没有这个问题。
D 显式保存了拥有者 owner,生命周期覆盖整个循环。
问题五
为什么输出操作可能在程序即将退出时才失败?
A. operator<< 不会执行任何操作
B. 数据可能先进入缓冲区,稍后才真正写入设备
C. C++ 不支持文件输出
D. return 0 会自动删除错误
答案与解释
答案是 B。
缓冲输出会把数据暂时保存在内存中,之后再统一写入操作系统。
A 错误,operator<< 会执行流输出操作,只是不一定立即触发最终设备写入。
C 错误,C++ 支持文件和设备输出。
D 错误,返回值不会删除错误;程序只是可能没有检查错误状态。
问题六
ABI 主要解决什么问题?
A. 源代码缩进风格
B. 已编译代码之间的底层协作规则
C. 变量名应该使用下划线还是驼峰
D. Git 分支怎样合并
答案与解释
答案是 B。
ABI 包括调用约定、数据布局、符号名称和异常机制等底层规则。
A 和 C 属于代码风格。
D 属于版本控制,与二进制接口不是同一问题。
初学者最容易出现的误解
误解一:代码看起来像一个运算符,它就是一个运算符
错误直觉:
x --> 0
包含 --> 运算符。
正确理解:
(x--) > 0
判断方法是观察编译器能够识别的合法记号,而不是只看视觉形状。
误解二:常见写法一定对应独立语法
错误直觉:
else if
是一种与 if、else 并列的结构。
正确理解:
else
└── if 语句
排版习惯和语言语法不是同一层次。
误解三:操纵器只影响下一个值
错误直觉:
std::hex
只影响紧随其后的一个整数。
正确理解:
std::hex 修改流状态
状态持续存在
直到再次修改
误解四:范围 for 中出现的所有临时对象都会安全存活
错误直觉:
for (auto value : expression)
只要表达式写在冒号右侧,所有相关对象都会保持存活。
正确理解:
- 顶层临时范围对象通常会被保存;
- 临时拥有者内部返回的引用需要单独分析;
- C++20 中应明确保存拥有者。
误解五:数据还在内存里,所以引用仍然有效
错误直觉:
程序还能输出原来的数字
所以对象没有真正销毁
正确理解:
对象生命周期结束
≠
内存字节立即被清零
对象销毁后继续通过悬空引用访问,即使偶尔得到旧数据,也属于未定义行为。
误解六:没有抛出异常就代表输出成功
错误直觉:
std::cout << data;
没有异常,因此写入成功。
正确理解:
- 流默认通常通过状态位记录错误;
- 数据可能尚在缓冲区;
- 错误可能在刷新或关闭时发生;
- 必须主动检查流状态。
误解七:API 没变化,二进制就一定兼容
错误直觉:
函数名和参数没变
所以旧程序一定能使用新库
正确理解:
ABI 还取决于:
- 对象布局;
- 类型大小;
- 参数传递;
- 名称编码;
- 异常机制;
- 调用约定。
本节课的完整知识串联
整节课可以串成一条从源代码逐渐深入系统底层的路线。
第一步:我们写下字符
例如:
x --> 0
编译器不会按照人类看到的“箭头”理解,而是先进行词法分析:
x -- > 0
第二步:记号组成语句结构
例如:
else if (...)
并不是独立结构,而是:
else
└── if 语句
第三步:语句中的表达式创建和引用对象
例如:
getCollection().getRef()
临时拥有者可能销毁,内部引用可能悬空。
第四步:标准库操作可能隐藏中间状态
例如:
std::cout << data;
数据可能先进入缓冲区。
第五步:真正操作由系统完成
缓冲区最终写向文件或设备时,才可能暴露:
No space left on device
第六步:程序还要与其他已编译代码协作
程序、标准库和操作系统需要遵守 ABI。
改变一个类型的布局或调用方式,可能破坏大量已有二进制程序。
一套处理 C++ 怪问题的方法
以后遇到“不符合直觉”的代码,可以依次检查:
1. 记号层
编译器把代码切成了什么?
--> 是否真的是一个记号?
2. 语法层
这些记号组成了什么结构?
else 属于哪个 if?
3. 类型层
每个表达式的类型是什么?
返回的是对象、值、指针还是引用?
4. 生命周期层
被引用的对象是否仍然存活?
临时拥有者什么时候销毁?
5. 标准库状态层
操作是否改变了持续状态?
std::hex 是否影响后续输出?
6. 系统层
操作是否已经真正到达设备?
数据仍在缓冲区,还是已经写入?
7. 二进制层
代码之间是否遵守同一套 ABI?
对象布局和调用约定是否一致?
这七个问题比死记单个“C++ 冷知识”更有价值。
本节知识如何连接到后续学习
这节课是 CS106L 本季度的收官课,因此课件没有安排新的正式章节。它留下的不是某个新的语法点,而是几条可以继续深入的学习方向。
如果继续研究编译器,可以从:
词法分析
语法分析
类型检查
代码生成
理解为什么源代码最终会产生某种行为。
如果继续研究现代 C++,可以深入:
值类别
临时对象生命周期
模板推导
约束与概念
异常安全
并发与内存模型
如果继续研究系统编程,可以深入:
缓冲 I/O
系统调用
文件描述符
进程退出状态
动态链接
ABI
如果继续研究性能和库设计,可以思考:
抽象是否真的零开销?
兼容性会限制哪些优化?
什么时候值得破坏旧接口?
如何设计能够长期演化的类型?
C++ 冰山并不是在告诉我们:
C++ 到处都是无法理解的陷阱
它真正传达的是:
C++ 同时跨越了语法、类型、对象生命周期、标准库、操作系统和二进制生态。很多奇怪现象,只是因为我们暂时只看到了其中一层。
当代码不符合直觉时,不必立即把它归因于“编译器抽风”或“C++ 太奇怪”。先找到问题所在的层次,再按照那一层的规则分析,冰山下面的结构就会逐渐变得清晰。