以前写c代码时,对于错误处理经常是看到写了一堆的if-else判断,分别将错误码返回。对于比较复杂的业务,错误码会被层层嵌套返回,要跟踪起来那叫一个头大。嫌if-else返回错误码麻烦?直接给你调一个assert()触发断言,去用gdb慢慢分析吧……

这种窘境归根结底是c中没有提供原生的错误和异常处理机制造成的,但是作为c的超集,c++改善了这个缺陷,引入了一套异常处理机制。它就像专门的 “问题快递员”:一旦出了错,直接把 “错误包裹”(异常)扔出去,不用层层传递,直到有 “人”(catch 块)接住并处理。

C++异常处理的基本概念

C++ 异常处理的核心是三个关键字:try(监控)、throw(抛异常)、catch(接异常)。它们的分工如下:

  • try:“我盯着这段代码,一旦出问题就喊人!”—— 包裹可能出错的代码块。

  • throw:“这里出问题了!把这个错误扔出去!”—— 当检测到错误时,抛出一个 “异常对象”(可以是 int、string、自定义类等)。

  • catch:“我来处理这个类型的错误!”—— 专门接收throw抛出的异常,类型要匹配。

看看 “除法除零” 这个经典异常场景,c++中该如何处理

#include <iostream>#include <string>using namespace std;
// 除法函数:如果除数为0,抛出异常int divide(int a, int b) {    if (b == 0) {        // 抛出异常:类型是string,携带具体错误信息        throw string("错误:除数不能为0!");    }    return a / b;  // 没出错就正常返回结果}int main() {    int x = 10, y = 0, result;    // 1. try块:监控可能抛出异常的代码    try {        cout << "开始计算除法..." << endl;        result = divide(x, y);  // 这里会抛出异常        cout << "计算结果:" << result << endl;  // 异常抛出后,这行不会执行    }    // 2. catch块:接收string类型的异常(和throw的类型要匹配)    catch (string& err_msg) {        // 处理异常:打印错误信息        cerr << "捕获到异常:" << err_msg << endl;    }    // 3. 异常处理完后,程序会继续执行这里    cout << "程序没有崩溃,继续运行~" << endl;    return 0;}

输出结果

图片

是不是很神奇?即使出现 “除零错误”,程序也没有直接崩溃,而是优雅地处理了错误,还能继续往下运行,这就是异常处理的核心价值。

这里单独提一下catch块的参数类型,和函数参数一样,catch的参数类型也存在值传递和引用传递的差异

  • 参数为非引用类型,在catch语句块中实际上使用的是原始对象的副本,不会改变传入的异常对象本身。对于大型自定义结构会存在拷贝的开销

  • 参数为引用类型,直接传递对象本身,无拷贝开销,如果在语句块内进行修改,也就是改变对象本身。也可以避免基类对象和派生类转换时的切片问题

所以对于抛出的是类对象的异常,一般都是使用引用类型来捕获

异常处理的使用方法

使用try,throw,catch处理异常,要想实际用好,可不像语法形式上看起来的那么简单,起码要知道下面几点

1. 异常类型必须严格匹配(除非是父子类)

throw抛出的异常类型,和catch接收的类型必须完全一致,否则异常会 “没人接”,最终导致程序崩溃。

比如下面这个错误例子:throw的是 int,catch的是 double,类型不匹配:

#include <iostream>using namespace std;void test() {    throw 1;  // 抛出int类型异常}int main() {    try {        test();    }    // 错误:想接double类型,接不到int类型的异常    catch (double err) {        cerr << "捕获到double异常:" << err << endl;    }    return 0;}

程序运行会直接崩溃,输出

图片


这就像你寄了一个 “int 快递”,但只有 “double 快递” 的收件人,快递没人收,系统就直接 “罢工” 了。

2. 多个 catch 块:从具体到一般,顺序有讲究

如果一个try块可能抛出多种类型的异常,可以写多个catch块,但顺序必须是 “具体类型在前,通用类型在后”。

比如下面的例子,先处理 int 和 string 类型,最后用catch (...)处理 “所有其他类型” 的异常(...是万能捕获,能接任何类型):

#include <iostream>#include <string>using namespace std;void process(int type) {    if (type == 1) throw 1;          // int:参数错误    if (type == 2) throw string("文件不存在");  // string:文件错误    if (type == 3) throw 3.14;       // double:其他错误}int main() {    try {        process(2);  // 抛出string类型异常    }    // 1. 先接具体类型:int    catch (int err) {        cerr << "参数错误:错误码" << err << endl;    }    // 2. 再接具体类型:string    catch (string& err) {        cerr << "文件错误:" << err << endl;  // 这个会被执行    }    // 3. 最后接万能类型:...(能接任何类型,但不推荐滥用)    catch (...) {        cerr << "捕获到未知类型异常!" << endl;    }    return 0;}


千万别把万能捕获catch (...)放在最前面, 否则后面的具体catch块永远不会执行(万能捕获会把所有异常都接走)。这里类似于switch语句的default,是一个保底分支,一定要放在最后

3. 异常会自动跨函数传播

这是异常处理最独特的特性之一:如果函数 A 调用 B,B 调用 C,C 抛出异常,异常会按照调用栈自动 “往回跑”,直到找到能处理它的catch块,中间函数不用手动传递错误。这个特性叫做栈解退(unwinding the stack)

这个特性底层怎么实现的就不展开论述了,有兴趣的同学可以自己查资料深究下,从下面这个例子看看是个什么效果

#include <iostream>#include <string>#include <fcntl.h>#include <unistd.h>#include <errno.h>using namespace std;// 底层函数:读取文件内容void read_content(int fd, char* buf, int size) {    ssize_t len = read(fd, buf, size - 1);    if (len == -1) {        // 抛出异常,不用管上层怎么处理        throw string("read失败:") + strerror(errno);    }    buf[len] = '\0';}// 中层函数:打开文件并调用读取void open_and_read(const char* filename, char* buf, int size) {    int fd = open(filename, O_RDONLY);    if (fd == -1) {        // 抛出异常,不用手动返回错误码        throw string("open失败:") + strerror(errno);    }    read_content(fd, buf, size);  // 调用底层函数,不处理异常    close(fd);  // 没异常才会执行这行}int main() {    char buf[1024];    const char* filename = "test.txt";    // 顶层函数:统一处理所有异常    try {        open_and_read(filename, buf, sizeof(buf));        cout << "文件内容:" << buf << endl;    }    catch (string& err) {        // 这里能接住open或read抛出的异常        cerr << "处理异常:" << err << endl;        return 1;    }    return 0;}

如果test.txt不存在,运行后会输出:


处理异常:open失败:No such file or directory


中层函数open_and_read和底层函数read_content都不用处理异常,异常会沿着函数调用顺序回传到顶层的main函数里统一处理。这个特性比 C 语言里要一层层传错误码好用太多了!

关键字noexcept:告诉编译器 “我绝不抛异常”

有时候我们能保证一个函数 “绝对不会抛出异常”(比如简单的加法、取绝对值),这时候可以用noexcept关键字告诉编译器:“放心,我这里不会出问题,不用给我准备异常处理的代码”。

编译器知道后,会做两件事:

  • 优化代码:减少异常处理的开销(比如不用保存调用栈信息);

  • 强制约束:如果noexcept函数里真的抛出异常,程序会直接崩溃(提醒你 “说了不抛,怎么还抛?”)

noexcept 的用法很简单,直接在函数声明后加noexcept(可以加括号,也可以不加)

void swap(T& a, T& b) noexcept {  // 承诺不抛异常    std::swap(a, b);}

noexcept 不要滥用,如果不小心在noexcept函数里抛出异常,编译器会直接调用terminate()终止程序,没有任何商量的余地


什么时候用 noexcept?

  • 简单的纯函数:比如加法、减法、取模,逻辑简单到不可能出错;

  • 性能敏感的代码:比如实时控制系统、高频交易程序,要减少任何多余开销;

  • 移动构造函数和移动赋值运算符:这一点在上篇介绍移动语义的文章中也提到了,特别是针对STL容器的操作
  • 析构函数:C++11 之后,析构函数默认是noexcept的(因为析构函数抛异常会导致程序崩溃)

exception 类:标准库的 “异常统一接口”

C++ 标准库提供了一个异常基类std::exception(在头文件exception中),所有标准异常(比如内存分配失败、数组越界)都是它的子类。我们也可以继承它自定义异常,这样做的好处是:能统一捕获所有异常,不用写一堆不同类型的 catch 块

先看看标准库自带的几个常用异常子类:

异常类

用途

抛出场景举例

bad_alloc

内存分配失败

new 一个超大内存时

out_of_range

越界访问

vector::at (5)(vector 只有 3 个元素)

invalid_argument

无效参数

stoi ("abc")(字符串不能转整数)

  • 使用标准库异常

用bad_alloc(内存分配失败)和out_of_range(越界)举个例子:

#include <iostream>#include <new>      // bad_alloc的头文件#include <vector>   // vector的头文件#include <stdexcept> // out_of_range的头文件using namespace std;int main() {    // 测试1:内存分配失败(bad_alloc)    try {        // 尝试分配100亿个int(大约40GB),造成内存无法分配        int* p = new int[10000000000];        delete[] p;    }    catch (bad_alloc& err) {        // what():返回异常的描述信息(exception类的虚函数)        cerr << "捕获到bad_alloc:" << err.what() << endl;    }    // 测试2:vector越界(out_of_range)    vector<int> vec = {1, 2, 3};    try {        // at()会检查越界,抛出out_of_range        cout << "vec[5] = " << vec.at(5) << endl;    }    catch (out_of_range& err) {        cerr << "捕获到out_of_range:" << err.what() << endl;    }    return 0;}

运行后输出:

图片

这里对第二句的打印有点疑问,异常是vec.at(5)这部分引起的,因此还是输出了前面"vec[5] =",不知道不同编译器的处理是否会不同


  • 使用自定义异常(继承 exception)

实际开发中,我们经常需要自定义异常(比如 “文件错误”“网络错误”),这时候只要继承std::exception,并重写what()方法即可。

我们自定义一个 “文件错误异常”,结合 Linux 文件操作:

#include <iostream>#include <string>#include <exception>  // exception的头文件#include <fcntl.h>#include <unistd.h>#include <errno.h>using namespace std;// 自定义文件异常:继承自exceptionclass FileException : public exception {private:    string err_msg;  // 存储错误信息public:    // 构造函数:接收错误描述    FileException(const string& msg) : err_msg(msg) {}    // 重写what()方法:返回错误信息(必须是const char*,且noexcept)    const char* what() const noexcept override {        return err_msg.c_str();    }};// 打开文件:抛出自定义异常int open_file(const char* filename) {    int fd = open(filename, O_RDONLY);    if (fd == -1) {        // 抛出自定义异常,携带详细信息        throw FileException(string("打开文件失败:") + strerror(errno));    }    return fd;}int main() {    const char* filename = "test.txt";    int fd;    try {        fd = open_file(filename);        cout << "文件打开成功,fd = " << fd << endl;        close(fd);    }    // 1. 先捕获子类异常(FileException)    catch (FileException& err) {        cerr << "文件错误:" << err.what() << endl;        return 1;    }    // 2. 再捕获父类异常(exception):处理其他标准异常    catch (exception& err) {        cerr << "其他异常:" << err.what() << endl;        return 1;    }    return 0;}

如果test.txt不存在,运行后输出:


文件错误:打开文件失败:No such file or directory

这里有个关键:子类异常的 catch 块要放在父类前面。如果把catch (exception& err)放在前面,它就会先捕获到父类异常,后面的catch (FileException& err)就永远不会执行了。

异常处理的优缺点总结

c++异常处理的 3 个核心优点

  • 代码更清晰

错误处理和正常逻辑彻底分离。比如前面的文件操作例子,正常逻辑是 “打开→读取→关闭”,错误处理全在 catch 块里,不用在每个步骤后面加if (error),代码可读性高。

  • 异常自动传播

不用中间函数传递错误码。底层函数抛异常,顶层函数统一处理,中间函数不用管,减少大量重复代码。

  • 可携带更多信息

异常可以是任意类型(比如 string、自定义类),能携带详细信息(比如 “哪个文件出错”“错误码是多少”),而 C 语言的错误码只能是 int,信息有限。

但是使用异常处理也是有代价的,除了增加代码量,最主要是会带来性能开销(保存调用栈、查找 catch 块等),因此某些情况下也要慎用

什么时候不该用异常?

  • 性能极其敏感的代码

比如实时控制系统、高频交易程序,在高并发场景下可能会造成系统瓶颈。

  • 简单的逻辑错误

比如函数参数不合法(比如给 “求平方根” 传负数),这种是程序 bug,应该用 assert 断言或者直接返回错误码,而不是抛异常(bug 要修复,不是光丢出来不管)。

  • 析构函数里不应当抛异常

析构函数在异常传播过程中可能被调用(比如 try 块里创建的对象,抛出异常后会调用析构函数),如果析构函数再抛异常,会导致程序崩溃(所以析构函数默认是 noexcept)。

异常处理的机制实际上是比较复杂的,对于我们大部分程序员来说需要掌握上面的一些基本原则,才能够正确的处理程序中各种的异常,从而增强程序的健壮性

如果你在实际使用中遇到疑问(比如异常和 RAII 怎么配合,或者怎么调试异常),欢迎在评论区留言讨论~

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐