到目前为止,我们都在假设你编写的程序是正确的,你程序运行的软、硬件环境是没有任何错误的,使用你的软件的用户都是精通计算机的高手和专家,但是你知道,这只是一种美好的假设。事实情况是,即使只有几行的程序,也可能存在设计和编码的缺陷。操作系统的各种不靠谱,硬盘上可能出现的物理损坏。你的用户可能连怎么关掉电脑都不知道,甚至于你的用户可能是一个玩耍的孩子,或者是一只淘气的小猫。
作为一名程序员,要有一个清醒的认识,那就是你要终身和bug 和各种错误为伴。所以我把这一章单独抽取出来,来突出这一部分内容的重要性。单独成为一章也有一定的缺点,那就是这一部分内容本身太多、太杂,所以本书决定尽量只介绍一些纲领性、策略性及基础性的知识,而不是过多涉及工具和方法的细节。有关细节的描述,大家可以参考《Windows 程序调试》[21]一书,虽然这本书的书名中包含Windows,但它介绍的有关调试的内容并不是只局限于Windows 平台。它的前半部分关于调试策略方面的介绍,可以应用到各种开发平台和开发语言上。我推荐这本书的原因还在于,它不仅教你正确的调试方法,更重要的是,它还教你正确的调试态度。
本章介绍的内容并不局限于C 语言,bug 与错误在各种语言中你都会遇到。所以掌握这些基本的调试知识对你使用各种开发语言都是有帮助的。
13.1 Bug、错误(error)及异常(exception)
之所以给本小节起了这么一个缺幺断九,十三不靠的名字,实在是这三个概念在软件编写领域的联系非常紧密,同时很多人对它们又经常混淆。所以把它们三个放到一起讲,希望通过对比能让大家有一个更清晰的理解。
Bug 这个词在英语中的本意是虫子的意思,这个词怎么会和计算机搭上关系呢?在计算机刚出现的上个世纪,那个时候还没有集成电路,计算机里面都是一些电子管。这个东西又发光又发热,通常会吸引一些bug。一旦bug 进入了计算机,通常会引起电子管的短路,所以那个时候如果人们发现计算机不工作了,第一件事就是打开计算机来看看其中是否又飞进去了bug。如果你现在手头正有一台联网的电脑,你可以在Google 中搜索“the first bug in computer”,你就会发现那只在历史上大名鼎鼎的第一个计算机bug。作为程序员,应该拜一拜它,据说可以保佑以后自己的职业生涯少碰到bug。
Bug 到底应该翻译成中文的那个词呢?直接翻译成“虫子”肯定是不对的。比较贴近的中文翻译应该是“缺陷”。或者你可以直接认为bug 其实就是汉语拼音bugai(不该)的一个缩写:)一般情况下,尤其在软件圈内,我们都不翻译这个词,而是直接说bug,这样每个编软件的人都知道你在说什么了。
错误(error)一般泛指结果并不是你预想的或者是应该的那样。例如,当你在电脑上输入1+1 后,发现结果等于3,这个时候就说明有错误了。引起错误的原因有多种多样,粗略地看,可以分成两大类:一类是由设计和实现的缺陷引起了,也就是说是由于上面说过的bug 引起了,在13.2 节中将主要介绍各种bug 引起的错误。另一类则是由各种运行时发生的用户错误,软硬件系统错误或各种突发情况引起的。例如在
上面的例子中,用户一不小心输入了一个1,一个2。在13.3 节我们将主要讨论这第二类错误。上面的关于错误的解释好像有点抽象,下面给出两个具体的例子。假设你想去逛街,但是结果令你很不开心。为什么呢?因为发生了三件事,首先你穿了一双高跟鞋,把自己的脚磨破了;其次你手机没电了;最后你的钱包被偷了。下面让我们仔细分析一下这三件事,明知道要去逛街,还穿了一双高跟鞋,这明显是一种设计上的缺陷。这个可以认为是一种bug。而手机没电和钱包被偷,并不是设计上的缺陷,只是你在逛街过程中遇到的你并没有预见到的突发情况。下面我再给出一个例子,我们都知道丰田车的油门踏板缺陷,这明显就是一个bug。但是如果车胎爆了,或者没油了。那么你不能说这是车设计或实现上的缺陷,它们只是运行时你没有预见到的错误。
异常(exception)这个词是在C++语言中被引入的,C 语言中并不支持异常。本质上来说,异常就是上面提到的第二类错误,你可以说车胎爆了是一种错误,也可以说车胎爆了是一种异常。在面向对象语言中使用的异常处理机制,比在C 语言中使用的利用函数返回值的错误处理机制,要有很多的优点。至于有哪些优点,我们会在13.4节给予介绍。
最后我想说明一点,我们介绍了bug、错误和异常三个词。不要试图从中文语义的角度去理解这三个词。手机没电是一种异常?日常生活中我们从来不这么说!如果说你的手机永远有电,这个才叫“异常”。Anyway,bug、错误和异常只是代表三种不同的概念,bug 是设计和实现上的固有缺陷。错误是运行时发生的用户错误,各种软硬件系统错误或突发的情况,异常只是错误这个概念在面向对象语言中的另外一种描述。
通过上面的逛街和汽车这两个现实生活中的例子,我希望大家能对bug 和错误这两个概念有进一步的了解。如果明白了这两者的区别,那么你就可以采用不同的策略来分别应对它们。对于bug,我们希望尽早发现并修正他们,越早越好。就像上面车的例子,如果能在车出厂前发现踏板的缺陷,要比都卖出去了以后才发现好太多。发现并修正bug 的过程叫做(调试)debug。而对于各种错误,我们希望能预见并在运行的时候有效处理它们,这个过程通常叫做Error-handle。例如,一般车上都有一个备胎,一旦发生了爆胎这种错误,可以马上处理掉这种错误,然后继续开。正所谓:上帝给你关上了门,没关系,他还会给你留一扇窗。如果你换完了备胎才发现车没油了,也没关系。如果上帝关上了门,同时也关上了窗户,那就是他老人家想见你了。
下面的13.2 节中,主要介绍bug 相关的知识;在13.3 节中,介绍了C 语言中利用函数返回值的错误预防与处理机制;最后13.4 节介绍了面向对象语言如C++、C#和Java 中使用的异常处理机制有哪些优点。
程序开发过程中,设计或编码阶段的bug 主要会引发三种错误:它们分别为编译错误、运行错误和逻辑错误。
• 编译错误
o 原因:不符合C 语言基本语法要求
o 表现:编译器的吼叫(error)或嘟囔(warn)
o 杀伤力:★★
o 修改难度:★★
• 运行错误
o 原因:非法操作,如被零除或读取非法内存
o 表现:操作系统终止该程序运行
o 杀伤力:★★★★
o 修改难度:★★★★
• 逻辑错误
o 原因:算法级或逻辑级错误
o 表现:程序正常运行,但是结果不对
o 杀伤力:★★★★★
o 修改难度:★★★★★
为了详细说明各种错误,我在程序13-1 给出了三种错误的对应实例。
程序13-1 三种错误的实例
1 int i / 编译错误,没有分号 /
2 int i = 0;
3 5/i; / 运行错误,被零除 /
4 int i = 0;
5 if(i=0) / 逻辑错误,相等判断实际上是赋值操作 /
6 {
7 printf("i is 0");
8 }
编译错误和警告处理起来比较简单,按照对应的编译器的提示修改即可。对于警告,很多人会简单地忽略,这是不对的。警告就代表编译器对你的描述理解起来有一定的歧义,你最好明白无误地告诉编译器,你到底想干什么。警告有的时候给出了非常重要的信息,例如当你写下if(i=0)这样的语句时,编译器会给出一个警告,如果你修改了这个警告,就有可能避免一个严重的逻辑错误,正所谓“小洞不补,覆水难收”。所以一定要养成关注警告的习惯。
运行错误修改起来比较麻烦,因为没有很明晰的线索。你只能逐步地缩小你的范围,所以最好在debug 版本下处理运行错误,配合使用断点以及各种监视功能,来最终定位并修改bug。关于debug 和release 版本的区别,我们在9.5 节中已经介绍过了。
三种错误中,逻辑错误是最难处理的,你的程序会一直运行,就是结果不对,而且等你发现结果不对的时候,通常为时已晚。一个经典的例子就是:1996 年,由于浮点数转换成整数发生溢出,导致阿丽亚娜5 火箭发射失败。4 亿美元转瞬消失,号称历史上最贵的一个大炮竹。
逻辑错误的起因一部分来源于语言编码层面,如:switch 无break,if(i=0),浮点数判断相等,数组和整型数溢出等。另外一部分来源于算法和设计层面。我曾经亲眼看到一个洗浴中心的广告,有下面的价目表:“洗澡15,搓澡20,洗澡加搓澡30 元”。它的本意很明显,就是鼓励客人洗澡的时候,最好再搓澡,这样好像比分开消费更便宜一些。但是,有一点我一直想不明白,到底是什么情况下,我能单独消费“搓澡”这项服务呢?难道我先在家里洗,然后一路裸奔到澡堂,等搓完澡,再一路裸奔回家……,这其实根本不可行,因为如果你这样做,等你回家后,你会发现你又变脏了!如果你不仔细地分析,你不会发现设计的这个广告词带来的一个潜在的逻辑错误。
上帝只用了7 天就创造了世界,严格的说,应该是6 天,因为第7 天他休息了。相信大家也都看到了,如此短的时间内创造出的世界就是这个鬼样子。然后人类不得不一直对这个世界进行debugging,直到现在,世界也还是勉强在运行。所以说,上帝并不是一个好的程序员。作为一个好的程序员,编码应该放到最后,并且时间应该只占整个任务用时的20%,其余的时间都应该在分析问题,设计算法,而不是一上来就开始编写代码,到最后把自己和任务搞得一团糟。
Bug 最喜欢栖身于复杂的设计中,越复杂的设计,隐含的bug 就越多。以前听说过一个故事,美国NASA 设计出一个超级复杂的、能在失重的太空中使用的圆珠笔,不过这个复杂的圆珠笔经常不好使。头大的美国科学家去问苏联同行是如何解决这个问题的。苏联人说,我们一直都用铅笔啊!这个故事的真实性有待考证,不过确实也揭示了一个道理,那就是简洁的设计可以避免很多bug。
除了简洁,良好的设计外,一些好的编程风格和习惯也可以帮助你减少程序中的bug。这本书从头到尾,一直都在介绍这些好的编程风格和习惯。第2 章介绍了变量名,函数长度以及格式等风格。后续的章节又分别介绍了避免溢出的技巧,多使用强制类型转换,多用括号描述运算的优先级,声明指针时最好同时赋一个初始值,数组使用半闭半开区间等等。这些内容都和各自每一章的内容相关,所以并没有把它们集中在一起,而是分散到不同的章中去。读者在阅读本书的时候,应该对这些好的风格和习惯加深理解,并在实际的工程中多多使用它们。所有这些良好的风格其实都是在践行一个写代码的最根本的原则,那就是书写清晰、简单并直接的代码。不要自作聪明地使用一些高级的语言特性和技巧,使用那些高级特性是因为你真正需要它们,而不是只是因为它们存在。
最后,细心和缜密永远是程序员的第一要求和职业素养。不要对用户的电脑水平做任何乐观地假设。如果你的程序需要处理1 年中的12 个月,不要想当然的只处理1到12 个整型数。想像你的程序正在运行,某些粗心的用户会输入13 的,某些用户会输入July 的,甚至于会有一只猫在键盘上来回走几趟的,无论各种情况,你的程序依然在无故障地运行,这才是一个鲁棒的程序。要不然以后传出去说,你的程序被猫踩死了,这也不是什么光彩的事情。再简单的设计,再好的编程习惯,再细心的程序员也不能100%避免bug 的存在。而且这些bug 也不会无缘无故地暴露自己,往往是产品都到了客户手上,这些bug 引起的错误才会显现出来。但是这个时候再修改bug,成本和代价都太大了。所以一般一个软件在上市之前,都要经过很多测试工作。大型或商用的软件通常都有专职的测试人员和测试设备,并且测试的周期也非常长,以便在软件交付给用户之前就能及时的发现并改正bug。软件的测试是一门庞大,复杂的学科,远远超过了本书的范围,所以这里就不涉及了。
除了专门的后期测试以外,在开发时,有一个目标应该时刻铭记在心,那就是:尽早让bug 现形。C 语言中提供了一个有趣的函数,那就是assert 断言函数,它使得我们在软件开发的过程中,就能够发现一些软件中潜在的bug。下面介绍一下这个函数。
首先,我介绍一些关于assert 函数的技术层面的三个基本知识。
1)assert 函数的原型如下:void assert (int expression)。它的作用就是判断表达式expression 的逻辑值,如果其值为假(即为0),那么它先向stderr 打印一条出错信息,然后通过调用abort 来终止程序运行;如果其值为真,那么它什么也不做。
2)使用assert 的缺点是,频繁地调用会极大地影响程序的性能,增加额外的开销,所以一般只用在开发和调试阶段,不用在发布的产品中。
3)在调试和开发阶段结束后,可以通过在#include <assert.h>的语句之前插入#define NDEBUG 来禁用或关闭assert 调用,示例代码如程序13-2 所示。软件发布后,assert 检查通常是关闭的。
程序13-2 关闭assert 函数
include <stdio.h>
define NDEBUG
include <assert.h>
从上面的三个介绍中可以明显地看出,assert 只用于软件开发和测试阶段,也就是说多用于debug 版,不用于release 版。虽然有一些争论认为,可以在release 版本下使用,但那只是争论,并不是业界的主流。
读到这里,你可能会想,这个函数不就是一个类似于if 判断的语句吗?没错,从技术的角度来说,这就是一个类似于if 判断的语句。技术层面的用法很简单,但是在实际的应用中,真正困扰我们的不是assert 函数的用法,而是在什么地方应该使用assert 函数?在什么地方不应该使用assert 函数?也就是说,assert 的用法的难度并不是难于技术,而是难于时机。
首先,应该在什么地方使用assert 函数,可以这么认为,assert 函数是用来发现正常情况下不应该出现的情况和假设。如果验证后发现了本不该出现的情况和假设,那么就说明可能存在一个bug 了,你应该仔细追踪为什么这个情况出现,找到其中的原因,否则以后可能会有麻烦。
这么说可能有点抽象,还是使用上面提到过的逛街的例子来说明。手机没电了是一种错误。它并不是“正常情况下绝不应该出现的”!对于手机来说,没电是手机经常会出现的一种错误,你不能说手机没电是一种不该出现的情况,事实上,它该出现,同时也经常出现,或者可以说是一种“期待”的错误。所以你要采取错误处理机制来防范它,比如多带一个电池。
但是如果你在逛街前闹肚子,这个可以认为是一种正常情况下不该出现的情况。它并不是一种期待的错误,因为平时我们都不会说“这都三天了,我也该闹肚子了!”平时也不会有人时刻防范自己闹肚子而带上足够的纸,并在厕所方圆50 米范围内活动。在这种情景上下文中,你才应该使用assert 函数。如果逻辑值“没闹肚子”是假的话,assert(没闹肚子)函数会首先取消你的出行计划(abort 你的程序),而且在你想再次逛街之前,你必须查清楚闹肚子的原因。
说完了日常生活中的例子,下面再介绍一下在实际的编写程序的过程中,assert 应该用在什么地方,不应该用在什么地方。
事实上,“不应该出现的情况和假设”完全是与你要解决的问题密切相关的。要根据具体的场景和上下文来定。在assert 之前,你应该先知道不应该的假设有哪些,然后去验证这些假设。除了在特定的上下文中使用外,我们一般经常在下面几种场合下使用assert 函数。
• 在函数开始处检验传入参数的合理性。
• 在调用库函数前,检查传入的参数的合理性。
• 检查变量的合理性和一致性。
• 其它的用于验证应该出现的假设情况,如程序13-3 第7~12 行。
程序13-3 assert 的三种情况
1 void fun(char *p){
2 assert(p != NULL);
3 …
4 }
5 assert(f>0.0f);
6 log(f);
7 if(i%2==0){
8 …
9 }else{
10 assert(i%2==1);
11 …
12 }
程序13-3 中的第2 行指针p,我们要得到指针p 指向的内容,一定会假设p 不是一个空值。但是如果经过assert 验证后发现假设不成立,那么调用fun 函数的部分一定有问题存在。是什么原因传入一个空指针p 给函数fun?你应该遵循这个线索找到原因,而不是简单地再运行一遍程序。在使用log 前,传入的参数应该是正的,所以我们验证assert(f>0.0f);。同样地,如果一个变量代表的是月份,那你可以通过assert(month<=12&&month>=1)来对它的合理性来进行验证。
与此对应的是,如果程序要打开一个文件,但是这个文件不存在,这是一个错误,或者说是一个期待的错误(expect error)。这种期待的错误无论是debug 版还是release 版都会出现,所以应该使用error-handle 的策略,使用if 判断,通过检查fopen 函数返回的值来做出正确的处理,而不是简单地利用assert 来终止整个程序。
最后总结一下assert 的常用用法,assert 不是用来处理错误的,而是用来提示你,一个应该的假设验证失败了,或者可以说,一个正常情况下不该出现的情况出现了。assert 提示你:“你必须关注我,因为可能有一个潜在的bug,为了引起你的注意,对不起,我把程序给abort 了”。所以assert 函数一般被用来帮助你发现开发过程的bug,这回你就会明白,为什么要在用于程序发布的release 版中关闭这个函数。
就算经过了严密的分析和设计,你也不可能一次成功编写程序,你的程序依然会有问题。你应该感谢这些问题,改正这些问题才是一个程序员的价值所在。一个好的程序员不仅会编写程序,更会改正错误的程序。
对待bug,必须有一个正确的态度,一个好的态度应该是首先承认bug 的存在,这个说起来挺简单,但是做起来还真的很难。大部分程序员对待错误的第一个反应就是否认这些错误是由他们的代码引起的,而是想这是用户不会使用或操作系统引起的。以前听说过一种正确地和程序员交流的方式。你不能直接对他说他的程序有bug,他的第一反应就是你不会使。你可以这样说:“你的程序没有错误,就是和我的预想不太一样。”这个时候他的心头就会一惊,心里暗道:“完了,一定是出bug 了!”如果你要再加上一句“我输入相同的值,但是有的时候是这个结果,有的时候是那个结果。”说这话的时候一定要和这个程序员保持一定的距离,因为这个程序员可能会一口鲜血喷出来的。
承认了bug 的存在只是第一步,下一步就是搜集bug 的上下文信息,并根据这些上下文信息去分析,推测及发现引起错误的bug。这一步通常可以配合使用一些常见的下面介绍的调试工具。
为了能够顺利地对一个程序进行调试,《C 语言程序设计》[4]给出了必须掌握的七种debug 武器。
• 蒙汗药——断点;
• 时间机器——单步执行;
• 手术刀——监视窗;
• 显微镜——内存影响;
• 病例——函数调用栈;
• 防火墙——assert;
• 板砖——fprintf。
断点(蒙汗药)可以将快速运行的程序麻翻在地,停止在你设置断点的地方。assert 函数前面已经介绍过了。
fprintf 函数被称为“板砖”,实在是因为这个武器取用方便,不需要任何专业的调试程序和IDE 的帮助,而且威力也不小,无愧“板砖”这一称号。你可以借助fprintf 函数把程序中你想知道的值或者状态输出到一个文件或屏幕上,帮助你定位及分析程序中的错误。事实上,在调试领域有一个更专业的名字与之对应,那就是跟踪语句,在后续的C++和C#语言中,都提供了更专业的Trace 语句来完成这一功能,但是在C 语言中,还没有专门的跟踪语句,所以只好利用fprintf 函数来充当跟踪语句了。一般情况下,首先使用跟踪语句来定位bug 的大致范围,然后采用断点+单步执行+监视窗等工具深入到程序的细节中来最终发现具体的bug。
如果你的程序是Windows 操作系统下的图形界面(GUI)的程序,你可以使用OutputDebugString(OutputString) 这个跟踪语句, 这个函数会把OutputString 输出到System debugger 中。为了查看这个函数的输出,你还需要下载一个DebugView 的应用程序,如图13-1 所示。具体的OutputDebugString 函数和DebugView 应用程序的使用,大家可以上google 查询。
图13-1 DebugView 应用程序
一旦发现了bug,修该bug 通常是件比较简单的事情。但是修改完毕后,一定要再次验证或测试你的程序,看看你的修改是否有效,同时验证一下你的修改是不是又引进了新的bug。
对于程序中的错误,你要有一个良好的心态。我送给大家一句话“没病不死人,不死总有救”。不可否认,有些问题确实比较strange,但是不要一遇到这种问题就埋怨操作系统,埋怨编译器,埋怨上帝。事实上,99.9999%的问题都是你自己的粗心和大意造成的。程序之所以“死”,还是因为它有“病”。“不死总有救”就是说,任何问题都可以解决,一个最简单的方法就是把问题的描述丢给Google,如果没有合适的答案,就换个描述,如果还没答案就换成英文的……。总之,不要惧怕程序出错,每一个bug 都是一个成长的阶梯。如果你一路都走得很顺,很有可能你在走下坡路,这句话在编程领域也同样适用。
如果让我定义程序员和bug 的关系,我想用八个字来形容,那就是“亦师亦友,又爱又恨。”不可否认,每一个bug 都是一位老师,通过经历并解决各种bug,你的水平才能不断的提高。同时,bug 也是你最要好的朋友,只要你从事软件开发一天,那么它就会忠心耿耿地陪伴你一天。当你最开始遇到bug 时,你可能会恨它,你有点沮丧,有点灰心,有点抓狂,有点迷茫,可能还会有点冷。但是当你最终解决了这个bug 后,你又会开始爱上它,因为它让你非常开心,非常自信,非常阳光。
如果你实在害怕bug,最好转行当销售人员。他们处理bug 的方式很简单,那就是当成一个feature。在他们的口中,一个软件几乎是没有bug 的,只有穿了衣服的feature,如图13-2 所示。如果你的程序不幸挂了,他们会说这是程序的一个新的feature,是为了保护你的视力和让你得到休息而专门设计的。
图13-2 bug 和feature 的区别
如果说debug 是一个不断去掉错误的过程,那么编程序就是一个不断加入错误的过程。你别指望着有一天程序里面没有任何bug,因为在你编写程序的同时,你会不断加入新的bug。如果你是一名程序员,你就可能一辈子与debug 相伴。就像“我爱你”三个字,讲出来只要三秒钟,解释要三小时,证明却要一辈子。“bug”三个字母,看到需要三秒,找到需要三小时,debug 却要一辈子。正所谓,为系统而生,为框架而死,为debug 奋斗一辈子!吃符号的亏,上大小写的当,最后死在需求上。这,也许就是一个程序员的宿命吧!
上一节介绍了bug 引起的各种错误。下面介绍另外一大类错误,那就是由运行时各种变化的条件来触发的错误。一般情况下,在程序内部,这种错误多发生在文件系统、内存和数据相关的操作上;同时,各种外围设备,如硬盘、网络、打印机等的故障也会引发程序的错误;最后,用户的误输入或操作也会引发程序运行错误。
一般情况下,程序都是使用各种库函数来操作系统中的文件,申请、释放和操作内存以及完成I/O 操作的。对于各种数据运算,C 语言运行库也提供了相应的数学运算函数。当程序调用各种对应的库函数的时候,无法保证库函数一定能正常地工作。错误的原因多种多样,有的时候我们传入一个非法的值,如对sqrt 函数传入一个负数;有的时候错误来源于操作系统或底层的设备,比如打开一个并不存在或没有访问权限的文件,或者申请一块过大的内存,或者要连接的打印机正忙等等。
发现错误的最常用的方法就是查看调用库函数的返回值。在使用库函数之前,你应该仔细阅读库函数的参考文档。一般每个库函数的参考文档都会告诉你,它会返回一个什么样的值来通知你发生了错误。例如,函数fopen 如果返回一个NULL 的值,那就是打开文件失败了;malloc 如果返回一个NULL 的值,那就是内存申请失败了。
本书的12.6 节,已经给出了有关文件操作常见函数的各种错误返回值以及通常的处理策略。在处理文件的时候,应该对这一部分内容非常熟悉,毕竟文件系统是软件错误的重灾区。同时,在实际的开发工作中,你也应该搜集并整理这些常见的错误处理策略。
既然发现了错误,下一个问题就是错误发生的原因是什么呢?如果打开文件失败,那么也许文件并不存在,或者你没有打开的权限,这个时候你可以使用perror 函数来得到具体的原因。程序13-4 中,如果你试图打开了一个文件more_handsome_than_I,函数perror 会输出“more_handsome_than_I:No such file or directory”。
程序13-4 perror 函数实例
include <stdio.h>
include <errno.h>
include <stdlib.h>
int main(void){
FILE *fp ;
fp = fopen( "more_handsome_than_I", "r+" );
if ( NULL == fp ){
printf("%d\n",errno);
perror("more_handsome_than_I");
}
}
perror 的输出一共分为两部分,第一部分是传入的字符串。这个字符串用来标识你程序中具体的出错线索或者是出错的位置,例如你是打开哪个文件了或者是在什么函数中等,这样有助于你快速地定位错误发生的位置;然后是一个冒号,冒号的后面打印出造成这个错误的原因。这个错误的原因用一个字符串来描述,并在标准库中定义。
每一个错误原因字符串对应一个整数编号。一旦某个库函数发生了错误,这个库函数就把标准库中的全局变量errno 设置为对应的一个非零值的整数编号。perror 函数根据errno 的值,找到与之对应的错误原因字符串并打印出来。如果明白了这个机制,你就会明白strerror 函数的用途了,它输入一个errno 值,打印出与之对应的错误原因字符串。
利用函数返回值来通知错误是最自然的一种方法,但是这种方法有的时候也有限制,那就是有的函数返回值并不适合用来定义错误状态。错误的状态值应该是一个整数,这样它才有唯一的、无歧义的值来定义错误。但是有些函数,例如sqrt 函数,正常情况下,它要返回一个浮点数的值,这个时候返回值就不能用来通知错误了。
上面我们讲过,库函数中如果发生了错误,会把errno 设为一个非零的值,这个时候就可以使用这个方法了。我们先把errno 设为0,就是一种正确的状态,这一点很重要,因为任何库函数在使用前都不会主动地将errno 设为0,我们只能手工去完成。当调用完相关函数以后,再去查看errno 的值,如果大于零,说明发生了错误了。这种判断程序错误的方法如程序13-5 所示。
程序13-5 利用errno 来通报错误
include <stdio.h>
include <errno.h>
include <stdlib.h>
main(void){
errno=0
sqrt(-1.0);
if ( errno>0 ){
perror("sqrt ");
}
}
什么库函数会设置errno,每个库函数都有详细的说明,大家可以分别参考。这里需要注意的是,根据函数的返回值来判断错误才是主流方法,errno 和perro 一般只用于得到错误的原因,或者用于函数的返回值不能正确指示错误的时候,如sqrt 函数等,所以使用的机会并不是太多。
对于发现的各种错误该如何处理,这是一个和具体应用密切相关的一个话题,完全取决于你的软件的用途,应用的场景和具体的各种错误类型。例如,如果你的程序只是一个普通的查询程序,你发现传入sqrt 函数的负数是用户输入的,这个时候没必要终止整个程序,大不了让用户重新输入一个正数。但是如果在一个航天控制软件中发现传入sqrt 函数的是一个负数,那就不是终止整个软件程序那么简单了,很有可能整个软硬件系统都需要从头到尾重新检查一遍了。
对于开发者来说,对于出现的各种错误,如果需要和用户配合解决的,就应该通过消息框或各种提示文本,对用户提供简洁、清晰的提示。对于不需要用户知道的各种错误状态,应该通过日志功能记录在案,这样以便日后进行进一步的分析。通常大型或商用的软件都有其配套的日志功能,通过日志可以清楚的看到运行这个软件的各种情况,包括各种出错情况。
如果不想使用复杂的日志功能,可以使用5.2 节介绍过的stderr,把错误信息输出到标准错误流中去。标准错误流一般指向屏幕,这样错误流和正常的输出流就混杂在一起,不是非常方便查看,我们可以使用5.2 节介绍过的重定向功能把stderr 重定向为一个文件。
为了了解异常处理机制的优点,必须首先分析一下利用函数的返回值来返回错误状态有哪些缺点,只有这样才能更好地理解异常处理机制的优点。
首先,函数返回的结果很容易被忽略。这显而易见,一般的程序员更愿意关注工作的逻辑,而不是把精力放到错误处理上。这就造成了很多程序员一般都不愿意关注琐碎的、可能出现的各种错误状态。例如,我编了15 年程序了,但是我从来没有检查过printf 函数的返回值,也许你不相信,但是printf 函数真的会返回一个负数来表示它出错了。但是一般情况下,包括我,好像真的没有多少人关注过printf 函数的返回值!如果你忽略函数返回的错误提示,一般函数本身不会主动终止你的程序,
这就使得你的程序会带着错误运行,结果就是没人知道结果会是什么。
如果你真的想检查每一个函数的返回值来看是否出错了,你就会发现你的程序会变得异常的混乱,100 行代码中可能只有20%在处理正常的工作逻辑,而剩下的80%都是在处理各种各样的错误。我们在程序13-6 中给出了一个实例,这个函数只是打开并读入一个文件的内容。完成正常功能的语句我用斜体突出地表示了出来,即使是这样,我相信在你阅读fun1 时,也会感到十分吃力的。这样的代码不仅难以读懂和维护,而且还变得复杂。上面说过,bug 最喜欢呆在复杂的设计和代码中,结果就是你为了防范错误,而引入了新的错误。
程序13-6 复杂的错误处理机制
int fun1
{
int errorCode = 0;
open the file;
if (theFileIsOpen) {
determine the length of the file;
if (gotTheFileLength) {
allocate that much memory;
if(gotEnoughMemory) {
read the file into memory;
if(readFailed) {
errorCode = -1;
}
}else{
errorCode = -2;
}
}else{
errorCode = -3;
}
close the file;
}else{
errorCode = -4;
}
return errorCode;
}
本节的代码和内容借鉴了网络上的《The Java Tutorials》里面的关于异常优点的一个介绍(http://docs.oracle.com/javase/tutorial/essential/exceptions/advantages.html)。不瞒大家说,原始的那个版本更加的复杂。当我试图理解原始的那个版本时,它把我也搞糊涂了,所以我决定把原始的代码做了精简,也就是说,你现在看到的fun1 是我精简以后的版本。就算是做了精简,就算是使用斜体字来突出显示正常的工作逻辑,结果你也看到了,确实还是不太容易理清头绪。
与上面的利用返回值返回错误状态的方法相比,异常处理机制有以下的优点。
优点1:把错误处理代码从正常的代码中分离出来,使得整个代码的逻辑变得清晰。还是以上面的例子为例,通过使用异常处理来处理错误,整个代码如程序13-7所示。
程序13-7 简单的异常处理机制
fun1 {
try {
open the file;
determine its size;
allocate that much memory;
read the file into memory;
close the file;
} catch (fileOpenFailed) {
doSomething;
} catch (sizeDeterminationFailed) {
doSomething;
} catch (memoryAllocationFailed) {
doSomething;
} catch (readFailed) {
doSomething;
}
}
可以看出,异常和正常工作逻辑很好地分隔开了。
优点2:异常可以高效率地在调用的堆栈上传递。
这个优点通过一个实例可能就更好理解了。假设上面的fun1 函数被函数method3 调用,同时函数method3 又被函数method2 调用,最后函数method2又被函数method1 调用。这种嵌套调用的情形在软件系统中是经常出现的。
现在的问题是,对于fun1 函数中出现的各种错误,只有函数method1 才知道该如何正确地处理。这个时候,我们可以想象利用fun1 函数的返回值来通知到函数method1 的话,函数method2 和函数method3 就必须充当一个中继者来传递这些错误的返回值。这不仅低效,而且还使得函数接口的设计变得复杂。设想,如果methods3 也需要返回一些错误状态,同时methods2 也需要返回一些错误状态。那么,在上层的函数method1 哪里,你将会面临一个接口异常复杂的函数methods2,它可能会返回20 个错误的状态,错误值从-1 到-20。那么请你马上回答我,返回-1 代表发生了什么错误?返回-10 又代表了什么?这种复杂的函数接口设计无疑是不优雅的,也为未来的程序错误埋下了伏笔。
如果使用异常机制,你就不需要重新更改函数method2和函数method3的接口,你只需要在fun1 中throw 一个异常,然后在method1 中利用catch 语句抓住这个异常就可以了。fun1 中throw 出的异常会自动的在调用的堆栈中向上传递,直到在某一个特定的调用层次上被一个对应的catch 语句抓住为止。这个时候,method1函数就可以简单地如程序13-8 所示。
程序13-8 异常在调用堆栈上传递
method1 {
try {
call method2;
} catch (fun1exception) {
doErrorProcessing;
}
}
优点3:可以把不同的异常类型进行分组以便区分。
异常是在C++和Java 等面向对象的语言中被引入的,所以程序中抛出的异常都是对象,在C++和Java 语言中,所有的异常类都是Exception 类的子类。各个子类代表了不同领域的异常类型。例如,在java.io 中定义的异常类都是IOException 和它的子类。IOException 是最普遍的,代表了在执行I/O 操作时发生错误的任何异常类型。它的子类就各自代表特定的错误,例如FileNotFoundException 就是无法在磁盘上定位到文件时发出的一种异常等。
当然,用户也可以构造自定义的异常类MyExpception,只要使得构造的自定义的异常类继承于Exception 基类就可以了。这让你构造的异常就会融合进异常类型的大家庭中了。
有了这种分组和继承方式,在编写代码的时候,我们就可以采用先特殊,后一般的方式来catch 异常,如程序13-9 所示。
程序13-9 异常的catch 语句顺序
method1 {
try {
call method2;
} catch (FileNotFoundException) {
doSpecificProcessing;
} catch (IOException) {
doIOProcessing;
} catch (Exception) {
doGeneralProcessing;
}
}
一般情况下,我们把最特殊的异常放到最前面处理,把最一般的异常情况放到最后来处理,由于我们自定义的异常MyExpception 也是继承与Exception 基类,所以即使在程序中你忘记了catch 你的MyExpception,MyExpception 也会在程序13-9 中被最后一个异常捕捉语句中被捕捉到。
异常处理机制与assert 函数有点类似,在技术层面上都很简单,三言两语也就介绍完了,但是难就难在什么时候用,如何高效率地用。要解决这个问题,你不仅需要对要解决问题的深刻理解,同时还需要多年的软件开发经验。虽然在使用上与具体的问题相关,但是在战略上还是有一些基本的应用策略,下面就来简单的介绍一下。
1.什么时候使用异常
与函数返回错误值不同,任何抛出来的异常都要被处理,如果不处理,异常会沿着调用堆栈一直传递到调用的最上层,然后会终止你的程序。所以你没有办法忽略掉抛出来的异常。所以异常应该用于真正的“异常”情况,也就是说,如果这个错误不修正,整个程序将没有办法继续运行。
所谓的异常处理的高效率,也只是相对而言,并不是完全没有效率损失。异常底层的实现借助于栈回退(Stack Unwind)的一种技术,具体的细节大家可以参考“C++异常机制的实现方式和开销分析”这篇文章,大家可以利用这个文章的标题输入Google 就可以查询到这篇文章了。所以一般情况下,应该避免在循环语句中使用异常。
同时也不应该利用异常机制来完全代替函数的返回值。返回值通常用于返回状态,而不是错误。一般就算是用它返回错误信息,也是那种可以忽略的错误信息。或者用在一个循环中,因为在循环中使用异常有性能的代价。
2.抛出什么?
利用throw 可以抛出任何东西,甚至于一个整数,但是抛出一个继承于基类的子类或自定义子类对象要好很多,有个这个异常类的等级,我们处理特定的问题就会简单一些,而且日后加入更多异常对象时,也不用更改任何的现有的异常处理代码。
3.避免使用空的catch 语句
如果catch 语句为空,那么只是捕获了异常,但是我们不做任何处理。这种空的catch 语句一般多出现捕捉基类异常的语句中,如catch(Exception)中。当程序捕捉到一个基类异常的时候,我们一般不知道何时、何地这个异常被抛出的,所以也不好采用什么特定的手段去处理,所以就写了这么一个空catch 语句。而且你会发现,如果不写这个空catch 语句,你的程序根本就运行不起来,它总是被各种各样的异常所终止,但是一旦你写了一个空的catch 语句,你的程序终于能运行起来了,对开发者来说,还有什么能比程序运行起来更让人高兴的事吗?
但是你可能没意识到,你正在采用一种“鸵鸟”的策略来处理错误,那就是把头插到沙子里,然后默认错误不存在。我们上面讲过,异常的出现说明你的程序有错误,如果你用空的catch 语句来屏蔽掉这些错误,虽然你的程序没有崩溃,但是有错误存在,这种程序一旦进入到用户的手里,造成的损失将是指数级别的。
所以说,不要采用空的catch 语句,如果你实在不知道该如何处理,至少应该加一个提示语句,或者重新再抛出这个异常。
你的程序并没有按照你的设想运行。这其中可能有两大类原因。本章首先介绍了这两大类原因。它们分别为bug 和运行时各种变化的条件来触发的错误。对于bug,主要介绍了bug 的预防、发现以及常见的debug 工具和方法。对于错误,我们分别介绍了C 和C++语言中的两种不同的错误处理机制,它们分别为利用返回值处理和利用异常处理机制处理,其中,异常处理机制更具优点。
异常处理是一个很大的话题,不可能在很短的篇幅介绍完全。当它和类和对象一起使用的时候,情况更是复杂,因为还涉及到对象的析构问题。所以对这一部分感兴趣的同学,可以参考C++语言或Java 语言专门的书籍,对这一部分加深了解。本书只是从错误处理的角度,与C 语言中利用函数返回值的方法来做对比,简单地介绍了一些关于异常处理机制的基本知识。
虽然异常处理有很多优点,但是这毕竟是一本C 语言的书,如果不能在C 语言中使用异常处理,那介绍这些又有什么用处呢?从理论上讲,C 语言不支持异常处理机制,这没有错。但是在实际上,你可以把你的C 语言文件名从f.c 改成f.cpp,然后再重新编译一遍,这个时候,你就可以在f.cpp 中使用异常机制了。即使你根本没有用过任何C++语言中的类(class)和对象(object),但是f.cpp 这个名字告诉了编译器,请按C++语言标准来编译我。目前的编译器都是同时支持C 和C++语言的,所以这个技巧一般都好用。不过,对于不同的编译器,你还应该查询各自的说明文档,看看他们对应的异常处理机制的编译开关是什么,并打开这些编译开关。
最后请记住,错误永远存在。这是因为我们创造错误的能力远远大于我们消除错误的能力。