C++ 异常处理全解析:从基础概念到工程实践
一、异常的核心概念
异常是程序在运行时出现的非正常情况(比如除零错误、内存分配失败、权限不足等),异常处理机制就是为这些非正常情况提供一套标准化的通信与处理流程。
1.1 异常 vsC 语言错误码
| 处理方式 | 核心特点 | 优势 | 劣势 |
|---|---|---|---|
| C 语言错误码 | 对错误进行编号,通过函数返回值传递 | 简单轻量,无额外性能开销 | 错误信息单一,需要层层判断返回值,业务代码与错误处理耦合严重,易出现遗漏错误判断的情况 |
| C++ 异常 | 抛出一个对象承载错误信息,通过 throw/catch 完成错误传递与处理 | 可承载丰富的错误信息,错误检测与处理解耦,无需层层传递,可沿调用链直接传递到处理层 | 有一定的运行时开销,使用不当易引发资源泄漏、程序终止等问题 |
1.2 异常处理的核心思想
异常处理的核心是分离关注点:
- 底层函数 / 模块只负责检测错误,当出现无法处理的异常时,直接抛出异常对象,无需关心错误在哪里、如何被处理;
- 上层业务逻辑负责统一捕获并处理异常,根据异常类型和内容,决定对应的处理方案。
二、异常的抛出与捕获基础
C++ 中异常的处理由三个核心关键字完成:throw(抛出异常)、try(标记可能抛出异常的代码块)、catch(捕获并处理异常)。
2.1 异常的抛出:throw
当程序检测到异常情况时,通过throw关键字抛出一个异常对象,语法如下:
throw 异常对象;
关于 throw 的核心特性:
throw执行后,其后的所有语句将不再执行,程序控制权会直接跳转到匹配的catch代码块;- 抛出的异常对象可以是任意类型(基本类型、字符串、自定义类对象等),工程中通常使用类对象承载错误信息;
- 若抛出的是局部对象,编译器会自动生成该对象的拷贝/移动(类似函数传值返回),因为原局部对象会在栈展开时销毁,拷贝后的对象会在 catch 处理完成后销毁。
2.2 异常的捕获:try-catch
我们需要将可能抛出异常的代码放在try代码块中,其后跟随一个或多个catch代码块,每个catch对应一种异常类型的处理逻辑,语法如下:
try {
// 可能抛出异常的代码
} catch (异常类型1 参数) {
// 处理类型1的异常
} catch (异常类型2 参数) {
// 处理类型2的异常
} catch (...) {
// 兜底捕获,处理所有未匹配的异常
}
2.3 基础代码示例
下面通过一个除零错误的示例,完整展示异常的抛出与捕获流程:
#include <iostream>
#include <string>
using namespace std;
double Divide(int a, int b) {
try {
// 检测到除零错误,抛出string类型的异常对象
if (b == 0) {
string s("Divide by zero condition!");
throw s;
} else {
return ((double)a / (double)b);
}
} catch (int errid) {
// 捕获int类型异常,此处抛出的是string,不匹配,不会执行
cout << errid << endl;
}
return 0;
}
void Func() {
int len, time;
cin >> len >> time;
try {
cout << Divide(len, time) << endl;
} catch (const char* errmsg) {
// 捕获const char*类型异常,抛出的是string,不匹配,不会执行
cout << errmsg << endl;
}
cout << __FUNCTION__ << ":" << __LINE__ << "行执行" << endl;
}
int main() {
while (1) {
try {
Func();
} catch (const string& errmsg) {
// 匹配string类型异常,此处成功捕获
cout << errmsg << endl;
}
}
return 0;
}
执行流程说明:
- 当输入的除数为 0 时,
Divide函数中抛出string类型的异常对象; Divide内部的catch(int)类型不匹配,无法捕获;- 栈展开到
Func函数,catch(const char*)类型仍不匹配,无法捕获; - 栈展开到
main函数,catch(const string&)类型完全匹配,成功捕获并处理异常。
三、栈展开
栈展开是 C++ 异常处理的底层核心机制,它决定了异常抛出后,程序如何沿着调用链找到匹配的catch块。
3.1 栈展开的定义
抛出异常后,程序会暂停当前函数的执行,沿着函数调用链,从内到外逐层查找匹配的catch子句,这个逐层查找、退栈的过程就叫做栈展开。
3.2 栈展开的完整执行流程
- 当前函数检查:首先检查
throw语句是否在try块内部,若是,查找与抛出对象类型匹配的catch语句,匹配成功则跳转到catch块处理异常; - 逐层退栈查找:若当前函数没有匹配的
try-catch,或类型不匹配,则退出当前函数栈,销毁当前栈内的所有局部对象,到上层调用函数的栈中继续重复第一步的查找; - 终止程序兜底:若栈展开到
main函数的栈,依旧没有找到匹配的catch子句,程序会调用标准库的terminate()函数,直接终止程序; - 后续执行:找到匹配的
catch并处理完成后,程序会继续执行catch块之后的代码,不会回到异常抛出的位置。
3.3 栈展开调用链示例
我们通过一个四层调用链,更直观地理解栈展开:
// 调用链:main -> func3 -> func2 -> func1,func1中抛出异常
void func1() {
throw "func1 exception"; // 抛出异常
}
void func2() {
func1(); // 无try-catch,栈展开直接退出func2
}
void func3() {
func2(); // 无try-catch,栈展开直接退出func3
}
int main() {
try {
func3();
} catch (const char* e) {
// 最终在main函数中匹配到catch,处理异常
cout << "捕获异常:" << e << endl;
}
cout << "程序继续执行" << endl;
return 0;
}
四、异常类型的匹配规则
异常的捕获有严格的类型匹配规则,只有符合规则的catch块才能捕获到对应的异常。
4.1 基础匹配规则
- 默认完全匹配:正常情况下,要求抛出的异常对象类型,与
catch的参数类型完全匹配; - 就近匹配原则:若有多个类型匹配的
catch块,会选择离异常抛出位置最近的那一个执行; - 兜底捕获:
catch(...)可以捕获任意类型的异常,通常放在所有catch块的最后,作为兜底方案,防止程序意外终止,但无法获取异常的具体信息。
4.2 允许的类型转换特例
C++ 并不会对异常类型做任意的隐式转换,仅允许以下 3 种特殊的类型转换:
- 非常量→常量的转换:即权限缩小,比如抛出
string类型,可被const string&捕获; - 数组 / 函数的指针转换:数组名可转换为指向数组元素类型的指针,函数名可转换为对应的函数指针;
- 派生类→基类的转换:公有继承体系下,派生类异常对象可被基类的引用 / 指针捕获,这是工程中最常用的特性。
4.3 工程化异常继承体系实战
在大型项目中,我们通常会设计一套异常继承体系,通过基类统一捕获所有派生类的异常,结合多态实现统一的错误处理。
4.3.1 异常体系设计代码
#include <iostream>
#include <string>
#include <thread>
#include <chrono>
#include <cstdlib>
#include <ctime>
using namespace std;
// 异常基类
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; // 错误码
};
// SQL模块异常,派生自Exception
class SqlException : public Exception {
public:
SqlException(const string& errmsg, int id, const string& sql)
: Exception(errmsg, id), _sql(sql)
{}
virtual string what() const override {
string str = "SqlException:";
str += _errmsg;
str += "->";
str += _sql;
return str;
}
private:
const string _sql; // 异常对应的SQL语句
};
// 缓存模块异常,派生自Exception
class CacheException : public Exception {
public:
CacheException(const string& errmsg, int id)
: Exception(errmsg, id)
{}
virtual string what() const override {
string str = "CacheException:";
str += _errmsg;
return str;
}
};
// Http模块异常,派生自Exception
class HttpException : public Exception {
public:
HttpException(const string& errmsg, int id, const string& type)
: Exception(errmsg, id), _type(type)
{}
virtual string what() const override {
string str = "HttpException:";
str += _type;
str += ":";
str += _errmsg;
return str;
}
private:
const string _type; // 请求类型
};
// 模拟SQL模块
void SQLMgr() {
if (rand() % 7 == 0) {
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
cout << "SQLMgr 调用成功" << endl;
}
// 模拟缓存模块
void CacheMgr() {
if (rand() % 5 == 0) {
throw CacheException("权限不足", 100);
} else if (rand() % 6 == 0) {
throw CacheException("数据不存在", 101);
}
cout << "CacheMgr 调用成功" << endl;
SQLMgr();
}
// 模拟Http服务模块
void HttpServer() {
if (rand() % 3 == 0) {
throw HttpException("请求资源不存在", 100, "get");
} else if (rand() % 4 == 0) {
throw HttpException("权限不足", 101, "post");
}
cout << "HttpServer调用成功" << endl;
CacheMgr();
}
int main() {
srand(time(0));
while (1) {
this_thread::sleep_for(chrono::seconds(1));
try {
HttpServer();
} catch (const Exception& e) {
// 捕获基类引用,所有派生类异常都能被捕获,多态调用what()
cout << e.what() << endl;
} catch (...) {
// 兜底捕获未知异常
cout << "Unkown Exception" << endl;
}
}
return 0;
}
4.3.2 设计优势
- 所有模块的异常都继承自统一的基类
Exception,上层只需捕获基类引用,即可处理所有模块的异常,无需为每个派生类单独写catch; - 通过虚函数
what()实现多态,每个派生类可自定义错误信息的格式,上层调用无需关心异常的具体类型; - 可扩展每个异常的专属信息(比如 SQL 异常的 SQL 语句、Http 异常的请求类型),错误信息更丰富,便于问题定位。
五、异常的重新抛出
在实际开发中,我们经常会遇到一种场景:catch捕获到异常后,只能做部分处理(比如资源释放、重试逻辑),无法完全解决问题,需要把异常继续传递给上层调用者处理,这就需要用到异常的重新抛出。
5.1 重新抛出的语法
在catch块中,直接使用不带参数的throw;语句,即可重新抛出当前捕获到的异常对象:
try {
// 可能抛出异常的代码
} catch (const Exception& e) {
// 部分处理逻辑
throw; // 重新抛出当前异常
}
5.2 异常重试机制
下面以消息发送的重试逻辑为例,展示异常重新抛出的典型应用场景:
#include <iostream>
#include <string>
#include <cstdlib>
#include <ctime>
using namespace std;
// 异常基类与HttpException派生类,复用上面的代码
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;
};
class HttpException : public Exception {
public:
HttpException(const string& errmsg, int id, const string& type)
: Exception(errmsg, id), _type(type) {}
virtual string what() const override {
return "HttpException:" + _type + ":" + _errmsg;
}
private:
const string _type;
};
// 底层消息发送函数
void _SeedMsg(const string& s) {
if (rand() % 2 == 0) {
throw HttpException("网络不稳定,发送失败", 102, "put");
} else if (rand() % 7 == 0) {
throw HttpException("你已经不是对象的好友,发送失败", 103, "put");
} else {
cout << "发送成功" << endl;
}
}
// 上层消息发送接口,实现重试逻辑
void SendMsg(const string& s) {
// 发送失败,最多重试3次
for (size_t i = 0; i < 4; i++) {
try {
_SeedMsg(s);
break; // 发送成功,直接退出循环
} catch (const Exception& e) {
// 仅对网络不稳定的异常进行重试
if (e.getid() == 102) {
// 重试3次都失败,重新抛出异常
if (i == 3) {
throw;
}
cout << "开始第" << i + 1 << "次重试" << endl;
} else {
// 非网络异常,直接重新抛出,不重试
throw;
}
}
}
}
int main() {
srand(time(0));
string str;
while (cin >> str) {
try {
SendMsg(str);
} catch (const Exception& e) {
// 上层统一处理最终的异常
cout << e.what() << endl << endl;
} catch (...) {
cout << "Unkown Exception" << endl;
}
}
return 0;
}
逻辑说明:
- 底层
_SeedMsg会抛出两种异常:网络不稳定(错误码 102)、非好友关系(错误码 103); - 中层
SendMsg捕获异常后,仅对网络异常进行重试,重试 3 次失败后,重新抛出异常; - 非网络异常直接重新抛出,交给上层
main函数统一处理。
六、异常安全问题
异常使用不当,极易引发资源泄漏、程序崩溃等严重问题。
6.1 异常导致的资源泄漏问题
6.1.1 问题场景
异常抛出后,throw之后的代码将永远不会执行。如果我们在throw之前申请了资源(内存、文件句柄、锁等),在throw之后才释放,就会导致资源无法释放,引发资源泄漏。
6.1.2 问题代码示例
#include <iostream>
using namespace std;
double Divide(int a, int b) {
if (b == 0) {
throw "Division by zero condition!";
}
return (double)a / b;
}
void Func() {
// 申请堆内存
int* array = new int[10];
try {
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl; // 此处抛异常,后续代码不执行
} catch (...) {
// 方案1:捕获异常后,先释放资源,再重新抛出
cout << "释放内存:" << array << endl;
delete[] array;
throw; // 重新抛出异常
}
// 正常流程释放内存,异常时不会执行到这里
cout << "释放内存:" << array << endl;
delete[] array;
}
int main() {
try {
Func();
} catch (const char* errmsg) {
cout << errmsg << endl;
}
return 0;
}
6.1.3 最佳解决方案
上述手动在catch中释放资源的方式,代码冗余且易遗漏,C++ 中解决这类问题的最佳方案是 RAII 机制(资源获取即初始化)。
RAII 的核心思想是:将资源的生命周期与对象的生命周期绑定,对象创建时获取资源,对象销毁时自动释放资源。即使发生异常,栈展开时会销毁局部对象,资源也会被自动释放,无需手动处理。
工程中最常用的 RAII 实现就是智能指针,比如unique_ptr、shared_ptr,完全无需手动管理内存,从根源上避免异常导致的内存泄漏。
6.2 析构函数的异常禁忌
《Effective C++》中有一个经典条款:别让异常逃离析构函数,这是异常使用的核心铁律。
6.2.1 问题原因
- 析构函数通常在对象销毁时被调用,可能发生在正常流程结束,也可能发生在异常栈展开的过程中;
- 如果栈展开过程中,析构函数抛出了异常且未在内部处理,会导致程序直接调用
terminate()终止; - 若析构函数中需要释放多个资源,释放到一半抛出异常,会导致后续的资源无法释放,引发双重资源泄漏。
6.2.2 处理方案
析构函数中如果有可能抛出异常的操作,必须在析构函数内部捕获并处理异常,绝对不能让异常抛出到析构函数外部。
class Test {
public:
~Test() {
try {
// 可能抛出异常的操作
throw "析构函数异常";
} catch (...) {
// 内部捕获处理,不向外抛出
cout << "析构函数捕获异常,内部处理" << endl;
}
}
};
七、异常规范
为了让开发者和编译器提前知道函数是否会抛出异常、会抛出哪些类型的异常,C++ 提供了异常规范的语法,C++98 到 C++11 发生了重大变化。
7.1 C++98 的异常规范(已弃用)
C++98 中通过throw()语法声明函数的异常类型:
// 声明函数只会抛出bad_alloc类型的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 声明函数不会抛出任何异常
void* operator delete (std::size_t size, void* ptr) throw();
// 声明函数可能抛出int、string两种类型的异常
void func() throw(int, string);
这种方式的缺陷非常明显:语法复杂,编译器不会强制检查,即使函数抛出了声明之外的异常,也能正常编译,仅在运行时终止程序,实践中几乎无法使用,在 C++11 中已被弃用。
7.2 C++11 的 noexcept 关键字
C++11 简化了异常规范,引入了noexcept关键字,用于声明函数不会抛出异常。
7.2.1 基本语法
// 声明该函数不会抛出异常
size_type size() const noexcept;
iterator begin() noexcept;
// 不加noexcept的函数,表示可能抛出任意类型的异常
double Divide(int a, int b);
7.2.2 核心注意点
- 编译器不会在编译期强制检查
noexcept函数是否真的抛出异常,最多给出警告; - 若声明了
noexcept的函数,在运行时真的抛出了异常,程序会直接调用terminate()终止,比普通函数抛异常的后果更严重; - 工程中,我们只会对绝对不会抛出异常的函数加
noexcept,比如析构函数、swap 函数、移动构造 / 移动赋值函数,这能帮助编译器做更好的性能优化。
7.2.3 noexcept 运算符
noexcept也可以作为运算符,用于检测一个表达式是否会抛出异常,返回bool值(不会抛出返回true,会抛出返回false),且不会执行该表达式。
#include <iostream>
using namespace std;
// 声明不会抛出异常
double Divide(int a, int b) noexcept {
if (b == 0) {
throw "Division by zero condition!";
}
return (double)a / b;
}
int main() {
int i = 0;
// 检测表达式是否会抛出异常
cout << noexcept(Divide(1,2)) << endl; // 输出1,函数声明了noexcept
cout << noexcept(Divide(1,0)) << endl; // 输出1,仅看函数声明,不判断运行时逻辑
cout << noexcept(++i) << endl; // 输出1,自增操作不会抛异常
try {
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
} catch (const char* errmsg) {
// noexcept函数抛异常,程序直接终止,不会走到这里
cout << errmsg << endl;
}
return 0;
}
八、C++ 标准库异常体系
C++ 标准库已经内置了一套完整的异常继承体系,所有标准库抛出的异常,都派生自统一的基类std::exception,定义在<exception>头文件中。
8.1 标准库异常核心结构
std::exception基类提供了一个核心的虚函数:
virtual const char* what() const throw();
该函数返回一个 C 风格字符串,描述异常的具体信息,所有派生类都会重写这个函数。
标准库异常的主要派生类分为两大分支:
- 逻辑异常
std::logic_error:编译期可检测的错误,比如参数无效、越界访问等std::invalid_argument:无效参数std::out_of_range:越界访问(比如 vector 的 at () 方法)std::length_error:对象长度超过最大值
- 运行时异常
std::runtime_error:运行时才能检测的错误std::overflow_error:算术溢出std::underflow_error:算术下溢std::range_error:结果超出范围
- 其他常用异常
std::bad_alloc:new/operator new 内存分配失败时抛出std::bad_cast:dynamic_cast 向下转型失败时抛出std::bad_typeid:对空指针执行 typeid 时抛出
8.2 标准库异常使用建议
- 主函数中建议捕获
const std::exception&,可以统一处理所有标准库抛出的异常; - 自定义异常体系时,建议派生自
std::exception,这样可以复用标准库的异常处理逻辑,与标准库兼容; - 捕获异常时,优先使用引用类型,避免对象拷贝,同时保证多态特性正常生效。
九、总结
- 优先使用异常继承体系:自定义异常派生自
std::exception或统一的基类,通过基类引用捕获,利用多态实现统一的错误处理; - 不滥用异常:仅在程序发生非正常、无法在当前函数处理的错误时使用异常,不要用异常控制程序的正常业务流程;
- 保证异常安全:使用 RAII 机制(智能指针、锁守卫等)管理资源,从根源上避免异常导致的资源泄漏;
- 严格遵守析构函数异常规则:绝对不要让异常逃离析构函数,析构函数中的异常必须在内部捕获处理;
- 合理使用 noexcept:仅对绝对不会抛出异常的函数加
noexcept,不要随意给函数加noexcept,否则可能导致程序意外终止; - 不忽略异常:除非明确知道异常的处理方式,否则不要写空的
catch块,要么记录日志后重新抛出,要么做明确的处理; - 合理使用兜底捕获:
catch(...)放在所有catch的最后,在服务端程序中用于兜底,防止程序意外崩溃。
如果本文对你有帮助,欢迎点赞、收藏、关注,有任何 C++ 异常相关的问题,都可以在评论区交流~
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)