一、C++异常概念
异常是 C++ 中处理程序运行时错误的标准化方式,允许程序在检测到无法处理的错误时 中断当前执行流程,并通过 栈展开(Stack Unwinding) 将控制权转移到能够处理该错误的代码块。
异常机制涉及三个关键操作:抛出异常(throw)、捕获异常(catch) 和 定义受保护代码(try)。
-
throw
- 用于抛出异常对象(可以是任意类型,但建议使用标准异常类或自定义异常类)。
- 抛出后,当前函数停止执行,控制权转移到最近的匹配的
catch
块。
-
try
- 定义受保护的代码块,其内部代码可能抛出异常。
- 如果
try
块中抛出异常,程序会跳过后续代码,直接进入匹配的catch
块。
-
catch
- 捕获特定类型的异常(按类型匹配)。
- 可以有多个
catch
块,按声明顺序匹配。 catch(...)
捕获所有未匹配的异常(类似default
分支)。
try
{
// 保护的标识代码
}catch( ExceptionName e1 )
{
// catch 块
}catch( ExceptionName e2 )
{
// catch 块
}catch( ExceptionName eN )
{
// catch 块
}
二、异常的用法
2.1 异常的抛出和捕获
异常机制是 C++ 中处理运行时错误的核心方式。它的核心思想是通过 抛出异常 和 捕获异常 来分离正常逻辑和错误处理逻辑。
1. 异常的抛出
异常是通过throw
语句抛出的,抛出的对象可以是任何类型(基本类型、类类型等)。抛出异常后,程序的控制流会立即从当前执行点跳转到最近的匹配的catch
块。
throw SomeException(); // 抛出一个异常对象
2. 异常的匹配
当异常被抛出后,程序会在调用栈中查找与之匹配的catch
块。匹配的原则是:
- 类型匹配:
catch
块中的异常类型必须与抛出的异常对象的类型匹配。 - 最近匹配:如果有多个
catch
块能够匹配抛出的异常类型,程序会选择离抛出点最近的那个catch
块来处理异常。
try {
// 可能抛出异常的代码
throw MyException();
} catch (MyException& e) {
// 处理 MyException 类型的异常
} catch (std::exception& e) {
// 处理 std::exception 类型的异常
} catch (...) {
// 捕获所有其他类型的异常
}
3. 异常对象的拷贝
当异常被抛出时,抛出的异常对象会被拷贝到一个临时对象中。这是因为抛出的异常对象可能是一个临时对象,或者可能在抛出后被销毁。因此,catch
块中捕获的是这个临时对象的拷贝。
try {
throw MyException(); // 抛出异常对象
} catch (MyException e) { // 捕获异常对象的拷贝
// 处理异常
}
4. catch(...)
捕获所有异常
catch(...)
是一个特殊的catch
块,它可以捕获任何类型的异常。然而,由于它没有指定异常类型,因此在catch(...)
块中无法直接访问异常对象,也无法知道具体的异常类型。
try {
// 可能抛出异常的代码
} catch (...) {
// 捕获所有类型的异常
}
5. 派生类异常与基类捕获
在实际应用中,抛出的异常对象可以是派生类对象,而catch
块可以使用基类来捕获。这是多态性的一种体现,允许我们通过基类引用来捕获派生类异常。
class BaseException : public std::exception {
// 基类异常
};
class DerivedException : public BaseException {
// 派生类异常
};
try {
throw DerivedException(); // 抛出派生类异常
} catch (BaseException& e) { // 使用基类捕获
// 处理基类或派生类异常
}
6. 栈展开(Stack Unwinding)
在C++中,异常处理机制通过try
、catch
和throw
关键字来实现。当程序抛出异常时,系统会沿着函数调用链向上查找匹配的catch
块来处理异常。这个过程称为栈展开(Stack Unwinding)。
异常栈展开的匹配原则
举例说明:
double Division(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
throw "Division by zero condition!";
else
return ((double)a / (double)b);
}
void Func()
{
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;
}
int main()
{
try {
Func();
}
catch (const char* errmsg) {
cout << errmsg << endl;
}
catch(...){
cout<<"unkown exception"<<endl;
}
return 0;
}
-
检查
throw
是否在try
块内部:- 当
Division
函数中的throw
语句执行时,系统首先检查throw
是否在try
块内部。 - 在
Division
函数中,throw
不在try
块内部,因此异常会向上传递到调用Division
的函数,即Func
函数。
- 当
-
在调用函数的栈中查找匹配的
catch
:Func
函数也没有try-catch
块,因此异常继续向上传递到main
函数。- 在
main
函数中,Func
函数的调用被包含在try
块中,因此系统会检查是否有匹配的catch
块。
-
匹配
catch
块并处理异常:main
函数中的第一个catch
块捕获const char*
类型的异常,因此异常被捕获并输出"Division by zero condition!"
。- 如果异常类型不匹配,系统会继续检查下一个
catch(...)
块,捕获所有其他类型的异常。
-
继续执行
catch
块后的代码:- 一旦异常被捕获并处理,程序会继续执行
catch
块后的代码。
- 一旦异常被捕获并处理,程序会继续执行
2.2 重新抛出
在C++中,异常的重新抛出是一种常见的异常处理模式。当一个catch
块捕获到异常后,可能无法完全处理该异常,或者需要在处理异常的同时执行一些清理操作(如释放资源),然后再将异常传递给更外层的调用链函数进行处理。这种机制可以通过在catch
块中使用throw;
语句来实现。
举例说明:
double Division(int a, int b)
{
if (b == 0)
{
throw "Division by zero condition!";
}
return (double)a / (double)b;
}
void Func()
{
int* array = new int[10];
try {
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;
}
catch (...)
{
cout << "delete []" << array << endl;
delete[] array;
throw; // 重新抛出异常
}
// 如果没有异常,继续执行
cout << "delete []" << array << endl;
delete[] array;
}
int main()
{
try {
Func();
}
catch (const char* errmsg) {
cout << errmsg << endl;
}
catch(...) {
cout << "unkown exception" << endl;
}
return 0;
}
异常重新抛出的机制
-
捕获异常并执行清理操作
-
重新抛出异常:
throw;
只能在catch
块中使用,它会将当前捕获的异常原封不动地传递给更外层的调用链函数。 -
外层函数处理异常
2.3 异常安全
异常安全是指程序在抛出异常时,能够正确地管理资源、保持数据一致性,并避免资源泄漏等问题。在C++中,异常安全是一个重要的设计目标,尤其是在涉及资源管理(如内存、文件句柄、锁等)
1. 构造函数与异常
构造函数负责对象的构造和初始化。如果在构造函数中抛出异常,可能会导致对象构造不完整或未完全初始化,从而引发未定义行为。
❓ 问题:
- 如果构造函数在初始化过程中抛出异常,对象可能处于部分构造状态。
- 如果对象是动态分配的(例如通过
new
),构造函数抛出异常会导致内存泄漏,因为析构函数不会被调用。
✅解决方案:
- 避免在构造函数中执行可能抛出异常的操作。
- 如果必须执行可能抛出异常的操作,使用 RAII(资源获取即初始化) 技术来管理资源,确保资源在异常发生时能够正确释放。
class MyClass {
public:
MyClass() {
ptr = new int[100]; // 可能抛出异常
// 如果这里抛出异常,ptr 不会被释放
}
~MyClass() {
delete[] ptr;
}
private:
int* ptr;
};
改进方案:使用智能指针(如std::unique_ptr
)来管理资源。
#include <memory>
class MyClass {
public:
MyClass() : ptr(std::make_unique<int[]>(100)) {
// 如果这里抛出异常,ptr 会自动释放
}
private:
std::unique_ptr<int[]> ptr;
};
2. 析构函数与异常
析构函数负责资源的清理。如果在析构函数中抛出异常,可能会导致资源泄漏(如内存泄漏、文件句柄未关闭等)。
❓ 问题:
- C++标准规定,如果析构函数抛出异常且未被捕获,程序会调用
std::terminate
,导致程序终止。 - 如果析构函数在栈展开(stack unwinding)过程中抛出异常,程序行为将不可预测。
✅解决方案:
- 析构函数中不要抛出异常。
- 如果析构函数必须执行可能抛出异常的操作,确保捕获并处理异常,避免异常传播到析构函数外部。
class MyClass {
public:
~MyClass() {
try {
// 可能抛出异常的操作
} catch (...) {
// 捕获并处理异常
}
}
};
3. 资源管理与异常
在C++中,异常经常会导致资源泄漏问题,例如:
- 在
new
和delete
之间抛出异常,导致内存泄漏。 - 在
lock
和unlock
之间抛出异常,导致死锁。
❓ 问题示例:
void unsafeFunction() {
int* ptr = new int[100];
// 可能抛出异常的操作
delete[] ptr; // 如果异常抛出,delete 不会执行
}
✅ 解决方案:
使用 RAII(Resource Acquisition Is Initialization) 技术,将资源管理与对象的生命周期绑定。RAII的核心思想是:
- 在构造函数中获取资源。
- 在析构函数中释放资源。
- 使用栈对象或智能指针来管理资源。
示例:使用智能指针
#include <memory>
void safeFunction() {
auto ptr = std::make_unique<int[]>(100); // RAII:资源由智能指针管理
// 可能抛出异常的操作
// 无需手动释放资源,智能指针会在作用域结束时自动释放
}
示例:使用RAII管理锁
#include <mutex>
std::mutex mtx;
void safeFunction() {
std::lock_guard<std::mutex> lock(mtx); // RAII:锁在构造时加锁,析构时解锁
// 可能抛出异常的操作
// 无需手动解锁,lock_guard 会在作用域结束时自动解锁
}
4. 异常安全级别
异常安全通常分为三个级别:
- 基本保证(Basic Guarantee):如果抛出异常,程序处于有效状态,没有资源泄漏,但对象的状态可能被修改。
- 强保证(Strong Guarantee):如果抛出异常,程序状态回滚到操作之前的状态(类似于事务)。
- 无异常保证(No-throw Guarantee): 操作保证不会抛出异常。
示例:强保证
class MyClass {
public:
void strongGuarantee() {
auto temp = data; // 创建临时副本
temp.modify(); // 修改临时副本
std::swap(data, temp); // 交换数据,确保强保证
}
private:
Data data;
};
2.4 异常规范
异常规范(Exception Specification)是C++中用于声明函数可能抛出的异常类型的机制。它的目的是让函数的调用者知道该函数可能抛出哪些异常,从而更好地处理异常情况。然而,C++11之后,异常规范的使用方式发生了变化,throw(type)
语法被弃用,取而代之的是noexcept
关键字。
1. 异常规格说明
在C++98/03中,可以通过throw(type)
语法来指定函数可能抛出的异常类型。
void func() throw(int, const char*); // 表示 func 可能抛出 int 和 const char* 类型的异常
void fun() throw(A,B,C,D);// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
- 如果函数抛出了未在
throw(type)
列表中声明的异常类型,程序会调用std::unexpected()
,默认行为是终止程序。 - 这种机制在实际使用中并不灵活,因此在C++11中被弃用。
2. throw()
表示不抛异常
在C++98/03中,throw()
表示函数不会抛出任何异常。
void func() throw(); // 表示 func 不会抛出任何异常
- 如果函数抛出了异常,程序会调用
std::unexpected()
,默认行为是终止程序。 - 在C++11中,
throw()
被noexcept
取代。
3. 无异常接口声明
如果函数没有异常规格说明,则表示该函数可能抛出任何类型的异常。
void func(); // 可能抛出任何类型的异常
4. C++11 的 noexcept
C++11引入了noexcept
关键字,用于替代throw()
,表示函数不会抛出异常。
void func() noexcept; // 表示 func 不会抛出异常
- 如果
noexcept
函数抛出了异常,程序会调用std::terminate()
,终止程序。 noexcept
还可以接受一个布尔参数,表示函数是否可能抛出异常。
void func() noexcept(true); // 不会抛出异常
void func() noexcept(false); // 可能抛出异常
三、 自定义异常体系
在实际项目中,尤其是大型项目中,异常管理是一个非常重要的部分。如果每个开发者都随意抛出异常,外层的调用者将难以处理这些异常,导致代码的可维护性和健壮性下降。因此,很多公司会定义一套自定义的异常体系,通过继承基类异常来统一管理异常类型。这样,所有异常都派生自同一个基类,调用者只需捕获基类异常即可处理所有可能的异常情况。
1. 自定义异常体系的设计
自定义异常体系通常包括一个基类异常和多个派生类异常。基类异常提供通用的异常信息(如错误消息、错误码等),派生类异常则用于表示具体的异常类型。
示例:服务器开发中的异常继承体系
#include <iostream>
#include <string>
using namespace std;
// 基类异常
class Exception {
protected:
string _errmsg; // 错误消息
int _id; // 错误码
// list<StackInfo> _traceStack; // 调用栈信息(可选)
public:
Exception(const string& errmsg, int id) : _errmsg(errmsg), _id(id) {}
virtual const string& GetErrorMessage() const {
return _errmsg;
}
virtual int GetErrorId() const {
return _id;
}
virtual ~Exception() {} // 虚析构函数,确保正确释放派生类对象
};
// 派生类异常:SQL 异常
class SqlException : public Exception {
public:
SqlException(const string& errmsg, int id) : Exception(errmsg, id) {}
};
// 派生类异常:缓存异常
class CacheException : public Exception {
public:
CacheException(const string& errmsg, int id) : Exception(errmsg, id) {}
};
// 派生类异常:HTTP 服务器异常
class HttpServerException : public Exception {
public:
HttpServerException(const string& errmsg, int id) : Exception(errmsg, id) {}
};
2. 异常的使用
在项目中,所有异常都派生自基类Exception
。调用者只需捕获基类异常即可处理所有可能的异常情况。
示例:抛出和捕获异常
void StartServer() {
// 模拟服务器启动过程中抛出异常
throw HttpServerException("Failed to start HTTP server", 500);
}
int main() {
try {
StartServer(); // 可能抛出派生类异常
} catch (const Exception& e) { // 捕获基类异常
cout << "Error: " << e.GetErrorMessage() << endl;
cout << "Error ID: " << e.GetErrorId() << endl;
} catch (...) { // 捕获未知异常
cout << "Unknown Exception" << endl;
}
return 0;
}
Error: Failed to start HTTP server
Error ID: 500
四、标准库异常体系
C++ 标准库提供了一套标准的异常类,定义在 <exception>
头文件中。这些异常类以父子类层次结构组织起来,形成了一个完整的异常体系。标准库异常体系的核心是基类 std::exception
,其他异常类都直接或间接继承自它。
1. 标准异常体系的结构
以下是 C++ 标准库异常体系的主要类层次结构:
std::exception
├── std::bad_alloc
├── std::bad_cast
├── std::bad_typeid
├── std::bad_exception
├── std::logic_error
│ ├── std::domain_error
│ ├── std::invalid_argument
│ ├── std::length_error
│ └── std::out_of_range
└── std::runtime_error
├── std::overflow_error
├── std::underflow_error
├── std::range_error
└── std::system_error
- 标准异常体系:C++ 标准库提供了一套完整的异常类层次结构,基类是
std::exception
。 - 常见异常类:
std::bad_alloc
:内存分配失败。std::logic_error
:程序逻辑错误。std::runtime_error
:运行时错误。
- 自定义异常:可以通过继承标准异常类来实现自定义异常,与标准异常体系兼容。
2. 基类:std::exception
std::exception
是所有标准异常类的基类。它提供了一个虚函数 what()
,用于返回异常的描述信息。
成员函数: virtual const char* what() const noexcept;
返回一个描述异常的 C 风格字符串。
#include <iostream>
#include <exception>
using namespace std;
int main() {
try {
throw exception();
} catch (const exception& e) {
cout << "Exception: " << e.what() << endl;
}
return 0;
}
Exception: std::exception
3. 常见标准异常类
(1) std::bad_alloc
- 用途:当动态内存分配失败时抛出(例如
new
操作失败)。 - 继承自:
std::exception
。
#include <iostream>
#include <new>
using namespace std;
int main() {
try {
int* ptr = new int[1000000000000]; // 尝试分配超大内存
} catch (const bad_alloc& e) {
cout << "Bad allocation: " << e.what() << endl;
}
return 0;
}
Bad allocation: std::bad_alloc
(2) std::logic_error
- 用途:表示程序逻辑错误,通常是由于程序员的错误导致的。
- 继承自:
std::exception
。 - 常见派生类:
std::domain_error
:数学函数参数超出定义域。std::invalid_argument
:函数接收到无效参数。std::length_error
:试图创建超出最大长度的对象。std::out_of_range
:访问超出范围的值(例如数组下标越界)。
#include <iostream>
#include <stdexcept>
using namespace std;
int main() {
try {
throw invalid_argument("Invalid argument!");
} catch (const logic_error& e) {
cout << "Logic error: " << e.what() << endl;
}
return 0;
}
Logic error: Invalid argument!
(3) std::runtime_error
- 用途:表示运行时错误,通常是由于外部因素导致的。
- 继承自:
std::exception
。 - 常见派生类:
std::overflow_error
:算术运算溢出。std::underflow_error
:算术运算下溢。std::range_error
:计算结果超出有效范围。std::system_error
:系统相关的错误。
#include <iostream>
#include <stdexcept>
using namespace std;
int main() {
try {
throw runtime_error("Runtime error!");
} catch (const runtime_error& e) {
cout << "Runtime error: " << e.what() << endl;
}
return 0;
}
Runtime error: Runtime error!
4. 自定义异常与标准异常的结合
在实际项目中,可以将自定义异常类继承自标准异常类(如 std::exception
或其派生类),以便与标准异常体系兼容。
#include <iostream>
#include <exception>
using namespace std;
// 自定义异常类,继承自 std::exception
class MyException : public exception {
public:
const char* what() const noexcept override {
return "My custom exception!";
}
};
int main() {
try {
throw MyException();
} catch (const exception& e) {
cout << "Caught exception: " << e.what() << endl;
}
return 0;
}
Caught exception: My custom exception!
五、异常的优缺点
5.1 C++ 异常的优点
-
清晰的错误信息
异常对象可以携带丰富的错误信息,包括错误类型、错误描述、调用栈信息等。相比传统的错误码方式,异常能够更清晰、更准确地展示错误信息,帮助开发者快速定位和修复问题。try { throw runtime_error("File not found!"); } catch (const exception& e) { cout << "Error: " << e.what() << endl; // 输出错误信息 }
Error: File not found!
-
简化错误处理逻辑
在传统的错误码方式中,深层的函数返回错误码时,调用链中的每一层都需要检查并传递错误码,直到最外层才能处理错误。这种方式会导致代码冗余且难以维护。
传统错误码方式:int ConnectSql() { if (/* 用户名密码错误 */) return 1; if (/* 权限不足 */) return 2; return 0; // 成功 } int ServerStart() { int ret = ConnectSql(); if (ret < 0) return ret; // 传递错误码 int fd = socket(); if (fd < 0) return errno; // 传递错误码 return 0; } int main() { if (ServerStart() < 0) { // 处理错误 } return 0; }
异常方式:
void ConnectSql() { if (/* 用户名密码错误 */) throw runtime_error("Invalid username or password"); if (/* 权限不足 */) throw runtime_error("Permission denied"); } void ServerStart() { ConnectSql(); // 无需检查返回值 int fd = socket(); if (fd < 0) throw runtime_error("Socket creation failed"); } int main() { try { ServerStart(); } catch (const exception& e) { cout << "Error: " << e.what() << endl; // 直接处理错误 } return 0; }
-
与第三方库兼容
许多第三方库(如 Boost、gtest、gmock)使用异常机制。如果项目中需要使用这些库,通常也需要使用异常。 -
便于单元测试
异常机制非常适合单元测试和白盒测试。测试框架可以通过捕获异常来验证代码的正确性和鲁棒性。 -
适用于特殊场景
某些场景下,异常是唯一的选择:-
构造函数:构造函数没有返回值,无法通过返回值表示错误。
-
运算符重载:例如
T& operator[]
,如果索引越界,只能通过异常或终止程序来处理。class Vector { public: int& operator[](size_t pos) { if (pos >= size) throw out_of_range("Index out of range"); return data[pos]; } private: int* data; size_t size; };
-
5.2 C++ 异常的缺点
-
执行流混乱
异常会导致程序的执行流在运行时突然跳转,这会给调试和代码分析带来困难。开发者需要仔细跟踪异常抛出的路径,以确保程序的正确性。 -
性能开销
异常机制会带来一定的性能开销,尤其是在异常抛出和捕获时。虽然现代硬件的性能已经足够强大,这种开销通常可以忽略不计,但在性能敏感的系统中仍需谨慎使用。 -
资源管理困难
C++ 没有垃圾回收机制,资源需要手动管理。异常可能导致资源泄漏(如内存泄漏、文件句柄未关闭)或死锁等问题。为了解决这个问题,必须使用 RAII(资源获取即初始化)技术来管理资源。void unsafeFunction() { int* ptr = new int[100]; throw runtime_error("Error occurred"); // 内存泄漏 delete[] ptr; // 不会执行 } void safeFunction() { unique_ptr<int[]> ptr = make_unique<int[]>(100); // RAII throw runtime_error("Error occurred"); // 内存自动释放 }
-
标准库异常体系不完善
C++ 标准库的异常体系设计得不够完善,导致许多项目都自定义自己的异常体系。这种混乱使得不同项目之间的异常处理方式不一致,增加了学习和使用的成本。 -
需要规范使用
异常机制如果使用不当,会导致代码难以维护。例如:- 随意抛出异常,导致外层调用者难以处理。
- 异常类型不统一,增加了捕获和处理的复杂性。