Solmyr 的小品文系列之七:异常

类别:编程语言 点击:0 评论:0 推荐:

大雨。

乌云象铅块一样低低的压了下来,豆大的雨滴打的玻璃窗啪啪作响,难得一见的异常天气正在竭力表现它令人讨厌的一面。不过这一切似乎并没有影响到 Solmyr,他仍然以他习惯的舒适姿势半躺在宽大的椅子里,手里还托着一杯热腾腾的果汁,在他背后,zero 在键盘上敲打着什么。

“唉,Solmyr ,标准库中的 stack 怎么会是这个样子?设计糟透了。”zero 停止了工作,转过身来面对 Solmyr ,看起来有些困惑。

“胡乱批评被纳入神圣标准的成员是会遭天遣的。”Solmyr 低着头,以一种算命先生似的语调答道。

不知道上天是否打算加强 Solmyr 的说服力,恰在此时天空划过一道闪电,蓝白色的电光挣扎着努力向地面扑来,紧接着就是“喀喇”一声巨响 ——— 这个雷很近。

一秒钟前还在想“这未免也太扯了”的 zero 表情一下子变得很古怪,良久才恢复正常。他标出了两行代码接着说到:“好、好吧,Solmyr,那请你解释一下为什么 stack 的界面是这个样子。”

std::stack<int> si;
……
int i = si.top();
si.pop();

“只要让 pop() 返回栈顶元素就可以把上面两行合成一行,而且更加直观,为什么要搞成现在这样?”

目睹了 zero 表情变化的 Solmyr 强忍住放声大笑的冲动 ——— 老天知道他忍的有多辛苦 ——— 缓缓的把杯子放到桌上,转过身来开始讲解这个问题:

“原因在于异常。”

“异常?”

“对,很多代码在没有异常的时候工作的挺好,但是一旦出现异常就变得不可收拾,就像一间茅草屋,平时看起来没什么问题,一遇到今天这种天气 …… ”,Solmyr 指了指窗外,“ …… 立刻就会垮掉。考虑一下如果 pop() 返回栈顶元素需要怎样实现,假设栈内部用数组实现,且不考虑栈是否为空的问题。”

“很简单啊。”,zero 打开了编辑器,写下:

template <typename T>
T stack<T>::pop()
{
    ... ...
    return data[top--]; // 假设数据存储于数组 data 中,top 代表栈顶位置
}

Solmyr 摇摇头:“这就是茅草屋。要知道 stack 是个模板类,它存放的元素 T 可能是用户定义的类。我来问你,如果类型 T 的拷贝构造函数抛出异常,会出现什么情况?”

“嗯 …… 按值返回,返回值是个临时对象,该临时对象以 data[top] 拷贝构造 …… 嗯,这样一来函数返回时可能抛出异常,客户此时无法取得该元素。”

“还有呢?”

“还有?”

“提示,你的 top 怎么了?”

“ …… 哎呀!糟了!top 此时已经减一,栈顶元素就此丢失了!这样的话 …… 必须实现一个函数允许客户修改 ……”,zero 说不下去了。他想了一会,摇摇头承认失败:“不行,这里拷贝构造发生在函数返回之后,无论如何无法避免这种情况。只能在文档里写明:要求 T 的拷贝构造函数不抛出异常。” zero 停了一停,小心翼翼的问 Solmyr :“这个不算过分的要求吧?”

Solmyr 的回答异常简短:“new”

“哦对,new 在分配内存失败时会抛出 std::bad_alloc …… 算我没说。Solmyr ,我明白了,为了处理异常的情况,调整栈顶位置必须在所有数据拷贝完成之后,所以按值返回是不可接受的。”

“正确。所以对于一个设计目标是最大限度可复用性的标准库成员而言,这是不可接受的。” Solmyr 顿了顿,继续说到:“而且异常带来的影响远不止此。我刚才说‘假设栈内部用数组实现’,但如果你充分考虑抛出异常的各种可能性,你就会发现用数组实现是糟糕的主意。”

“ …… …… …… …… …… 这是为什么?在没有传值返回的情况下,我们总可以捕捉到发生的异常并加以处理啊?”,zero 谨慎的发问。

Solmyr 赞许的看着 zero 。“发问之前先自行思考,习惯不错。”,Solmyr 心想,但是脸上一点也没表现出来:“没错,但捕捉到异常不代表你总能正确的处理它。考虑一下 stack 的赋值运算符,如果我们用数组来实现,那么在拷贝数据的时候肯定会有类似这样的一个循环:”

// 各变量的意义与上面相同
template <typename T>
stack<T>& stack<T>::operator=(const stack<T>& rhs)
{
    ... ...
    for(int i=0; i<rhs.top; i++)
        data[i] = rhs.data[i];
    ... ...
}

“现在考虑类型 T 的赋值运算符可能抛出异常,该怎样修改上面的代码。” Solmyr 停了下来,再度捧起了杯子。

“用 try 把 …… 哦 …… …… …… …… …… ……”,zero 似乎发现了问题所在,沉默良久,才接着说到:“这个循环可能在运行到一半的时候抛出异常,这样会导致一部分数据已经成功赋值,另一部分却还是老的。除非我们用 catch(...) 捕捉所有异常,忽略之并继续赋值。”

“但是这样 ……”,Solmyr 有意识的引导 zero 继续深入思考。

“…… 但是这样,赋值运算符抛出的异常就被我们‘吃掉了’,异常总是代表着某些不该发生的事情发生了,所以应该让客户接收到这个异常才对。” zero 皱着眉头,一字一顿,显得相当辛苦。

“正确。stack 作为一个通用的标准库成员,在面对异常时必须做到两点。一、异常安全,也就是说异常不会导致它本身处于一种错误的状态或是导致数据丢失或是造成资源泄漏;二、异常透明,也就是说客户代码 ——— 这里指它存放的类型 T 的实现 ——— 抛出的任何异常,不应该被‘吃掉’或者被改变,应该透明的传递给客户。一望即知,上面的代码无可能同时做到这两点。”

“是这样,我懂了,这大概就是标准库中的 stack 不用数组实现的主要原因了吧”,zero 露出了很有把握的神情。

“当然不是!有点常识好不好,用数组实现的话 stack 的大小固定,这怎么能够接受呢?!”

又一次的,Solmyr 目睹了 zero 表情发生难以言喻的剧烈变化。这次他没能忍住放声大笑的冲动,连杯子里的果汁也洒了出来,一时间,笑声充满了整个办公室 ——— 不仅仅是他的,还包括了(众位看官应该猜的到吧?)围观同事们的笑声。

驱散了围观者之后,zero 面带愠色的坐下:“有那么好笑吗?”

“抱歉抱歉,我 …… 哈哈哈 …… 我 …… 哈哈 …… 我只是一时忍不住 …… 哈哈哈哈 …… ”,Solmyr 好容易平息了大笑,坐直了身子,放下了果汁,正色道:“关键在于上面引入的应该遵循的两条原则,也就是异常安全,和异常透明。现在你考虑一下如果 stack 内部的数据以指针存放,怎样在赋值运算符中保证上述两点?”

“ …… 嗯 …… 还是会有上面那样一个循环 …… 呃 …… ”,zero 面有难色。

“提示,不一定非得直接拷贝到 stack 保存数据的内存里。”

“ …… 嗯 …… 不直接拷贝,那么就是 …… 就是拷贝到 …… 啊!我明白了!”,zero 抓住了其中的关键,飞快的写下:

// pdata 代表指向存放数据内存的指针,top 代表栈顶元素的偏移量
template <typename T>
stack<T>& stack<T>::operator=(const stack<T>& rhs)
{
    ... ...
    T* ptemp = new T[rhs.top];
    try
    {
        for(int i=0; i<rhs.top; i++)
            *(ptemp+i) = *(rhs.pdata+i);
    }
    catch(...)  // 捕捉可能出现的异常
    {
        delete[] ptemp;
        throw;  // 重新抛出
    }

    delete[] pdata;  // 释放当前的内存
    pdata = ptemp;  // 让 pdata 指向赋值成功的内存块
    ... ...
}

“只要这样”,zero 边输入边说,“只要先把数据拷贝到一个临时分配的缓冲区,在此过程中处理异常,然后让 pdata 指向成功分配的内存就行了。这里的关键是让拷贝动作成为可以 …… 呃 …… 可以安全的取消的,剩下的赋值动作就是简单的指针赋值,肯定不会抛出异常了。”

“非常好。值得指出的是,这是一种相当常见的手段,有个名字叫做 copy & swap ,它不仅仅可以用来应付异常,也可以有效的实现一些其他特征。OK,这个问题大概就是这样了。”

问题似乎可以告一段落了,Solmyr 开始打算就此结束这个话题。可 zero 疑惑的表情阻止了他。

“还有什么问题吗?zero ?”

“啊 …… 没什么,我只是在想,异常导致了这么多麻烦,这一次,还有上一次的线程死锁问题(参见“小品文系列”的前一篇,“成对出现”)都是因为异常的存在才会变得如此复杂的,那为什么 C++ 还要支持它呢?有错误完全可以在返回值里报告嘛。”

“嗯,这确实是个常见的疑惑,不过答案也很简单,异常的存在有它自己的价值。一、使用异常报告错误可以避免污染函数界面;二、如果你希望报告比较丰富的错误信息,使用一个异常对象比简单的返回值要有效的多,而且避免了返回复杂对象造成的开销;三、也是我认为比较重要的,有些错误不合适用返回值来报告。举个例子,动态内存分配。我问你,C 语言中怎样报告动态内存分配错误?”,Solmyr 转过头来看着 zero 。

“malloc 函数返回一个 NULL 值代表动态内存分配错误。”

“但是你见过多少 C 程序员在每次使用 malloc 之后都检查返回值?”

“ …… ”

“没有是吗?这很正常,每次使用 malloc 之后检查返回值是件令人痛苦的事情,所以即使有 Steve Maguire(注:《Writing Clean Code》一书的作者)这样的老程序员谆谆教导、耳提面命,还是有数以万计的 C 程序中存在这样的代码:”,Solmyr 顺手键入:

/* 传统 C 程序 */
int* p = malloc( sizeof(int) );
*p = 10;

“一旦 malloc 失败返回 NULL,这个程序就会崩溃。然而如果是 C++ 程序,使用 new 的话 …… ”,Solmyr 键入了对应的代码:

// C++ 程序
int* p = new int;
*p = 10;

“就不存在这样的问题。我问你,这是为什么?”

zero 很快找到了答案:“因为如果 new 失败,它会抛出 std::bad_alloc 异常,于是函数在此中断、退出,下面这一行也就不会被调用了。”

“正确。而且你不必在每一处处理这个异常,你只要保证你的程序对异常透明,就可以在 main 函数中写下 try ... catch 对,捕获所有未捕获的异常。比如你可以在 main 函数中捕捉 std::bad_alloc,在输出‘内存不足’错误信息,然后保存所有未保存的数据,完成所有的清理工作,最后结束程序。一言以蔽之,体面的退出。”

zero 点着头,喃喃的重复着:“对,体面的退出。”

见 zero 领会了他的意思,Solmyr 继续开始下一个议题:“异常的存在还有最后一个重要价值 ——— 也是当初设计它的初衷之一 ——— 提供一个通用的手段让构造函数可以方便的报告错误:因为构造函数没有返回值。”

“还有析构函数也是。”没等 Solmyr 说完,zero 就加上了这一句。

Solmyr 对着自作聪明的 zero 摇了摇头:“不要想当然,关于异常有一个非常重要的原则:永远不要让你的析构函数抛出异常。知道为什么吗?”

“ …… 不知道。” zero 这次决定老实承认。

“因为抛出异常的析构函数会导致最简单的程序无法正确运行,比如下面两句:”这次出现在屏幕上的,是看来似乎毫无瑕疵的两行代码:

evil p = new evil[10];
delete[] p;

“看上去一点问题也没有是么?仔细分析一下 delete[] p 这一句,它会调用 10 次 evil 类的析构函数,假设其中第 5 次 evil 类的析构函数抛出异常,会出现什么情况?”

zero 陷入了沉思,视线盯着屏幕一动不动,神情看起来就象是一段执行复杂运算的程序,而且是没有输出的那种。不过没多久,zero 就换了一种表情,这种表情通常被形容为胸有成竹:“我知道了 Solmyr ,在这种情况下,delete[] 面临两难选择。选择一是不捕捉这个异常,让它传播到调用者那里,但这样一来 delete[] 就被中断了,后面的 5 个 evil 对象占用的内存就会无法释放,导致资源泄漏;选择二是捕捉这个异常以防止资源泄漏,但这样一来这个异常就被 delete[] 吃掉了,违反了‘对异常透明’的原则。所以无论怎么做,都没法妥善的处理析构函数抛出异常的情况。”

Solmyr 赞许的点头:“非常好。接下来,你的任务是 ……”

“我知道我知道,把这些讨论整理成文档是吧?我这就动手。”

zero 转过身去,开始埋头于他的文档。而 Solmyr 则再度恢复了半躺半坐的舒适姿势,捧起了他的果汁,并且略略有些意外的发现 ———

天气放晴了。

=====================================

这篇小品文大量参考了以下两篇文章:

"Exception Handling: A False Sense of Security" by Tom Cargill

Exception-Safe Generic Containers by Herb Sutter

 

本文地址:http://com.8s8s.com/it/it29585.htm