✨✨ 欢迎大家来到贝蒂大讲堂✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:C++学习
贝蒂的主页:Betty’s blog
1. C语言处理错误的方式
一般而言,在程序方式异常时,C语言会采用以下两种错误处理的方式:
- 终止程序,如assert,缺陷:用户难以接受。如发生内存错误,除0错误时就会终止程序。
- 返回错误码,缺陷:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通
过把错误码放到errno
中,表示错误。
实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误。
一、使用终止程序的方式
当发生严重的内存分配错误时,可以使用终止程序的方式来处理。
#include <stdio.h>
#include <stdlib.h>
int main()
{
int *ptr = (int*)malloc(sizeof(int));
if (ptr == NULL)
{
printf("内存分配失败,程序终止。\n");
exit(EXIT_FAILURE);
}
// 使用 ptr 的逻辑
free(ptr);
return 0;
}
在这个例子中,如果malloc
函数无法分配足够的内存,程序将打印错误信息并使用exit
函数终止程序,这种情况适用于严重的错误,因为继续执行程序可能会导致不可预测的结果。
二、使用返回错误码的方式
假设我们有一个函数用于打开文件并读取内容。如果文件无法打开,函数将返回一个错误码表示错误情况。
#include <stdio.h>
#include <stdlib.h>
#define FILE_OPEN_ERROR -1
#define READ_SUCCESS 0
int readFileContent(const char *filename)
{
FILE *file = fopen(filename, "r");
if (file == NULL)
{
return FILE_OPEN_ERROR;
}
// 读取文件内容的逻辑
fclose(file);
return READ_SUCCESS;
}
int main()
{
int result = readFileContent("nonexistent.txt");
if (result == FILE_OPEN_ERROR)
{
printf("无法打开文件。\n");
} else
{
printf("文件打开成功并处理完毕。\n");
}
return 0;
}
在这个例子中,readFileContent
函数尝试打开一个文件,如果打开失败则返回错误码FILE_OPEN_ERROR
,在main
函数中,程序员需要检查这个错误码来确定是否发生了错误。
2. C++异常的概念
异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误。 而关于异常有三个关键字:throw
,catch
,try
。其作用分别为:
throw: 当问题出现时,程序会抛出一个异常。这是通过使用
throw
关键字来完成的。
catch: 在您想要处理问题的地方,通过异常处理程序捕获异常catch
关键字用于捕获异常,可以有多个catch
进行捕获。
try:try
块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个catch
块
如果有一个块抛出一个异常,捕获异常的方法会使用try
和catch
关键字。try
块中放置可能抛出异常的代码,try
块中的代码被称为保护代码。使用try/catch
语句的语法如下所示:
try
{
// 保护的标识代码
}catch( ExceptionName e1 )
{
// catch 块
}catch( ExceptionName e2 )
{
// catch 块
}catch( ExceptionName eN )
{
// catch 块
}
比如我们写一个简单的除法错误,我们为了防止除零错误就可以抛出异常:
double div()
{
double a, b;
cin >> a >> b;
if (b == 0)
{
throw "除零错误";//抛出异常
}
return a / b;
}
int main()
{
try
{
cout << div() << endl;
}
catch (const char* a)//捕获异常
{
cout << a << endl;
}
catch (...)//未知异常
{
cout << "unkonwn exception" << endl;
}
return 0;
}
3. 异常的用法
3.1 抛出与捕获的基本规则
一般而言,异常的抛出和捕获遵循以下原则:
- 异常是通过抛出对象引发的,抛出对象的类型决定激活哪个
catch
处理代码。若抛出的异常对象未被捕获或没有匹配类型的捕获,程序会终止报错。- 被选中的处理代码(
catch
块)是在调用链中与该对象类型匹配且离抛出异常位置最近的那个。- 抛出异常对象后,会生成一个异常对象的拷贝。因为抛出的异常对象可能是临时对象,所以这个拷贝的临时对象会在被
catch
以后销毁,类似于函数的传值返回。catch(...)
可以捕获任意类型的异常,但捕获后无法得知异常错误具体内容。
此外,实际中异常的抛出和捕获的匹配原则有一个例外情况:**可以抛出派生类对象,使用基类进行捕获,**这在实际中非常有用。
并且当异常被抛出后,还会遵循在函数调用链中异常栈展开的匹配原则::
- 首先检查
throw
本身是否在try
块内部。如果在,则查找匹配的catch
语句。若有匹配的,就跳到catch
的地方进行处理。- 若当前函数栈没有匹配的
catch
,则退出当前函数栈,继续在上一个调用函数栈中进行查找匹配的catch
。- 当找到匹配的
catch
子句并处理以后,会沿着catch
子句后面继续执行,而不会跳回到原来抛异常的地方。- 如果到达
main
函数的栈,依旧没有找到匹配的catch
,则终止程序。
例如,在下面的代码中,main
函数中调用了func3
,func3
中调用了func2
,func2
中调用了func1
,在func1
中抛出了一个string
类型的异常对象:
void func1()
{
throw string("这是一个异常");
cout << "hello betty!" << endl;
}
void func2()
{
func1();
cout << "hello betty!" << endl;
}
void func3()
{
func2();
cout << "hello betty!" << endl;
}
int main()
{
try
{
func3();
}
catch (const string& s)
{
cout << "错误描述:" << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
当func1
中的异常被抛出后:
- 首先会检查
throw
本身是否在try
块内部。这里由于throw
不在try
块内部,所以会退出func1
所在的函数栈。- 接着继续在上一个调用函数栈中进行查找,即
func2
所在的函数栈。由于func2
中也没有匹配的catch
,因此会继续查找。- 继续在上一个调用函数栈中进行查找,也就是
func3
所在的函数栈。func3
中同样没有匹配的catch
。- 于是就会在
main
所在的函数栈中进行查找,最终在main
函数栈中找到了匹配的catch
。- 这时就会跳到
main
函数中对应的catch
块中执行对应的代码块,执行完后继续执行该代码块后续的代码。所以也并不会打印出hello betty
。
调用如下图:
3.2 异常的重新抛出
有可能单个的catch
不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,catch
则可以通过重新抛出将异常传递给更上层的函数进行处理。
首先我们来看看这段代码:
void func1()
{
//抛出异常
throw string("这是一个异常");
}
void func2()
{
int* arr = new int[10];
func1();
//...
cout << "delete[] arr" << endl;
delete[] arr;//释放内存
}
int main()
{
try
{
func2();
}
catch (const string& s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
因为fun1
被抛异常,而func2
中并没有对应的捕获, 只在主函数main
中被捕获,这就会导致一个问题:func2
动态申请了内存,但fun2
中后续语句没有被执行,就会造成内存泄漏的问题。
解决方法非常简单,就是在fun2
先捕获,释放内存,再重新抛出。
void func1()
{
//抛出异常
throw string("这是一个异常");
}
void func2()
{
int* arr = new int[10];
try
{
func1();
}
catch (...)
{
cout << "delete[] arr" << endl;
delete[] arr;//释放内存
throw;//重新抛出
}
//...
cout << "delete[] arr" << endl;
delete[] arr;//释放内存
}
int main()
{
try
{
func2();
}
catch (const string& s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
3.3 异常安全
为了保证异常并不会引起其他安全问题,我们一般要遵循以下几个建议:
- 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化。
- 析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
- C++中异常经常会导致资源泄漏的问题,比如在
new
和delete
中抛出了异常,导致内存泄漏,在lock
和unlock
之间抛出了异常导致死锁,一般C++经常使用RAII
来解决以上问题。
3.4 异常规范
为了方便函数使用者知晓函数的抛出异常的类型,以及个数,C++标准规定:可以在函数的后面接throw(类型)
,列出这个函数可能抛掷的所有异常类型。
- 函数的后面接
throw()
或noexcept
(C++11),表示函数不抛异常。- 若无异常接口声明,则此函数可以抛掷任何类型的异常。
// 这里表示这个函数会抛出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++11 中新增的noexcept,表示不会抛异常
thread() noexcept;
thread (thread&& x) noexcept;
4. 自定义异常体系
实际使用中很多公司都会自定义自己的异常体系进行规范的异常管理,因为一个项目中如果大家随意抛异常,那么外层的调用者就需要捕获所有类型不同的异常,所以实际中都会定义一套继承的规范体系。这样大家抛出的都是继承的派生类对象,捕获一个基类就可以了。
以下就是服务器开发中通常使用的异常继承体系:
// 服务器开发中通常使用的异常继承体系
class Exception
{
public:
Exception(const string& errmsg, int id)
:_errmsg(errmsg)
, _id(id)
{}
virtual string what() const
{
return _errmsg;
}
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
{
string str = "SqlException:";
str += _errmsg;
str += "->";
str += _sql;
return str;
}
protected:
const string _sql;
};
class CacheException : public Exception
{
public:
CacheException(const string& errmsg, int id)
:Exception(errmsg, id)
{}
virtual string what() const
{
string str = "CacheException:";
str += _errmsg;
return str;
}
};
class HttpServerException : public Exception
{
public:
HttpServerException(const string& errmsg, int id, const string& type)
:Exception(errmsg, id)
, _type(type)
{}
virtual string what() const
{
string str = "HttpServerException:";
str += _type;
str += ":";
str += _errmsg;
return str;
}
protected:
const string _type;
};
void SQLMgr()
{
srand(time(0));
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
//throw "xxxxxx";
}
void CacheMgr()
{
srand(time(0));
if (rand() % 5 == 0)
{
throw CacheException("权限不足", 100);
}
else if (rand() % 6 == 0)
{
throw CacheException("数据不存在", 101);
}
SQLMgr();
}
void HttpServer()
{
// ...
srand(time(0));
if (rand() % 3 == 0)
{
throw HttpServerException("请求资源不存在", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpServerException("权限不足", 101, "post");
}
CacheMgr();
}
int main()
{
while (1)
{
this_thread::sleep_for(chrono::seconds(1));
try {
HttpServer();
}
catch (const Exception& e) // 这里捕获父类对象就可以
{
// 多态
cout << e.what() << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
}
return 0;
}
一般而言基类与派生类的成员变量要设为protected
,因为如果是private
,继承不可见。并且将what
函数定义为虚函数,方便重写形成多态。
5. C++标准库的异常体系
C++ 提供了一系列标准的异常,定义在 中,我们可以在程序中使用这些标准的异常。它们是以父子类层次结构组织起来的,如下所示:
以下是对上述异常的解释:
异常 | 描述 |
---|---|
std::exception | 该异常是所有标准 C++异常的父类。 |
std::bad_alloc | 该异常可以通过 new 抛出。 |
std::bad_cast | 该异常可以通过 dynamic_cast 抛出。 |
std::bad_exception | 这在处理 C++程序中无法预期的异常时非常有用。 |
std::bad_typeid | 该异常可以通过 typeid 抛出。 |
std::logic_error | 理论上可以通过读取代码来检测到的异常。 |
std::domain_error | 当使用了一个无效的数学域时,会抛出该异常。 |
std::invalid_argument | 当使用了无效的参数时,会抛出该异常。 |
std::length_error | 当创建了太长的 std::string 时,会抛出该异常。 |
std::out_of_range | 该异常可以通过方法抛出,例如 std::vector 和 std::bitset<>::operator。 |
std::runtime_error | 理论上不可以通过读取代码来检测到的异常。 |
std::overflow_error | 当发生数学上溢时,会抛出该异常。 |
std::range_error | 当尝试存储超出范围的值时,会抛出该异常。 |
std::underflow_error | 当发生数学下溢时,会抛出该异常。 |
注意:实际中我们可以去继承exception
类实现自己的异常类。但是实际中很多公司像上面一样自己定义一套异常继承体系。因为C++标准库设计也有缺陷。
#include<vector>
int main()
{
try
{
vector<int> v(10, 5);
// 这里如果系统内存不够也会抛异常
v.reserve(1000000000);
// 这里越界会抛异常
v.at(10) = 100;
}
catch (const exception& e) // 这里捕获父类对象就可以
{
cout << e.what() << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
return 0;
}
6. 异常的优缺点
优点:
- 错误信息清晰准确:异常对象定义好后,可以清晰准确地展示出错误的各种信息,甚至可以包含堆栈调用等信息,有助于更好地定位程序的
bug
。- 避免层层返回错误码:返回错误码的传统方式在函数调用链中,深层的函数返回错误后,需要层层返回错误码,最终最外层才能拿到错误。而异常可以直接从错误发生的地方抛出,被合适的处理代码捕获。
- 与第三方库和测试框架配合好:很多第三方库(如 boost、gtest、gmock 等)和测试框架都使用异常,使用异常能更好地发挥这些库的作用,也能更好地进行白盒测试。
- 部分函数更适合用异常处理:比如
T& operator
这样的函数,如果出现问题(如 pos 越界),只能使用异常或者终止程序处理,没办法通过返回值表示错误。
缺点:
- 执行流混乱:异常会导致程序的执行流乱跳,并且非常混乱,这会使得跟踪调试以及分析程序时比较困难。
- 有性能开销:异常会有一些性能的开销,不过在现代硬件速度很快的情况下,这个影响基本可以忽略不计。
- 易导致资源管理问题:C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄露、死锁等异常安全问题,需要使用
RAII
来处理资源的管理问题,学习成本比较高。- 标准库异常体系不完善:C++标准库的异常体系定义得不够好,导致大家各自定义自己的异常体系,非常混乱。
- 异常使用不规范问题:异常尽量规范使用,否则后果不堪设想,随意抛异常会让外层捕获的用户苦不堪言。
- 接口声明不强制:异常接口声明不是强制的,对于没有声明异常类型的函数,无法预知该函数是否会抛出异常。
总体而言,虽然异常有一些缺点,但利大于弊,所以在工程中我们还是鼓励使用异常的,并且面向对象的语言基本都使用异常处理错误,这也可以看出这是大势所趋。