先来回顾上一篇开头处的一段代码的结尾处
EXIT_FREE_BUFFER: free(buffer); EXIT_CLOSE_DST: fclose(dstf); EXIT_CLOSE_SRC: fclose(srcf); return ret;
有一个问题是, 当有错误发生时 (ret 为非零值) 如果同时 free, fclose 这样的函数又出错, 那这个时候如何处理两个不同的错误呢? 答案是, 看一下手头任何一本 C 语言手册, 它都会说只要语言的使用者不手贱传递一个错误的指针到函数或者瞎掰似的连续释放两次, 函数都不会出错, 此外, 这些函数都返回 void 类型, 换句话说, 它们不会标记任何错误.
首先简单说一下 C++ 怎么处理资源释放问题, 那就是 RAII 机制, C++ 在栈上构造出的每一个对象, 在栈退出时都会析构掉, 甚至可以说, C++ 区别于任何其它一门面向对象程序设计语言的地方就是, 在对象机制上的自动化, 即对象的析构函数以明确的时序被调用. 参照下面的代码可以窥出其中的端倪
#include <iostream>
struct echo {
int n;
echo(int nn)
: n(nn)
{
std::cout << "constructing " << n << std::endl;
}
~echo()
{
std::cout << "destructing " << n << std::endl;
}
};
int main(int argc, char* argv[])
{
echo e0(0);
if (argc > 1) {
echo e1(1);
}
return 0;
}
而且, 即使产生异常, 在函数非正常退栈时也会析构掉已经构造的对象. 比如
#include <iostream>
struct echo {
int n;
echo(int nn)
: n(nn)
{
std::cout << "constructing " << n << std::endl;
}
~echo()
{
std::cout << "destructing " << n << std::endl;
}
};
void func_throw()
{
echo e(0);
throw int(0);
}
int main()
{
try {
func_throw();
} catch (int) {
std::cout << "int caught." << std::endl;
}
return 0;
}
而且可以看出来, 析构对象的工作是在 catch 之前发生的. 那么, 很明显的类比是, C++ 应该有一个不抛出异常协约, 任何类型的析构函数和 operator delete 都应该声明 throw().
设想一下你正在喝水, 这时手一滑, 产生了一个杯具悲剧异常, 好, 那么就直接跳到清理阶段, 也就是无论是否喝到了水, 都得把资源释放掉; 然而不幸的是这时不能再用扫帚了, 因为申请扫帚就是请求另一份资源, 而请求资源就会有潜在的资源无法满足的情形发生, 从而产生另一个错误, 于是你只好用肉体解决; 当然, 这样一来, 如果某个刹那你稍有闪失, 让玻璃边缘与皮肤切面来个正交接触, 从而发生一个手割破了异常, 好了, 这个时候就没办法处理了不是吗?
但是 C++ 的世界显然不是这样的, 在处理错误的过程中不允许发生第二个错误, 这样的规定看起来简直是反人类的. 然而, 无论是面向过程编程, 还是面向对象编程, 或是面向显示器编程, 从一开始就认定计算机处理错误的能力非常有限, 出现一个错误, 那就处理这个错误, 如果此时又出现另外一个错误, 那么程序崩溃, 比如下面这段代码
#include <string>
#include <iostream>
struct throw_on_dtor {
~throw_on_dtor()
{
throw int(0);
}
};
void func_throws()
{
throw_on_dtor t;
throw std::string("func_throw");
}
void wrapper0()
{
try {
func_throws();
} catch (int) {
std::cout << "0: int caught." << std::endl;
} catch (std::string) {
std::cout << "0: string caught." << std::endl;
}
}
void wrapper1()
{
try {
wrapper0();
} catch (int) {
std::cout << "1: int caught." << std::endl;
} catch (std::string) {
std::cout << "1: string caught." << std::endl;
}
}
void wrapper2()
{
try {
wrapper1();
} catch (int) {
std::cout << "1: int caught." << std::endl;
} catch (std::string) {
std::cout << "1: string caught." << std::endl;
}
}
int main()
{
try {
wrapper2();
return 0;
} catch (...) {
std::cout << "main: exception caught." << std::endl;
return 1;
}
}
这里包了很多层, 每一层都全副武装, 然而这样都是徒劳的, catch 已经无法阻止异常了. 不过看看控制台的输出, 很有意思的是, 贯穿堆栈而下的是 throw_on_dtor 析构时甩出来的 int, 大家有兴趣可以再做一下试验, 时序上第二个抛出的异常将是摧毁程序的必备良药, 而实际上再多抛任何异常, 这些异常对象甚至都不会被构造.
想想自诩为万物之灵的人类处理异常的方法则好得多, 割破手? 没问题, 把清扫杯具的事情压到栈里面去, 先把血止了, 然后再来处理玻璃碎片. 只有在极端情况下, 比如血有病患者同时又找不到创口贴资源和止血药品, 这时才会引发严重的问题, 但不管怎么说, 只要人不死掉, 总会想到办法来一个个处理问题的, 正如侏罗纪公园那句名言 "Life will find a way". 而自诩为万物之灵的人类创造出来的计算机则并没有这个灵性.
不过幸好, 析构函数并不会真的像真实环境中这样这么环境复杂, 对于计算机而言, 关闭文件, 释放内存, 退临界区这种操作就不应该发生异常. 当然说应该不应该是一回事, 说是不是确实肯定以及保证不出错不抛异常是另一回事. 在上面某处的 "应该" 二字我设了粗体, 确实存在这样的问题, 在某个应该予以确保的环境却险象环生. 对于 C++ 而言, 就是本该声明 throw() 的析构函数却没有这么做, 而且并不是因为写代码的人的疏忽, 很多析构函数后面都非常非常明确地给了注释, 标出了 never throw, 如果有兴趣可以运行下面这个脚本
find /usr/include/ -type f | awk '{print "cat", $1;}' | sh | grep never\ throw
但注释是注释, 很多情况下前面真的并没有 throw().
为什么应该却没人这么干呢? 回想一下 C++ 中处理除零错误这篇文章中所看到的东西吧, 看起来一个整数除法也能轻易抛出异常不是吗? 那么是不是应该来这样规定: "析构函数里面, 不能够使用整数除法"? 析构函数你们到底要闹哪样啊? 这是我的 Blog, 又不是冷笑话.
说到底不是整数除法的问题, 而是信号中断的那些事儿. 除零这种事情相对而言可控性是很高的了, 而且哪有那么多人真没事做在析构的时候摆弄除法运算, 撑死了用加减法搞搞指针偏移就行了. 关键是, 信号很可能是外部来的, 比如, 进程中断.
为什么 kill -9 比直接 kill 要给力得多, 而且有些程序 kill 都 kill 不掉呢? 因为 -9, 也就是 SIGKILL 不允许设置信号处理函数的, 而直接 kill 传递的是 SIGTERM, 这个的信号处理函数可以有. 假定我们设计一个正常的程序, 而参照整数除零异常, 类似地, 可以在 C++ 程序中设置一个进程中断异常, 在处理 SIGTERM 时抛出一个, 接到后做一些比如用户设置保留之类的处理然后退出, 听起来非常完美. 不过, 在可能有这样的情况出现, 程序内部刚刚发生了一个异常, 并且这时正在执行某些应该不会产生异常的对象析构时, 从系统的某个地方突然窜出来一个 SIGTERM, 暗无天日的堆栈猛然间打开了一道口子, 只见进程中断异常一路狂飙冲到栈底, 把程序给弄崩溃了. 虽然非常小概率, 但是无论怎么说, 这都是潜在可能的结局.
C++ 标准中有一个非常邪恶的, 如果函数不声明抛出什么异常, 那么就是可能抛出任何异常的阴谋, 大抵也就是这个原因. 看到这个规定不可避免会脱口而出, 我擦叻, 那岂不是随便一个 C 函数都能抛出异常? 但是生活在石器时代的 C 语言连什么是异常都不知道, 还抛什么抛? 任何异常都能抛, C 真的有这凶残能力吗? 现在很明了了, 确实是这样, 信号让一切都这么简单, 想抛就抛, 随心所欲.
最后请大家自问一下, 真的相信自己的运气足够好, 信号中断永远不会发生在异常来袭的时候吗?