目录标题
- c语言传统处理错误的方式
- c++异常的使用
- 异常的规则
- 服务器常用的异常继承体系
- 异常安全
- 异常的规范
- 异常的优缺点:
c语言传统处理错误的方式
第一种:
程序在运行过程中终止了程序,比如说assert
int main()
{
int i = 0;
scanf("%d", &i);
assert(i != 2);
return 0;
}
如果assert括号里面的表达式结果为假,那么assert就会中断程序并报错,所以使用assert可以帮助我们在程序判断一些可能出错的地方,上面的代码我们输入一个2就可以看到程序因为assert报错中止了:
再比如说在代码的运行过程遇到了严重错误比如说:表达式中的除0错误,数组的越界访问错误和野指针带来的错误,都会导致程序在运行的过程中出现错误从而终止了程序,比如说下面的代码就出现了除0错误:
int main()
{
int i;
cin >> i;
int z = i / 0;
cout << z << endl;
return 0;
}
那么这就是c语言的第一种报错的形式在程序的运行过程中的访问。
第二种:
返回错误码,比如说我们上面的写的代码就返回了一个很奇怪的错误码,那错误码就会存在一个缺点:需要程序员自己去查找对应的错误那这是不是就很麻烦啊。如系统的很多库的接口函数都是通过把错误码放到errno中表示错误,那么这就是c语言的第二种报错方式通过错误码来查找错误。
c++异常的使用
c++处理错误需要三个关键字 try,throw,catch,try是一个块我们把可能会出现错误的代码放到这个块里面,throw就是抛出异常对象,当代码某个地方可能会出现问题时我们就使用throw来抛出一个异常对象,这个对象可以是任意的类型,在try块的外面我们就使用catch关键字来捕获throw抛出的异常对象,并对这些对象进行分析,那么只通过语言的描述想必大家还是不能知道抛出异常的过程,那么接下来我们通过一段代码来带着大家了解上述的过程,首先我们有个名为func函数,在函数里面我们要接收两个参数,并打印这两个参数相除的结果,那么这个时候我们就创建再创建一个函数来实现两个参数相除,并将相除的结果进行返回,那么这里的代码就如下:
double Division(int a, int b)
{
return ((double)a / (double)b);
}
void Func()
{
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;
}
但是这么写会存在一个问题,在数学里面除数是不允许为0的所以这里可能会出现异常,所以当我们发现异常之后就得使用throw来抛出异常,那这里抛出的异常对象我们就可以使用字符串,当我们发现除数为0的时候就使用throw来抛出一个字符串,字符串的内容为:Division by zero condition!
,那么这里的代码如下:
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;
}
函数写完之后就可以在main函数里面进行Func函数来运行我们写的函数,又因为这个函数里面可能会抛出异常所以我们把这个Func函数放到try块里面:
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();
}
return 0;
}
在try块里面抛出了异常就得在try块的外面使用catch来进行接收,因为抛出的是字符串类型的异常对象,所以在使用catch接收的时候就得在catch后面的括号中写入字符串类型的参数来进行接收,那么这里的代码如下:
int main()
{
try
{
Func();
}
catch (const char* errmsg)
{
cout << errmsg << endl;
}
catch (...)
{
cout << "unkown exception" << endl;
}
return 0;
}
在catch对应的语句块里面外面就可以打印对应的语句在屏幕上面来告诉使用者这里出现了问题且问题的原因是什么,那么这里我们的例子就完成了,将其运行以下就可以看到如果输入的数据除数不为0的话,这里的catch语句是捕获不到异常的,并且会打印两个数据计算的结果:
如果输入的除数为0的话这里就会catch语句里面的内容不会输出计算的结果:
那么这就是异常的使用,通过try catch throw来实现异常的发现和异常的捕获。
异常的规则
第一个:
当代码中抛出了异常的话,throw所在作用域后面的内容是不会执行的比如说下面的代码:
void func(int x)
{
if (x == 10)
{
throw(10);
}
cout << "如果抛出了异常我这里就不会打印" << endl;
}
int main()
{
try
{
int x;
cin >> x;
func(x);
}
catch (int x)
{
cout << "传递的值不能为10" << endl;
}
return 0;
}
如果我们输入一个10以外的整型数字,那么屏幕上是会打印一段话的:
如果我们输入10的话屏幕上就不会打印那段话而是告诉我们输入的值不能为10:
第二个:
当catch语句执行完之后还可以继续后面的内容,比如说下面的代码:
void func(int x)
{
if (x == 10)
{
throw(10);
}
cout << "如果抛出了异常我这里就不会打印" << endl;
}
int main()
{
try
{
int x;
cin >> x;
func(x);
}
catch (int x)
{
cout << "传递的值不能为10" << endl;
}
cout << "这里可以接着执行后面的内容" << endl;
return 0;
}
我们输入一个10可以看到这里进行catch语句里面,但是当里面的代码被执行完之后又会接着执行catch语句后面的内容:
第三个:
在捕获异常的时候可能会有多个catch语句来捕获不同的异常,比如说下面的代码:
void func(int x)
{
if (x == 10){throw(10);}
if (x == 9){throw("x is nine");}
cout << "如果抛出了异常我这里就不会打印" << endl;
}
int main()
{
try
{
int x;
cin >> x;
func(x);
}
catch (const char* errmsg){cout << errmsg << endl;}
catch (int errid){cout << errid << endl;}
return 0;
}
如果我们输入的值是10的话,这里的throw抛出的异常对象就是10,所以在catch捕捉的时候就是整型类型的捕捉:
如果我们输入的值是9的话,这里的throw抛出对象就是字符串,那么catch在捕捉的时候就是字符串char*类型的捕捉:
虽然catch语句可以存在多个,但是抛出的异常在被捕捉的时候只能被一个catch语句所捕捉,一旦一个catch语句捕捉成功了,那么其他的catch语句都不会被执行,所以异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
第四点:
如果抛出的异常没有一个类型所匹配的话,那么编译器会终止程序并报出警告,比如说上面的代码有时候会抛出一个字符串类型的异常对象,字符串的类型为const char *那我们把上面的代码进行以下修改就之前const char *的接收类型改成char*的话,还能够正常的接收吗?比如说下面的代码:
void func(int x)
{
if (x == 10){throw(10);}
if (x == 9){throw("x is nine");}
cout << "如果抛出了异常我这里就不会打印" << endl;
}
int main()
{
try
{
int x;
cin >> x;
func(x);
}
catch (char* errmsg){cout << errmsg << endl;}
catch (int errid){cout << errid << endl;}
return 0;
}
代码的运行结果如下:
可以看到这里报出了错误,并且程序的退出码为3
第五点
虽然在同一个作用域里面不能有相同有相同类型的catch语句,但是在不同的作用域里面可以存在相同的catch语句,比如说下面的代码:
double Division(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
throw "Division by zero condition!";
else
return ((double)a / (double)b);
}
void Func()
{
try
{
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;
}
catch (const char* errmsg){cout<<"Func->" << errmsg << endl;}
}
int main()
{
try {Func();}
catch (const char* errmsg){cout << "main->" << errmsg << endl;}
return 0;
}
上面这段代码我们在main函数里面使用catch语句捕捉了const char *类型的异常,在func函数也是用了catch来捕捉const char *类型的异常,那么这里就会存在一个问题:这里有两个catch来不做const char *类型的异常,那当我们抛出异常之后是哪个catch进行捕捉呢?那么我们就把这样的形式称为调用链,对于调用链在捕获异常的时候就是选择类型匹配且离抛出异常位置最近的那一个,类型匹配我们知道那如何理解位置最近呢?我们来分析一下上面的代码,首先main函数调用了func函数,在func函数里面又调用了Division函数,那么这里就可以画出下面这样的图片:
在Division函数里面我们抛出了异常,在func和main函数里面我们都有catch来接收异常,那这里的距离就可以根据函数调用之间的关系来判断,mian函数调用了func函数再调用了Division函数,而func函数是直接调用了Division函数,所以func函数的距离更近,所以当func函数和main函数的catch都于异常对象相匹配的话,那么这里匹配的catch就是更近的func中的catch,那么上面的代码运行的结果如下:
如果func函数中的catch于异常对象的类型不匹配的话,最后还是会匹配main函数中的catch,比如说将func中的catch类型改成int:
void Func()
{
try
{
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;
}
catch (int errmsg){cout<<"Func->" << errmsg << endl;}
}
那么上面的代码运行结果如下:
第六点:
异常的抛出可能会带来一系列的问题,比如说下面的代码:
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* p = new int[10];
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;
cout << "delete " << p << endl;
delete[] p;
}
int main()
{
try {Func();}
catch (const char* errmsg){cout << "main->" << errmsg << endl;}
return 0;
}
func函数里面没有捕获异常,但是在func函数里面我们使用new申请了一段空间,并且在调用Division函数之后使用delete将这段空间给释放掉了,这么写如果没有抛出异常的话是没有问题的,比如说下面的图片:
正常的打印出来了5,并且也释放掉了空间,但是一旦这里抛出了异常那么就会发生内存泄漏,比如说下面的图片:
原因就是Division函数里面发生了异常,throw抛出一个异常之后func函数里面没有catch语句来进行接收,所以就会直接来到main函数里面被main函数里面的catch所接收,但是这样的话也就意味着不会执行func函数剩下的代码,所以就发生了泄漏,在之前的学习中我们认为内存泄漏多半是因为编写代码的人忘记了使用delete来释放空间而导致的内存泄漏而现在大家应该能够发现异常的抛出也会导致内存泄漏,原因就是异常的抛出和接收导致程序跳过了一些重要的代码,那这里的解决方法就是在func函数里面接收该类型的异常,那么这种方式上面有我们就不在展示,那如果有人要求这里的异常必须得在main函数里面进行接收的话那有该如何来解决呢?答案是接收异常然后再抛出异常,在func函数里面接收抛出的异常,将内存的空间释放之后再把异常重新抛出,这样我们既保证异常能够在main函数里面被接收又能够保证申请的空间能够被正常的释放掉,那么这里的代码就如下:
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* p = new int[10];
int len, time;
cin >> len >> time;
try
{
cout << Division(len, time) << endl;
}
catch (int errmsg)
{
cout << "delete " << p << endl;
delete[] p;
throw errmsg;
}
}
int main()
{
try {Func();}
catch (const char* errmsg){cout << "main->" << errmsg << endl;}
catch (...) {cout << "unkown exception" << endl;}
return 0;
}
代码的运行结果如下:
第七点:
写代码的时候可能会遇到各种各样的异常,那么为了应对一些我们可能预测不到的异常c++提供了一个这样类型的捕捉:catch(...)
这个表示可以捕捉任意的类型的异常,比如说下面的代码:
void func(int x)
{
if (x == 9) { throw(10); }
else if (x == 10) { throw(1.1); }
else { throw("hello"); }
}
int main()
{
int x;
cin >> x;
try {func(x);}
catch (int x){cout << "出现了异常" << endl;}
catch (...){cout << "出现了未知异常" << endl;}
return 0;
}
当我们输入9的时候代码的运行结果如下:
当时当我们输入10的时候就会出现下面的情况:
输入其他的数字时就是下面这样的情况:
那么这就是catch(…)的作用。
第八点
抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回),我们可以通过下面的代码进行理解:
void func(int x)
{
if (x == 10)
{
string s("x的值不能等于10");
throw(s);
}
}
int main()
{
int x;
cin >> x;
try {func(x);}
catch (const string& s){cout << s << endl;}
return 0;
}
func里面创建了一个临时对象,并且throw抛出的刚好就是这个临时对象s,s的生命周期只存在于if语句块里面,出了if语句s就销毁了,所以严格来说main函数中的捕获捕得并不是if语句里面的s而是s的临时拷贝,这里的效率可能会有点低,但是大家不要担心,编译器会把这里的s识别称为将亡值,这样就会使用移动拷贝所以效率不会降低很多。
匹配规则的总结:
- 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则
调到catch的地方进行处理。 - 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。
- 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的
catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(…)捕获任意类型的异
常,否则当有异常没捕获,程序就会直接终止。 - 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。
服务器常用的异常继承体系
在以后写代码的时候会遇到小组合作的形式每个小组负责不同的模块比如说有个小组负责数据库模块,有个小组负责缓存模块,有个小组负责业务模块每个小组都会抛出异常,但是每个小组抛出的异常类型又十分的不同,如果一起在main函数里面进行捕捉的话就会非常的多并且非常的复杂,并且每个小组所抛出的异常都有着自己的模块的属性,所以使用一个类来解决这里的异常就不现实,那么为了解决这个问题异常里就有了一个这样的特性:实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配可以抛出的派生类对象使用基类捕获,这样每个小组都可以抛出派生类的异常,然后在main函数里面使用基类来统一捕获,那么在实际的项目里面我们就可以创建一个父类,父类里面有个int类型的错误码和一个string类型的对象来记录当前错误的描述信息,然后在这个类里面就可以创建一个虚拟函数用于返回内部的string对象以方便使用人员进行打印,那么这里的代码就如下:
class Exception
{
public:
Exception(const string& errmsg, int id)
:_errmsg(errmsg)
, _id(id)
{}
virtual string what() const
{
return _errmsg;
}
protected:
string _errmsg;
int _id;
};
那么不同的小组抛出的异常都会具有本小组的特点,那么这里我们就可以选择以继承上面类的方式再创建一个类,并且在子类的里面再添加一个成员变量用来记录当前模块的特殊特殊错误信息,并且该类所对应的what函数就可以通过重写来添加一个标志性的内容这样我们就可以更加容易地知道哪里出现了问题,那么这里的代码就如下:
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;
}
private:
const string _sql;
};
一个项目存在多个模块,上面是sql模块地重写,那么同样的道理还有缓存模块的重写,这个也是同样的道理继承上面的父类并完成what函数的重写:
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;
}
};
还有http模块也是同样的道理,因为http拥有自己的错误类型,所以我们添加一个变量用于记录该类型的错误,那么这里的代码就如下:
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;
}
private:
const string _type;
};
那么有了这些描述异常的父子类之后就可以用下面的代码来测试异常:
void SQLMgr()
{
srand(time(0));
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
//throw "xxxxxx";
cout << "调用成功" << endl;
}
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;
}
首先在main函数里面先调用httpserver函数,在这个函数里面我们就生成一个随机数,我们就把这个数字看成一种情况,如果这个数字能被3 和4整除那么这种情况就出错了,直接使用throw来抛出异常,如果没有出错的花就调用缓冲区的函数,这个函数里面也是相同道理,在这个函数之后就调用数据库的相关函数遇到一些情况就抛出异常,那么在main函数里面我们就可以统一使用父类类型的catch来统一捕获异常,并使用里面的what函数来打印出从的内容,那么上面的代码运行的结果如下:
可以看到我们这里就可以很清楚的发现究竟事哪个位置出现了异常,并且出现异常的原因是什么,并且我们上面的代码在处理异常的时候添加了三个点的捕获,这样就可以让我们的程序在面对有写小组在没有按规定抛出异常时也不会突然的终止程序,比如说下面的代码:
void HttpServer()
{
// ...
srand(time(0));
if (rand() % 3 == 0)
{
throw HttpServerException("请求资源不存在", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpServerException("权限不足", 101, "post");
}
else if(rand() % 5 == 0)
throw(10);
CacheMgr();
}
代码的运行结果如下:
异常安全
1.构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化
2.析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
3.C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题,关于RAII我们智能指针这节进行讲解。
异常的规范
- 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的后面接throw(类型),列出这个函数可能抛掷的所有异常类型。
- 函数的后面接throw(),表示函数不抛异常。
- 若无异常接口声明,则此函数可以抛掷任何类型的异常
比如说下面的代码:
//c++98
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的,那么为了c++11就提供了一个库来专门处理异常:
这个库里面包含了各种类型的异常:
bad_alloc是new抛出的异常,out_of_rang 就是抛的越界的异常比如说at函数等等,
这个库中存在各种各样的异常类,所以就提供了一个基类叫做exception,这个是库中所有异常类的基类,c++没有强制我们每个函数都得在后面添加throw,但是c++提供了noexcepect来代替throw,添加了noexcepect表示一定不会抛出异常,但是添加了noexcepect的花这个编译器会检查这个函数是否真的没有抛出异常。
异常的优缺点:
C++异常的优点:
- 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。
- 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误,具体看下面的详细解释。
// 1.下面这段伪代码我们可以看到ConnnectSql中出错了,先返回给ServerStart,
//ServerStart再返回给main函数,main函数再针对问题处理具体的错误。
// 2.如果是异常体系,不管是ConnnectSql还是ServerStart及调用函数出错,
//都不用检查,因为抛出的异常异常会直接跳到main函数中catch捕获的地方,main函数直接处理错误。
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;
}
- 很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们也需要使用异常。
- 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
C++异常的缺点:
5. 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难。
6. 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
7. C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题。学习成本较高。
8. C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。
9. 异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言。所以异常规范有两点:一、抛出异常类型都继承自一个基类。二、函数是否抛异常、抛什么异常,都使用 func() throw();的方式规范化。
总结:异常总体而言,利大于弊,所以工程中我们还是鼓励使用异常的。另外OO的语言基本都是用异常处理错误,这也可以看出这是大势所趋