上一篇文章详细讨论了C++的异常对象按值传递的方式,本文继续讨论另外的一种的方式:引用传递。
异常对象在什么时候构造?
其实在上一篇文章中就已经讨论到了,假如异常对象按引用方式被传递,异常对象更应该被构造出一个临时的变量。因此这里不再重复讨论了。
异常对象按引用方式传递
引用是C++语言中引入的一种数据类型形式。它本质上是一个指针,通过这个特殊的隐性指针来引用其它地方的一个变量。因此引用与指针有很多相似之处,但是引用用起来较指针更为安全,更为直观和方便,所以C++语言建议C++程序员在编写代码中尽可能地多使用引用的方式来代替原来在C语言中使用指针的地方。这些地方主要是函数参数的定义上,另外还有就是catch到的异常对象的定义。
所以异常对象按引用方式传递,是不会发生对象的拷贝复制过程。这就导致引用方式要比传值方式效率高,此时从抛出异常、捕获异常再到异常错误处理结束过程中,总共只会发生两次对象的构造过程(一次是异常对象的初始化构造过程,另一次就是当执行throw语句时所发生的临时异常对象的拷贝复制的构造过程)。而按值传递的方式总共是发生三次。看看示例程序吧!如下:
void main()
{
try
{
{
throw MyException();
}
}
// 注意:这里是定义了引用的方式
catch(MyException& e)
{
cout<<"捕获到一个MyException类型的异常,名称为:"<<e.GetName()<<endl;
}
}
程序运行的结果是:
构造一个MyException异常对象,名称为:none
拷贝一个MyException异常对象,名称为:none
销毁一个MyException异常对象,名称为:none
捕获到一个MyException类型的异常,名称为:none
销毁一个MyException异常对象,名称为:none
程序的运行结果是不是显示出:异常对象确实是只发生两次构造过程。并且在执行catch block之前,局部变量的异常对象已经被析构销毁了,而属于临时变量的异常对象则是在catch block执行错误处理完毕后才销毁的。
那个被引用的临时异常对象究竟身在何处?
呵呵!这还用问吗,临时异常对象当然是在栈中。是的没错,就像发生函数调用时,与引用类型的参数传递一样,它也是引用栈中的某块区域的一个变量。但请大家提高警惕的是,这两处有着非常大的不同,其实在一开始讨论异常对象如何传递时就提到过,函数调用的过程是有序的的压栈过程,请回顾一下《第9集 C++的异常对象如何传送》中函数的调用过程与“栈”那一节的内容。栈是从高往低的不断延伸扩展,每发生一次函数调用时,栈中便添加了一块格式非常整齐的函数帧区域(包含参数、返回地址和局部变量),当前的函数通过ebp寄存器来寻址函数传入的参数和函数内部的局部变量。因此这样对栈中的数据存储是非常安全的,依照函数的调用次序(call stack),在栈中都有唯一的一个对应的函数帧一层层地从上往下整齐排列,当一个函数执行完毕,那么最低层的函数帧清除(该函数作用域内的局部变量都析构销毁了),返回到上一层,如此不断有序地进行函数的调用与返回。
但发生异常时的情况呢?它的异常对象传递却并没有这么简单,它需要在栈中把异常对象往上传送,而且可能还要跳跃多个函数帧块完成传送,所以这就复杂了很多,当然即便如此,只要我们找到了源对象数据块和目标对象数据块,也能很方便地完成异常对象的数据的复制。但现在最棘手的问题是,如果采用引用传递的方式将会有很大的麻烦,为什么?试想!前面多次提到的临时异常对象是在那里构造的?对象数据又保存在什么地方?毫无疑问,对象数据肯定是在当前(throw异常的函数)的那个函数帧区域,这是处于栈的最低部,现在假使匹配到的catch block是在上层(或更上层)的函数中,那么将会导致出现一种现象:就是在catch block的那个函数(执行异常处理的模块代码中)会引用下面抛出异常的那个函数帧中的临时异常对象。主人公阿愚现在终于恍然大悟了(不知阅读到此处的C++程序员朋友们现在领会了作者所说的意思没有!如果还没有,自己动手画画栈图看看),是啊!确是如此,这太不安全了,按理说当执行到catch block中的代码时,它下面的所有的函数帧(包括抛出异常的哪个函数帧)都将会无效,但此时却引用到了下面的已经失效了的函数帧中的临时异常对象,虽说这个异常对象还没有被析构,但完全有可能会发生覆盖呀(栈是往下扩展的)!
怎么办!难道真的有可能会发生覆盖吗?那就太危险了。朋友们!放心吧!实际情况是绝对不会发生覆盖的。为什么?哈哈!编译器真是很聪明,它这里采用了一点点技巧,巧妙的避免的这个问题。下面用一个跨越了多个函数的异常的例子程序来详细阐述之,如下:
void test2()
{
throw MyException();
}
void test()
{
test2();
}
void main()
{
try
{
test();
}
catch(MyException& e)
{
cout<<"捕获到一个MyException类型的异常,名称为:"<<e.GetName()<<endl;
}
cout<<"那个临时的异常对象应该是在这之前析构销毁"<<endl;
}
怎样来分析呢?当然最简单的方法是调试一下,跟踪它的ebp和esp的变化。首先在函数调用的地方和抛出异常的地方设置好断点,F5开始调试,截图如下:
纪录一下ebp和esp的值(ebp 0012FF70;esp 0012FEF8),通过ebp和esp可以确定main函数的函数帧在栈中位置,F5继续,截图如下:
同样也纪录一下ebp和esp的值(ebp 0012FE9C;esp 0012FE08),通过ebp和esp可以看出栈是往下扩展,此时的ebp和esp指向抛出异常的test2函数的函数帧在栈中位置,F5继续,此时抛出异常,控制进入main函数中的catch(MyException& e)中,截图如下:
请注意了,现在ebp恢复了main函数在先前时的函数帧在栈中位置,但esp却并没有,它甚至比刚刚抛出异常的那个test2函数中的esp还要往下,这就是编译器编译程序时耍的小技巧,当前ebp和esp指向的函数帧实际上并不是真正的main函数原来的哪个函数帧,它实际上包含了多个函数的函数帧,因此catch block执行程序时当然不会发生覆盖。我们还是看看异常对象所引用指向的临时的变量究竟身在何处。截图如下:
哈哈!e指向了0x0012fe7c内存区域,再看看上面的抛出异常的test2函数的函数帧的ebp和esp的值。结果0x0012fe7c恰好是ebp 0012FE9C和esp 0012FE08之间。
不过阿愚又开始有点疑惑了,哦!这样做岂不是破坏了函数的帧栈吗,结果还不导致程序崩溃呀!呵呵!不用担心,F5继续,截图如下:
当离开了catch block作用域之后,再看看ebp和esp的值,是不是和最开始的那个main函数进入时的ebp和esp一模一样,哈哈!恢复了,厉害吧!先暂时不管它是如何恢复的,总之ebp和esp都是得以恢复了,而且同时catch block执行时也不会发生异常对象的覆盖。这就解决了异常对象按引用传递时可能存在的不安全隐患。
引用方式下,异常对象会发生对象切片吗?
当然不会,要不测试一下,把上一篇文章中的对应的那个例子改为按引用的方式接受异常对象。示例如下:
void main()
{
try
{
{
MyMemoryException ex_obj1("ex_obj1");
cout <<endl<< "抛出一个MyMemoryException类型的异常" <<endl<<endl;
throw ex_obj1;
}
}
// 注意这里引用的方式了
// 还会发生了对象切片吗?
catch(MyException& e)
{
// 调用虚函数,验证一下这个异常对象是否发生了对象切片
cout<<endl<<e.Test_Virtual_Func()<<endl<<endl;
}
}
程序运行的结果是:
构造一个MyException异常对象,名称为:ex_obj1
构造一个MyMemoryException异常对象,名称为:ex_obj1
抛出一个MyMemoryException类型的异常
构造一个MyException异常对象,名称为:none
拷贝一个MyMemoryException异常对象,名称为:ex_obj1
销毁一个MyMemoryException异常对象,名称为:ex_obj1
销毁一个MyException异常对象,名称为:ex_obj1
这是MyMemoryException类型的异常对象
销毁一个MyMemoryException异常对象,名称为:ex_obj1
销毁一个MyException异常对象,名称为:ex_obj1
总结
(1) 被抛出的异常对象都是临时的局部变量;
(2) 异常对象至少要被构造二次;
(3) catch 后面带的异常对象的作用域仅限于catch bock中;
(4) 按引用方式传递不会发生异常对象的切片。
下一篇文章讨论C++的异常对象被按指针的方式传递。继续吧!
评论