C++中的异常是用于处理程序执行过程中出现的错误情况。通过异常处理,程序可以在遇到错误时优雅地处理这些问题,而不是直接崩溃。
C语言处理错误的方式
C语言传统的处理错误的方式主要有两种:
- 终止程序:使用如
assert
这样的宏来检查错误条件,并在条件不满足时直接终止程序。这种方式非常直接但粗暴,不考虑程序的恢复或错误处理,用户难以接受。 - 返回错误码:函数通过返回值来指示是否成功执行,并将错误码(通常是整数)存储在全局变量(如
errno
)中或通过其他方式返回。这种方式需要程序员手动检查错误码,并查找对应的错误原因,增加了代码的复杂性和出错的可能性。
示例1:
#include <assert.h>int main()
{int a = 3;assert(a < 5); // 条件成立,不终止程序assert(a > 5); // 条件不成立,终止程序return 0;
}
示例2:
#include <stdio.h>
#include <errno.h>
#include <string.h>int readFile(char *filename, char *buffer, size_t bufferSize) {FILE *file = fopen(filename, "r");if (file == NULL) {return -1; // 文件打开失败}if (fgets(buffer, bufferSize, file) == NULL) {fclose(file);return -2; // 读取失败}fclose(file);return 0; // 成功
}int main() {char buffer[100];if (readFile("example.txt", buffer, sizeof(buffer)) != 0) {printf("Error reading file.\n");return 1;}printf("%s\n", buffer);return 0;
}
实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误。
C++异常概念
C++中的异常是一种处理程序运行期间发生的意外或错误情况的机制。当函数遇到无法处理的错误时,可以抛出一个异常对象,让函数的直接或间接调用者捕获并处理这个异常。异常提供了一种结构化的方式来处理程序中的错误情况,提高了程序的健壮性和可维护性。
异常的用法
C++中异常的使用主要涉及三个关键字:try
、catch
和throw
。
- throw:用于抛出异常。当程序遇到错误情况时,可以使用
throw
关键字后跟一个异常对象(可以是任何类型的对象,但通常是派生自std::exception
或其子类的对象)来抛出异常。 - try:
try
块用于包裹可能抛出异常的代码。当try
块中的代码抛出异常时,控制权会转移到紧随其后的catch
块(如果有的话)。 - catch:
catch
块用于捕获并处理异常。可以有多个catch
块来捕获不同类型的异常,或者使用一个catch(...)
块来捕获所有类型的异常。
double Division(int a, int b)
{if (b == 0)throw "Division by zero condition!"; // 抛出异常return (double)a / (double)b;
}int main()
{try {Division(1, 0); // 会抛出异常Division(2, 1); // 不会抛出异常}catch(const char* errmsg) // 捕获异常{std::cout << errmsg << std::endl;}// 如果没有异常,则向下执行。如果有异常,被捕获处理后,也向下执行// ... std::cout << "end" << std::endl;return 0;
}
异常的抛出和捕获
异常的抛出和匹配原则
- 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个
catch
的处理代码。 - 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
- 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这拷贝的临时对象会在被
catch
以后被销毁。(这里的处理类似于函数的传值返回:catch
接收throw
抛出的对象 作为参数。这个过程可能会触发移动语义(¬‿¬))。 catch(...)
可以捕获任意类型的异常对象。(使用这一捕获方式,我们就不知道被捕获对象的具体类型)。- 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配:可以抛出派生类对象,使用基类捕获(这里的应用后面详细讲解)。
- 激活与
throw
对象匹配的catch
处理代码
#include <iostream>double Division(int a, int b)
{if (b == 0)throw "Division by zero condition!"; // 抛出异常return (double)a / (double)b;
}int main()
{try {Division(1, 0); // 会抛出异常}catch (int errnum){std::cout << "int errnum = " << errnum << std::endl;}catch(const char* errmsg) // 捕获异常{std::cout << "const char* errmsg = " << errmsg << std::endl;}catch (...){std::cout << "..." << std::endl;}std::cout << "end" << std::endl;return 0;
}
运行结果:
const char* errmsg = Division by zero condition!
end
- 匹配且离抛出异常位置最近的
catch
处理代码
在函数调用链中异常栈展开匹配原则
- 首先,需要确认抛出异常的代码逻辑是否位于
try
块内部。只有在try
块内部抛出的异常才会被查找匹配的catch
子句。如果不在try
块内部,则不会进行任何异常处理,异常会直接沿调用栈向上抛出。 - 如果当前函数栈中有匹配的,则会跳转执行匹配的
catch代码块
;如果当前函数栈中没有匹配的,就继续在调用函数的栈中进行查找匹配的catch
。 - 如果到达
main()
函数的栈中,还没有匹配的catch
,则终止程序。上述这个沿着调用链查找匹配的catch子句
的过程称为栈展开。在实际应用中,main()
最后都要加上catch(...)
捕获任意类型的异常,否则当有异常没有被捕获,程序就会直接终止(例如,服务器是一直在运行中的,如果遇到一个异常就要停掉服务器,代价比较大)。 - 当找到匹配的
catch子句
并处理以后,会执行catch子句
后面的代码。
示例:
#include <iostream>double Division(int a, int b)
{if (b == 0)throw "Division by zero condition!"; // 抛出异常return (double)a / (double)b;
}void Func()
{try{Division(1, 0);}catch (char ch){std::cout << "char ch" << std::endl;}
}int main()
{try {Func();}catch (const char* errmsg){std::cout << errmsg << std::endl;}catch (...) {std::cout << "unkown exception" << std::endl;}std::cout << "end" << std::endl;return 0;
}
分析:
- 首先,
Division()
函数中,throw
抛出异常的代码逻辑没有位于try
块内部,则不会进行任何异常处理,异常会直接沿调用栈向上抛出,也就是Func()
函数栈中。 Func()
函数栈中没有匹配的catch
(char
并不匹配const char*
类型),就继续在调用函数main()
的栈中进行查找匹配的catch
。- 到达
main()
函数的栈中,有匹配的catch
(类型为const char*
),则捕获异常并处理异常。 - 当找到匹配的
catch子句
并处理以后,会执行catch子句
后面的代码。
有了上面的知识,匹配原则的第二点应该就能理解了(匹配且离抛出异常位置最近的catch
处理代码)。
抛出派生类对象,使用基类捕获
抛出派生类对象并使用基类捕获是一种常见的异常处理模式,它利用了C++中的继承关系和多态性。通过这种方式,可以在不同的层次上处理不同类型的异常,同时保持代码的灵活性和扩展性。
抛出派生类对象,使用基类捕获的使用场景
如果你抛出了一个派生类的对象,并使用基类捕获异常,那么这个异常是可以被捕获的。这是因为派生类的对象可以被视为基类的对象,即派生类对象可以隐式转换为基类对象。
使用场景
-
统一异常处理:
- 当你不知道具体抛出哪种类型的异常时,可以使用基类来捕获所有可能的派生类异常。
- 例如,在一个模块中,你可能需要处理多种不同类型的运行时错误,可以使用
std::runtime_error
作为基类来捕获所有派生类异常。
-
分层处理:
- 在多层调用中,可以在不同的层次上捕获和处理不同类型的异常。
- 下层函数可以抛出具体的异常类型,而上层函数可以使用基类来捕获这些异常,并进行统一处理。
-
异常类型扩展:
- 当需要添加新的异常类型时,可以继承已有的基类,而不需要修改现有的捕获逻辑。
- 这样可以保持代码的可扩展性,未来添加的新异常类型可以无缝集成到现有系统中。
示例代码
假设我们有以下异常类的定义:
#include <iostream>
#include <exception>class BaseException : public std::exception {
public:virtual const char* what() const noexcept = 0;
};class DerivedException1 : public BaseException {
public:const char* what() const noexcept override {return "Derived Exception 1";}
};class DerivedException2 : public BaseException {
public:const char* what() const noexcept override {return "Derived Exception 2";}
};
抛出派生类对象
void functionThatThrows() {throw DerivedException1(); // 抛出派生类异常
}void anotherFunctionThatThrows() {throw DerivedException2(); // 抛出另一个派生类异常
}
使用基类捕获
int main() {try {functionThatThrows();} catch (const BaseException& e) {std::cerr << "Caught base exception: " << e.what() << std::endl;}try {anotherFunctionThatThrows();} catch (const BaseException& e) {std::cerr << "Caught base exception: " << e.what() << std::endl;}return 0;
}
代码分析
-
定义异常类:
BaseException
是一个抽象基类,定义了一个纯虚函数what()
。DerivedException1
和DerivedException2
是BaseException
的派生类,实现了what()
函数。
-
抛出异常:
functionThatThrows
和anotherFunctionThatThrows
分别抛出DerivedException1
和DerivedException2
。
-
捕获异常:
- 在
main
函数中,使用BaseException
类型的catch
子句来捕获所有派生类异常。
- 在
注意事项
-
类型信息丢失:
- 使用基类捕获可能会丢失具体的派生类类型信息。如果需要区分具体的派生类异常,可以在捕获基类异常后进一步判断具体的派生类类型。
-
多态性:
- 利用多态性,可以在捕获基类异常后调用虚函数来获取具体的信息。
异常的重新抛出
有可能单个的 catch
不能完全处理一个异常,在进行一些校正处理以后(没有处理完全),希望再交给更外层的调用链函数来处理,catch
则可以通过重新抛出将异常传递给更上层的函数进行处理。
#include <iostream>
double Division(int a, int b)
{if (b == 0)throw "Division by zero condition!";return (double)a / (double)b;
}void Func()
{int* arr = new int[10];try {std::cout << Division(1, 0) << std::endl;}catch (...) {std::cout << "delete[] " << arr << std::endl;delete[] arr;// 这里并没处理Division()实际抛出的异常throw;}// ...std::cout << "delete[]" << arr << std::endl;delete[] arr;
}int main()
{try {Func();}catch(const char* errmsg) {// 这里处理Division()抛出的异常std::cout << errmsg << std::endl;}return 0;
}
补充:try(...); throw;
两者配合使用时,try
捕获什么类型的异常,throw
便会抛出什么类型的异常。
异常安全
- 构造函数的主要任务是初始化对象。如果在构造函数中抛出异常,而对象的部分成员已经初始化,这可能会导致对象处于不一致的状态。当这种对象被销毁时,其析构函数可能会尝试访问那些未完全初始化的成员,从而引发更多的问题。
- 析构函数主要完成资源的清理。最好不要在析构函数内抛出异常,否则可能导致资源泄露(内存泄漏、句柄未关闭等)。
- C++中异常经常会导致资源泄露的问题,比如:在
new
和delete
中抛出了异常,导致内存泄露;在lock
和unlock
之间抛出了异常,导致死锁。C++经常使用RAII来解决以上问题(关于RAII,智能指针章节会进行讲解~)。
示例一:构造函数:
double Division(int a, int b)
{if (b == 0)throw "Division by zero condition!"; // 抛出异常return (double)a / (double)b;
}class AA
{
public:AA(){std::cout << "AA()" << std::endl;_ptr1 = new int;Division(1, 0);_ptr2 = new int;}~AA(){std::cout << "~AA()" << std::endl;delete _ptr1;delete _ptr2;}
private:int* _ptr1;int* _ptr2;
};int main()
{try{AA a;}catch(...){std::cout << "处理异常" << std::endl;}return 0;
}
运行结果:
AA()
处理异常
代码分析
-
构造函数中的异常抛出:
AA
类的构造函数中调用了Division(1, 0)
,该函数会在b == 0
的情况下抛出异常。- 当异常抛出时,构造函数不会继续执行,因此
_ptr2
不会被初始化(实际上某些编译器会将其初始化为nullptr
)。
-
异常处理:
- 异常被抛出后,程序会跳转到最近的
catch
块进行处理。 - 因为异常是在构造函数中抛出的,构造函数没有完成,所以对象
a
并没有完全构造完毕。
- 异常被抛出后,程序会跳转到最近的
-
析构函数的调用:
- 在构造函数中抛出异常的情况下,对象
a
并没有完全构造完成,因此它的析构函数不会被调用。 - 在 C++ 中,如果一个对象的构造函数抛出异常,该对象就不会被视为已经构造完成,析构函数也不会被调用。
- 在构造函数中抛出异常的情况下,对象
总结
-
构造函数中的异常抛出:
- 如果在构造函数中抛出异常,对象不会被视为完全构造完成。
- 因此,析构函数不会被调用。
-
对象状态:
- 对象
a
在构造函数抛出异常后不会被完全构造完成,因此它在catch
块中不存在完全构造的状态。 - 析构函数不会被调用,导致
_ptr1
没有被释放,从而产生内存泄漏。
- 对象
示例二:析构函数:
double Division(int a, int b)
{if (b == 0)throw "Division by zero condition!"; // 抛出异常return (double)a / (double)b;
}class AA
{
public:AA(){_ptr1 = new int;_ptr2 = new int;std::cout << "AA()" << std::endl;}~AA(){delete _ptr1;Division(1, 0);delete _ptr2;std::cout << "~AA()" << std::endl;}
private:int* _ptr1;int* _ptr2;
};int main()
{try{AA a;}catch (...){std::cout << "处理异常" << std::endl;}return 0;
}
代码分析:
-
析构函数中的异常抛出:
- 析构函数中调用了
Division(1, 0)
,如果b == 0
,则抛出异常。 - 一旦析构函数抛出异常,程序会终止,并且不会执行析构函数中剩余的代码。
- 析构函数中调用了
-
资源释放:
- 析构函数中的异常导致
_ptr2
没有被删除,从而导致内存泄漏。 - 这是不推荐的做法,因为析构函数应该始终正确地释放资源,而不会抛出异常。
- 析构函数中的异常导致
示例三:及时析构
错误示例:
#include <iostream>double Division(int a, int b)
{if (b == 0)throw "Division by zero condition!";return (double)a / (double)b;
}
void Func()
{int* arr = new int[10];int len, time;std::cin >> len >> time;std::cout << Division(len, time) << std::endl;// ...std::cout << "delete[]" << arr << std::endl;delete[] arr;
}int main()
{try {Func();}catch(const char* errmsg) {std::cout << errmsg << std::endl;}return 0;
}
此时如果Division()
函数执行时抛出异常,就会导致 arr
申请的资源没有被释放,造成内存泄漏。
正确示例:
void Func()
{int* arr = new int[10];try {int len, time;std::cin >> len >> time;std::cout << Division(len, time) << std::endl;}catch (...) {std::cout << "delete[] " << arr << std::endl;delete[] arr;throw;}// ...std::cout << "delete[]" << arr << std::endl;delete[] arr;
}
示例四:手动处理异常的缺点
如果遇到以下场景,处理异常就会比较棘手:
void Func()
{int* arr1 = new int[10];int* arr2 = new int[20];int* arr3 = new int[30];// ...delete[] arr1;delete[] arr2;delete[] arr3;
}
这里 arr1 arr2 arr3
在使用 new
申请空间时可能会抛出异常(当空间不足时),这样就有四种情况:
arr1
抛出异常,不需要释放空间。arr2
抛出异常,需要释放arr1
申请的空间。arr3
抛出异常,需要释放arr1
和arr2
申请的空间。- 没有异常抛出,需要释放
arr1
、arr2
和arr3
申请的空间。
那我们在处理异常时,就需要像下面类似写法,才能保证异常安全:
void Func()
{int* arr1 = new int[10];int* arr2;int* arr3;try{arr2 = new int[20];try{arr3 = new int[30];}catch(...){delete[] arr1;delete[] arr2;throw;}}catch(...){delete[] arr1;throw;}// 没有异常delete[] arr1;delete[] arr2;delete[] arr3;
}
这样处理起来会比较麻烦,其实通过 智能指针
可以很好地解决这个问题。
在没有设计出 智能指针
时,为了解决这个问题,就提出了 异常规范 的概念。
异常规范
C++98
- 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常类型。这可以通过在函数后面接
throw(类型列表)
来实现,列出函数可能抛出的所有异常类型。 - 如果一个函数声明为
throw()
,表示该函数不会抛出任何异常。 - 如果一个函数没有声明异常规范,则此函数可以抛掷任何类型的异常。
// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A,B,C,D);
// 这里表示这个函数只会抛出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中提供了对异常抛出类型的声明,但它存在一些问题:
-
开发人员误用:
- 如果开发人员声明了一个函数不会抛出异常(
throw()
),但实际上该函数却抛出了异常,编译器只会发出警告而不是错误。
- 如果开发人员声明了一个函数不会抛出异常(
-
内部函数抛出异常:
- 即使函数本身没有抛出异常,但如果它调用了其他可能抛出异常的函数,那么整个函数仍然可能抛出异常。
-
非强制性:
- 异常规范只是一个建议,编译器不会强制执行。
但是这个规范在实际应用中,还存在很多问题:
- 异常规范只是一个建议,编译器不会强制执行。
开发人员认为该函数不会抛异常
throw()
,但实际上却抛出了异常
double Division(int a, int b) throw()
{if (b == 0)throw "Division by zero condition!";return (double)a / (double)b;
}int main()
{try {Division(1, 0);}catch(const char* errmsg) {std::cout << errmsg << std::endl;}return 0;
}
此时编译器不会报错,而只是警告(有些程序员会不处理警告>﹏<),例如:
warning C4297: “Division”: 假定函数不引发异常,但确实发生了
函数本体没有出现异常,但内部函数抛出了异常。
double Division(int a, int b) throw(const char*)
{if (b == 0)throw "Division by zero condition!";return (double)a / (double)b;
}void func() throw()
{// ...Division(1, 0);
}int main()
{try {func();}catch(const char* errmsg) {std::cout << errmsg << std::endl;}return 0;
}
此时编译器不会报错,而只是警告( ̄へ ̄),例如:
warning C4290: 忽略 C++ 异常规范,但指示函数不是 __declspec(nothrow)
并且这个异常规范只是一个建议,并不是强制的,所以这个异常规范并不能很好地解决异常安全的问题。
C++11
C++11 引入了 noexcept
关键字来替代旧的异常规范,并且提供了更好的语义和性能优化。
即, 对于可能会抛异常的函数,什么都不要写。确定不抛异常的函数,要加一个 noexcept
。
1. noexcept
的使用
noexcept
关键字用于声明一个函数不会抛出异常。如果一个函数被声明为 noexcept
,那么编译器可以对其进行优化,例如减少异常处理的开销。
2. 语法
// 不会抛出异常
void thread() noexcept;
void thread(thread&& x) noexcept;
如果一个函数没有显式声明 noexcept
,则默认行为是它可以抛出任何类型的异常。
3. 与 throw()
的区别
noexcept
更加明确地表达了函数不会抛出异常的意图。noexcept
可以用在表达式中,例如noexcept(expr)
,用于检测一个表达式是否会抛出异常。noexcept
提供了更好的语义,并且编译器可以利用这一信息进行优化。
4. 使用 noexcept
表达式
#include <iostream>
#include <stdexcept>bool isSafe(int a, int b) noexcept {return b != 0;
}double safeDivision(int a, int b) noexcept(isSafe(a, b)) {return static_cast<double>(a) / static_cast<double>(b);
}int main() {try {std::cout << "Result: " << safeDivision(1, 0) << std::endl;} catch (const std::exception& e) {std::cerr << "Caught exception: " << e.what() << std::endl;}return 0;
}
总结
-
C++98 中的异常规范:
- 使用
throw(类型列表)
来声明函数可能抛出的异常类型。 - 使用
throw()
表示函数不会抛出异常。 - 但存在开发人员误用和内部函数抛出异常的问题。
- 使用
-
C++11 中的
noexcept
:- 更加明确地表达了函数不会抛出异常的意图。
- 可以用在表达式中,例如
noexcept(expr)
。 - 提供了更好的语义,并且编译器可以利用这一信息进行优化。
通过使用 noexcept
,可以更好地表达函数的异常抛出特性,并且允许编译器进行更有效的优化。这使得代码更易于理解和维护。
标准库异常体系
C++标准库提供了一系列标准的异常类,这些类定义在<exception>
头文件中,并以父子类层次结构组织起来。主要的异常类包括:
- std::exception:所有标准异常类的基类,提供了
what()
方法用于返回异常的描述信息。 - std::bad_alloc:当内存分配失败时抛出的异常。
- std::bad_cast:在使用
dynamic_cast
进行向下转换时,如果转换失败则抛出此异常。 - std::bad_typeid:在使用
typeid
运算符时,如果操作数是一个多态类型对象的指针或引用,但该对象不是当前处理的多态类型或其派生类型之一,则抛出此异常。 - std::bad_function_call:当尝试调用一个空的
std::function
对象时抛出的异常。 - std::invalid_argument:当传递给函数的参数无效时抛出的异常。
- std::out_of_range:当尝试访问超出有效范围的元素时抛出的异常(如
std::vector
的越界访问)。
异常的优缺点
优点
- 清晰的错误信息:异常对象可以包含丰富的错误信息,相比错误码的方式可以清晰准确的展示出错误的各种信息,有助于快速定位问题。
- 便于错误传播:异常可以自动向上层传播,直到找到相应的处理代码,减少了错误处理的代码量。
- 结构化错误处理:通过
try
、catch
和throw
关键字,可以结构化地处理错误情况,提高了代码的可读性和可维护性。 - 第三方库的支持:很多第三方库(如 Boost、Google Test、Google Mock 等)都内置了异常处理机制,如果你使用这些库,那么自然也需要使用异常来处理错误情况。
- 部分函数使用异常会更好处理,比如构造函数没有返回,不方便使用错误码方式处理。比如
T& operator
这样的函数,如果pos
越界了只能使用异常或者终止程序处理(使用错误码不方便表示),没办法通过返回值表示错误。
返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,我么需要层层返回错误,最外层才能拿到错误信息,如:
int ConnnectSql()
{// 用户名密码错误if (...)return 1;// 权限不足if (...)return 2;
}
int ServerStart() {if (int ret = ConnnectSql() < 0)return ret;int fd = socket()if(fd < 0)return errno;
}
int main()
{if (ServerStart() < 0)...return 0;
}
代码分析:
- 这段伪代码我们可以看到
ConnnectSql
中出错了,先返回给ServerStart
,ServerStart
再返回给main
函数,main
函数再针对问题处理具体的错误。 - 如果是异常体系,不管是
ConnnectSql
还是ServerStart
及调用函数出错,都不用检查,因为抛出的异常异常会直接跳到main
函数中catch
捕获的地方,main
函数直接处理错误。
缺点
- 执行流混乱:异常可能导致程序的执行流变得难以预测,增加了调试的难度。当异常被抛出时,程序会立即跳转到最近的
catch
块,这打破了正常的控制流。这种“乱跳”使得程序的逻辑变得难以追踪,尤其是在复杂的多层嵌套调用中。 - 性能开销:异常处理机制本身需要一定的性能开销,特别是在频繁抛出和捕获异常的情况下。
- 资源管理问题:在异常发生时,如果资源(如内存、文件句柄等)没有得到正确释放,可能会导致资源泄漏等问题。因此,在使用异常时需要注意资源的管理和释放。
- 标准库异常体系定义不完善:C++标准库的异常体系定义并不完善,导致开发者往往需要自己定义异常体系,这容易造成混乱。通常,团队内部或项目中采用统一的异常体系,减少混乱。
- 异常规范:不规范的异常使用会导致代码难以维护和理解。为了保证代码质量,需要遵循一定的异常规范。
总结
尽管C++中的异常处理机制存在一些潜在的缺点,但其提供的清晰的错误信息、便于错误传播、结构化错误处理以及对特殊函数的支持,使得异常处理在许多场景下都是一个非常有用的工具。通过合理使用异常处理,并结合RAII等技术,可以编写出既健壮又易维护的代码。
今天的分享就到这里了,如果,你感觉这篇博客对你有帮助的话,就点个赞吧!感谢感谢……