Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← C++ 编程 / C++ Programming

基础语法与编译模型 / Language & Compilation

1. C++ 语言基础完整学习手册 / Complete C++ Language Basics Guide

2. C++ 语言基础与程序运行 / C++ Language Basics and Program Execution

3. C++ 程序是怎样做出来的:从源码到可执行文件 / From Source to Executable

4. 值、类型与对象:C++ 程序中的第一层现实 / Values, Types, and Objects

5. 表达式、运算符与控制流 / Expressions, Operators, and Control Flow

6. 函数、作用域与重载 / Functions, Scope, and Overloading

7. 数组、指针与字符串:从地址到借用范围 / Arrays, Pointers, and Strings

8. 引用、const 与类型推导:从复制到借用 / References, const, and Type Deduction

9. 让数据说清楚自己:枚举、结构体、联合体与类型别名 / Enums, Structs, Unions, and Aliases

10. 一个头文件怎样影响整个程序:预处理、头文件与命名空间 / Preprocessor, Headers, and Namespaces

11. 基础 I/O、错误与调试:让程序会观察自己的失败 / Basic I/O, Errors, and Debugging

本页目录

基础 I/O、错误与调试:让程序会观察自己的失败 / Basic I/O, Errors, and Debugging ​

本文是 C++ 语言基础模块的最后一篇。

前面我们已经学过编译、类型、表达式、函数、指针、引用、结构体、头文件。

这一篇要把程序从“能写”推进到“能观察、能判断、能排错”。

很多初学者写程序时会有一种感觉:

text
代码看起来没错
  |
  v
运行结果却不对
  |
  v
不知道是输入错、逻辑错、文件没打开,还是内存坏了
1
2
3
4
5
6
7

这不是你“笨”。

而是因为程序失败经常不是一个点,而是一条链:

text
外部输入
  |
  v
缓冲区
  |
  v
解析
  |
  v
对象状态
  |
  v
控制流选择
  |
  v
错误处理或继续出错
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如果不知道这条链,就很容易只盯着最后一行输出。

本文会从一个很小的输入程序开始,慢慢展开到文件、RAII、错误策略和调试工具。

1. 先看一个“看似简单”的输入失败 ​

先写一个程序:

cpp
#include <iostream>

int main() {
    int first{};
    int second{};

    std::cin >> first;
    std::cin >> second;

    std::cout << "first = " << first << '\n';
    std::cout << "second = " << second << '\n';
}
1
2
3
4
5
6
7
8
9
10
11
12

编译:

bash
g++ -std=c++17 -Wall -Wextra -g main.cpp -o app
1

这条命令的意思是:

text
g++
  使用 GNU C++ 编译器

-std=c++17
  按 C++17 标准理解源代码

-Wall -Wextra
  打开常用警告,让编译器帮你发现可疑代码

-g
  生成调试信息,后面用调试器时能看到源码行和变量名

main.cpp
  输入源文件

-o app
  输出可执行程序 app
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

运行:

bash
./app
1

输入:

text
hello 42
1

你可能会想:

text
first 读 hello 失败
second 接着读 42
1
2

但 C++ 的输入流通常不会这样工作。

更准确的过程是:

text
输入缓冲区里有字符:

hello 42
^
|
当前读取位置

std::cin >> first
  |
  v
尝试把 h 开头的文本解析成 int
  |
  v
失败
  |
  +-- first 没有得到一个新的有效整数
  +-- std::cin 进入 fail 状态
  +-- hello 这段坏输入可能仍留在缓冲区

std::cin >> second
  |
  v
发现 std::cin 已经处于失败状态
  |
  v
不会正常继续解析 42
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

这里最重要的结论是:

text
std::cin 不是一个“读一次就结束”的函数。

std::cin 是一个对象。

这个对象内部保存着状态。
1
2
3
4
5

一旦它进入失败状态,后面的读取就会受到影响。

2. 本文基础词小注释 ​

这一节先把本文会频繁出现的词讲清楚。

这些词不要求你一次背下来,但后面看到时不要觉得陌生。

I/O:Input / Output,输入和输出。输入可以来自键盘、文件、管道、网络;输出可以去终端、文件、日志或其他设备。

流 stream:把输入或输出看成一串连续数据的抽象。std::cin、std::cout、std::ifstream 都是流相关对象。

标准输入 stdin:程序默认接收输入的地方,通常是键盘,也可以被重定向成文件。

标准输出 stdout:程序默认打印正常结果的地方,通常是终端。

标准错误 stderr:程序默认打印错误信息的地方,通常也是终端,但可以和标准输出分开重定向。

缓冲区 buffer:暂时保存数据的一块内存。缓冲可以减少频繁访问设备的成本,但也会让“写了”和“真正显示/落盘”之间有一个中间层。

解析 parse:把字符解释成某种类型的值。比如把 "123" 解析成 int,把 "3.14" 解析成 double。

EOF:End Of File,输入结束。它不是普通字符,而是一次读取尝试发现“没有更多内容”后得到的状态。

状态位 state bits:流对象内部记录当前读写状态的标志。常见有 goodbit、failbit、eofbit、badbit。

资源 resource:程序必须向系统申请并且用完后归还的东西。文件句柄、内存、锁、网络连接都可以是资源。

RAII:Resource Acquisition Is Initialization,直译是“资源获取即初始化”。意思是:把资源的获得放进对象构造,把资源的释放放进对象析构,让对象生命周期管理资源生命周期。

析构 destructor:对象生命周期结束时自动调用的清理动作。比如局部对象离开作用域时会析构。

异常 exception:一种错误传播机制。某层函数无法处理错误时,可以抛出异常,让上层决定怎么处理。

断言 assert:程序员写给程序自己的检查,通常用于验证内部不变量,而不是验证用户输入。

调试器 debugger:能让程序暂停、单步执行、查看变量和调用栈的工具。

Sanitizer:编译器提供的一类运行时检查工具,能帮助发现数组越界、释放后使用、未定义行为等问题。

3. 四个标准流:程序默认就连接了外部世界 ​

先看代码:

cpp
#include <iostream>

int main() {
    std::cout << "normal output\n";
    std::cerr << "error output\n";
    std::clog << "diagnostic log\n";

    int value{};
    std::cin >> value;

    std::cout << "value = " << value << '\n';
}
1
2
3
4
5
6
7
8
9
10
11
12

这里有四个常见对象:

text
std::cin
  标准输入,通常来自键盘

std::cout
  标准输出,通常用于正常结果

std::cerr
  标准错误,通常用于错误提示

std::clog
  日志输出,通常用于诊断信息
1
2
3
4
5
6
7
8
9
10
11

它们和操作系统之间大致可以这样看:

text
            +------------------+
keyboard -> | standard input   | -> std::cin
            +------------------+

std::cout -> +-----------------+ -> terminal / file / pipe
             | standard output |
             +-----------------+

std::cerr -> +-----------------+ -> terminal / error log
             | standard error  |
             +-----------------+
1
2
3
4
5
6
7
8
9
10
11

为什么要区分 std::cout 和 std::cerr?

因为正常输出和错误输出经常要分开处理。

例如:

bash
./app > result.txt
1

这表示把标准输出写进 result.txt。

如果错误信息走 std::cerr,它仍然可以显示在终端,帮助你知道程序出了问题。

学习阶段先记住:

text
正常结果 -> std::cout
错误提示 -> std::cerr
调试日志 -> std::clog 或项目日志系统
1
2
3

不要把所有东西都塞进 std::cout。

4. << 和 >> 不是箭头魔法 ​

很多人第一次看到:

cpp
std::cout << value;
std::cin >> value;
1
2

会把 << 和 >> 当成“C++ 的输入输出语法”。

但它们本质上是运算符。

在 I/O 这里,标准库给它们定义了特殊含义:

text
std::cout << value
  |
  v
把 value 插入输出流

std::cin >> value
  |
  v
从输入流提取一个值,放入 value
1
2
3
4
5
6
7
8
9

可以这样记:

text
数据流向 cout:

value ---> std::cout

写成:

std::cout << value

数据从 cin 流向变量:

std::cin ---> value

写成:

std::cin >> value
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这不是完全严格的底层图,但足够帮助你建立方向感。

5. 输出缓冲:为什么 '\n' 和 std::endl 不一样 ​

看两行代码:

cpp
std::cout << "done\n";
std::cout << "done" << std::endl;
1
2

它们都会换行。

但 std::endl 多做了一件事:刷新。

text
程序写入 std::cout
  |
  v
用户态缓冲区
  |
  +-- 累积一段时间再交给系统
  |
  +-- flush 时立刻交给底层
1
2
3
4
5
6
7
8

'\n' 只是写入一个换行字符。

std::endl 等价于:

text
写入换行
  +
刷新输出流
1
2
3

刷新有成本。

如果你在循环里大量使用:

cpp
for (int i = 0; i < 100000; ++i) {
    std::cout << i << std::endl;
}
1
2
3

程序可能会比使用 '\n' 慢很多。

推荐习惯:

cpp
std::cout << "line\n";
1

真正需要立刻显示提示时:

cpp
std::cout << "input age: " << std::flush;
1

这里 std::flush 的意思是:

text
我没有换行,但请现在就把提示显示出来
1

6. 输入不是赋值,而是“读取、解析、更新状态” ​

这段代码:

cpp
int age{};
std::cin >> age;
1
2

不要理解成:

text
把键盘输入直接赋值给 age
1

更准确是:

text
从输入缓冲区取字符
  |
  v
跳过前导空白
  |
  v
尝试按 int 的规则解析
  |
  +-- 成功:修改 age
  |
  +-- 失败:设置流状态
1
2
3
4
5
6
7
8
9
10
11

所以更好的写法是把读取动作放在条件里:

cpp
#include <iostream>

int main() {
    int age{};

    if (std::cin >> age) {
        std::cout << "age = " << age << '\n';
    } else {
        std::cerr << "expected an integer\n";
    }
}
1
2
3
4
5
6
7
8
9
10
11

if (std::cin >> age) 能成立,是因为流对象可以被放到条件语境里。

它表达的是:

text
这次读取之后,流是否仍处于可继续 I/O 的状态?
1

因此常见循环是:

cpp
int value{};

while (std::cin >> value) {
    std::cout << "read " << value << '\n';
}
1
2
3
4
5

这不是把 std::cin 当成布尔变量。

它是在说:

text
每轮先尝试读取一个 int
  |
  +-- 成功:进入循环体
  |
  +-- 失败或结束:退出循环
1
2
3
4
5

7. 流状态位:good、fail、eof、bad 分别是什么 ​

输入流内部有状态位。

常见查询函数有:

cpp
std::cin.good();
std::cin.fail();
std::cin.eof();
std::cin.bad();
1
2
3
4

它们大致表示:

text
good()
  当前没有错误状态

fail()
  格式解析失败,或者 badbit 已经设置

eof()
  已经遇到输入结束

bad()
  底层 I/O 出现严重错误
1
2
3
4
5
6
7
8
9
10
11

可以画成:

text
读取一个 int
  |
  +-- 输入是 "42"
  |     |
  |     v
  |   成功,通常 good
  |
  +-- 输入是 "abc"
  |     |
  |     v
  |   格式不匹配,failbit
  |
  +-- 输入已经结束
  |     |
  |     v
  |   eofbit,读取也可能失败
  |
  +-- 文件/设备损坏
        |
        v
      badbit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

注意 eof() 的时机。

EOF 通常不是“还没读之前就知道没有了”。

它常常是在一次读取尝试之后才被发现。

所以这个写法经常有问题:

cpp
while (!std::cin.eof()) {
    int value{};
    std::cin >> value;
    std::cout << value << '\n';
}
1
2
3
4
5

问题在于:

text
判断时还没 eof
  |
  v
进入循环
  |
  v
读取时才发现失败或 EOF
  |
  v
仍然执行后续处理
1
2
3
4
5
6
7
8
9
10

正确习惯是:

cpp
int value{};

while (std::cin >> value) {
    std::cout << value << '\n';
}
1
2
3
4
5

把读取成功作为进入循环体的条件。

8. 错误输入恢复:为什么 clear 之后还要 ignore ​

看这个读取整数函数:

cpp
#include <iostream>
#include <limits>
#include <stdexcept>

int read_integer() {
    int value{};

    while (true) {
        std::cout << "integer: " << std::flush;

        if (std::cin >> value) {
            return value;
        }

        if (std::cin.eof()) {
            throw std::runtime_error("input ended");
        }

        std::cin.clear();
        std::cin.ignore(
            std::numeric_limits<std::streamsize>::max(),
            '\n'
        );

        std::cerr << "invalid input, try again\n";
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

这里有两个关键动作:

text
clear()
  清除流的失败状态,让它允许下一次读取

ignore(...)
  丢弃当前行剩余的坏输入,避免下一次又读到同一批字符
1
2
3
4
5

为什么不能只 clear()?

假设输入缓冲区是:

text
abc
^
|
当前读取位置
1
2
3
4

std::cin >> value 失败后:

text
流状态:fail
坏字符:abc 仍在缓冲区
1
2

调用 clear() 后:

text
流状态:恢复
坏字符:abc 仍在缓冲区
1
2

下一轮又尝试读取整数:

text
再次看到 a
  |
  v
再次失败
1
2
3
4

所以恢复输入通常要两步:

text
1. clear:让流从失败状态恢复
2. ignore:把导致失败的输入移走
1
2

9. >> 和 getline 为什么容易打架 ​

看代码:

cpp
#include <iostream>
#include <string>

int main() {
    int age{};
    std::string name;

    std::cin >> age;
    std::getline(std::cin, name);

    std::cout << "age = " << age << '\n';
    std::cout << "name = [" << name << "]\n";
}
1
2
3
4
5
6
7
8
9
10
11
12
13

输入:

text
18
Alice
1
2

你可能期待:

text
age = 18
name = [Alice]
1
2

但经常会得到:

text
age = 18
name = []
1
2

原因是:

text
输入缓冲区:

18\nAlice\n
^

std::cin >> age
  |
  v
取走 18
  |
  v
留下换行符 \n

缓冲区剩余:

\nAlice\n
^

std::getline(std::cin, name)
  |
  v
读到第一个 \n 就认为这一行结束
  |
  v
name 是空字符串
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

修复方式之一:

cpp
std::cin >> age;
std::cin.ignore(
    std::numeric_limits<std::streamsize>::max(),
    '\n'
);
std::getline(std::cin, name);
1
2
3
4
5
6

更稳定的输入设计是整行读取,再解析:

cpp
#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line;

    if (std::getline(std::cin, line)) {
        std::istringstream input(line);

        int age{};
        if (input >> age) {
            std::cout << "age = " << age << '\n';
        } else {
            std::cerr << "expected age\n";
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这里多了一个 std::istringstream。

它的意思是:

text
把一个字符串当作输入流来读
1

这样可以把问题拆成两步:

text
从外部读取一整行
  |
  v
在内存字符串中解析字段
1
2
3
4

拆开之后,错误处理会更清楚。

10. 文件流:文件不是字符串,而是系统资源 ​

先看一个读文件程序:

cpp
#include <fstream>
#include <iostream>
#include <string>

int main() {
    std::ifstream input("data.txt");

    if (!input) {
        std::cerr << "cannot open data.txt\n";
        return 1;
    }

    std::string line;
    while (std::getline(input, line)) {
        std::cout << line << '\n';
    }

    if (input.bad()) {
        std::cerr << "I/O error while reading\n";
        return 2;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

我们不要急着说 RAII。

先把 std::ifstream input("data.txt"); 拆开看。

std::ifstream 是 input file stream,输入文件流。

这一行创建了一个对象,名字叫 input。

这个对象内部大致保存:

text
input 对象
  |
  +-- 当前流状态:good / fail / eof / bad
  |
  +-- 当前读取位置:读到文件哪里了
  |
  +-- 缓冲区:暂存从文件读来的字节
  |
  +-- 底层文件句柄:操作系统给程序的文件访问入口
1
2
3
4
5
6
7
8
9

“文件句柄”可以理解成:

text
程序并不直接抓住硬盘上的文件。

程序向操作系统请求:
  我想打开 data.txt

操作系统如果同意,会返回一个代表这次打开结果的东西。

这个东西就是文件描述符或文件句柄一类的资源。
1
2
3
4
5
6
7
8

所以文件流不是一个简单字符串。

它连接着外部资源:

text
C++ ifstream 对象
  |
  v
C++ 标准库缓冲区
  |
  v
操作系统文件句柄
  |
  v
文件系统
  |
  v
磁盘或其他存储设备
1
2
3
4
5
6
7
8
9
10
11
12
13

这就是为什么打开文件要检查失败:

cpp
if (!input) {
    std::cerr << "cannot open data.txt\n";
    return 1;
}
1
2
3
4

打开可能失败,因为:

text
文件不存在
路径写错
没有权限
目录不存在
文件被其他程序以冲突方式占用
系统资源不足
1
2
3
4
5
6

读取循环:

cpp
while (std::getline(input, line)) {
    std::cout << line << '\n';
}
1
2
3

表示:

text
每次尝试读取一行
  |
  +-- 成功:处理这一行
  |
  +-- 失败或文件结束:退出循环
1
2
3
4
5

循环结束之后为什么还要检查 input.bad()?

因为结束可能有两种含义:

text
正常读到文件末尾
  |
  v
这是正常情况

底层 I/O 错误
  |
  v
这是异常情况
1
2
3
4
5
6
7
8
9

bad() 用来判断是否发生了比较严重的底层错误。

11. RAII:不是一句“析构释放资源”就够了 ​

现在正式讲 RAII。

RAII 的全称是:

text
Resource Acquisition Is Initialization
1

直译很别扭:

text
资源获取就是初始化
1

更适合学习时理解成:

text
让对象的生命周期负责资源的生命周期。
1

资源是什么?

资源是程序需要申请、使用完必须释放的东西。

例如:

text
堆内存
文件句柄
互斥锁
网络连接
数据库连接
临时文件
图形资源
1
2
3
4
5
6
7

这些东西如果只申请不释放,会出问题:

text
内存泄漏
文件一直被占用
锁没有解开导致卡死
连接耗尽
数据没有刷新
1
2
3
4
5

不用 RAII 时,你可能会写出这种形状:

text
申请资源
  |
  v
做事情
  |
  +-- 正常结束 -> 手动释放
  |
  +-- 中途 return -> 容易忘记释放
  |
  +-- 中途抛异常 -> 更容易跳过释放
1
2
3
4
5
6
7
8
9
10

RAII 要把它改成:

text
创建对象
  |
  +-- 构造函数中获得资源
  |
  v
使用对象
  |
  v
离开作用域
  |
  +-- 析构函数自动释放资源
1
2
3
4
5
6
7
8
9
10
11

用文件流来看:

cpp
void print_file() {
    std::ifstream input("data.txt");

    if (!input) {
        std::cerr << "cannot open data.txt\n";
        return;
    }

    std::string line;
    while (std::getline(input, line)) {
        std::cout << line << '\n';
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

input 是局部对象。

它的生命周期是:

text
进入 print_file
  |
  v
构造 input
  |
  v
尝试打开 data.txt
  |
  v
使用 input 读取
  |
  v
离开 print_file
  |
  v
input 析构
  |
  v
关闭文件资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

即使中途 return:

cpp
if (!input) {
    return;
}
1
2
3

已经构造成功的局部对象也会按规则析构。

如果中途抛异常,栈展开时也会析构已经构造的局部对象。

这就是 RAII 在 C++ 中非常重要的原因:

text
资源清理不依赖你记不记得写 close。

资源清理绑定到对象生命周期。
1
2
3

你仍然可以手动调用:

cpp
input.close();
1

但在普通代码里,常常不需要显式写。

因为局部 std::ifstream 离开作用域会自动关闭。

什么时候需要显式 close()?

text
想在作用域结束前就关闭文件
想立刻检查关闭/刷新时的错误
想释放文件占用后再做别的操作
1
2
3

否则,让对象自然析构通常更简单。

12. RAII 和异常:为什么 C++ 特别依赖它 ​

看一个没有 RAII 的伪代码:

cpp
void work() {
    Resource resource = acquire();

    step1(resource);
    step2(resource);  // 如果这里抛异常
    step3(resource);

    release(resource);
}
1
2
3
4
5
6
7
8
9

如果 step2 抛异常,程序会跳过后面的普通语句。

于是 release(resource) 可能不会执行。

RAII 的写法是:

cpp
void work() {
    ResourceOwner owner;

    step1(owner);
    step2(owner);
    step3(owner);
}
1
2
3
4
5
6
7

其中 ResourceOwner 的析构函数负责释放资源。

当异常发生时:

text
step2 抛异常
  |
  v
当前函数不能继续正常执行
  |
  v
开始栈展开
  |
  v
已经构造的局部对象按相反顺序析构
  |
  v
owner 析构,资源被释放
1
2
3
4
5
6
7
8
9
10
11
12
13

这也是为什么 C++ 标准库里很多类型都体现 RAII:

text
std::vector
  管理动态数组内存

std::string
  管理字符存储

std::ifstream / std::ofstream
  管理文件资源

std::unique_ptr
  管理独占堆对象

std::lock_guard
  管理互斥锁加锁和解锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14

RAII 不是某个语法点。

它是 C++ 管理资源的核心设计习惯。

13. 写文件:输出也可能失败 ​

写文件示例:

cpp
#include <fstream>
#include <iostream>

int main() {
    std::ofstream output("app.log", std::ios::app);

    if (!output) {
        std::cerr << "cannot open app.log\n";
        return 1;
    }

    output << "program started\n";

    if (!output) {
        std::cerr << "write failed\n";
        return 2;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

std::ofstream 是 output file stream,输出文件流。

std::ios::app 表示追加模式:

text
原文件内容保留
新内容写到末尾
1
2

如果不用追加模式:

cpp
std::ofstream output("app.log");
1

默认可能会截断原文件。

也就是:

text
打开文件
  |
  v
清空旧内容
  |
  v
写入新内容
1
2
3
4
5
6
7

这在学习时很容易造成“我的文件内容怎么没了”。

写文件也可能失败:

text
磁盘满
权限不足
路径不存在
设备断开
刷新到底层时出错
1
2
3
4
5

所以重要数据写入不能只假设成功。

14. 二进制文件:内存布局不等于文件格式 ​

有时你会看到这样的代码:

cpp
struct Header {
    int magic;
    int version;
};

Header header{0x12345678, 1};

output.write(
    reinterpret_cast<const char*>(&header),
    sizeof(header)
);
1
2
3
4
5
6
7
8
9
10
11

这段代码把结构体对象在内存中的字节直接写到文件。

它能运行,但要非常小心。

因为:

text
结构体可能有 padding
  编译器为了对齐插入的空字节

整数有字节序
  小端和大端机器上的字节顺序可能不同

类型大小可能因平台不同而变化
  int 不一定永远是你以为的大小

指针值不能写进文件当作长期数据
  指针只在当前进程当前运行中有意义
1
2
3
4
5
6
7
8
9
10
11

所以:

text
内存表示
  是程序当前运行时对象的字节样子

文件格式
  应该是你明确设计、跨版本可解释的数据约定
1
2
3
4
5

这和前面结构体那篇是连在一起的。

如果只是学习实验,直接写结构体可以帮助观察内存。

如果是正式文件格式,要明确字段大小、字节序和版本。

15. 三种常见错误表达方式 ​

错误处理没有一个永远正确的方案。

要先问:

text
失败是不是正常业务分支?
调用者能不能在本层处理?
需要携带多少错误信息?
失败后对象是否仍然可用?
1
2
3
4

15.1 返回 bool ​

cpp
bool parse_age(std::string_view text, int& output);
1

含义:

text
返回 true
  解析成功,output 中有结果

返回 false
  解析失败,output 不应被当作有效结果
1
2
3
4
5

优点:

text
简单
没有异常控制流
适合失败很常见的场景
1
2
3

缺点:

text
调用者可能忘记检查返回值
输出参数让数据流不够直观
失败原因表达不丰富
1
2
3

15.2 返回 std::optional ​

cpp
#include <optional>
#include <string_view>

std::optional<int> parse_age(std::string_view text);
1
2
3
4

含义:

text
有值
  成功

没有值
  失败
1
2
3
4
5

使用:

cpp
if (auto age = parse_age("18")) {
    std::cout << *age << '\n';
} else {
    std::cerr << "invalid age\n";
}
1
2
3
4
5

*age 表示取出 optional 里面的值。

这和指针的 * 长得一样,但这里是 std::optional 定义的取值操作。

15.3 抛异常 ​

cpp
throw std::runtime_error("configuration is invalid");
1

异常适合:

text
当前函数无法处理
需要向上层传播
失败不是普通分支
错误信息需要跨多层函数传递
1
2
3
4

但异常不是用来替代所有 if 的。

例如用户输错年龄,这通常是普通输入失败。

配置文件格式彻底损坏,程序无法继续启动,这可能适合异常。

16. assert:检查程序员自己的假设 ​

看代码:

cpp
#include <cassert>

double divide(double numerator, double denominator) {
    assert(denominator != 0.0);
    return numerator / denominator;
}
1
2
3
4
5
6

assert 的意思不是:

text
请帮我优雅处理用户输入错误
1

它的意思更接近:

text
如果程序逻辑正确,这个条件应该永远成立。
如果不成立,说明程序内部有 bug,请尽早暴露。
1
2

所以 assert 适合检查内部不变量:

text
数组下标已经由上层保证合法
指针在这里按设计不应该为空
状态机不应该进入某个非法状态
1
2
3

不适合:

text
检查用户输入是不是整数
检查文件是否存在
检查网络请求是否成功
1
2
3

还有一个重要点:

发布构建可能定义 NDEBUG。

定义后,assert 可能被移除。

所以不能把必须执行的业务检查写成 assert。

17. static_assert:编译期检查 ​

assert 在运行时检查。

static_assert 在编译期检查。

示例:

cpp
static_assert(sizeof(char) == 1);
1

再看一个更有意义的:

cpp
#include <cstdint>

static_assert(sizeof(std::uint32_t) == 4);
1
2
3

它表达:

text
如果这个平台上 uint32_t 不是 4 字节,就不要通过编译。
1

static_assert 适合:

text
类型大小假设
模板参数约束
平台前提
编译期常量条件
1
2
3
4

它不是调试输出。

它是让错误尽可能早地在编译阶段暴露。

18. 编译警告:编译器是你的第一位审稿人 ​

推荐学习阶段使用:

bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -g main.cpp -o app
1

其中:

text
-Wall
  打开一组常用警告

-Wextra
  打开额外警告

-Wpedantic
  对不符合标准的写法更敏感

-g
  生成调试信息
1
2
3
4
5
6
7
8
9
10
11

警告不是错误。

但警告也不是噪音。

正确态度是:

text
先读懂警告在说什么
  |
  v
判断是代码真的有问题,还是接口表达不清楚
  |
  v
修正代码或明确处理
1
2
3
4
5
6
7

不要为了“消警告”随便强制类型转换。

强制类型转换会降低编译器帮你发现问题的能力。

19. 调试器:不要只靠猜 ​

调试器做的事情可以很朴素:

text
让程序停下来
  |
  v
查看当前变量
  |
  v
查看调用栈
  |
  v
一行一行执行
  |
  v
找到第一次偏离预期的位置
1
2
3
4
5
6
7
8
9
10
11
12
13

编译时加:

bash
g++ -std=c++17 -O0 -g main.cpp -o app
1

这里:

text
-O0
  关闭优化,让源码和机器执行过程更接近,适合初学调试

-g
  生成调试信息,让调试器知道源码行、变量名、函数名
1
2
3
4
5

常见调试器动作:

text
break
  设置断点,让程序运行到某处停下

run
  启动程序

next
  执行下一行,不进入函数内部

step
  执行下一步,如果遇到函数调用就进入

print
  查看变量值

backtrace
  查看调用栈
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

调用栈是什么?

可以理解成:

text
main()
  |
  v
read_config()
  |
  v
parse_line()
  |
  v
parse_number()
1
2
3
4
5
6
7
8
9
10

如果程序在 parse_number() 里出错,调用栈能告诉你:

text
它是从哪一层一路调用过来的。
1

这对理解程序路径非常重要。

20. Sanitizer:运行时帮你抓一部分未定义行为 ​

看一个越界程序:

cpp
int main() {
    int values[3]{1, 2, 3};
    return values[3];
}
1
2
3
4

values[3] 越界。

数组有效下标是:

text
0, 1, 2
1

编译:

bash
g++ -std=c++17 -g -O1 \
    -fsanitize=address,undefined \
    -fno-omit-frame-pointer \
    main.cpp -o app
1
2
3
4

拆开:

text
-fsanitize=address
  打开 AddressSanitizer,检查很多内存访问错误

-fsanitize=undefined
  打开 UndefinedBehaviorSanitizer,检查部分未定义行为

-fno-omit-frame-pointer
  保留帧指针,让错误栈更容易读

-O1
  开一点优化,同时保留较好的检查效果
1
2
3
4
5
6
7
8
9
10
11

运行:

bash
./app
1

如果触发,Sanitizer 可能会输出:

text
发生了什么错误
在哪个源文件哪一行
调用栈是什么
附近内存状态是什么
1
2
3
4

但要注意:

text
Sanitizer 没报警
  !=
程序一定正确
1
2
3

它只能检测:

text
工具能检测的错误
  +
这次运行路径实际触发的错误
1
2
3

所以还需要测试、审查和良好的接口设计。

21. 最小复现:把混乱问题压缩成可观察问题 ​

调试时最痛苦的状态是:

text
项目很大
输入很多
现象偶发
错误信息很长
不知道从哪看
1
2
3
4
5

这时要做最小复现。

最小复现不是“把代码删得越短越好”。

它是:

text
保留触发问题的关键机制
删除和问题无关的干扰
1
2

过程可以这样:

text
固定编译命令
  |
  v
固定输入
  |
  v
确认问题稳定复现
  |
  v
删除一个无关部分
  |
  v
重新运行确认问题仍在
  |
  v
继续缩小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

记录信息:

text
操作系统
编译器版本
完整编译命令
完整输入
预期输出
实际输出
错误信息
最早偏离预期的位置
1
2
3
4
5
6
7
8

这不是形式主义。

它能把“感觉不对”变成“证据链”。

22. 一条完整排查链 ​

遇到问题时,可以按这个顺序:

text
1. 保存完整错误信息
   |
   v
2. 判断问题阶段
   编译?链接?运行?输入?文件?内存?
   |
   v
3. 固定复现方式
   同一条命令,同一份输入
   |
   v
4. 增加观察点
   打印、断点、状态检查、Sanitizer
   |
   v
5. 找第一次偏离预期的位置
   |
   v
6. 提出根因假设
   |
   v
7. 做最小修改验证
   |
   v
8. 修复并增加回归实验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

不要一次改很多地方。

否则问题消失时,你也不知道是哪一个改动真的有效。

23. 常见误解 ​

text
误解 1:std::cin >> value 就像普通赋值
  实际:它会读取、解析、修改对象,并更新流状态

误解 2:while (!stream.eof()) 是读文件的标准写法
  实际:应该把读取动作本身作为循环条件

误解 3:clear() 后输入就恢复正常
  实际:坏字符可能还在缓冲区,通常还要 ignore()

误解 4:std::endl 只是换行
  实际:它还会 flush,频繁使用可能有性能成本

误解 5:文件就是字符串
  实际:文件流背后有缓冲区、状态和操作系统资源

误解 6:RAII 就是“自动 close”
  实际:RAII 是把资源生命周期绑定到对象生命周期

误解 7:assert 可以检查用户输入
  实际:assert 适合检查程序内部不变量,发布构建可能移除

误解 8:Sanitizer 没报警就说明没 bug
  实际:它只覆盖特定错误和本次执行路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

24. 最小实验清单 ​

实验 1:输入错误整数。

text
程序读取 int
输入 abc
观察 fail 状态
1
2
3

实验 2:只 clear() 不 ignore()。

text
确认程序会反复卡在同一段坏输入
1

实验 3:比较 '\n'、std::endl 和 std::flush。

text
理解换行和刷新不是一回事
1

实验 4:混用 >> 和 getline。

text
观察残留换行如何影响 getline
1

实验 5:打开不存在的文件。

text
检查 ifstream 的失败状态
1

实验 6:让文件流提前离开作用域。

text
理解 RAII 自动关闭资源
1

实验 7:使用 -g 和调试器查看调用栈。

text
找到函数是从哪里被调用的
1

实验 8:使用 Sanitizer 检测数组越界。

text
观察错误报告里的文件名、行号和调用栈
1

25. 本篇总结 ​

本文的主线不是“学几个 I/O API”。

主线是:

text
程序要学会观察失败。
1

输入流的核心模型:

text
外部输入
  |
  v
缓冲区
  |
  v
解析规则
  |
  +-- 成功:对象获得新值
  |
  +-- 格式错误:failbit
  |
  +-- 输入结束:eofbit
  |
  +-- 底层错误:badbit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

文件流的核心模型:

text
C++ 文件流对象
  |
  +-- 状态
  +-- 缓冲区
  +-- 当前位置
  +-- 底层文件资源
1
2
3
4
5
6

RAII 的核心模型:

text
构造对象
  |
  v
获得资源
  |
  v
使用资源
  |
  v
对象离开作用域
  |
  v
析构释放资源
1
2
3
4
5
6
7
8
9
10
11
12
13

调试的核心模型:

text
现象
  |
  v
复现
  |
  v
观察状态
  |
  v
找到第一次偏离预期
  |
  v
验证根因
1
2
3
4
5
6
7
8
9
10
11
12
13

学完语言基础模块后,你不需要一下子变成 C++ 高手。

但你应该开始具备一种能力:

text
不只问“怎么写”,
也会问“它现在处于什么状态,为什么会失败,我怎样证明”。
1
2

这会直接影响后面学习对象生命周期、内存管理、并发、网络和系统编程的质量。

下一阶段,我们会进入更深入的 C++ 对象、资源和内存模型。

最后更新于:

Pager
上一篇10. 一个头文件怎样影响整个程序:预处理、头文件与命名空间 / Preprocessor, Headers, and Namespaces

持续记录,持续成长

Copyright © Tidenflow