目录
引言:
写代码不可避免地会出错:文件打不开、内存申请失败、网络突然断开、用户输入了非法数据……如何处理这些错误,直接决定了一门语言工程代码的健壮性和可维护性。
在 C 语言中,错误处理几乎等同于错误码:函数返回 0 表示成功,返回 -1 表示失败,细节塞进全局变量 errno。这种方式简单直接,却也有天然的硬伤——返回值被错误码占用、信息贫瘠、层层传递容易遗漏。当项目规模变大,错误码逐渐变成一张谁也理不清的蜘蛛网。
C++ 提供了一套完全不同的机制:异常(Exception)。它不是对错误码的修修补补,而是另一种范式——用对象携带错误信息,用栈展开自动清理现场,用类型匹配精准定位处理代码。更重要的是,它让错误检测和错误处理在代码结构上彻底解耦:底层函数只管抛出问题,上层调用者决定如何应对。
本文将从异常的基本概念出发,按以下脉络逐步深入:
| 章节 | 内容 | 核心问题 |
|---|---|---|
| 1.1 | 异常的概念 | 异常和 C 语言错误码的本质区别是什么? |
| 1.2 | 异常的抛出与捕获 | throw、try、catch 如何协作?异常对象存在哪里? |
| 1.3 | 调用链与栈展开 | 异常抛出后程序如何查找处理代码?局部对象怎么办? |
| 1.4 | 查找匹配的处理代码 | 类型匹配规则是什么?如何用继承体系设计多模块异常? |
| 1.5 | 异常重新抛出 | 什么时候该捕获后再抛出?throw; 和 throw e; 有什么区别? |
| 1.6 | 异常安全问题 | 异常发生时如何保证不泄漏资源?析构函数的铁律是什么? |
| 1.7 | 异常规范 | noexcept 是什么意思?为什么移动构造函数要加它? |
| 2.0 | 标准库的异常 | std::exception 体系长什么样?日常该捕获什么? |
那么话不多说,接下来进入正文—————————>

1.异常的概念及使用
1.1.异常的概念
在 C 语言中,错误处理通常依赖错误码。比如 fopen 打开文件失败时返回 NULL,错误细节塞进全局变量 errno。这种方式有几个硬伤:
| 问题 | 说明 |
|---|---|
| 返回值冲突 | 函数本身需要返回值承载正常结果(如文件指针),错误状态无处安放,只能塞进全局变量errno |
| 信息贫瘠 | 一个整数编号能表达的内容有限,调用者还要查表翻译 |
| 传播困难 | 错误码需要层层手动传递,中间某层忘了判断,错误就悄无声息地吞掉了 |
C++ 的异常机制彻底换了一种思路:当程序遇到无法继续的错误时,直接抛出一个对象,打断正常的执行流。这个对象可以携带任意信息——错误描述、错误码、甚至完整的上下文数据。
异常的核心设计哲学是关注点分离:
-
检测端(如底层函数):只负责发现错误并抛出异常,不需要知道错误最终如何处理。
-
处理端(如上层调用者):在合适的层级统一捕获异常,决定是恢复、重试还是终止。
小结: 异常不是对错误码的修补,而是另一种错误处理范式。它用对象替代整数编号,用自动跳转替代手动判断,让错误检测与错误处理在代码结构上解耦。
1.2.异常的抛出和捕获
C++ 异常机制由三个关键字构成:throw(抛出)、try(监护)、catch(捕获)。它们协作完成一次错误的报告与处理。
| 关键字 | 作用 | 位置 |
|---|---|---|
throw | 创建一个异常对象并中断当前执行流 | 错误发生处 |
try {} | 划定需要监护的代码范围 | 调用方 |
catch (Type) | 匹配并处理特定类型的异常 | try 之后 |
示例:最基本的异常流程
cpp
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<string>
#include<exception>
using namespace std;
double Divide(int a, int b)
{
// 当 b == 0 时抛出异常
if (b == 0)
{
string s("Divide by zero condition!");
throw s; // 抛出异常,执行流从此处跳转
}
return static_cast<double>(a) / b;
}
void Func()
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl; // 若 Divide 抛异常,此处后续代码不再执行
}
int main()
{
while (true)
{
try {
Func();
}
catch (const string& errmsg) {
cout << errmsg << endl; // 捕获并处理
}
}
return 0;
}
当 b == 0 时,throw s 会触发异常。程序立即暂停当前执行流,沿着调用链向上查找匹配的 catch:Divide → Func → main。最终在 main 的 catch (const string&) 处匹配成功,进入处理逻辑。
调用链的任意一层都可以捕获,具体在哪一层处理由开发者决定。
调用链查找规则
被选中的处理代码是调用链中与该对象类型匹配且离抛出位置最近的那一个。类型匹配的基本规则是精确匹配,但 C++ 标准规定了几种允许的转换(详见 1.4)。
再看一个跨多层不匹配的例子。Divide 内部有 catch(int) 但不匹配,Func 内部有 catch(const char*) 也不匹配,最终由 main 捕获:
cpp
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<string>
#include<exception>
using namespace std;
double Divide(int a, int b)
{
try
{
// 当 b == 0 时抛出异常
if (b == 0)
{
string s("Divide by zero condition!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
catch (int errid) // 类型不匹配,无法捕获 string 异常
{
cout << errid << endl;
}
return 0;
}
void Func()
{
int len, time;
cin >> len >> time;
try
{
cout << Divide(len, time) << endl;
}
catch (const char* errmsg) // 类型不匹配,无法捕获 string 异常
{
cout << errmsg << endl;
}
cout << __FUNCTION__ << ":" << __LINE__ << "行执行" << endl;
}
int main()
{
while (1)
{
try
{
Func();
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
}
return 0;
}
执行流跳转的关键特性
当 throw 执行时,以下规则同时生效:
-
执行流立即跳转:
throw之后的语句不再执行,控制权直接转移到匹配的catch。 -
调用链函数提早退出:从抛出点到捕获点之间的所有函数栈帧会被逐层销毁,这个过程称为栈展开。
-
局部对象自动销毁(需配合 RAII-即智能指针):栈展开时,已构造的局部对象会调用析构函数。但这里销毁的是对象本身(栈上的变量)。如果对象内部封装了资源(如
vector、unique_ptr、文件句柄等),析构函数会释放这些资源;可如果是裸指针(如int* p = new int[10]),指针变量本身被销毁了,它指向的堆内存却不会自动释放——这正是后面 1.6 要讲的异常安全问题。因此 C++ 才强调用 RAII 类来管理资源,而不是裸new/delete。 -
catch只在异常时进入:正常情况下catch块不会执行,直接跳过。 -
捕获后继续执行:异常处理完毕后,
catch块之后的代码恢复正常执行。
第 5 点用代码验证如下:
cpp
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<string>
#include<exception>
using namespace std;
double Divide(int a, int b)
{
try
{
if (b == 0)
{
string s("Divide by zero condition!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
catch (int errid)
{
cout << errid << endl;
}
return 0;
}
void Func()
{
int len, time;
cin >> len >> time;
try
{
cout << Divide(len, time) << endl;
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
cout << __FUNCTION__ << ":" << __LINE__ << "行执行" << endl;
}
int main()
{
while (1)
{
try
{
Func();
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
}
return 0;
}
这里异常在 Func 的 catch 中被捕获,catch 内的代码执行完后,会继续执行 catch 之外的代码(即打印 Func:xx行执行)。这说明异常被捕获后,程序恢复正常执行流程。
异常对象的存储与传递
前面提到,throw 抛出的是 Divide 函数栈帧中的局部对象 s。当栈展开时,Divide 的栈帧被销毁,s 理应失效。那么 catch 中引用的对象从何而来?
答案是:throw 会创建一个异常对象的副本,存放在由运行时管理的独立存储区中(不在任何函数的栈帧上),因此能安全地跨函数传递。这个副本的生命周期从 throw 开始,到匹配的 catch 块结束时销毁。
cpp
try
{
if (b == 0)
{
string s("Divide by zero condition!");
throw s; // 抛出的不是 s 本身,而是 s 的副本
}
}
副本的构造方式类似于函数返回值:C++11 起通过拷贝构造或移动构造生成。这个副本在 catch 块内是一个具名左值,有确定的存储位置,生命周期跨越整个 catch 块。虽然 C++11 语法上允许 catch(string&& e),但异常对象在 catch 块内表现为左值,不会像普通临时对象那样按右值语义被"窃取资源"。
小结:
throw会触发执行流跳转,程序沿调用链寻找最近且类型匹配的catch。栈展开过程中局部对象本身会调用析构函数销毁;若对象内部用 RAII 封装了资源,资源会被正确释放,但裸指针指向的堆内存不会自动释放。异常对象以副本形式存放在独立存储区,catch块内引用的是具名左值。异常被捕获后,后续代码正常执行。
1.3.调用链与栈展开
当 throw 触发后,程序不会无头苍蝇一样乱撞,而是遵循一套严格的查找规则。这套规则的核心就是栈展开(Stack Unwinding)。
栈展开的查找流程
程序从抛出点出发,按以下步骤逐级向上排查:
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 检查 throw 是否位于某个 try 块内部 | 若是,进入步骤 2;若否,进入步骤 3 |
| 2 | 按书写顺序匹配该 try 对应的 catch 列表 | 匹配成功 → 进入 catch 处理;匹配失败 → 进入步骤 3 |
| 3 | 销毁当前函数的局部对象,退出当前栈帧 | 进入上一层调用栈,回到步骤 1 |
| 4 | 若一路展开到 main 仍未匹配 | 调用 std::terminate(),程序崩溃 |
每退出一个函数,该函数栈帧中的局部对象就会被销毁,但异常对象本身(的副本)会继续向上传递,直到被捕获或进程终止。
未捕获异常:程序直接崩溃
如果到达 main 函数,依旧没有找到匹配的 catch 子句,程序会调用标准库的 terminate 函数终止程序。terminate 默认调用 std::abort(),直接杀死进程,不会执行任何清理逻辑。
下面这种就是全部不匹配、最终导致程序终止的情况:
cpp
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<string>
#include<exception>
using namespace std;
double Divide(int a, int b)
{
try
{
// 当 b == 0 时抛出异常
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 (int errmsg) // 只捕获 int,不匹配 string
{
cout << errmsg << endl;
}
cout << __FUNCTION__ << ":" << __LINE__ << "行执行" << endl;
}
int main()
{
while (1)
{
try
{
Func();
}
catch (int errmsg) // 只捕获 int,不匹配 string
{
cout << errmsg << endl;
}
}
return 0;
}

编译运行后,一旦 b == 0,程序会直接弹出上图这样的反馈然后崩溃。这种反馈其实我们在先前就已经见过很多次,比如栈溢出等。从这里能看出,异常如果没有被捕获是很严重的事情。
在日常使用中,如果一个 APP 经常在信号不好的地方发消息时突然闪退,本质上就是某个异常没有被捕获,导致进程被 terminate 强制终止。因此,非严重错误的情况下,我们都不期望程序直接终止。(详见 1.4 的 catch(...) 兜底机制)
栈展开与对象销毁
栈展开过程中有一个非常重要的特性:一旦程序开始执行异常处理,沿着调用链创建的局部对象都会被自动销毁(调用析构函数)。
这意味着:
-
如果资源封装在类对象中管理(如
vector、fstream、unique_ptr等 RAII 类),它们会被正确释放。 -
但如果只是裸指针(如
int* p = new int[10]),指针变量本身被销毁了,它指向的堆内存不会自动释放。
更重要的是,这里有一个绝对禁忌:析构函数中绝不能抛出异常。如果栈展开时某个局部对象的析构函数又抛出新异常,程序会立即调用 std::terminate 强制终止。
这也是 C++ 倡导 RAII(资源获取即初始化) 的根本原因——让对象的生命周期来管理资源,而不是依赖手动在 catch 里释放。你不需要在每一处 catch 中写 delete 或 close(),只要把资源交给 RAII 对象,栈展开时的自动析构会帮你收拾干净。
小结: 栈展开是沿调用链向上查找匹配
catch的规范化流程;若到main仍未匹配则程序崩溃(std::terminate)。栈展开会自动调用局部对象的析构函数,是 RAII 机制能够生效的前提;但析构函数绝不能抛异常,否则会导致std::terminate。资源应通过 RAII 类管理,而非裸指针。
1.4.查找匹配的处理代码
基本匹配规则
异常抛出后,编译器按以下优先级查找 catch:
表格
| 优先级 | 规则 | 说明 |
|---|---|---|
| 1 | 同层顺序匹配 | 在当前 try 对应的 catch 列表中,按书写顺序依次匹配 |
| 2 | 沿调用链向上冒泡 | 若无匹配,则弹出当前栈帧,进入外层函数的 try/catch 继续查找 |
| 3 | 最近匹配原则 | 多个类型都匹配时,同层选书写顺序更靠前的,跨层选调用链更近的 |
如果一路展开到 main 函数仍未匹配,程序调用 std::terminate() 终止。因此,main 函数通常会在最后放置一个 catch(...) 作为兜底。它可以捕获任意类型的异常,但缺点是不知道具体错误信息。这也是为什么异常不被捕获的情况很少见的原因。
try/catch 语句允许一个 try 对应多个 catch:
cpp
int main()
{
while (1)
{
try
{
Func();
}
catch (int errmsg)
{
cout << errmsg << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
}
return 0;
}
catch(...) 的位置要求:必须放在所有具体 catch 之后。如果写在前面,后面的具体 catch 将永远不可达,编译器通常会报错或警告。
允许的转换
虽然基本规则是类型精确匹配,但 C++ 标准规定了几种允许的转换:
表格
| 转换类型 | 示例 | 实用度 |
|---|---|---|
非 const → const | catch(const T&) 可以捕获 T 类型的对象 | ⭐⭐⭐ 常用 |
| 数组 → 指向元素的指针 | catch(int*) 可以捕获 int[] | ⭐ 极少用 |
| 函数 → 函数指针 | catch(void(*)()) 可以捕获函数 | ⭐ 极少用 |
| 派生类 → 基类 | catch(const Base&) 可以捕获 Derived 对象 | ⭐⭐⭐⭐⭐ 最常用 |
其中最实用的是派生类向基类的赋值兼容转换,这也是实际项目中异常体系设计的基石。
致命顺序陷阱
当使用继承体系时,catch 的书写顺序至关重要:
cpp
// ❌ 错误顺序!SqlException 的 catch 永远不会进入
try { /* ... */ }
catch (const Exception& e) { /* 基类 —— 会截获所有派生类异常 */ }
catch (const SqlException& e) { /* 派生类 —— 不可达!编译器通常会警告 */ }
因为 SqlException 也是 Exception,所以它永远被第一个 catch 截获。正确顺序是派生类在前,基类在后:
cpp
// ✅ 正确顺序
catch (const SqlException& e) { /* 先匹配最具体的 */ }
catch (const Exception& e) { /* 再匹配通用的 */ }
catch (...) { /* 最后兜底 */ }
不过在实际工程中,配合多态机制,通常只需要写一个基类 catch 即可统一处理所有派生类异常(如下面的项目实战所示)。但即便如此,顺序规则仍然适用——当你需要为特定派生类写特殊处理时,必须把它放在基类 catch 之前。
项目实战:多模块异常体系
在实际项目中,不同模块通常定义各自的异常类,继承自统一的基类。这样可以在调用方统一捕获,同时通过多态获取具体的错误信息。
基类设计:
一般的异常基类会包含两个信息:错误信息描述和错误编号。
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;
};
各模块派生类:
cpp
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:
string _sql;
};
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;
}
};
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:
string _type;
};
这些模块由不同的人负责。抛异常时通过派生类向基类的赋值兼容转换,catch 捕获基类即可。输出的异常信息会显示是哪个模块出的问题,直接找对应负责人就行。
下面展示一个典型的网络服务结构,体会异常捕获允许赋值兼容转换的好处,同时这里也实现了多态机制:
cpp
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<string>
#include<thread>
#include<exception>
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;
};
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:
string _sql;
};
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;
}
};
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:
string _type;
};
void SQLMgr()
{
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
else
{
cout << "SQLMgr 调用成功" << endl;
}
}
void CacheMgr()
{
if (rand() % 5 == 0)
{
throw CacheException("权限不足", 100);
}
else if (rand() % 6 == 0)
{
throw CacheException("数据不存在", 101);
}
else
{
cout << "CacheMgr 调用成功" << endl;
}
SQLMgr();
}
void HttpServer()
{
if (rand() % 3 == 0) // 404 就类似这种,资源不存在
{
throw HttpException("请求资源不存在", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpException("权限不足", 101, "post");
}
else
{
cout << "HttpServer调用成功" << endl;
}
CacheMgr();
}
int main()
{
srand((unsigned int)time(0));
while (1)
{
try
{
this_thread::sleep_for(chrono::seconds(1));
HttpServer();
}
catch (const Exception& e) // 捕获基类,派生类对象也可以被捕获
{
cout << e.what() << endl; // 多态调用,根据实际对象类型输出不同信息
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
}
return 0;
}
运行后日志中会看到类似这样的信息:

小结: 异常捕获以类型匹配为基本规则,同时允许几种标准转换(最实用的是派生类→基类)。
catch(...)作为兜底必须放在最后。使用继承体系时,派生类catch必须写在基类之前,否则会被截获。在实际项目中,通过基类异常 + 多态,只需一个catch (const Exception&)即可统一处理所有模块的异常,各模块通过重写what()输出各自的错误信息,实现了错误检测与错误报告的模块化解耦。
1.5.异常重新输出
有时 catch 到一个异常后,需要对错误进行分类处理:某些异常可以现场修复(如网络波动,重试即可),另一些则必须继续上报(如对方已删除你,重试无意义)。这时就需要先捕获、再判断、处理不了就重新抛出。
语法:throw;
在 catch 块中,写 throw;(后面不接任何表达式)表示重新抛出当前正在处理的异常。这个异常会沿着调用链继续向上传播,交给外层 catch 处理。
throw; 只能在 catch 块内部使用,它会保留异常对象的原始类型和 const 限定信息。
这里有一个极易踩的坑:throw; 和 throw e; 完全不同:
表格
| 写法 | 行为 | 风险 |
|---|---|---|
throw; | 重新抛出原始异常对象 | 无,保留完整类型信息 |
throw e; | 抛出 e 的副本,类型退化为 catch 参数声明的类型 | 对象切片:若 e 是基类引用,实际对象是派生类,副本会被"切掉"派生部分 |
结论:重新抛出时永远用 throw;,不要用 throw e;。
应用场景:重试机制
以微信发消息为例:网络太差时消息发不出去,APP 会"转圈圈"重试几次;但如果对方已经把你删除,重试再多次也没用,应该直接报错。
下面的代码模拟了这个逻辑:错误编号 102 表示网络问题,重试 3 次;错误编号 103 表示已被删除,直接上报。
cpp
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<string>
#include<thread>
#include<exception>
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;
};
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 += _type;
str += ":";
str += _errmsg;
return str;
}
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)
{
// 102 号错误:网络不稳定,尝试重试
if (e.getid() == 102)
{
// 重试三次后仍然失败,说明网络太差,重新抛出异常
if (i == 3)
throw; // ✅ 重新抛出原始异常
cout << "开始第" << i + 1 << "次重试" << endl;
}
else
{
// 其他错误(如 103 已被删除),无需重试,直接上报
throw; // ✅ 重新抛出原始异常
}
}
}
}
int main()
{
srand((unsigned int)time(0));
string str;
while (cin >> str)
{
try
{
this_thread::sleep_for(chrono::seconds(1));
SendMsg(str);
}
catch (const Exception& e) // 捕获基类,派生类对象也可以被捕获
{
cout << e.what() << endl; // 多态调用
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
}
return 0;
}
核心逻辑:SendMsg 作为中间层,捕获异常后判断错误编号。能自救的(网络问题)就地重试,自救不了的(已被删除)用 throw; 继续抛给 main 处理。这就是分层异常处理的典型模式。
小结: 异常重新抛出的价值在于分层处理——底层
catch做力所能及的补救(如重试、回滚、记录日志),处理不了的用throw;继续上报。throw;会保留异常对象的原始类型,是重新抛出的唯一正确写法;throw e;可能导致对象切片,应避免使用。
1.6.异常安全问题
什么是异常安全
异常安全(Exception Safety)指的是:当异常发生时,程序不会泄漏资源,也不会让数据结构处于非法状态。
C++ 标准定义了三种异常安全保证等级,这是面试和代码审查中的高频考点:
表格
| 等级 | 名称 | 含义 | 典型场景 |
|---|---|---|---|
| 基本保证 | Basic Guarantee | 异常发生后,对象处于合法但不确定的状态,不泄漏资源 | 最低要求 |
| 强保证 | Strong Guarantee | 异常发生后,程序状态回滚到操作前,仿佛操作从未发生 | 事务性操作 |
| 不抛保证 | Nothrow Guarantee | 承诺绝不抛异常 | 析构函数、swap、移动操作 |
资源泄漏:裸指针的陷阱
异常抛出后,当前函数内 throw 之后的代码不再执行。如果前面用 new 申请了堆内存,后面才 delete,中间一旦抛异常,delete 就永远执行不到了。
cpp
void Func()
{
int* array = new int[10]; // 申请资源
int len, time;
cin >> len >> time;
// 如果 Divide 抛异常,下面的 delete[] 永远不会执行
cout << Divide(len, time) << endl;
delete[] array; // 可能执行不到 → 内存泄漏
}
手动修复方案:在 catch 中释放资源,然后重新抛出。
cpp
void Func()
{
int* array = new int[10];
try
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
catch (...)
{
// 捕获异常,先释放内存,再重新抛出
cout << "delete []" << array << endl;
delete[] array;
throw; // 重新抛出,让上层决定如何处理
}
// 正常路径也要释放
cout << "delete []" << array << endl;
delete[] array;
}
这种写法能解决问题,但有两个明显缺陷:
-
代码冗余:正常路径和异常路径都要写
delete[],容易遗漏。 -
维护困难:每新增一个资源,就要在
catch和正常路径各加一行释放逻辑。
正确的做法:用 RAII 类管理资源(如 vector、fstream 等标准库容器,或后续会讲到的智能指针)。对象销毁时自动释放资源,无论是否发生异常。
cpp
void Func()
{
vector<int> array(10); // RAII:异常时 vector 析构自动释放内存
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
// 无需手动 delete,无论正常返回还是异常,array 的析构函数都会执行
}
析构函数绝不能抛异常
这是异常安全的铁律。栈展开时,如果某个局部对象的析构函数抛出异常,程序会立即调用 std::terminate 终止。
更隐蔽的场景:析构函数要释放 10 个资源,释放到第 5 个时抛异常,则后面 5 个资源永远没机会释放,造成资源泄漏。
cpp
class ResourceManager
{
Resource* resources[10];
public:
~ResourceManager()
{
for (int i = 0; i < 10; ++i)
{
// 如果 release(resources[i]) 抛异常,后面 5 个资源泄漏
release(resources[i]);
}
}
};
修复方案:在析构函数内部捕获所有异常,确保释放逻辑继续执行。
cpp
~ResourceManager()
{
for (int i = 0; i < 10; ++i)
{
try
{
release(resources[i]);
}
catch (...)
{
// 记录日志,但绝不继续向上抛
// 确保后续资源仍被释放
}
}
}
《Effective C++》第 8 条专门讲了这个问题:别让异常逃离析构函数。
补充:构造函数异常
如果对象构造到一半抛异常,已构造完成的成员会自动析构,但对象本身不会进入生命周期(不会调用析构函数)。
cpp
class MyClass
{
string name; // 成员 1
int* data; // 成员 2
public:
MyClass(const string& n, int size)
: name(n) // 先构造 name
, data(new int[size]) // 再构造 data,若这里抛异常(如 bad_alloc)...
{
// ...name 已经被构造,会自动调用 string 的析构函数
// 但 MyClass 的析构函数不会执行,data 的泄漏需要用智能指针避免
}
};
因此,构造函数中申请资源也应使用智能指针或 RAII 类,避免半构造状态下的资源泄漏。
小结: 异常安全的核心是"异常发生时,资源不泄漏、状态不破坏"。C++ 定义了三种保证等级(基本/强/不抛)。裸指针在异常路径下极易泄漏,应通过 RAII 让对象的析构函数自动管理资源。析构函数绝不能抛异常,若内部操作可能失败,应在析构函数内部捕获并消化异常。构造函数抛异常时,已构造的成员会自动析构,但对象本身不会析构,因此构造过程中的资源也应由 RAII 类托管。
1.7.异常规范
异常规范(Exception Specification)是函数接口的一部分,用于声明该函数可能抛出哪些异常,或承诺不抛出任何异常。这对调用方和编译器都有价值:调用方可以据此简化错误处理逻辑,编译器则可以生成更高效的代码。
C++98:过于繁琐的 throw(类型列表)
C++98 中,函数参数列表后面接 throw() 表示不抛异常;接 throw(类型1, 类型2, ...) 表示可能抛出指定类型的异常。
cpp
// C++98
// 表示这个函数只会抛出 bad_alloc 异常
void* operator new(std::size_t size) throw(std::bad_alloc);
// 表示这个函数不会抛出异常
void* operator delete(std::size_t size, void* ptr) throw();
这种设计在实践中很难用。一个函数内部可能调用其他函数,其他函数抛出的异常也算当前函数抛出的。这意味着异常列表不仅要写很长,还可能包含调用链深处未知的异常类型。因此 C++98 的异常规范几乎被弃用。
C++11:noexcept 简化
C++11 进行了大刀阔斧的简化:
| 写法 | 含义 |
|---|---|
| 函数参数列表后什么都不加 | 可能抛出异常(默认行为) |
函数参数列表后加 noexcept | 承诺不抛异常 |
函数参数列表后加 noexcept(false) | 可能抛出异常(显式声明) |
函数参数列表后加 noexcept(true) | 承诺不抛异常(显式声明) |
cpp
// C++11
size_type size() const noexcept;
iterator begin() noexcept;
const_iterator begin() const noexcept;
noexcept 的编译期检查陷阱
编译器不会在编译时检查 noexcept 承诺。如果一个函数用 noexcept 修饰,但内部包含 throw 语句,或调用了可能抛异常的函数,编译器通常只会报个警告,代码仍能编译通过。
但一旦运行时真的抛出了异常,程序会立即调用 std::terminate() 终止,不会走正常的异常传播路径。
cpp
double Divide(int a, int b) noexcept
{
if (b == 0)
{
// 编译能通过(可能报警告),但运行时抛异常会直接 terminate
throw "Division by zero condition!";
}
return static_cast<double>(a) / b;
}
int main()
{
try
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
catch (const char* errmsg)
{
// 永远不会执行到这里!noexcept 函数抛异常直接 terminate
cout << errmsg << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
return 0;
}
结论:noexcept 是一种承诺,不是保险。一旦承诺了就要确保实现上真的不抛异常。
noexcept 作为运算符
noexcept 还可以作为运算符,在编译期检测一个表达式是否被声明为不抛异常。
cpp
double Divide(int a, int b)
{
if (b == 0)
{
throw "Division by zero condition!";
}
return static_cast<double>(a) / b;
}
int main()
{
int i = 0;
cout << noexcept(Divide(1, 2)) << endl; // 0:Divide 没有 noexcept 修饰
cout << noexcept(Divide(1, 0)) << endl; // 0:同上,只看声明不看内部实现
cout << noexcept(++i) << endl; // 1:内置运算符不抛异常
return 0;
}
注意:noexcept(表达式) 检测的是该表达式的类型是否带有 noexcept 声明,它不会分析表达式内部是否真的可能抛异常。即使 Divide 内部有 throw,只要函数声明没写 noexcept,结果就返回 0(false)。
noexcept 的工程价值:移动语义优化
noexcept 最重要的实际价值在于移动操作。以 std::vector 扩容为例:
当 vector 容量不足需要重新分配内存时,它需要将旧空间的元素迁移到新空间。此时编译器面临一个选择:
-
如果元素的移动构造函数是
noexcept,vector会调用移动操作(高效,只是指针交换)。 -
如果移动构造函数不是
noexcept,vector会退而求其次调用拷贝构造函数(低效,深拷贝)。
因为如果在移动过程中发生异常,强保证(Strong Guarantee)要求回滚到操作前状态;而移动操作已经破坏了源对象,无法回滚。只有承诺 noexcept(绝不抛异常),编译器才敢放心使用移动。
cpp
class MyString
{
public:
MyString(MyString&& other) noexcept // 加上 noexcept,vector 扩容时才敢用移动
: _data(other._data)
, _size(other._size)
{
other._data = nullptr;
other._size = 0;
}
private:
char* _data;
size_t _size;
};
建议:自定义类的移动构造函数和移动赋值运算符,只要内部不涉及可能抛异常的操作(如内存分配),都应该标记 noexcept。
小结: C++98 的
throw(类型列表)过于繁琐已被弃用,C++11 用noexcept简化为"承诺不抛异常"。编译器不会在编译期强制执行该承诺,但运行时违约会直接terminate。noexcept(表达式)运算符用于编译期检测表达式是否声明了不抛异常。noexcept最重要的工程价值在于移动语义优化——移动构造函数标记noexcept后,标准库容器(如vector)在扩容等场景下才会调用移动而非拷贝,大幅提升性能。
2.标准库的异常
C++ 标准库定义了一套完整的异常继承体系,基类是 std::exception。日常写程序时,在主函数捕获 std::exception 即可应对绝大多数标准库抛出的错误。
继承体系概览
标准库的异常类都继承自 std::exception,主要分成两大分支:
| 分支 | 含义 | 典型场景 |
|---|---|---|
std::logic_error | 逻辑错误——程序逻辑本身有问题,理论上可以通过代码检查避免 | 参数非法、越界访问、长度超限 |
std::runtime_error | 运行时错误——程序逻辑没问题,但运行环境出了问题 | 内存不足、类型转换失败、数值溢出 |
常用派生类如下图:

使用方式
std::exception 提供了虚函数 what(),返回异常描述字符串。派生类可以重写该函数。
cpp
#include <iostream>
#include <exception>
#include <vector>
using namespace std;
int main()
{
try
{
vector<int> v(5);
v.at(10) = 100; // 越界,抛出 std::out_of_range
}
catch (const exception& e) // 捕获基类,所有标准异常都能接住
{
cout << e.what() << endl; // 输出: vector::_M_range_check: __n >= this->size()
}
catch (...)
{
cout << "Unknown Exception" << endl;
}
return 0;
}
两个最常用场景
-
std::bad_alloc:operator new申请空间失败时抛出。这是你在堆内存耗尽时最常遇到的异常。 -
std::out_of_range:STL 容器的at()成员函数(如vector::at、string::at、map::at)在索引越界时抛出。注意它与operator[]的区别——[]越界通常是未定义行为(可能直接崩溃),而at()会安全地抛异常。
小结: C++ 标准异常以
std::exception为根,主要分为logic_error(逻辑错误,可预防)和runtime_error(运行时错误,难预防)两大分支。日常编程在主函数捕获const std::exception&即可统一处理标准库抛出的异常,通过what()获取错误描述。最常用的是bad_alloc(内存不足)和out_of_range(at()越界)。
结语:
至此,我们已经完整走完了 C++ 异常机制的整条链路:从 throw 抛出对象、catch 类型匹配,到栈展开时局部对象的自动销毁;从派生类向基类的赋值兼容转换,到项目级的多模块异常体系设计;从异常重新抛出的分层处理,到异常安全的三种保证等级;最后落脚在 noexcept 的语义和标准库的异常继承体系。
异常是一把双刃剑。用得好,代码结构清晰、错误处理集中、资源管理自动化;用不好,程序莫名其妙崩溃、资源泄漏、栈信息混乱。这里给出三条实践建议,作为本文的收尾:
| 建议 | 说明 |
|---|---|
| 1. 异常只用于真正的异常情况 | 不要用异常控制正常流程(如遍历查找)。异常有开销,且破坏代码局部性。 |
| 2. 析构函数绝不抛异常 | 这是铁律。若内部操作可能失败,在析构函数内部捕获并消化。 |
| 3. 资源交给 RAII 管理 | 不要裸 new/delete。用容器、智能指针(下一篇详讲)等 RAII 类托管资源,让栈展开自动帮你清理。 |
异常机制讲完了,但异常安全的最佳搭档——智能指针——还没登场。下一篇我们将深入 unique_ptr、shared_ptr 和 weak_ptr,看看现代 C++ 如何用 RAII 彻底解决内存管理问题。
文章到这里结束,感谢阅读。若觉得写的还可以,可以分享给朋友一起来看,一起进步更有动力;当然,能关注一下就更好啦,我们下篇见。


1190

被折叠的 条评论
为什么被折叠?



