本篇文章主要介绍C++中的异常的相关语法
目录
[1 异常的概念](#1 异常的概念)
[2 异常的抛出与捕获](#2 异常的抛出与捕获)
[3 栈展开](#3 栈展开)
[4 匹配的 catch 处理代码](#4 匹配的 catch 处理代码)
[5 异常的重新抛出](#5 异常的重新抛出)
[6 异常可能引发的安全问题](#6 异常可能引发的安全问题)
[7 noexcept 关键字](#7 noexcept 关键字)
1 异常的概念
在 C++ 中,异常是指在程序执行过程中由于偏离正常执行流程而发生的意外时间或者错误,例如除0,野指针访问等等。异常处理机制可以使得程序检测错误与解决问题的部分分开,并进行及时的通信,同时也允许程序有组织的传递错误信息,不至于程序崩溃或者发生未定义行为。
在 C 语言中,我们主要是通过返回错误码的方式来告知错误,但是错误码就是一个数字,本质上就是错误的分类编号,但是返回一个数字我们还需要去查看该数字代表的错误信息才能知道是什么错误。异常返回的直接就是一个对象,里面可以包含更全面、详细的信息。
2 异常的抛出与捕获
当发生错误时,我们可以使用 throw关键字抛出一个异常,该异常可以是任何类型的对象;然后代码会直接跳过 throw 后面的代码,跳到与抛出对象类型相同且是调用链里最近的异常处理程序。比如我们写一个处理除零错误的异常处理代码:
cpp
#include <iostream>
#include <string>
using namespace std;
double Div(double x, double y)
{
try
{
//除 0 了抛出 error 字符串异常
if (y == 0)
{
string error = "Divide by zero condition!";
throw error;
cout << "after throw code" << endl;
}
return x / y;
}
catch (int x)
{
//这里故意设计为捕捉 int 类型异常对象
cout << "Div: " << x << endl;
}
}
int main()
{
double x = 0, y = 0;
while (true)
{
cin >> x >> y;
try
{
cout << Div(x, y) << endl;
}
catch (const string& error)
{
cout << "main: " << error << endl;
}
}
return 0;
}

可以看到当发生了异常,其实是从 throw 跳到了 main 函数的 catch 部分执行了打印 string 的代码,throw 后面的代码并没有执行。还有很重要的一个现象就是在捕捉完异常之后,整个程序,也就是进程并没有结束,还是在继续运行,这也是异常和之前错误码区别很大的一个点。之前用错误码一般进程发生错误,结束运行,然后返回对应的错误码;但是异常不同,发生异常之后可以打印错误信息,继续运行。所以使用异常也可以防止程序因为发生错误而中断运行。
需要注意的是当我们使用 throw 抛出了一个异常之后,跳转到的 catch 代码是根据整个函数的调用链以及类型匹配决定的。比如上面的除零异常处理代码,main 函数调用了 Div 函数,但是在 Div 函数里抛出一个 string 对象的异常,最先匹配的其实是 Div 函数里的 catch 部分代码,但是 Div 里的 catch 捕获的是 int 类型的异常,所以没有匹配成功,代码会沿着调用链向上寻找,于是找到了 main 函数中的 catch 异常捕获代码,而且类型匹配成功,所以就会执行 main 函数里面的 catch 代码。如果我们将 Div 函数里的 catch 对象类型变为 string,就会去匹配 Div 里的 catch 代码了:
cpp
#include <iostream>
#include <string>
using namespace std;
double Div(double x, double y)
{
try
{
//除 0 了抛出 error 字符串异常
if (y == 0)
{
string error = "Divide by zero condition!";
throw error;
cout << "after throw code" << endl;
}
return x / y;
}
catch (const string& error)
{
cout << "Div: " << error << endl;
return -1;
}
}
int main()
{
double x = 0, y = 0;
while (true)
{
cin >> x >> y;
try
{
cout << Div(x, y) << endl;
}
catch (const string& error)
{
cout << "main: " << error << endl;
}
}
return 0;
}

所以在当前函数抛出异常,并不一定会在当前函数进行捕获,而是会沿着调用链向上查找(一般都是在 main 函数里统一捕获)。所以这就注定会带来两个重要机制:
(1) 沿着调用链的函数可能会提早结束
(2) 一旦异常处理程序开始,沿着调用链创建的对象都将被销毁
而且抛出的异常对象也是原对象的拷贝,因为如果异常不在当前函数进行捕获,那么一旦异常处理程序开始执行,那么该对象就会被销毁,catch 语句再引用就变成野引用了,所以必须是原对象的拷贝,该拷贝对象也会在 catch 代码执行结束之后自动被销毁。
3 栈展开
上面我们说在抛出异常之后,程序会按照调用链向上查找,直到找到匹配的 catch 代码,此过程就称为栈展开。具体来说:
(1)当在一个函数内部 throw 抛出异常,程序会暂停当前函数的执行,转而去寻找 catch 代码;
(2)首先检查 throw 是否在 try 代码块内部,如果在,那就在当前函数内部寻找对应的 catch 代码;
(3)如果当前函数的 catch 代码类型不匹配或者没有 try\catch 代码块,那就退出当前函数,继续沿调用链查找;
(4)如果在 main 函数里还没有找到匹配的 catch 代码,那就会使用 terminate 函数终止程序。
cpp
#include <iostream>
#include <string>
// 自定义资源类
class Resource
{
int id;
public:
Resource(int id) : id(id)
{
std::cout << "构造函数" << id << std::endl;
}
~Resource()
{
std::cout << "析构函数" << id << "触发栈展开" << std::endl;
}
};
void level3()
{
Resource res3(3);
std::string error("在 level3 中发生错误!");
throw error;
}
void level2()
{
Resource res2(2);
level3();
}
void level1()
{
Resource res1(1);
level2();
}
int main()
{
try
{
level1();
}
catch (const std::string& e)
{
std::cout << "main: " << e << std::endl;
}
return 0;
}

上面的代码异常处理栈展开逻辑为:

4 匹配的 catch 处理代码
当抛出异常后,程序会沿着函数调用链向上查找与异常类型匹配的 catch 处理代码,但是有特殊情况,此时不一定与异常对象类型完全相同也可以进行匹配:
(1) 普通对象可以匹配 const 常量对象,也就是权限缩小;
(2) 数组可以匹配指向数组元素的指针,函数可以匹配指向函数的函数指针;
(3) 派生类可以匹配基类。
对于第三点,派生类可以匹配基类,库中就是这样做的:









上面列出了库中的大部分异常类型,可以看到异常体系也是继承机制,都是继承自基类 exception,而异常捕获时,派生类是可以转换为基类的。所以以后我们如果想捕获 C++ 库函数的异常时,我们只需要捕获 exception 这一个类型就可以了。exception 类中提供的接口为:

其中最重要的是 what 函数,其会返回一个 const char*,也就是异常的错误信息:
cpp
#include <iostream>
#include <exception>
using namespace std;
template<class T>
T* Alloc(int size)
{
T* ret = new T[size];
return ret;
}
int main()
{
int size = 0;
while (true)
{
size = 0;
cin >> size;
try
{
int* p = Alloc<int>(size);
cout << "申请成功" << endl;
delete[] p;
}
catch (const exception& e) //使用 exception 统一捕获异常
{
cout << e.what() << endl;
continue;
}
}
return 0;
}

可以看到 new 申请空间失败之后,我们使用 exception 捕获了 std::bad_alloc 异常,打印出了 bad allocation 信息。
但是有时候,我们不知道具体抛出的是什么类型的异常,但是我们又不希望程序结束,那么我们就可以使用catch(...) 来捕获任意类型的异常,但是具体是什么类型的异常是不确定的:
cpp
#include <iostream>
#include <exception>
using namespace std;
template<class T>
T* Alloc(int size)
{
T* ret = new T[size];
return ret;
}
int main()
{
int size = 0;
while (true)
{
size = 0;
cin >> size;
try
{
int* p = Alloc<int>(size);
cout << "申请成功" << endl;
delete[] p;
}
catch (...) //使用 exception 统一捕获异常
{
cout << "未知异常" << endl;
continue;
}
}
return 0;
}

5 异常的重新抛出
当在当前函数 catch 到了异常,但是当前函数只能处理一部分问题,但没有能力彻底解决这个异常,所以先做一些局部处理,再交给上层函数继续处理**。** 比如当前函数 Alloc 只负责申请空间。如果申请失败,它可以打印一条日志,但它不知道程序接下来应该怎么办,是重新输入呢?还是退出程序呢?还是换一个更小的 size 呢?这些决策的环节不是在 Alloc 内部完成的,而是在上层函数甚至是 main 函数里完成的。异常重新抛出,我们只需要在 catch 里再写 throw,就可以把捕获到的异常重新抛出:
cpp
#include <iostream>
#include <exception>
using namespace std;
template<class T>
T* Alloc(int size)
{
try
{
if (size <= 0)
{
throw invalid_argument("申请空间大小必须大于 0");
}
T* ret = new T[size];
return ret;
}
catch (const exception& e)
{
cout << "[Alloc函数内部记录日志] " << e.what() << endl;
// 重新抛出异常,交给外层继续处理
throw;
}
}
int main()
{
int size = 0;
cout << "请输入申请空间大小:";
cin >> size;
try
{
int* p = Alloc<int>(size);
cout << "申请成功" << endl;
delete[] p;
}
catch (const exception& e)
{
cout << "[main函数中处理异常] " << e.what() << endl;
cout << "程序申请空间失败,请重新检查输入。" << endl;
}
return 0;
}
我们在 Alloc 函数里面抛出了一个异常,也捕获了一个异常,但是 Alloc 函数只是打印一条日志信息,至于怎么处理都是在 main 函数中决策的,所以我们就可以在 Alloc 中将异常重新抛出。
6 异常可能引发的安全问题
虽然异常不会中断程序运行,但是却可能产生内存泄露等安全问题。比如下面这个代码:
cpp
#include <iostream>
#include <exception>
using namespace std;
void Func()
{
int* p = new int[10]; // 申请了一块堆空间
cout << "空间申请成功" << endl;
// 中间发生异常
throw runtime_error("函数中发生异常");
// 这句不会被执行
delete[] p;
}
int main()
{
try
{
Func();
}
catch (const exception& e)
{
cout << "捕获异常:" << e.what() << endl;
}
return 0;
}
因为 throw 抛出异常之后,后面的代码就不会执行了,所以在 Func 函数中 throw 语句前申请的资源,一旦发生异常,那就不会释放了,因此就造成了资源泄露。那么我们该如何解决这个问题呢?第一个方法就是我们可以在 Func 函数中套一个 try/catch 代码块:
cpp
#include <iostream>
#include <exception>
using namespace std;
void Func()
{
int* p = new int[10]; // 申请了一块堆空间
try
{
cout << "空间申请成功" << endl;
// 中间发生异常
throw runtime_error("函数中发生异常");
}
catch (const exception& e)
{
cout << "delete[] p" << endl;
delete[] p;
//将异常重新抛出
throw;
}
}
int main()
{
try
{
Func();
}
catch (const exception& e)
{
cout << "捕获异常:" << e.what() << endl;
}
return 0;
}
虽然这种方法可以解决,但是如果处理逻辑很复杂,我们就需要套很多层 try/catch,代码写起来很复杂与冗余。所以我们更推荐第二种方法 --- 智能指针(下一篇文章会进行讲解),智能指针会帮我们自行管理资源:
cpp
#include <iostream>
#include <exception>
#include <memory>
using namespace std;
void Func()
{
unique_ptr<int[]> sp(new int[10]); // 申请了一块堆空间
cout << "空间申请成功" << endl;
throw runtime_error("函数中发生异常");
}
int main()
{
try
{
Func();
}
catch (const exception& e)
{
cout << "捕获异常:" << e.what() << endl;
}
return 0;
}
另外,如果在析构函数中也抛出了异常,也要记得进行及时捕捉,要不然在释放到一半资源时,突然抛出了异常,那么也是会发生资源泄露的。
7 noexcept 关键字
异常如果处理不当,就可能会造成资源泄露安全,而如果我们提前知道一个函数不会抛出异常,就可以简化函数调用的逻辑。
在 C++98 中,主要是通过在函数参数列表后面添加 throw() 实现的。如果在一个参数列表后面添加 throw(),就代表该函数不会抛出异常;如果添加 throw(类型1, 类型2, ...) 就代表该函数可能会抛出多种类型的异常。但是有一个需要注意的一点,就是如果你写了 throw(),但是抛出了异常或者抛出了不属于 throw() 中的异常类型,程序只会终止的:
cpp
#include <iostream>
#include <exception>
#include <stdexcept>
using namespace std;
void Func1() throw()
{
cout << "Func1():throw(),不抛异常" << endl;
}
void Func2() throw(int)
{
cout << "Func2():throw(int),只允许抛 int" << endl;
throw 10;
}
void Func3(int flag) throw(int, double)
{
cout << "Func3():throw(int, double),只允许抛 int 或 double" << endl;
if (flag == 1)
{
throw 20;
}
else if (flag == 2)
{
throw 3.14;
}
}
void Func4() throw(int)
{
cout << "Func4():声明 throw(int),但实际抛 const char*" << endl;
throw "hello";
}
void Func5() throw()
{
cout << "Func5():声明 throw(),但实际抛 runtime_error" << endl;
throw runtime_error("Func5 中发生异常");
}
int main()
{
int choice = 0;
cout << "请输入测试编号:" << endl;
cout << "1. throw(),不抛异常" << endl;
cout << "2. throw(int),抛 int" << endl;
cout << "3. throw(int, double),抛 double" << endl;
cout << "4. 违反 throw(int)" << endl;
cout << "5. 违反 throw()" << endl;
cout << "请输入:";
cin >> choice;
try
{
switch (choice)
{
case 1:
Func1();
break;
case 2:
Func2();
break;
case 3:
Func3(2);
break;
case 4:
Func4();
break;
case 5:
Func5();
break;
default:
cout << "输入错误" << endl;
break;
}
}
catch (int e)
{
cout << "捕获 int 异常:" << e << endl;
}
catch (double e)
{
cout << "捕获 double 异常:" << e << endl;
}
catch (const exception& e)
{
cout << "捕获标准异常:" << e.what() << endl;
}
catch (...)
{
cout << "捕获其他异常" << endl;
}
cout << "main 函数正常结束" << endl;
return 0;
}
C++11 觉得 C++98 的方式太复杂了,所以就添加了一个 noexcept 关键字,在一个函数后面添加 noexcept 关键字,就代表该函数不会抛出异常,不添加就代表会抛出异常。需要注意的是如果添加了 noexcept 关键字,但是你抛出异常了,程序是会调用 terminate 函数终止程序的:
cpp
#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;
// 1. 普通 noexcept 函数:承诺不抛异常
void Func1() noexcept
{
cout << "Func1(): noexcept,不抛异常" << endl;
}
// 2. noexcept 函数里面抛异常:程序会直接终止
void Func2() noexcept
{
cout << "Func2(): noexcept,但是内部抛异常" << endl;
throw runtime_error("Func2 中发生异常");
}
// 3. 一个测试类
class Test
{
public:
Test()
{
cout << "默认构造" << endl;
}
Test(const Test&)
{
cout << "拷贝构造" << endl;
}
// 4. 移动构造加 noexcept
Test(Test&&) noexcept
{
cout << "移动构造 noexcept" << endl;
}
~Test()
{
cout << "析构" << endl;
}
};
int main()
{
cout << "====== 情况1:普通 noexcept 函数 ======" << endl;
Func1();
cout << endl;
cout << "====== 情况2:vector 中 noexcept 移动构造 ======" << endl;
vector<Test> v;
v.reserve(1);
cout << "第一次 push_back" << endl;
v.push_back(Test());
cout << "----------------" << endl;
cout << "第二次 push_back,vector 扩容" << endl;
v.push_back(Test());
cout << endl;
cout << "====== 情况3:noexcept 中抛异常 ======" << endl;
// 注意:这句一旦执行,程序会直接 terminate
// 外层 try-catch 通常也捕获不到
// Func2();
cout << "main 正常结束" << endl;
return 0;
}
noexcept 还可以作为一个运算符去检查一个表达式会不会抛出异常,如果会,那就返回 true,否则就返回 false:
cpp
#include <iostream>
using namespace std;
int main()
{
int size = 0;
cin >> size;
bool ret = noexcept(new int[size]);
cout << ret << endl;
return 0;
}
总结
在面向对象语言中,比如 C++、Java、Python,一般都是用异常来展示错误,代替了 C 语言这种面向过程语言的错误码,异常返回的直接就是一个对象,意味着异常可以返回更为详细的信息,而不仅仅是一个数字。
我们可以在 try 代码块中通过 throw 抛出一个异常对象,然后会在对应的 catch 代码块处进行异常捕捉,寻找 catch 代码块会沿着调用链一步一步向上查找,此称为栈展开。匹配 catch 代码块时并不一定要完全类型匹配,有几种特殊情况:(1) 普通对象可以匹配 const 常量对象(2)数组匹配指向数组元素的指针,函数匹配函数指针(3)派生类对象可以匹配基类对象。如果我们在一个 catch 中没有处理完异常,可以通过 throw 将捕获到的异常对象重新抛出。
异常虽然有很多好处,但是异常依然有内存泄露等安全问题,所以我们在使用异常时一定要注意异常安全问题。