C++ 异常处理(try...catch 与 throw)

在 C++ 中,异常处理(Exception Handling)是一种专门用来应对程序运行期错误的机制。当深层函数遇到无法继续往下执行的错误时,我们可以通过异常处理把错误直接传递给外层调用者。

为什么需要异常处理?

在单层函数调用中,遇到除数为 0 或参数不合法的情况,我们直接使用 if...else 语句判断并返回 false 或 -1 就可以了。但如果遇到多层函数嵌套调用,只靠返回值一层层传递错误就会非常繁琐。

假设现在有 3 层函数调用:main() 调用 step1(),step1() 调用 step2(),step2() 又调用 step3()。真正发生失败的是最深层的 step3(),而能决定后续怎么处理的只有最外层的 main()。我们先来看看使用普通返回值是怎么传递错误的。

示例 1:返回值逐层传递错误

#include <iostream>

// 最深层函数:执行失败
bool step3() {
    return false;
}

// 中间层:向上传递结果
bool step2() {
    if (!step3()) {
        return false;
    }
    return true;
}

// 中间层:继续向上传递
bool step1() {
    if (!step2()) {
        return false;
    }
    return true;
}

int main() {
    if (!step1()) {
        std::cout << "main 处理错误:任务执行失败\n";
    }
    return 0;
}

运行结果如下。

main 处理错误:任务执行失败

分析:

在这个例子中,真正发现错误的是最深层的 step3()。当它返回 false 后,step2() 和 step1() 都要依次判断并继续向上返回 false,最后才由 main() 输出错误提示。

中间的 step2() 和 step1() 其实并不知道应该怎么处理这个错误,只是充当了 “传话筒”。如果项目里的调用链有七八层深,业务代码就会塞满这种重复的判断语句,既啰嗦又容易漏写。

C++ 异常处理提供了更干脆的解决思路:谁发现任务没法继续了,谁就直接把问题 “抛” 出去;谁有能力决定怎么处理,谁就在外层把问题 “接” 住。至于中间那些不负责处理错误的函数,完全不需要编写任何传递代码。

C++ 异常处理基本语法

在 C++ 中,一套完整的异常处理流程主要由以下 3 个关键字配合完成:

  • throw(抛出异常):当函数发现无法继续完成任务时,我们可以通过 throw 主动抛出一个异常对象。
  • try(监控代码块):在调用端把可能会抛出异常的代码放进 try 的花括号里,用来划定监控范围。
  • catch(捕获异常):紧跟在 try 后面,一旦 try 块抛出了匹配的异常,catch 就会将它接住并执行补救逻辑。

语法:

// 抛出异常
throw 异常对象;

// 监控并捕获异常
try {
    // 可能抛出异常的代码
} catch (const 异常类型& e) {
    // 处理异常的代码
}

说明:

在抛出端,“异常对象” 用来描述这次具体出了什么错。在实际开发中,我们最常抛出的是 <stdexcept> 头文件中的 std::runtime_error 等标准异常,下一节会详细介绍。

小伙伴们不要把 throw 理解成 “打印一条报错信息”。它真正做的事情是立即打断当前函数的正常执行流程,带着异常对象往外退出,去寻找匹配的 catch 块。

在捕获端,try 和 catch 必须成对出现,哪怕里面只有一行代码,花括号 {} 也不能省略。在 catch 的小括号里,我们通常使用 <exception> 头文件提供的基类 std::exception 来接收标准异常。其中变量名 e 取自 “exception(异常)” 的首字母,调用 e.what() 就能拿到异常里的错误说明文字。

下面我们以 “两个整数相除” 为例来看看具体用法。在数学中除数不能为 0,一旦检测到除数为 0,我们就使用 throw 主动抛出异常,再在 main() 中通过 try...catch 将它接住。

示例 2:主动抛出并捕获异常

#include <exception>    // 提供 std::exception
#include <iostream>
#include <stdexcept>    // 提供 std::runtime_error

// 计算两数相除
int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("除数不能为 0");
    }
    return a / b;
}

int main() {
    try {
        int res1 = divide(10, 2);
        std::cout << "10 / 2 = " << res1 << '\n';

        int res2 = divide(10, 0);
        std::cout << "10 / 0 = " << res2 << '\n';
    } catch (const std::exception& e) {
        std::cout << "捕获到异常:" << e.what() << '\n';
    }

    std::cout << "程序继续正常往下执行\n";
    return 0;
}

运行结果如下。

10 / 2 = 5
捕获到异常:除数不能为 0
程序继续正常往下执行

分析:

先看第 1 次调用 divide(10, 2),除数不为 0,函数正常算出结果 5 并输出。到了第 2 次调用 divide(10, 0) 时,触发 b == 0 条件并执行 throw 抛出异常。

一旦执行到 throw,divide() 函数和 try 块内排在后面的代码都会立即中断。因此输出 10 / 0 的那行语句被直接跳过,程序转入 catch 块打印错误原因。

这里要注意一个关键细节:C++ 在直接计算整数 10 / 0 时并不会自动抛出异常,而是直接引发底层崩溃。上面之所以能被 try...catch 接住,是因为我们在相除前先写了 if (b == 0) 检查,并手动执行了 throw。

另外,catch 处理完异常之后,程序不会再跳回刚才出错的 try 块内部。它会继续往下执行整个 try...catch 后面的代码,所以最后输出了 "程序继续正常往下执行"。对于上面这个例子,如果我们把 divide(10, 2) 和 divide(10, 0) 的调用顺序对调一下,输出结果会变成什么样?小伙伴们可以思考一下。

在编写 catch 时,小括号里推荐使用 const std::exception& e 按常量引用接收异常。这样既能省去额外复制对象的开销,又能借助基类引用触发多态,让 e.what() 准确拿到具体异常子类的错误信息。

同时,抛出异常时我们应该优先抛出标准异常对象,不要直接写 throw -1; 或 throw "出错了";。因为整数和字符串字面量不属于 std::exception 家族,外层的 catch (const std::exception& e) 无法接住它们。

// 推荐
throw std::runtime_error("配置文件损坏");

// 不推荐
throw -1;
throw "配置文件损坏";

注意 在 throw 语句的正下方,不要编写业务代码或 delete p; 这类手动释放内存的代码。程序一旦执行到 throw 就会立即跳走,排在它后面的语句根本没有机会执行。

多个 catch 分支与重新抛出

同一个 try 块里的代码有时可能会抛出不止一种异常。如果我们需要针对不同错误分别处理,或者在记录日志后把异常继续往上交,可以使用多个 catch 分支、通配捕获以及重新抛出语法。

1. 使用多个 catch 分支

当我们需要区分不同类型的异常并执行各自的补救逻辑时,可以在 try 后面连续编写多个 catch 分支。

语法:

try {
    // 可能抛出多种异常的代码
} catch (const 异常类型1& e) {
    // 处理异常类型 1
} catch (const 异常类型2& e) {
    // 处理异常类型 2
}

说明:

当 try 块抛出异常后,程序会按照 catch 的书写顺序从上往下依次比对类型。只要找到第一个匹配的分支,就会进入该分支执行,执行完毕后直接跳过后面剩下的所有 catch 分支。

示例 3:按类型捕获不同异常

#include <exception>
#include <iostream>
#include <stdexcept>

void checkOrder(int count, int stock) {
    if (count <= 0) {
        throw std::invalid_argument("购买数量必须大于 0");
    }
    if (count > stock) {
        throw std::runtime_error("商品库存不足");
    }
    std::cout << "下单成功,数量:" << count << '\n';
}

void placeOrder(int count, int stock) {
    try {
        checkOrder(count, stock);
    } catch (const std::invalid_argument& e) {
        std::cout << "参数异常:" << e.what() << '\n';
    } catch (const std::exception& e) {
        std::cout << "其他标准异常:" << e.what() << '\n';
    }
}

int main() {
    placeOrder(-1, 10);
    placeOrder(20, 10);
    placeOrder(3, 10);
    return 0;
}

运行结果如下。

参数异常:购买数量必须大于 0
其他标准异常:商品库存不足
下单成功,数量:3

分析:

在这个例子中,placeOrder() 内部编写了两个 catch 分支。传入 placeOrder(-1, 10) 时,抛出的 std::invalid_argument 与第 1 个分支匹配,直接输出参数异常提示。

接着调用 placeOrder(20, 10) 时,抛出的 std::runtime_error 与第 1 个分支不匹配,继续往下命中基类 std::exception 分支。最后调用 placeOrder(3, 10) 传入合法参数,没有抛出异常,两个 catch 分支都被跳过。

这里大家要注意一个排列顺序问题:当多个 catch 分支存在父子继承关系时,必须遵循 “子类在前,基类在后” 的顺序。如果把基类 std::exception 写在最前面,它就会把所有标准异常全部接走,导致排在后面的子类分支永远没有机会执行。

// 正确方式
try {
    checkOrder(count, stock);
} catch (const std::invalid_argument& e) {
    std::cout << "参数异常:" << e.what() << '\n';
} catch (const std::exception& e) {
    std::cout << "其他标准异常:" << e.what() << '\n';
}

// 错误方式
try {
    checkOrder(count, stock);
} catch (const std::exception& e) {
    std::cout << "其他标准异常:" << e.what() << '\n';
} catch (const std::invalid_argument& e) {
    std::cout << "参数异常:" << e.what() << '\n';
}

2. 使用 catch (...) 捕获任意异常

如果 try 块里调用了早期旧代码或第三方库,有时可能会抛出非标准类型的异常(比如直接抛出整数或自定义结构体)。遇到这类不属于 std::exception 家族的异常,我们可以使用 catch (...) 来统一接住。

语法:

try {
    // 可能抛出未知异常的代码
} catch (const std::exception& e) {
    // 优先捕获标准异常
} catch (...) {
    // 捕获其他任意类型的异常
}

说明:

小括号里面是 3 个连续的英文句点(...),表示可以接住任何类型的异常。由于 catch (...) 能匹配所有异常,我们必须将它放在所有 catch 分支的最后一个。同时因为小括号里没有异常对象,在它内部无法调用 e.what() 查看错误信息。

示例 4:捕获非标准类型的异常

#include <exception>
#include <iostream>

void checkCode(int code) {
    if (code == 404) {
        throw 404;    // 抛出 int 整数
    }
}

int main() {
    try {
        checkCode(404);
    } catch (const std::exception& e) {
        std::cout << "标准异常:" << e.what() << '\n';
    } catch (...) {
        std::cout << "捕获到非标准类型的异常\n";
    }
    return 0;
}

运行结果如下。

捕获到非标准类型的异常

分析:

throw 404; 表示直接抛出了一个 int 类型的整数。由于整数不属于 std::exception 标准异常家族,第 1 个 catch 分支无法匹配,最终由排在末尾的 catch (...) 将它接住。

3. 使用 throw; 重新抛出异常

在实际开发中还有一种常见需求:当前函数捕获到异常后,只想先记录一条错误日志,然后把这个异常继续交给更外层的函数去处理。此时我们可以在 catch 块内部单独写一句 throw;,把当前异常重新抛出去。

语法:

catch (const std::exception& e) {
    // 先记录日志或局部清理
    throw;
}

说明:

不带任何参数的 throw; 只能在 catch 块内部使用。它会原样保留当前的异常对象和真实类型,继续向外层的 try...catch 传递。

示例 5:记录日志后重新抛出

#include <exception>
#include <iostream>
#include <stdexcept>

void connectServer() {
    throw std::runtime_error("连接服务器超时");
}

void handleRequest() {
    try {
        connectServer();
    } catch (const std::exception& e) {
        std::cout << "[日志记录] " << e.what() << '\n';
        throw;    // 原样向外层重新抛出
    }
}

int main() {
    try {
        handleRequest();
    } catch (const std::exception& e) {
        std::cout << "[顶层处理] " << e.what() << '\n';
    }
    return 0;
}

运行结果如下。

[日志记录] 连接服务器超时
[顶层处理] 连接服务器超时

分析:

当 handleRequest() 调用 connectServer() 时,抛出的异常先被 handleRequest() 内部的 catch 块接住。打印完日志后执行 throw;,异常继续向外传递,最终交由 main() 的 catch 块完成顶层处理。

需要注意的是,重新抛出当前异常时应该直接写 throw;,不要写成 throw e;。因为形参 e 的类型是基类引用 const std::exception&,写成 throw e; 只会按基类 std::exception 复制出一个新对象。这样一来,原本具体的子类类型和里面保存的错误说明文字都会丢失。

异常传播与栈展开

当深层函数执行 throw 抛出异常时,如果当前函数内部没有 try...catch,异常就会顺着调用链一层层往外退。它会直接跳过中间层函数,直到在外层找到匹配的 catch 块。如果退到 main() 外部仍然没有任何 catch 接住它,程序就会调用 std::terminate() 强制终止运行。

可能有些小伙伴会问:“函数执行到一半被异常打断退出了,里面已经创建好的局部对象怎么办?会不会发生内存泄漏?”

其实不用担心。当异常顺着调用链一层层退出各个函数时,C++ 会自动把沿途已经构造完成的局部对象依次销毁,也就是自动调用它们的析构函数。这个在异常传播过程中自动清理沿途局部对象的过程,叫做 “栈展开(Stack Unwinding)”。

为了直观观察栈展开的执行顺序,下面我们定义一个简单的 Trace 结构体,在构造和析构时分别打印提示信息。

示例 6:观察异常传播与栈展开

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

struct Trace {
    std::string name;
    Trace(const std::string& n) : name(n) {
        std::cout << "进入 " << name << '\n';
    }
    ~Trace() {
        std::cout << "清理 " << name << '\n';
    }
};

void step2() {
    Trace t("step2");
    throw std::runtime_error("step2 执行失败");
    std::cout << "step2 正常结束\n";
}

void step1() {
    Trace t("step1");
    step2();
    std::cout << "step1 正常结束\n";
}

int main() {
    try {
        step1();
    } catch (const std::exception& e) {
        std::cout << "main 捕获到异常:" << e.what() << '\n';
    }
    return 0;
}

运行结果如下。

进入 step1
进入 step2
清理 step2
清理 step1
main 捕获到异常:step2 执行失败

分析:

对比开头示例 1 的逐层返回可以发现,这里的中间层 step1() 完全没有编写错误判断代码,step2() 抛出的异常直接穿过它传到了 main()。结合运行结果,整个过程按以下 4 步依次执行:

  • main() 调用 step1() 构造局部对象 step1,接着调用 step2() 构造局部对象 step2。
  • step2() 执行 throw 抛出异常,step2() 和 step1() 中排在后面的输出语句全部被跳过。
  • 进入 main() 的 catch 块之前,程序依次退出 step2() 和 step1(),按 “后创建先销毁” 的顺序自动销毁两个局部对象。
  • 沿途局部对象全部清理完毕后,程序进入 main() 的 catch 块输出错误信息。

这也体现了 RAII 机制的优势。只要你把动态内存、文件等资源交给 std::unique_ptr 或 std::vector 这类局部对象管理,哪怕函数半路因异常退出,栈展开也会自动触发析构函数把资源释放干净。

什么时候使用异常处理?

异常处理并不是 if...else 或普通返回值的替代品。在实际开发中,这几种错误处理方式有着各自适合的场景,如下表所示。

错误处理方式对比
常见场景 推荐处理方式 选择原因
根据考试分数判断是否及格 if...else 分支 及格与不及格都属于预期内的正常业务分支
检查用户输入的菜单编号是否有效 if...else 或普通返回值 输错编号很常见,在当前层直接提示重输即可
深层函数无法继续执行,需外层统一处理 异常处理(throw / catch) 可跨越多个中间层传递错误,省去逐层判断代码

最后大家还要留意一点,不要误以为 “只要程序运行出了错,C++ 就会自动抛出异常”。只有当代码或标准库函数真正执行了 throw 时,try...catch 才能接住。像 std::cin 输入类型不匹配默认并不会抛异常,我们仍然需要通过 if (!std::cin) 检查输入状态。

提示

缩小 try 块的监控范围:我们应该只用 try 包裹真正可能抛出异常的那几行关键操作。如果把几十行无关代码全塞进同一个 try 块,一旦报错就很难分清到底是哪一步出了问题。

在有能力处理的那一层才写 catch:如果某个底层函数自己不知道出错后应该重试、退出还是提示用户,就不要在它内部强行 catch 吞掉异常,直接让异常往外传给上层调用者处理即可。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号