文章目录
- 异常概念及使用
- 自定义类型的异常
- C++ 标准库的异常体系
- 异常的重新抛出
- 异常安全
- 异常规范
- 异常的优缺点
异常概念及使用
C语言常见的错误处理机制如下:
-
返回值约定
通过定义一些列的返回值以及其对应的错误信息表述,通过不同的返回值来查看当前函数是否与调用成功;
通常情况下开发者需要检查返回值来判断是否发生错误;
int main() { FILE* fp = fopen("log.txt", "r"); if (fp == NULL) { // 返回值约定 如果返回空指针则表示打开文件错误 cout << "fopen fail" << endl; } return 0; } /* 输出结果为: $ ./test fopen fail */
-
errno
变量C语言提供了一个全局变量
errno
,用于存储最近一次函数调用发生的错误代码;开发者可检查
errno
的值来获取更多的错误信息;int main() { FILE* fp = fopen("log.txt", "r"); if (fp == NULL) { cout << strerror(errno) << endl; // 使用strerror打印errno对应的错误信息 } return 0; } /* 输出结果为: $ ./test No such file or directory */
-
perror()
函数这个函数可根据
errno
的值打印出一条错误信息以定位对应的错误;int main() { FILE* fp = fopen("log.txt", "r"); if (fp == NULL) { perror("fopen fail\n"); // 使用perror打印最近的错误信息 } return 0; } /* 输出结果为: $ ./test fopen fail : No such file or directory */
-
assert()
assert()
将直接终止程序,如内存错误,除零错误时;
异常是C++引入的一个错误处理机制,使用try-catch
块来捕获和处理异常;
当函数抛出异常时控制流会转移到相应的catch
块中处理;
通常使用throw
抛异常,并使用try-catch
来处理异常;
-
throw
问题出现时,程序将使用
throw
抛一个异常;可抛出任意类型的异常,无论是内置类型还是自定义类型;
-
catch
该关键字用于捕获异常,可以有多个
catch
进行捕获; -
try
try
块中的代码标识将被激活特定异常,try
块后可跟一个或是多个catch
;
double Div(double num1, double num2) {
double ret = 0;
if (num2 == 0) {
throw "Division by zero error"; // throw 抛异常
} else {
ret = num1 / num2;
}
cout << "the process executed sucess" << endl; // 捕获异常时不执行
return ret;
}
int main() {
try { // 捕获异常
cout << Div(10, 0) << endl;
cout << "No exception was caught" << endl; // 捕获异常时不执行
} catch (const char* erostr) { // 处理异常
cout << erostr << endl;
}
}
throw
可抛任意类型的异常,其中当异常被抛出后,出现异常的后续代码将不会执行,程序将逐层释放栈帧并直接跳转至catch
异常处理部分;
这个例子运行结果为:
$ ./test
Division by zero error
程序的运行是以一个执行链式的存在,即在内存中不停的压栈与出栈;
通常情况下捕获异常时程序将跳转到最近的最匹配的catch
处对异常进行处理,此处的匹配指的是当一个异常被抛出时必须被一个catch
以相同类型的方式进行接收,类似于函数的传参;
当异常处理结束后程序将继续向后执行(已释放栈帧的函数内容后续不再执行);
double Div(double num1, double num2) {
double ret = 0;
if (num2 == 0) {
throw "Division by zero error"; // throw 抛异常
} else {
ret = num1 / num2;
}
cout << "the process executed sucess" << endl; // 捕获异常时不执行
return ret;
}
void func() {
try { // func 捕获异常
cout << Div(10, 0) << endl;
cout << "No exception was caught" << endl; // 捕获异常时不执行
} catch (const char* erostr) { // func 处理异常
cout << "func : " << erostr << endl;
}
}
int main() {
try { // main 捕获异常
func();
} catch (const char* erostr) { // main处理异常
cout << "main : " << erostr << endl;
}
}
在这个例子中func()
函数与main()
函数依次尝试捕获异常,当异常被抛出后执行流将跳转至最近且最匹配的栈帧中的catch
进行异常处理;
这个例子的执行结果为:
$ ./test
func : Division by zero error # 异常处理在func中进行
# 同时抛出的异常已经在 func 中被处理
# main 函数中的 catch 在这次中不会再捕获到异常
当某个函数抛出一个异常时必须被捕获,若是一个异常被抛出没有被捕获,或者是没有以约定形式捕获(抛出了一个int
类型的异常,但唯一的catch
为double
类型),程序将直接终止;
double Div(double num1, double num2) {
double ret = 0;
if (num2 == 0) {
throw "Division by zero error"; // throw 抛异常
} else {
ret = num1 / num2;
}
cout << "the process executed sucess" << endl; // 捕获异常时不执行
return ret;
}
int main() {
try {
cout << Div(10, 0) << endl;
cout << "No exception was caught" << endl;
} catch (int erostr) { // 未按约定捕获异常
cout << "main : " << erostr << endl;
}
}
/*
运行结果为:
./test
terminate called after throwing an instance of 'char const*'
Aborted
程序异常终止;
*/
自定义类型的异常
异常的抛出通常不使用单纯的内置类型,而是抛出一个自定义类型的异常;
通常这个自定义类型的异常为一个基类异常的派生类对象;
当抛异常抛出一个基类的派生类时无论是怎样的派生类,最终都可以被该基类的指针或是引用进行接收;
同时可在基类中以virtual
关键字修饰一个虚函数,使派生类对该虚函数进行重写;
当基类对象的指针或是引用接收到一个派生类对象时可直接以多态的形式去调用其对应类型的虚函数从而使开发者明白具体在项目中的哪个模块抛出了异常出现了错误;
#include <iostream>
#include <string>
using namespace std;
// 基类:abnormalBase
class abnormalBase {
public:
// 构造函数,初始化错误ID和错误信息
abnormalBase(int id, const string& err) : erid_(id), errstr_(err) {}
// 虚函数,用于输出错误信息
virtual void what() {
printf("the error id : %d\nwhat: %s\n", erid_, errstr_.c_str());
}
// 虚析构函数,确保派生类的析构函数被调用
virtual ~abnormalBase() {}
protected:
int erid_; // 错误ID
string errstr_; // 错误信息
};
// 派生类:abnormalDeriver
class abnormalDeriver : public abnormalBase {
public:
// 构造函数,调用基类构造函数
abnormalDeriver(int id, const string& er) : abnormalBase(id, er) {}
// 重写基类的what()函数,输出派生类特定的错误信息
void what() override {
printf("Derived class error id: %d\nwhat: %s\n", erid_, errstr_.c_str());
}
};
// 除法函数
double Div(double num1, double num2) {
double ret = 0;
// 检查除数是否为零
if (num2 == 0) {
// 创建异常对象并抛出
abnormalDeriver* erptr = new abnormalDeriver(1, "Division by zero error");
throw erptr; // 抛出异常指针
} else {
// 进行除法运算
ret = num1 / num2;
}
cout << "the process executed successfully" << endl; // 捕获异常时不执行
return ret; // 返回结果
}
// 函数func,调用Div函数
void func() {
Div(10, 0); // 这里会抛出异常
}
// 主函数
int main() {
try {
func(); // 调用func,可能会抛出异常
} catch (abnormalBase* er) { // 捕获异常
er->what(); // 调用异常对象的what()方法输出错误信息
delete er; // 释放异常对象的内存
}
return 0; // 程序结束
}
在这个例子中定义了一个名为abnormalBase
的基类,并写了一个名为what
的虚函数;
其中将析构函数设定为虚函数是确保其派生类在析构时能够在析构派生类部分后再析构基类部分从而达到完全清理的功能;
该基类派生了一个abnormalDeriver
的类,该类重写了基类的虚函数,使得能够在调用该函数时达到多态效果;
同样以除零错误为例,当出现除零错误时将用new
实例化出一个异常对象,并抛出异常对象的指针;
对应的执行流将跳转到最近且最匹配的catch
部分并对异常进行处理,在main
函数中以基类指针接收派生类对象指针,同时其基类虚函数被派生类重写,构成多态条件,该处可直接调用what
函数查看异常的错误信息;
不同的项目对于异常的处理都可能不同,可能在有些项目中需要提示异常的错误信息,而有些项目中需要遇到异常时对异常操作重新进行若干次的重试(可能是该操作的资源未就绪所导致的异常)等等;
常见的异常处理策略为如下:
-
错误提示
在某些项目中异常信息需要及时反馈给用户使其能够知道具体错误发生的部分从而定位错误信息;
通常通过日志记录或用户界面提示实现;
-
重试机制
对于一些操作(网络请求或文件访问),可能因为资源未就绪而导致异常;
此时可设计重试机制,则在捕获到异常后自动尝试该操作若干次,若是仍不成功则再进行提示用户信息等操作;
-
资源清理
确保在捕获异常时,正确释放占用的资源以避免内存泄漏或资源锁定;
-
不同策略结合
在复杂项目中可结合多种策略,如先进性重试机制,当重试至若干次数时则进行资源清理并错误提示等;
-
自定义异常类型
可通过项目需求定义多个自定义异常类型以便细致地处理不同种类的错误;
C++ 标准库的异常体系
C++标准库提供了一套异常处理机制,允许程序在运行时捕获和处理错误;
C++标准库定义了一个基本的异常类std::exception
,所有的标准异常类都继承自这个类,其中该类提供了一个what()
虚函数,用于返回异常的描述信息;
同时C++标准库提供了一系列的标准异常类,常见的包括:
-
std::runtime_error
表示运行时错误;
-
std::logic_error
表示逻辑错误,如违反了程序的逻辑;
-
std::out_of_range
表示访问超出范围的错误;
-
std::invalid_argument
表示传递给函数的参数无效;
-
std::bad_alloc
表示内存分配失败;
这些异常类可根据不同的错误类型进行捕获和处理;
#include <iostream>
#include <stdexcept> // 包含标准异常类
using namespace std;
// 除法函数
double Div(double num1, double num2) {
if (num2 == 0) {
throw runtime_error("Division by zero error"); // 抛出标准异常
}
cout << "The process executed successfully" << endl; // 捕获异常时不执行
return num1 / num2;
}
int main() {
try {
cout << Div(10, 0) << endl; // 尝试除以零
cout << "No exception was caught" << endl;
} catch (const runtime_error& e) {
cout << "main: " << e.what() << endl; // 捕获并处理异常
}
return 0; // 程序结束
}
在这个例子中调用Div
函数,如果num2
为零则抛出一个std::runtime_error
异常;
这个例子的运行结果为:
$ ./test
main: Division by zero error
C++标准库的异常体系通常较为复杂与混乱,不建议使用;
异常的重新抛出
异常的重新抛出指的是当一个函数抛出异常时首先让执行流跳跃至一个最近的且最匹配的catch
处,在该catch
中处理一些特定的操作后再将该异常重新抛出至较浅层的catch
处对异常进行处理;
double Div(double num1, double num2) {
if (num2 == 0) {
throw runtime_error("Division by zero error");
}
cout << "The process executed successfully" << endl;
return num1 / num2;
}
void func() {
try {
cout << Div(10, 0) << endl;
cout << "No exception was caught" << endl;
} catch (const runtime_error& e) { // 第一次捕获异常
cout << "func get a abnormal " << endl;
throw e; // 将异常重新抛出
}
}
int main() {
try {
func();
} catch (const runtime_error& e) { // 最终捕获异常并对异常进行处理
cout << "main handler a abonormal: " << e.what() << endl;
}
return 0;
}
在这个例子中调用了Div
函数并在func
函数中捕获了一场进行了一些特定处理,而后将异常重新抛出;
允许更高层次的代码(main
函数)处理该异常;
这种机制允许在不同的层次上对异常进行处理,使得程序能够保持灵活性和可维护性;
异常安全
函数的调用可以类比于一个调用链,不停的在内存当中压栈以及释放;
当一个函数抛出一个异常时其执行流将直接跳转至最近且最匹配的catch
处,同时逐层释放对应的栈帧;
此时该函数的栈结构以及对应的栈中的变量资源将被释放,但若是在该函数中在堆中开辟了空间时可能会导致执行流的跳转而未对该堆空间进行清理则会导致内存泄漏的问题,这种情况是异常安全的较为常见的异常安全问题;
class ObjectTest {
public:
ObjectTest() { cout << " ObjectTest()" << endl; }
~ObjectTest() { cout << " ~ObjectTest()" << endl; }
};
double Div(double num1, double num2) {
if (num2 == 0) {
throw runtime_error("Division by zero error");
}
cout << "The process executed successfully" << endl;
return num1 / num2;
}
void func() {
ObjectTest *t1 = new ObjectTest; // 在堆上实例化一个对象
ObjectTest *t2 = new ObjectTest; // 在堆上实例化另一个对象
cout << Div(10, 0) << endl; // 这里会抛出异常
// 当抛出一个异常时,执行流将直接跳跃至最近且最匹配的 catch 处
// Div 函数若是抛出了异常则会直接跳跃至 main 函数中的 catch 处
// 下面的 delete 清理语句则不会执行
delete t1; // 不会执行
delete t2; // 不会执行
}
int main() {
try {
func(); // 调用 func,可能抛出异常
} catch (const runtime_error &e) {
cout << "main handler a abnormal: " << e.what() << endl;
}
return 0;
}
在这个例子中在堆中实例化了两个对象,在调用可能抛出异常的函数后对资源进行清理;
当Div
函数抛出异常后执行流将直接跳跃至main
函数中的catch
处,对应的func
函数中的delete
资源清理将不会被执行;
这个例子的运行结果为:
$ ./test
ObjectTest()
ObjectTest()
# 只调用了构造函数 未调用析构函数 表示资源未正确清理
在这种情况下可使用异常的重新抛出对资源进行清理;
class ObjectTest {
public:
ObjectTest() { cout << " ObjectTest()" << endl; }
~ObjectTest() { cout << " ~ObjectTest()" << endl; }
};
double Div(double num1, double num2) {
if (num2 == 0) {
throw runtime_error("Division by zero error");
}
cout << "The process executed successfully" << endl;
return num1 / num2;
}
void func() {
ObjectTest *t1 = new ObjectTest;
ObjectTest *t2 = new ObjectTest;
try {
cout << Div(10, 0) << endl;
} catch (const runtime_error &e) {
delete t1;
delete t2;
cout << "the t1 and d2 deleted sucessful,and throw abnormal again" << endl;
throw e; // 对堆空间上的资源进行清理而后将异常重新抛出
}
}
int main() {
try {
func();
} catch (const runtime_error &e) {
cout << "main handler a abonormal: " << e.what() << endl;
}
return 0;
}
在这个例子中func
函数在堆上实例化两个对象后再调用可能出现异常的函数时对对应异常进行捕获;
当异常被抛出时将先被func
函数的catch
捕获,此处并不作异常的处理,而是对无法自行清理的动态分配资源进行清理,而后将异常重新抛出;
最终异常会被main
函数中的catch
捕获并对异常进行处理;
这个例子的运行结果为:
$ ./test
ObjectTest()
ObjectTest()
~ObjectTest()
~ObjectTest()
the t1 and d2 deleted sucessful,and throw abnormal again
main handler a abonormal: Division by zero error
# 析构函数被调用表示资源被正确清理
除了在new
与delete
之间抛出的异常导致内存泄漏所引发的异常安全以外,异常安全还有:
-
在构造函数中抛异常
通常不建议在构造函数中抛异常,构造函数主要完成对象的构造和初始化,若是在构造函数中抛异常可能导致对象不完整或是没有完全初始化;
-
在析构函数中抛异常
通常不建议在析构函数中抛异常,析构函数主要完成对象的资源清理工作,若是在析构函数中抛异常可能导致对象资源清理不完全导致内存泄漏;
-
加锁解锁中抛异常
加锁解锁的异常情况与
new
,delete
的情况相似;在加锁与解锁中抛异常可能会导致死锁问题;
同时这种情况都可以以
RAII
的方式处理该问题;
异常规范
异常规范是C++所引入的一个特性,用于声明一个函数可能抛出的异常类型,其基本语法为:
return_type function_name(parameters) throw(exception_type1, exception_type2, ...);
异常规范用于指定一个函数可以抛出的类型,可在函数声明中使用throw
关键字后跟一个或多个异常类型;
若是保证这个函数将不会抛出任何异常可直接以return_type function_name(parameters) throw()
的方式进行声明;
double Div(double num1, double num2)throw(runtime_error,int) {
if (num2 == 0) {
throw runtime_error("Division by zero error");
}
cout << "The process executed successfully" << endl;
return num1 / num2;
}
int main() {
try {
Div(10,0);
} catch (const runtime_error &e) {
cout << "main handler a abonormal: " << e.what() << endl;
}
return 0;
}
在这个例子中对Div
函数声明了可能会抛出runtime_error
类型与int
类型的异常,但实际上并不会抛出int
类型的异常,这是因为编译器无法确认该类型的异常是否一定会被抛出;
但通常throw
关键字是一个强制性的约定,当一种类型的异常未被throw
声明但仍抛出了这个异常,那么即使在调用链中存在对应异常类型的catch
时,该异常也不会被这个catch
给捕获,而是会被程序以调用std::unexpected()
的形式接收;
double Div(double num1, double num2)throw() {
if (num2 == 0) {
throw runtime_error("Division by zero error");
}
cout << "The process executed successfully" << endl;
return num1 / num2;
}
int main() {
try {
Div(10,0);
} catch (const runtime_error &e) {
cout << "main handler a abonormal: " << e.what() << endl;
}
return 0;
}
在这个例子中Div
函数声明了throw()
关键字表示不会抛出任何异常,但实际上会抛出一个runtime_error
类型的异常;
在main
函数中存在对应类型的catch
捕获异常;
这个例子的运行结果为:
$ ./test
terminate called after throwing an instance of 'std::runtime_error'
what(): Division by zero error
Aborted
原因是当一个函数声明throw()
但实际抛出异常时,程序不会进入任何catch
块,这是因为编译器会假设这个函数不会抛出异常从而导致未定义行为;
这种情况下程序会被系统处理异常,通常表现为程序崩溃或终止;
C++11标准引入了noexcept
关键字用来声明一个函数不会抛出任何异常,对应的该异常规范也逐渐开始被弃用,属于是一种过时的特性,;
-
noexcept
关键字在C++11标准中,引入了更为简洁和安全的机制来处理异常;
该关键字通常声明一个函数不会抛出任何异常;
void func() noexcept; // 表示该函数不会抛出异常
该关键字的优势主要表现在于:
-
性能
使用
noexcept
使编译器可以进行更多的优化,因为该关键字可以假设此函数不会抛出任何异常; -
明确性
noexcept
的语义更加清晰,表示函数不会抛出异常而不需要列出所有可能的异常类型;
-
为了使得所有异常都被捕获可使用try{}catch(...){}
来确保捕获了所有异常;
其中catch(...)
则会捕获任何异常;
catch(...)
可以捕获任何类型的异常并阻止他们向上传播;
double Div(double num1, double num2) {
if (num2 == 0) {
throw runtime_error("Division by zero error");
}
cout << "The process executed successfully" << endl;
return num1 / num2;
}
void func() {
try {
Div(10, 0);
} catch (...) { // 捕获所有异常并阻止异常向上传播
cout << "未知异常" << endl;
}
}
int main() {
try {
func();
} catch (const runtime_error &e) {
cout << "main handler a abonormal: " << e.what() << endl;
}
return 0;
}
在这个例子中Div
函数将抛出一个异常,其中func
函数将试图调用这个函数并以catch(...)
的方式捕获该函数的异常,main
函数将试图调用func
函数,并catch(const runtime_error &e)
捕获Div
函数将会抛出的异常;
这个例子的运行结果为:
$ ./test
未知异常
本质原因是异常被catch(...)
所捕获并且阻止该异常向上传播导致main
函数中的catch
未正确捕获到异常;
通常catch(...)
捕获应置于调用链的最上层(main
函数),以确保所有的异常都被捕获;
异常的优缺点
-
异常的优点
-
清晰的错误处理逻辑
异常处理使错误与正常逻辑分离,使得代码更易于阅读与维护;
-
自动传播
异常被抛出时将自动向上冒泡到调用栈中的上层函数,知道找到合适的
catch
块;这个机制避免了在每个函数中都需要检查错误并返回错误代码的繁琐;
-
错误处理灵活
使用异常处理可以针对不同类型的异常执行不同的处理逻辑;
可通过定义多个
catch
块来处理不同类型的异常从而实现更加灵活和细致的错误处理; -
资源管理
异常发生时可使用
RAII
(资源获取即初始化)模式来确保资源(内存,文件句柄等)的正确释放;即使发生异常,局部对象的析构函数仍会被调用从而避免资源泄露;
-
简化的错误传递
异常提供了一种简单的方式来传递错误信息,而不必通过返回值或全局变量;
使得错误信息可以更加丰富与详细;
-
-
缺点
-
性能开销
异常处理可能导致一定的性能开销,尤其是在异常频繁发生的情况下;
虽然在正常情况下不会影响性能,但异常被抛出时堆栈展开和其他相关操作可能会变得十分昂贵;
-
复杂性
异常处理机制可能增加代码的复杂性,在多线程环境中异常的传播和处理可能会变得更加复杂;
此外,过度使用异常可能导致代码难以理解和调试(执行流跳跃);
-
难以追踪的异常
异常的传播机制可能导致错误发生的地点与最终捕获异常的地点调用链相隔很远,使得定位和修复问题变得更加困难;
-