前言:程序运行时难免会遇到一些问题,在C语言中程序出错后处理问题是线性的,但C++引入异常机制让错误处理更灵活功能更强大,但就像是 goto 一样过于灵活的同时也带来一些问题。而且异常这个不太好举例子因为异常和继承多态一样一般用于大型的项目中,我平常写的练习小程序压根用不到,所以后面文章中我选择了模拟的方式来举例子
1.异常的概念与基本使用
1.1从错误码到异常
在C语言中,我们经常通过返回值或errno判断函数是否执行成功。错误码可以告诉调用者发生了哪一类错误,但是如果函数调用了很多层,就需要在各层判断结果,再决定是否继续向上返回错误信息。
C++的异常机制提供了另一种处理方式:**发现问题的位置负责抛出异常,能够处理问题的位置负责捕获异常。**底层函数不需要知道最终由谁处理这个问题,也不用每经过一层函数就手动返回一次错误码。
异常可以携带一个对象。这个对象既可以是整数、字符串,也可以是我们自己定义的类对象,里面可以保存错误信息、错误编号以及与当前错误有关的其他内容。
异常处理主要涉及三个关键字:
try:把需要监测异常的代码放在其中,包括这些代码调用的其他函数。throw:抛出一个异常,将控制权交给匹配的异常处理代码。catch:根据异常类型进行匹配,并执行相应的处理逻辑。
先来看一个把字符串转换成整数的函数。字符串为空,或者包含非数字字符时,都通过throw抛出一个string对象:
cpp
int GetNumber(const string& str)
{
try
{
// 如果字符串为空,抛出异常
if (str.empty())
{
string errmsg = "字符串不能为空";
throw errmsg;
}
// 检查是否全是数字
for (char ch : str)
{
if (ch < '0' || ch > '9')
{
string errmsg = "字符串中存在非数字字符";
throw errmsg;
}
}
return stoi(str);
}
catch (int errid)
{
// 上面抛出的是 string
// 这里捕获的是 int
// 类型不匹配,所以不会进入这里
cout << "GetNumber捕获异常:" << errid << endl;
}
return 0;
}
这里的throw errmsg;会根据errmsg创建异常对象,错误信息也就随异常一起传递出去了。异常对象的生命周期独立于函数中的局部变量,因此即使函数退出,外层仍然可以读取异常信息。
函数中的catch (int errid)接收的是int,而实际抛出的是string,类型不匹配,所以这个catch不会处理它,异常会继续向外寻找合适的处理位置。
1.2异常沿调用链传递与栈展开
继续向外看,Func负责读取字符串并调用GetNumber,main再调用Func:
cpp
void Func()
{
string str;
cout << "请输入一个整数:";
cin >> str;
try
{
int num = GetNumber(str);
cout << "转换结果:" << num << endl;
}
catch (const char* errmsg)
{
// GetNumber抛的是 string
// 这里捕获的是 const char*
// 类型依然不匹配
cout << "Func捕获异常:" << errmsg << endl;
}
cout << "Func正常执行结束" << endl;
}
int main()
{
try
{
Func();
}
catch (const string& errmsg)
{
// string类型匹配
// 所以最终异常在这里被捕获
cout << "main捕获异常:" << errmsg << endl;
}
cout << "程序继续运行" << endl;
return 0;
}
这一次的执行过程可以分成三步:
GetNumber抛出string异常,但它内部的catch (int)不匹配。- 继续到调用它的
Func中寻找,catch (const char*)仍然不匹配。 - 到
main中找到catch (const string&),由这里输出错误信息。

在控制权转移到匹配的catch时,途经的作用域会退出,已经完成构造、需要离开这些作用域的自动对象会按构造的逆序析构,这个清理过程叫做栈展开 。它和普通的逐层return不同,不会继续执行被中断路径上剩余的普通语句。
因此,第二次运行没有输出"转换结果"和"Func正常执行结束"。main处理完异常以后,从整个try/catch之后继续运行,所以仍然会输出"程序继续运行"。捕获以后不会再回到原来的throw位置继续往下执行。
如果一直找不到匹配的处理函数,程序会调用terminate终止。
1.3catch的匹配规则
异常匹配主要依据类型,不能把它当成一次普通函数传参,随意进行隐式转换。前面的string异常不会自动转换成const char*,抛出的整数也不会为了匹配catch自动进行普通的算术类型转换。
所以,如果既要单独处理某个派生类异常,又要用基类统一处理其余异常,应先写派生类对应的catch,再写基类对应的catch。否则基类已经把异常接住。具体规则可以对照C++标准草案中的异常处理说明。
前面的转换示例主要用来观察string异常的匹配过程。全是数字也不代表一定能放进int:如果数字过大,stoi还可能抛出标准库的std::out_of_range,它不属于string,因此不会进入前面的字符串捕获分支。标准库异常的关系会在最后介绍哦。
2.通过继承设计异常体系
2.1异常基类与派生类
一个业务可能同时涉及数据库、网络和权限检查。如果每种错误都随意抛出一种类型,外层就需要写很多不同的处理分支。可以把公共错误信息放进异常基类,各模块再根据自己的需要派生出具体的异常类,这也是我们多态的重要运用。
先定义一个Exception,保存错误描述和错误编号:
cpp
// 异常基类
class Exception
{
public:
Exception(const string& errmsg, int id)
: _errmsg(errmsg)
, _id(id)
{}
// 获取异常信息
virtual string what() const
{
return _errmsg;
}
// 获取错误编号
int getid() const
{
return _id;
}
protected:
string _errmsg;
int _id;
};
getid()返回错误编号,what()返回错误信息。what()定义成虚函数,是为了让外层通过基类引用访问异常时,仍然能够调用实际派生类的版本。
接下来分别定义数据库异常、网络异常和权限异常:
cpp
// 数据库异常
class SqlException : public Exception
{
public:
SqlException(const string& errmsg, int id, const string& sql)
: Exception(errmsg, id)
, _sql(sql)
{ }
virtual string what() const
{
string str = "SqlException: ";
str += _errmsg;
str += "\nSQL语句:";
str += _sql;
return str;
}
private:
string _sql;
};
// 网络异常
class HttpException : public Exception
{
public:
HttpException(const string& errmsg, int id, const string& type)
: Exception(errmsg, id)
, _type(type)
{ }
virtual string what() const
{
string str = "HttpException: ";
str += _errmsg;
str += "\n请求类型:";
str += _type;
return str;
}
private:
string _type;
};
// 权限异常
class PermissionException : public Exception
{
public:
PermissionException(const string& errmsg, int id)
: Exception(errmsg, id)
{ }
virtual string what() const
{
string str = "PermissionException: ";
str += _errmsg;
return str;
}
};
这样一来,底层可以抛出具体的派生类对象,外层则只需要用const Exception&统一接收。基类引用负责统一访问,虚函数负责保留具体异常的描述方式。
2.2在登录业务中统一捕获异常
下面用随机结果模拟数据库访问、网络请求和权限验证。这里只模拟成功或失败的分支,并不真正连接数据库或服务器:
cpp
// 模拟数据库操作
void Sql()
{
int n = rand() % 3;
if (n == 0)
{
throw SqlException(
"数据库连接失败",
100,
"select * from user"
);
}
cout << "数据库访问成功" << endl;
}
// 模拟网络请求
void Http()
{
int n = rand() % 3;
if (n == 0)
{
throw HttpException(
"服务器连接超时",
200,
"GET"
);
}
cout << "网络请求成功" << endl;
}
// 模拟权限验证
void CheckPermission()
{
int n = rand() % 3;
if (n == 0)
{
throw PermissionException(
"当前用户没有访问权限",
300
);
}
cout << "权限验证成功" << endl;
}
// 登录业务
void Login()
{
// 访问数据库
Sql();
// 发送网络请求
Http();
// 检查权限
CheckPermission();
cout << "用户登录成功" << endl;
}
Login按顺序执行Sql()、Http()和CheckPermission()。三个步骤全部成功以后,才会输出"用户登录成功"。
这里各层都没有手动判断错误返回值。如果数据库访问失败,异常会直接中断这次登录,后面的网络请求和权限验证都不会执行;如果网络请求失败,那么前面的数据库访问已经执行,但后面的权限验证不会继续。
外层统一捕获异常:
cpp
int main()
{
srand((unsigned int)time(nullptr));
int cnt = 3;
while (cnt--)
{
try
{
Login();
}
// Exception是所有异常类的基类
// 所以可以捕获这种派生类
catch (const Exception& e)
{
cout << "---------------------" << endl;
cout << "捕获异常:" << endl;
cout << e.what() << endl;
cout << "错误编号:"
<< e.getid() << endl;
cout << "---------------------" << endl;
}
// 捕获所有未知类型异常
catch (...)
{
cout << "Unknown Exception" << endl;
}
cout << endl;
}
return 0;
}
catch (const Exception& e)可以匹配这三个派生类,因为它们都公开继承自Exception。这里使用引用不会复制出一个独立的基类对象,因此通过e.what()仍然能够调用实际的派生类版本。
如果按值捕获基类,就可能发生对象切片,只保留基类部分;使用基类引用正好避免了这个问题。异常匹配本身依赖继承关系,what()的虚函数机制则决定匹配以后调用哪一个版本,两者分别发挥作用。需要注意,这里的Exception只统一了本例中继承自它的异常类型
3.异常的重新抛出
3.1能够处理的异常留在当前层
捕获异常以后,不一定要在当前层把所有事情都处理完。比如下载文件时,网络暂时不稳定可以重试;但如果服务器上根本没有这个文件,继续重试就没有意义了。当前层可以处理自己认识的错误,剩下的再交给外层。
下面沿用前面的Exception基类,增加一个下载时使用的网络异常:
cpp
// 网络异常
class NetException : public Exception
{
public:
NetException(const string& errmsg, int id)
: Exception(errmsg, id)
{ }
virtual string what() const
{
string str = "NetException: ";
str += _errmsg;
return str;
}
};
底层函数负责尝试一次下载,并通过错误编号区分两种失败:
cpp
// 底层函数执行一次下载
void _Download(const string& filename)
{
int n = rand() % 5;
// 错误码100:网络临时波动
// 这种错误可以通过重试解决
if (n == 0)
{
throw NetException(
"网络不稳定,下载失败",
100
);
}
// 错误码101:文件不存在
// 重试没有意义
else if (n == 1)
{
throw NetException(
"服务器上不存在该文件",
101
);
}
else
{
cout << filename << " 下载成功" << endl;
}
}
错误码100表示网络暂时不稳定,错误码101表示文件不存在。编号的具体含义由这一组程序自行约定,异常对象则同时保存了对应的文字信息。
3.2重试失败以后使用throw;向外传递
中间层的Download负责重试,最多尝试四次,也就是首次尝试加上最多三次重试:
cpp
// 中间层:负责重试
void Download(const string& filename)
{
// 最多尝试4次
for (int i = 0; i < 4; i++)
{
try
{
_Download(filename);
// 下载成功就退出循环
break;
}
catch (const Exception& e)
{
// 网络临时不稳定,可以重试
if (e.getid() == 100)
{
// 已经重试到最后一次还是失败
if (i == 3)
{
// 当前层已经处理不了
// 重新抛给上一层
throw;
}
cout << "网络异常,第 "
<< i + 1
<< " 次下载失败,准备重试..."
<< endl;
}
else
{
// 如果不是100号错误:
// 当前函数没有能力处理直接重新抛给上一层
throw;
}
}
}
}
成功时执行break离开循环;遇到100号错误时,如果还没有用完次数,就提示重试,并进入下一轮。第四次仍然失败,或者捕获到其他错误编号时,都会执行throw;。
这里的throw;后面没有再跟对象,表示重新抛出当前正在处理的异常 ,保留原异常对象及其动态类型。它不会回到之前失败的语句重试,重试是由这里的for循环重新调用_Download实现的。
throw;和throw e;也有区别。在这里,e的静态类型是基类引用,如果写成throw e;,会依据基类类型创建新的异常对象,可能发生切片;直接使用throw;才能保留原来的NetException异常。
外层再决定如何显示最终处理不了的错误:
cpp
int main()
{
srand((unsigned int)time(nullptr));
string filename;
while (cin >> filename)
{
try
{
Download(filename);
}
catch (const Exception& e)
{
cout << "main捕获异常:" << endl;
cout << e.what() << endl;
cout << "错误码:" << e.getid() << endl;
}
catch (...)
{
cout << "Unknown Exception" << endl;
}
cout << "------------------------" << endl;
}
return 0;
}
运行过程如下:

可以看到,有的下载第一次就成功,有的遇到网络波动后重试成功,还有的在重试过程中遇到101号错误,随后进入main中的catch。
这里两层catch的职责并不相同:Download知道哪些错误值得重试,main负责展示最终失败的信息。异常重新抛出让每一层只需要处理自己有能力解决的部分。
4.异常安全与noexcept
4.1异常发生时也要释放资源
前面提到,异常会跳过被中断路径上剩余的普通语句。如果前面申请了资源,把释放操作写在后面,而中间某个函数抛出了异常,释放资源的语句就可能被跳过。
栈展开会析构自动对象,但是一个普通指针变量离开作用域,并不会自动释放它指向的堆空间。下面的arr就是这种情况:
cpp
double Divide(int a, int b)
{
if (b == 0)
throw "除数不能为0";
return (double)a / b;
}
void Func()
{
int* arr = new int[10]; // 申请资源
try
{
int a, b;
cin >> a >> b;
cout << Divide(a, b) << endl;
}
catch (...)
{
// 如果发生异常,必须先释放资源
// 不然会造成资源泄漏哦!
delete[] arr;
// 当前层不处理异常,继续交给外层
throw;
}
// 没有异常时正常释放
delete[] arr;
}
int main()
{
try
{
Func();
}
catch (const char* errmsg)
{
cout << errmsg << endl;
}
return 0;
}
这段代码为资源释放准备了两条路径:
- 没有发生异常:正常完成计算,执行
try/catch后面的delete[] arr;。 - 发生异常:进入
catch (...),先执行delete[] arr;释放资源,再使用throw;把异常交给外层。
异常路径中不会重复释放。因为执行throw;以后,控制权继续向外转移,不会再执行Func最后面的那一次delete[]。
输入10 2时输出5,输入10 0时由外层输出"除数不能为0"。这里对除数为零的处理,是Divide主动检查后抛出字符串字面量,并不是任何除零操作都会自动变成一个可被catch捕获的C++异常。
这种"先捕获、再清理、然后重新抛出"的方式可以保证当前例子释放内存。进一步考虑,可以把资源交给一个对象管理,利用析构函数统一释放,使正常离开作用域和异常栈展开都能触发清理。
析构函数本身也应当妥善处理可能出现的异常,尤其不要让新的异常逃离栈展开期间调用的析构函数,否则会触发std::terminate。资源清理是异常处理的一部分,不能只关注最后有没有输出错误信息。
4.2noexcept声明与noexcept运算符
如果调用者提前知道某个函数不会让异常传播出来,就更容易安排后续的处理逻辑。C++98曾使用throw()表示不抛出异常,使用throw(类型列表)限制允许抛出的异常类型但这个东西太难用了;C++11以后,声明不抛出异常通常使用noexcept,所以这里重点看这种写法。
下面的Divide可能抛出异常,Func则在函数声明后面加上了noexcept:
cpp
// 这个函数可能抛异常
double Divide(int a, int b)
{
if (b == 0)
throw "除数不能为0";
return (double)a / b;
}
// noexcept可以声明这个函数不会抛异常
void Func() noexcept
{
cout << "Func不会抛异常" << endl;
}
int main()
{
// 判断某个表达式是否承诺不抛异常
cout << "Func(): " << noexcept(Func()) << endl;
cout << "Divide(10, 2): " << noexcept(Divide(10, 2)) << endl;
int a = 10;
cout << "++a: " << noexcept(++a) << endl;
return 0;
}
void Func() noexcept表达的是不允许异常逃离这个函数。如果函数内部抛出了异常,又在内部自行捕获和处理,没有继续向外传播,仍然符合这个约定;如果异常最终越过这个函数的边界,就会调用std::terminate,外层的catch无法按普通方式接住它。
因此,noexcept不是让编译器禁止函数体中出现throw。某个函数即使写了noexcept,内部仍可能调用会抛异常的函数,真正需要遵守的是"不让异常逸出"的约定。
noexcept还可以作为运算符,对一个表达式进行判断。示例的运行结果如下:
text
Func(): 1
Divide(10, 2): 0
++a: 1

noexcept(Func())为true,因为Func声明了不抛出异常。noexcept(Divide(10, 2))为false,因为这个普通函数调用属于潜在抛出表达式。它并不是在判断本次10 / 2是否会失败。noexcept(++a)为true,这里对内置整数的自增操作不会抛出C++异常。
**noexcept运算符的操作数不会被求值。**因此这里没有真正调用Func(),不会输出函数体中的"Func不会抛异常";Divide(10, 2)没有实际计算,++a也不会执行,a仍然是10。这一点也可以对照C++标准草案对noexcept运算符的定义。
5.标准库中的异常
除了自己设计异常类,C++标准库也有一套异常继承体系,公共基类是std::exception。标准库中的许多异常类型都派生自它,因此可以通过catch (const std::exception& e)统一接收,再调用虚函数e.what()获取错误信息。

还要区分两个同名接口:本文自定义的Exception::what()返回string,标准库的std::exception::what()返回const char*,并带有const noexcept约束。使用标准异常体系时,应按照它已有的接口约定调用或重写。相关接口可以查看标准库异常基类的定义。
但据说有些公司会认为标准库的不好用自己写一个自己用,难道是因为返回的报错信息不满足需求吗?这个我也不清楚
完