浅谈 C++ 异常处理机制 Shepard-Wang

1. C++ 异常继承体系

2. 异常概念

异常处理提供了一种可以使程序从 执行的某点 将 控制流 转移到与 执行先前经过的某点 的代码处理方式。(换言之,异常处理将 控制权 沿调用栈向上转移)。

throw 表达式dynamic_casttypeidnew 表达式分配函数,以及专门用来抛出特定异常以指示特定错误状态的任何标准库函数(例如 std::vector::atstd::string::substr 等)都可以抛出异常。

为捕获异常,throw 表达式必须在 try 块或者 try 块中所调用的函数中,而且必须有与异常对象的类型相匹配的 catch 子句

在声明函数时,可以提供以下说明以限制函数能够抛出的异常类型:

异常处理过程中发生的错误由 std::terminatestd::unexpected (C++17 前) 处理。

3. 异常抛出

异常的抛出用于从函数中为错误发信号,其中“错误”通常仅限于以下内容:

  1. 无法满足后置条件,例如不能产生有效的返回值对象
  2. 无法满足另一个必须调用的函数的前置条件
  3. (对于非私有成员函数)无法(再)建立类不变量

这意味着 构造函数 和大多数 运算符 应该通过抛出异常来报告程序错误。

4. 异常安全

在函数报告了错误状态后,可以提供附加保证以保障程序的状态。以下是四个被广泛认可的异常保证等级[4][5][6],每个是另一个的严格超集:

  1. 不抛出nothrow)异常保证——函数始终不会抛出异常。析构函数和其他可能在栈回溯中调用的函数被期待为不会抛出(以其他方式报告或隐瞒错误)。析构函数 默认为 noexcept。 (C++11 起)交换函数(swap),及为提供强异常保证所使用的其他函数,都被期待为不会失败(函数总是成功)。
  2. (strong)异常保证——如果函数抛出异常,那么程序的状态会恰好被回滚到该函数调用前的状态。(例如 std::vector::push_back
  3. 基础(basic)异常保证——如果函数抛出异常,那么程序处于某个有效状态。不泄漏资源,而所有对象的不变式都保持完好。
  4. 无异常 保证——如果函数抛出异常,那么程序可能不会处于有效的状态:可能已经发生了资源泄漏、内存损坏,或其他摧毁不变式的错误。

此外,泛型组件还可以提供 异常中性(exception neutral)保证:如果从某个模板形参(例如从 std::sortCompare 函数对象,或从 std::make_sharedT 的构造函数)抛出异常,那么它会被无修改地传播给调用方。

5. 异常对象

虽然任意完整类型和指向 void 的 cv 指针都能作为异常对象抛出,但所有标准库函数都以值抛出匿名临时对象,而且这些对象的类型都(直接或间接)派生于 std::exception。用户定义的异常通常遵循此模式.

为避免不必要的异常对象复制和对象切片,catch 子句在实践中最好以引用捕获

6. 异常使用

C++ 异常处理使用 trycatchthrow 三个关键词来完成。

  1. try 块是用来包含可能引发异常的代码块。当 try 块中的代码执行时,如果发生了异常,程序将立即跳转到匹配的 catch 块,而不会继续执行 try 块中的剩余代码。
  2. catch 块用于捕获和处理 try 块中引发的异常。每个 catch 块可以捕获特定类型的异常,当匹配的异常发生时,相应的 catch 块将执行。通常,catch 块会处理异常并执行相应的操作,如日志记录、资源释放或异常信息的传递。
  3. throw 关键字用于引发异常。当程序中的某个条件导致错误或异常情况发生时,你可以使用 throw 来抛出一个异常对象,以表示该异常情况。异常对象通常是派生自 std::exception 的自定义异常类的实例。

异常是通过 抛出对象而引发 的,该 对象的类型 决定了应该激活哪个 catch 的处理代码

int func()
{
	throw 0;

	return 1;
}

//异常
void test1()
{
	try
	{
		func();
	}
	catch (char ch)
	{
		cout << "ch" << endl;
	}
	catch (const char* str)
	{
		cout << "str" << endl;
	}
    // i 将被输出,下面的 catch 捕获异常
	catch (int i)
	{
		cout << "i" << endl;
	}
}

被 选中的处理代码 是调用链中与该对象 类型匹配离抛出异常位置最近 的那一个

int func()
{
	throw 0;

	return 1;
}

//异常
void test1()
{
	try
	{
		func();
	}
	catch (char ch)
	{
		cout << "ch" << endl;
	}
	catch (const char* str)
	{
		cout << "str" << endl;
	}
	catch (int i)
	{
		cout << "test1 i" << endl;
	}
}

void test2()
{
	try
	{
		test1();
	}
	catch (int i)
	{
		cout << "test2 i" << endl;
	}
}

//输出:
test1 i

如果将 test1 中,catch(int i) 注释掉:

void test1()
{
	try
	{
		func();
	}
	catch (char ch)
	{
		cout << "ch" << endl;
	}
	catch (const char* str)
	{
		cout << "str" << endl;
	}
	//catch (int i)
	//{
	//	cout << "test1 i" << endl;
	//}
}

void test2()
{
	try
	{
		test1();
	}
	catch (int i)
	{
		cout << "test2 i" << endl;
	}
}

输出:
test2 i

catch(...) 可以捕获任意类型的异常,问题是不知道异常错误是什么。

try
{
    func();
}
catch (char ch)
{
    cout << "ch" << endl;
}
catch (const char* str)
{
    cout << "str" << endl;
}
catch (int i)
{
    cout << "test1 i" << endl;
}
// 可以捕获任意类型的异常,问题是不知道异常错误是什么
// 如果将 ... 写成第一个,后面的 catch 都没有意义。因此,一般来说,
// catch(...) 写在 catch 的最后
catch (...)
{
    cout << "..." << endl;
}

实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获 (类似切片)

如果当前函数不能处理异常,则退出当前函数栈,在上层调用的函数栈中进行查找。如果到 main 函数,都不能处理,则程序崩溃

最终在哪个函数处理完异常,则在从该函数继续向下执行。

void test1()
{
	try
	{
		func();
	}
	catch (char ch)
	{
		cout << "ch" << endl;
	}
	catch (const char* str)
	{
		cout << "str" << endl;
	}
	
	cout << "Go Back Here" << endl;
}

void test2()
{
	try
	{
		test1();
	}
	catch (int i)
	{
		cout << "test2 i" << endl;
	}

	cout << "Not Go Back" << endl;
}

// 输出:
test2 i
Not Go Back

在函数声明中指定可能会抛出的异常:

void func() throw(A, B, C); // 可能抛出 A,B,C
void func2() throw(std::bad_alloc); // 只可能抛出这一个异常
void fun3() throw(); // 表示不会抛出异常
// 若接口无声明,可能会抛出任何异常

在用 catch 接受异常时,我们使用异常类型的引用或者指针来接受,这样父类引用引用子类对象时,可以调用子类的重写方法:

catch(exception& e)
{
    // 查看异常的类型
    e.what();
    ...
}

7. 异常的优缺点

优点:

  • 比错误码更清晰的展示错误信息,更好的定位程序 bug。
  • 抛出异常的好处一是可以不干扰正常的返回值,另一个是调用者必须处理异常,而不像以前c语言返回一个整数型的错误码,调用者往往将它忽略了。
  • 不用像错误码那样层层返回。

缺点:

  • 导致程序流混乱

  • 异常会有一些性能的开销

  • C++ 没有垃圾回收机制,资源需要自己管理,容易导致资源泄露,死锁等安全问题。需要使用 RAII 来处理资源的管理问题。

8. 析构为什么不能抛出异常

如果对象在运行期间出现了异常,C++ 异常处理模型有责任清除那些由于出现异常所导致的已经失效了的对象(也即对象超出了它原来的作用域),并释放对象原来所分配的资源, 这就是调用这些对象的析构函数来完成释放资源的任务,所以从这个意义上说,析构函数已经变成了异常处理的一部分。

  1. 如果析构函数抛出异常,则 异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
  2. 通常异常发生时,C++ 的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

那么当无法保证在析构函数中不发生异常时, 该怎么办?

其实还是有很好办法来解决的。那就是把异常完全封装在析构函数内部,决不让异常抛出函数之外。

参考文章: