异常是面向对象语法处理错误的一种方式

那么在C语言时期,没有异常是怎么处理错误的呢?

  1. 返回错误码。有些API接口在发生错误时会直接将错误码放在error中,或直接return指定错误码
  2. 终止程序。例如发生越界等严重问题时,可以主动调用exit(错误码)或assert断言程序

但这些传统的处理错误的方式有许多缺陷

例如,拿到错误码后,还需要查找错误码表,才知道是什么错误

再比如下面这种场景

T& operator[](int index)
{
	//如果index超出容器的范围,如何返回?
}

看上面这个例子,如果容器里只有4个元素,但现在我们用"[]"重载调用了[4],此时难道还要返回错误码吗?那怎么能断定这个错误码不是"[]"重载的正常返回结果呢?这种情况如果不用异常,就只能是assert断言exit终止程序

并且,如果函数栈调用的很深,一层层返回错误码,处理很难受

int fun1()
{
	//此时出错,就需要先将错误码返回到fun2,再由fun2返回到fun3
}

int fun2()
{
	int ret = fun1();
}

int fun3()
{
	int ret = fun2();
}

而面对上述这些问题,C++的异常就能解决

什么是异常? 

这里我们拿vector来举例,当我们要遍历一个容器时,有可能会出现下面这种限定条件写错的情况(应该是i < v.size() 但这里写成了 i <= v.size()),从而导致越界 

vector<int> v = { 1,2,3,4 };
for (int i = 0; i <= v.size(); i++)
{
	cout << v[i] << " ";
}

当我们尝试运行,就会报断言错误 (assert()函数)

但除了[]外,还有一个at接口,它和[]不同的点在于at接口出错误会抛出异常,而[]接口出错误只会assert断言

可以看到,当我们换成at接口时,运行会抛异常

这是因为我们没有捕获异常,想要捕获异常,需要用到trycatch关键字

异常的使用

捕获异常

  • catch: 在您想要处理问题的地方,通过异常处理程序捕获异常.catch 关键字用于捕获异常,可以有多个catch进行捕获。
  • try: try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块。

如果有一个块抛出一个异常,捕获异常的方法会使用 try 和 catch 关键字。try 块中放置可能抛出异常的代码,try 块中的代码被称为保护代码。catch用来捕获异常,块中是捕获异常后执行的操作,catch也需要一个参数,这样抛出异常后会寻找与该异常参数相匹配的catch

try//将可能会抛异常的代码放在try块中
{
	vector<int> v = { 1,2,3,4 };
	for (int i = 0; i <= v.size(); i++)
	{
		cout << v.at(i) << " ";
	}
}
catch (exception& e)//try下面需要跟一个或多个catch块,用来捕获抛出的异常并处理
{
	cout << e.what() << endl;//e中的what接口可以让我们知道报的什么错
}

如果是异常的初学者,那么可能不能完全看懂这段代码,例如exception是什么?e.what()又是哪来的,这个在后面会一一揭晓

输出:

1 2 3 4 invalid vector subscript

invalid vector subscript的意思就是无效的vector下标

抛出异常

现在现在我们已经对异常的捕获有初步认识了,那再来认识一下异常的抛出

  • throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
int Div(int a, int b)
{
	return a / b;
}

int main() 
{
	cout << Div(3, 0) << endl;
	return 0;
}

来看上面这段代码,当我调用Div(3,0)时,由于分母为0,就会引发分子除0错误

 这里因为没有抛出异常,就会引发报错 

 因此我们可以用throw关键字来抛出异常,它可以抛出任意类型一般来说都是抛出字符串,而字符串内就是该错误的信息

int Div(int a, int b)
{
	if (b == 0)
		throw string("分子除0错误");
	return a / b;
}

之后再捕获异常

int Div(int a, int b)
{
	if (b == 0)
		throw "分子除0错误";
	return a / b;
}

int main() 
{
	try
	{
		cout << Div(3, 0) << endl;
	}
	catch (string& e)
	{
		cout << "异常:" << e << endl;
	}
	return 0;
}

但运行时会发现还是报错

 这是因为没有匹配参数的catch,所以依然不会捕获异常(我们抛出异常时的类型是char*,而唯一的一个catch参数是string)

因此我们可以再加一个char*参数的catch

int Div(int a, int b)
{
	if (b == 0)
		throw "分子除0错误";
	return a / b;
}

int main() 
{
	try
	{
		cout << Div(3, 0) << endl;
	}
	catch (string& e)
	{
		cout << "异常:" << e << endl;
	}
	catch (char*& e)
	{
		cout << "异常:" << e << endl;
	}
	return 0;
}

输出:

异常:分子除0错误

或者将throw抛出的异常类型改为string

int Div(int a, int b)
{
	if (b == 0)
		throw string("分子除0错误");
	return a / b;
}

int main() 
{
	try
	{
		cout << Div(3, 0) << endl;
	}
	catch (string& e)
	{
		cout << "异常:" << e << endl;
	}
	return 0;
}

异常的抛出和匹配原则

  1. 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
  2. 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
  3. 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回)
  4. catch(...)可以捕获任意类型的异常,问题是不知道异常错误是什么。
  5. 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获,这个在实际中非常实用,我们后面会详细讲解这个。

若外层和内层都有符合参数的catch,那么就会先进内层的,即截胡

int Div(int a, int b)
{
	try
	{
		if (b == 0)
			throw string("分子除0错误");
		return a / b;
	}
	catch (string& e)
	{
		cout << 1 << "异常:" << e << endl;
	}
}

int main() 
{
	try
	{
		Div(3, 0);
	}
	catch (string& e)
	{
		cout << 2 << "异常:" << e << endl;
	}
	return 0;
}

对于这个程序来说,抛出异常的地方在Div栈帧内,因此它会先从Div栈帧内找有没有符合的catch,如果没有,再去外面一层栈帧,即main中找

因此对于这个程序来说,抛出的异常会先被Div栈帧内的catch捕捉

输出:

1异常:分子除0错误

在实际应用中,往往都需要加一个catch(...)的捕获,这样可以防止因为类型不匹配而导致没捕获异常,导致程序终止

int Div(int a, int b)
{
	if (b == 0)
		throw string("分子除0错误");
	return a / b;
}

int main() 
{
	try
	{
		Div(3, 0);
	}
	catch (string& e)
	{
		cout << "异常:" << e << endl;
	}
	catch (...)
	{
		cout << "未知异常" << endl;
	}
	return 0;
}

在函数调用链中异常栈展开匹配原则

  1. 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则调到catch的地方进行处理。
  2. 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。
  3. 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止。
  4. 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。

由于当找到合适的catch语句执行完后,会接着catch后面走,因此如果throw下面还有语句,就会被跳过

int Div(int a, int b)
{
	if (b == 0)
		throw string("分子除0错误");
	cout << "执行Div" << endl;
	return a / b;
}

int main() 
{
	try
	{
		Div(3, 0);
	}
	catch (string& e)
	{
		cout << "异常:" << e << endl;
	}
	catch (...)
	{
		cout << "未知异常" << endl;
	}
	return 0;
}

此时"执行Div"这句话如果抛异常就输出不了

异常安全

上面我们说过,找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。我们来看下面代码

double Division(int a, int b)
{
	// 当b == 0时抛出异常
	if (b == 0)
	{
		throw string("除0错误");
	}
	return (double)a / (double)b;
}
void Func()
{
	int* array = new int[10];
	int len, time;
	cin >> len >> time;
	cout << Division(len, time) << endl;//这一行可能会抛出异常

	cout << "delete []" << array << endl;
	delete[] array;
}
int main()
{
	try
	{
		Func();
	}
	catch (string& errmsg)
	{
		cout << errmsg << endl;
	}
	return 0;
}

当Division函数抛出异常后,就会直接跳到main函数中的catch执行语句,而Func()函数最后的释放空间操作就会被跳过,从而导致内存泄漏,这种因为异常而出现的安全问题,就被称为异常安全

对于上面这种情况,就需要异常重新抛出 

异常的重新抛出

如果在抛出异常的下面还涉及内存的释放,就可以在该栈帧内先捕获异常,在该catch块中释放空间,然后再重新抛出异常交给外面的catch处理

double Division(int a, int b)
{
	// 当b == 0时抛出异常
	if (b == 0)
	{
		throw "Division by zero condition!";
	}
	return (double)a / (double)b;
}
void Func()
{
	// 这里可以看到如果发生除0错误抛出异常,另外下面的array没有得到释放。
	// 所以这里捕获异常后并不处理异常,异常还是交给外面处理,这里捕获了再
	// 重新抛出去。
	int* array = new int[10];
	try {
		int len, time;
		cin >> len >> time;
		cout << Division(len, time) << endl;
	}
	catch (...)//这里选用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;
	}
	return 0;
}

ps:异常的重新抛出用catch(...)是为了防止因为异常类型不匹配而导致捕获失败,否则这样还是会内存泄漏;想要重新抛出,只需要写  throw;  即可

这样就可以防止内存泄漏

除了上述这种情况,还有其他几种情况的异常安全问题:

  • 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化
  • 析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
  • C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题,关于RAII可与看智能指针这篇文章。

 自定义异常体系

 在上述的捕获异常时,我们都用的与抛出异常时类型相同的参数。例如throw string,那么catch(string& e),但是在实际开发中,每个组都是分开的,不可能抛出的每个异常都是相同类型,也不可能将每个类型都写一个catch

上面我们也说过,抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出派生类对象,使用基类捕获

实际使用中很多公司都会自定义自己的异常体系进行规范的异常管理,因为一个项目中如果大家随意抛异常,那么外层的调用者基本就没办法玩了,所以实际中都会定义一套继承的规范体系。
这样大家抛出的都是继承的派生类对象,捕获时候捕获一个基类就可以了

公司会给一个基类,要求你可以自己抛自己定义的异常,但是必须继承这个基类,这样的话,外层捕获就只需要捕获基类

class Exception//用于catch接收的基类
{
public:
	Exception(const int& errid,const char*& errmsg)//构造函数,用来为派生类初始化信息
		:_errid(errid)
		,_errmsg(errmsg)
	{}
	virtual string what() = 0;//该函数可以返回对应派生类的_errmsg信息
protected:
	int _errid;//错误码
	string _errmsg;//错误信息
	//stack<string> _st;//有些公司还会将调用的栈帧作为异常抛出
};

将what设为虚函数,这样可以让每个派生类都重写一遍该函数设为纯虚函数即是强制派生类重写该函数

例如,该程序一共可能会报2种错误,分别对应2个组:数据库组、网络组。        

class Exception//用于catch接收的基类
{
public:
	Exception(const int& errid,const char*& errmsg)//构造函数,用来为派生类初始化信息
		:_errid(errid)
		,_errmsg(errmsg)
	{}
	virtual string what() = 0;//该函数可以返回对应派生类的_errmsg信息
protected:
	int _errid;//错误码
	string _errmsg;//错误信息
	//stack<string> _st;//有些公司还会将调用的栈帧作为异常抛出
};

class SqlException : public Exception//数据库组
{
public:
	SqlException(const int& errid, const char*& errmsg)
		:Exception(errid, errmsg)
	{}
	string what()
	{
		return "数据库错误:" + _errmsg;
	}
};

class Network : public Exception//网络组
{
public:
	Network(const int& errid, const char*& errmsg)
		:Exception(errid, errmsg)
	{}
	string what()
	{
		return "网络错误:" + _errmsg;
	}
};

不同的异常,可以用SqlException或Network抛出,但不管抛出的是什么类,catch就用基类Exception接收

现在我们来模拟一下抛异常的条件 

int main()
{
	for (size_t i = 0; i < 20; i++)
	{
		try
		{
			if (i % 3 == 0)
				throw SqlException(1, "数据库启动失败");
			else if (i % 10 == 0)
				throw Network(4, "网络连接失败");
			else
				cout << "正常运行" << endl;
		}
	
		catch (Exception& e)
		{
			cout << e.what() << endl;
		}
		catch (...)//防止捕获异常失败
		{
			cout << "未知异常" << endl;
		}
	}
	return 0;
}

输出:

数据库错误:数据库启动失败
正常运行
正常运行
数据库错误:数据库启动失败
正常运行
正常运行
数据库错误:数据库启动失败
正常运行
正常运行
数据库错误:数据库启动失败
网络错误:网络连接失败
正常运行
数据库错误:数据库启动失败
正常运行
正常运行
数据库错误:数据库启动失败
正常运行
正常运行
数据库错误:数据库启动失败
正常运行

C++标准库也有一套类似于上面的异常体系,它的基类叫作 exception,下面的每个派生类都是一个错误类型

但是标准库的异常体系实在太过凌乱,例如 std:invalid_argument 继承了 std:logic_error,而 std:logic_error 又继承了 exception

 这也是一开始用vector举例时,catch参数为什么要用exception的原因

所以在实际中,公司一般不会用C++自带的异常体系,而是自己写一套异常体系

异常规范

异常规范是为了提高代码的可靠性和可预测性

就是在每个函数的后面都加上一句声明,用来表示这个函数可能会抛出什么异常

// 这里表示这个函数会抛出bad_alloc或bad_cast的异常
void fun() throw(std::bad_alloc,std::bad_cast);
// 这里表示这个函数只会抛出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

// 这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) noexcept;

总结:

异常优点:

  1. 清晰的包含错误信息
  2. 面对T operator[](int index)这样的函数越界错误,异常可以很好的解决
  3. 多层调用时,里面发生错误,不再需要层层处理,最外层直接捕获即可
  4. 很多第三方库都是用异常,我们也使用异常可以更好的适配他们。例如boost、gtest、gmock

异常缺点:

  1. 异常会导致执行流乱跳,会给我们调试分析程序bug带来一些困难
  2. C++没有GC(垃圾回收),异常可能导致资源泄露等异常安全问题,需要学会使用RAII来解决
  3. C++标准库里的6异常体系定义不太好用,很多公司都会选择自己定义
  4. C++的异常可以抛任意类型,如果项目中没有做好规范处理,那么会非常的混乱,所以一般需要定义出继承体系的异常规范
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐