C++异常处理:从崩溃到优雅的错误处理艺术
以前写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. 先接具体类型:intcatch (int err) {cerr << "参数错误:错误码" << err << endl;}// 2. 再接具体类型:stringcatch (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_rangecout << "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 怎么配合,或者怎么调试异常),欢迎在评论区留言讨论~
更多推荐
所有评论(0)