Skip to content

By FunnyAWM

在我们一般的程序编写中,我们一般情况下总是会遇到程序没有预期到的情况。这章我们来学习如何在 C++ 中处理这些未预期的情况。

1. 关于异常 —— 什么是异常?为什么要抛出异常?

我们在程序编写时总是会遇到一些骨骼清奇的用户 —— 这些用户不按照常理使用软件,例如我们的程序接受一个 0-3 的输入,用户要输入 5,怎么办呢?如果这个程序没有异常处理机制,程序会直接终止,没有报错信息,用户体验会十分糟糕。简单来说,程序能产生的错误分为编译期错误与运行时错误。其中编译期错误在编译时就会被编译器捕获,异常处理机制只针对运行时错误进行处理。常见的运行时错误包括:

  • 设备或外部设施错误:指定的设备没有接入、没有开启或设备发生故障、外部服务器宕机或访问超时、找不到指定的资源等,例如在为单片机烧录程序时没有连接单片机下载器、网络请求超时等就属于这种情况;
  • 物理限制:内存或存储空间耗尽等;
  • 输入错误:来自文件或用户的输入不符合指定格式、输入值超出程序处理范围、文件长度不符合要求等,例如 程序要求从文件读入 JSON 格式,而读入的实际格式为 YAML 就属于这种情况;
  • 程序内部错误:数组越界、解引用空指针、栈溢出、对空容器的元素执行访问操作等,例如从 0 下标遍历空容器、无限递归等就属于这种情况。
  • 人为逻辑限制:用户输入不符合指定的操作顺序、用户超出人为设定的边界等,例如游戏中角色跑出设定的地图边界就属于这种情况。前面的区域,以后再来探索吧?

在运行时错误发生时,如果我们没有明确的异常处理机制,程序就可能以异常状态继续运行或直接静默崩溃。这显然不是我们想让用户看到的。利用异常机制为程序添加用户友好的错误信息就成为了一种解决方法。这种方法不仅能改进我们程序的用户友好性,还能够为我们后续修复程序 bug 提供充足的信息。

DLC1:程序异常处理与 SQA 的关系

SQA(Software Quality Assurance)全称软件质量保证,是整个软件产品开发期间的重要指标之一。SQA 部门通常由软件开发人员与软件测试人员共同组成,其中测试人员负责通过白盒程序分析与黑盒输入 / 输出验证等方法尽可能发现程序中的缺陷与异常,由开发人员进行修复。其中黑盒测试中有一种测试方法称作等价类划分法,其核心是通过拟定多个预期有效的程序输入(被称为有效等价类)以及预期无效的程序输入(被称为无效等价类)来测试程序对无效输入的处理是否恰当,且能否正确处理有效输出 —— 既要确保有效输入获得正确结果,又要保证无效输入被妥善拒绝而非导致程序崩溃。一般来说,通过等价类划分法,测试人员可以设计出预期触发异常的输入场景,从而验证程序的异常处理逻辑是否正确(例如是否抛出了合适的异常、是否记录了清晰的错误信息等测试人员发现的问题最终会反馈给开发人员,然后由开发人员解决测试人员提出的问题。这种测试与开发的迭代循环正是 SQA 的核心价值。下面的例子充分展示了我们的程序可能遇到的各种输入,而异常处理机制就是为了防止这些输入导致程序崩溃:

[!TIPS] 经典软件测试笑话 一个测试工程师走进一家酒吧,要了一杯啤酒 一个测试工程师走进一家酒吧,要了一杯咖啡 一个测试工程师走进一家酒吧,要了 0.7 杯啤酒 一个测试工程师走进一家酒吧,要了 - 1 杯啤酒 一个测试工程师走进一家酒吧,要了 2^32 杯啤酒 一个测试工程师走进一家酒吧,要了一杯洗脚水 一个测试工程师走进一家酒吧,要了一杯蜥蜴 一个测试工程师走进一家酒吧,要了一份 asdfQwer@24dg!&*(@ 一个测试工程师走进一家酒吧,什么也没要 一个测试工程师走进家酒吧,又走出去又从窗户进来又从后门出去从下水道钻进来 一个测试工程师走进家酒吧,又走出去又进来又出去又进来又出去,最后在外面把老板打了一顿 一个测试工程师走进一 一个测试工程师走进一家酒吧,要了一杯烫烫烫的锟斤拷 一个测试工程师走进一家酒吧,要了 NaN 杯 Null 一个测试工程师化装成老板走进一家酒吧,要了 500 杯啤酒,并且不付钱 一万个测试工程师在酒吧外呼啸而过 测试工程师们满意地离开了酒吧 然后一名顾客点了一份炒饭,酒吧炸了

DLC2:异常处理与人机交互设计之间的关系

异常处理能够帮助我们提升程序的用户体验,这点在 Nielsen 十大可用性原则等人机交互设计领域的 “黄金法则” 中都有体现。其中异常处理在 Nielsen 十大可用性原则的体现如下:

  • 防错原则:通过设计预防错误发生,例如删除前弹出二次确认,在代码设计中的体现为进行边界检查等;
  • 容错原则:错误提示要清晰易懂并给出解决建议。例如在资源不存在时显示 “404” 页面并设计返回按钮,在代码设计中的体现为抛出携带用户友好信息的异常、在 catch 块中展示可操作的错误信息(例如 “请检查配置文件路径是否正确等。

2. 异常处理 —— 到底选哪种方法?

1. 返回值

在一些极为简单的程序中,我们可以通过程序的返回值来报告问题。例如:

cpp
#include <iostream>
using std::cin;
using std::cout;
using std::endl;

int main() {
    cout << "输入一个从0到3的数字:\n";
    int input;
    cin >> input;
    if (input >= 0 && input <= 3) {
        cout << "你输入了:" << input << endl;
        return 0;
    }
    return 1;
}

这样我们就能够通过程序执行的返回值来确定问题所在了。这种方法在程序需要紧急退出,否则会导致数据损坏时会使用,同时在命令行工具等需要通过返回值指示错误时会使用。同时 C++ 提供了一个 exit 函数,能够在这个函数的参数中指定程序的返回值,在调用这个函数时程序会立即终止并返回参数中的返回值,在遇到无法恢复的致命错误时需要快速退出程序时很常用。为什么呢?因为 exit 函数不会展开栈,即不会调用所有涉及该程序的对象的析构函数。这样这些对象在 exit 调用时就无法执行对应的 IO 或其他操作而直接以不正常状态被直接释放,在进行一些操作(例如写回操作结果到日志)时可能会导致结果不完整或状态不一致。

警告

exit 函数在调用时,涉及到这个程序的所有对象都不会被正常销毁,资源(例如打开的文件、内存等)也不会被释放,缓冲区也不会被刷新。这种 “直接打断程序运行并退出” 的做法有时会导致一些不良后果。例如我们有一个数据库连接器对象与日志记录器对象,前者负责对数据库中的数据进行操作,后者负责将这些操作记录进日志。显然,操作数据库中的操作与记录日志存在明显的先后顺序 —— 程序必须先操作数据库并获取数据库返回的结果或错误后日志才能写入。我们假设程序在另一个线程中运行的函数因为自身出现了无法恢复的错误而调用了 exit 函数,而数据库连接器刚刚对数据库进行了操作,还没有把要写入的日志交给日志记录器。这种情况下,虽然对数据库中数据的操作已经成功完成,但是日志中并没有记录这一操作,导致我们根本不可能从日志中知道这个操作。

这时候可能就有人要问了:主播主播,我感觉这种方法挺方便的啊,为什么不能在所有情况下用呢?问题在于:第一,这种方法对用户体验与静默崩溃一样不友好。在程序返回非 0 结果时,用户通常需要依靠产品文档中说明的返回值才能定位问题。在非正常情况很少或很多情况都能使用同一个异常码,且这些异常码有约定俗成规定的时候,这并没有问题。但是这些情况以外的情况呢?我们不妨思考一下:用户看完那份比自己命还长的文档之后,还可能会有精力继续使用我们的产品吗?用户不打一星差评对我们都已经是仁至义尽了。第二,这种方法不适用于图形界面程序。图形界面程序在运行时一般是不带控制台运行的。就算带控制台运行,在没有活跃 Shell 的情况下,程序也会在返回返回值以后立刻退出,用户无法看清返回值。我们需要返回更有建设性意见、更能让用户理解的异常信息来让用户知道自己的操作到底哪里出了问题,于是异常处理便出现了。

2. try、catch 与 throw 关键字 ——C++ 异常处理的核心

1. 使用 C++ 的异常处理机制

有些错误并不必要直接导致程序退出。例如我们想要用户输入一个从 0 到 3 的值,这种情况一般不会导致数据损坏或其他任何问题,当用户输入了超出我们限制的数据时,我们只需要告诉用户要输入指定范围的数字,然后让用户重新输入一次就可以了。这种情况下就可以使用 C++ 为我们准备的 try-catch 块来处理这种错误了。

在 C++ 中,如果一段代码可能引发异常,我们需要用 try 块包裹这些代码。在出现错误的时候,我们需要使用 throw 关键字来抛出这个错误。例如上面的程序可以改为:

cpp
#include <stdexcept> // 这个头文件提供了一些C++自带的错误类型
#include <string> // 提供to_string函数(C++11开始可用,以前标准需要寻找兼容方案)
// 其他部分省略,只写程序逻辑
try {
    cout << "输入一个从0到3的数字:\n";
    int input;
    cin >> input;
    if (input >= 0 && input <= 3) {
        cout << "你输入了:" << input << endl;
    } else {
        throw std::logic_error("输入超出指定范围:" +
                               std::to_string(input));
    }
}

在抛出异常之后,我们该做什么呢?什么也不做的话,这个异常同样会作为未处理异常导致程序调用 std::terminate 函数后退出,这并不是我们想要的结果。为什么会有未处理异常呢?这是因为我们没有告诉程序要对这个异常进行什么操作。在这种情况下,程序会默认自己出现了无法恢复的问题而直接终止运行。这个时候就该我们的 catch 块入场了 ——catch 块可以捕获 try 块中抛出的、且能被转换为该异常类型的异常,并执行代码块中的处理方式。我们继续扩展上面的代码:

cpp
#include <stdexcept> // 这个头文件提供了一些C++自带的错误类型
#include <string>    // 提供to_string函数
// 其他部分省略,只写程序逻辑
try {
    cout << "输入一个从0到3的数字:\n";
    int input;
    cin >> input;
    if (input >= 0 && input <= 3) {
        cout << "你输入了:" << input << endl;
    } else {
        throw std::logic_error("输入超出指定范围:" +
                               std::to_string(input));
    }
} catch (const std::logic_error &e) {
    cout << "程序发生错误:" << e.what() << endl;
}

在这个代码片段中,catch 块捕获到了我们上面抛出的 logic_error 错误,并输出了这个错误的详细信息。对于上面的错误输出,我们还有一种做法:把上面的操作包装成函数,在函数中抛出错误(但不在函数中使用 try 块然后在调用函数时用 try 块包含:

cpp
void inputNum() {
    cout << "输入一个从0到3的数字:\n";
    int input;
    cin >> input;
    if (input >= 0 && input <= 3) {
        cout << "你输入了:" << input << endl;
    } else {
        throw std::logic_error("输入超出指定范围:" +
                               std::to_string(input));
    }
}

然后在调用时使用 try-catch:

cpp
try {
    inputNum();
} catch (const std::logic_error &e) {
    cout << "程序发生错误:" << e.what() << endl;
}

这样我们就能够在大幅提升程序可读性与降低耦合的情况下同时保证异常能够被正确处理。

catch 块可以连用,我们可以利用多个 catch 来捕获不同类型的异常,进而对这些不同的异常采取不同的处理方式。

如果我们想要在 catch 块中捕获所有异常,只需要将括号中的内容替换为... 即可。例如:

cpp
try {
    inputNum();
} catch (const std::logic_error &e) {
    cout << "程序发生错误:" << e.what() << endl;
} catch (...) {
    cout << "发生未知错误\n";
}

2. 自定义异常类

大部分时候,仅仅依靠 C++ 自身提供的异常类并不能囊括我们所遇到的所有情况。例如上面的 logic_error 就存在这样的问题:到底是哪种逻辑异常?是输入不符合我们的规定、数组越界访问、递归层数过多,还是其他的什么问题?runtime_error 类同样存在这样的问题:到底是什么错误?是服务器访问超时、数据库错误、设备未连接,还是其他的任何问题?如果我们需要在代码中体现这些具体的问题,我们就需要创建我们自己自定义的异常类了。

如果我们只需要传递错误信息,那么我们只需要继承 std::runtime_error 类就可以了。但是如果我们需要传递更多的信息(例如在 HTTP 请求中我们想要传入完整的请求参数和内容、HTTP 返回码与完整的响应参数和内容)时,我们就需要基于 std::exception 类创建我们的自定义异常类了。关于继承这两者的策略的区别如下:

特性(下)/策略(右)继承 std::exception继承 std::runtime_error
需要手动存储错误信息
需要手动实现返回信息函数
灵活性高(可以扩充任意成员)低(只能存储错误信息)

例如我们要针对上面的 HTTP 错误定义一个错误类,其中包括要返回的错误信息和错误码,只需要写:

cpp
class NetworkException : public std::exception {
  private:
    std::string message;
    int errorCode;

  public:
    NetworkException(const std::string &msg, int code)
        : message(msg), errorCode(code) {}

    virtual const char *what() const throw() {
        // 在C++11中,应该把throw()替换为noexcept,以适应现代C++需要
        // 如果你的编译器使用了C++17及以上标准,不要用throw()——它已经被完全移除,使用noexcept
        return message.c_str();
    }

    int getErrorCode() const throw() { // 同上
        return errorCode;
    }
};

注意

在实现自定义异常类时,一定不要忘记包含 exception 头文件。

其中 what 函数是 exception 类为我们提供的输出错误信息的函数,这里直接重写这个函数即可。这个函数的约定行为是直接返回这个错误的 C 风格字符串(也就是类型为 const char * 的字符串,而非 string这样我们就可以在代码中这样抛出这个异常:

cpp
throw NetworkException("找不到资源", 404);

3. 标准异常继承体系

在标准的 C++ 库中,异常类以以下的结构实现(以下以树形结构呈现

text
std::exception
├── std::logic_error
│   ├── std::invalid_argument
│   ├── std::domain_error
│   ├── std::length_error
│   └── std::out_of_range
├── std::runtime_error
│   ├── std::range_error
│   ├── std::overflow_error
│   └── std::underflow_error
├── std::bad_alloc
├── std::bad_cast
└── ...

4. 栈展开

之前我们提到,exit 函数被调用时所有相关资源都不会被释放,因为析构函数没有被调用。那么在 try-catch 机制中呢?

答案是 —— 所有在 try 块中被声明的局部对象全部都会被析构,资源被回收,内存被释放。这种特性在 C++ 中被称为栈展开,是 C++ 异常机制与 exit 函数的主要区别之一。

5. 重新抛出异常

有的时候,我们的异常出于一些原因并不能被就地处理。例如我们在某个函数中抛出的异常应该由其上层的函数处理、或者我们想要保留程序的调用栈以定位问题所在,该怎么做呢?我们可以利用 throw 关键字再次抛出异常例如:

cpp
try { // 我们假设这里的logic_error应由调用这个代码块的其他函数处理
    inputNum();
} catch (const std::logic_error &e) {
    cout << "程序发生错误:" << e.what() << endl;
    throw;
} catch (...) {
    cout << "发生未知错误\n";
}

注意

在重新 throw 异常后,当前 catch 块下的 catch 块就不会继续捕获相关异常了。

程序调用栈的作用

程序的完整调用栈在调试时十分有用。它可以告诉我们:函数在抛出这个异常时,有哪些函数在调用它、它们是以什么顺序被调用的。通常在拥有程序调用栈的情况下,如果问题不在这个函数本身,我们也可以通过在上层函数中查找问题来了解这个异常为什么会出现,进而定位并解决问题。

6. 函数 try 块

有些异常没有办法被 try-catch 捕获。例如在对象初始化时,构造函数与初始化列表中发生的异常无法被常规 try-catch 捕获。这个时候我们就需要通过将整个函数体都声明为 try 块的方式来捕获这种异常。例如:

cpp
MyClass::MyClass(int arg) try : base(arg) {
    // 构造函数体
} catch (const std::exception &e) {
    // 处理异常,例如记录日志、重新抛出、转换为其他异常抛出等,不能修复对象状态
}

这样我们就能够在那些无法使用常规 try-catch 方法的地方捕获异常了。例如我们有一个程序设定的类,它接收一个配置 JSON 文件的路径作为参数,从这个文件中读取程序设置并使用这些设置初始化程序。在这里出现的异常就能够用这种方法捕获。

DLC:其他语言中的异常处理

1. Java 与 Python 等语言中的 try-with-resource

在 Java、Python 及其他部分语言中,支持在 try 时打开指定资源,在异常发生时自动或手动回收这些资源。例如:

在 Java 中:

java
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;
// Java 7+ 的 try-with-resources 语法
try (FileReader fr = new FileReader("config.txt");
     BufferedReader br = new BufferedReader(fr)) {
    // 读取文件内容
    String line = br.readLine();
} catch (IOException e) {
    // 处理 IO 异常
}
// 不需要 finally 块——try-with-resources 会自动关闭资源

在 Python 中:

python
with open("config.txt", 'r') as f:
	try:
		config = f.read()
		# 根据配置文件初始化程序
	except IOError as e:
	  pass
		# 处理文件读取失败的逻辑
# 在退出with块时解释器会自动释放资源(在这里是关闭文件)

2. 根据不同运行情况返回不同类型

部分语言同样支持在返回时指定正确返回类型与异常返回类型。例如:

在 Python 中:

python
# 类型提示建议返回 str
def greet(name: str) -> str:
    return f"Hello, {name}"

# 文档字符串说明可能抛出 ValueError
def divide(a: float, b: float) -> float:
    """执行除法运算。

    Raises:
        ValueError: 当除数为零时抛出。
    """
    if b == 0:
        raise ValueError("除数不能为零")
    return a / b

# NoReturn 表示函数永不正常返回
from typing import NoReturn

def always_fail() -> NoReturn:
    raise RuntimeError("这个函数总是抛出异常")

Python 3.10 开始支持了联合返回类型,能够令同一个函数根据不同的情况返回不同的类型。这种情况适用于需要强制调用者处理空值的情况,这种特性与抛出异常的使用场景不同。例如:

python
def divide(a: float, b: float) -> float | None:
    if b == 0:
        print("除数不能为0")
        return None  # 不抛出异常,而是返回 None
    return a / b

在 Rust 中,通过 Result 枚举区分正常结果与异常结果,没有异常机制。例如:

rust
use std::fs::File;

// 返回值类型是 Result<T, E>
// 成功时返回 File 句柄(T = File),失败时返回 io::Error(E = io::Error)
fn open_file(path: &str) -> Result<File, std::io::Error> {
    File::open(path)
}

fn main() {
    match open_file("hello.txt") {
        Ok(file) => println!("文件打开成功: {:?}", file),
        Err(e) => println!("文件打开失败: {}", e),
    }
}