在软件开发过程中,不可避免的会遇到错误处理,而且这部分对于整个软件的健壮性有非常大的作用,它是软件除了功能性以外最重要的指标了,一个软件成功与否与其健壮性有很大的联系。我在以前的开发中也时常思考错误处理,因为这部分代码逻辑比较不容易梳理清楚。以异常的处理为例,以前通常就采用比较简单粗暴的处理方式:用try..catch加Exception把所有异常都包起来,这样简单省事,写的代码最少,相信很多童鞋曾经跟我一样写过这样的代码,很明显,这样写有很大的问题,最主要的问题在于:
- Exception会吃掉所有可以处理的异常,使得对于某些我们关心的异常无法捕获,因为对于不同的异常我们可能需要做不同的处理,有些可以在本函数内处理掉,有些需要提示用户(例如文件不存在,网络无法访问),有些需要告诉上一层代码该如何处理,所有这些在直接用Exception处理异常时都无法做到,简而言之就是无法做到异常的精细化处理;
那么怎么做才好呢?这部分代码真不少,虽然无关软件功能性,但是确是健壮性的基础。具体如何处理这些没有完全标准的答案,软件设计本来就是一项带有艺术色彩的智力劳动,没有一劳永逸的解决方案,最关键的在于掌握好基础知识,因地制宜地采取措施。下面主要谈谈实现健壮性的基本技术,基本的实现软件健壮性的技术有以下几种:
- 断言
- 错误处理
- 异常
- 从设计上简化异常处理的技术;隔离程序
- 辅助调试的代码(print打印之类的小段函数)
1 断言
断言是个非常常用的软件设计技术,基本上现代的高级程序设计语言都支持它,那么断言到底是什么?很简单,就是一个判断一个布尔表达式的语句,如果这个布尔表达式为真,不会有任何效果,但是如果为假,根据不同实现技术会出现不同的效果,但是基本上都是会告诉程序员(注意,不是用户),这里有一个断言,去看一下。
上面是一个简单的形式化的定义,从定义中我们不难看到,断言是给程序员看的,为什么?用来查找bug。所以我们应该在内部逻辑的问题上使用断言去检查一些理论上不可能发生的情况,因为如果发生了就说明内部逻辑有问题,也就是有bug了。
举个简单的例子,例如某个函数有一个参数,这个参数是某数据流,这个数据流是软件下层通过读取文件传进来的,调用这个函数的时候,内部逻辑已经确定是正确读取到了文件,否则是不会调用这个函数的,那么,一般会在函数开头,对这个参数用断言加以检查,如果不幸,出现问题,就说明内部逻辑错了(读取失败仍然调用?内存被意外析构?),这就是典型的通过断言查找程序bug的例子。
有一点需要注意,断言是用来检测程序内部逻辑的,如果是和外部有数据交流,就不是断言的范畴,因为外部的情况,程序是不能假定的,既然不能假定,就无法设断言了,那应该是错误处理或者异常的范畴了,因此,理解断言的关键点在于,作用于内部逻辑,用来查找bug。通常,现代编译工具都会在编译release版本软件的时候去掉异常,因为异常是给程序员看的。
2 错误处理
错误处理可以说是软件健壮性的核心,程序员在编写软件的时候,应该尽可能的预测到可能发生的错误,并对这些错误进行处理,正常情况下要对这些错误进行分类,
- 重大错误,这类错误一般不可恢复,通常的做法都是报告后直接退出,类似windows中的蓝屏,普通程序在遇到堆栈溢出,内存不足等错误时也是会这样做;
- 无关用户的一般性错误,这类错误一般情况下不会导致程序退出,而且和用户没有直接的联系,这时最好的做法是能自动恢复并解决,如果不行,可以写入日志,以便以后进行排查,不过通常情况下需要用相对抽象的语言告诉用户(例如,程序遇到问题,可能是某些文件找不到),只是为了让用户知道这个操作没有成功,具体的技术原因可以写入日志。
- 与用户相关的一般性错误,这类错误通常是由于用户输入错误数据引起,例如本来程序UI需要用户输入年龄,结果用户输错,填入的不是整数。这个时候,通常需要告诉用户,让用户重新输入,以达到自动恢复的作用。所以通常的做法,都是弹出对话框(有UI)或者输出提示到标准输出(无UI);
理解错误处理的关键在于分清楚项目需要处理错误的类型,以及如何处理(集中处理?写入日志吗?通过网络提交错误报告?),要根据项目的类型设计好采取的策略(例如Service一类的通常都是只记入日志(会有各种日志,函数调用日志,错误日志,性能日志等等),因为不直接和用户打交道),具体情况具体分析地设计错误处理策略,并对不同的错误采取恰当的处理方式。
3 异常
异常是指程序无法预料到的情况引发的错误,通常本函数不知道这种错误该如何处理需要让调用方决定(例如系统库函数,像.NET的库函数都会有抛出异常的列表)。这通常是由语言支持的,在遇到异常而又没有捕获时,会中断本函数的执行去查看调用方是否处理,这就有了一种直接中断函数处理的方式,有人会说为什么不直接return呢?是的,return可以达到中断函数执行,但是却无法像异常那样让调用方针对特定的异常做出特定处理,毕竟return的东西有限,无法表示错误的类型,通常都只能返回一个false。
以.NET的CLR对异常处理机制(两轮遍历)为例:
- 发生异常后,CLR先去在引发异常的那一层搜索catch语句,看看有没有兼容此类型异常的处理代码,如果没有,则跳到上一层去搜索,如果还没有,则再上一层,直到应用程序的最顶层,此即为第一轮,查找合适的异常处理程序。
- 如果在某一层找到了异常处理处理程序,CLR不会马上执行,而是回到事故现场再次进行第二轮遍历,执行所有中间层次的finally语句块。
可见,异常的出现使得我们对于无法在本函数(局部)处理的错误提供了一种强大的手段,使得我们能够清楚的告诉函数调用链的上层,某函数发生错误了,需要处理。所以,理解异常,就要知道它是处理无法在本函数处理的错误,同时,一般情况下不要用Exception吃掉所有的异常,而要对异常进行精细化处理。但是也不是完全不用它,因为没有处理的异常通常会导致程序直接崩溃,这对用户非常不友好,所以处理异常要特别谨慎,我通常会在函数调用链的顶层使用Exception,并计入日志,以防止这一情况的发生。
4 隔离程序以简化错误处理
这是一种在设计上简化错误处理的策略,事实上,如果所有的代码都做异常和错误处理,会使代码变得臃肿,可读性下降,我们需要在高层次上面避免这种情况的发生,这个思想来自代码大全,不过实际开发中也已经用到了,这里做个总结。我比较同意这种设计思想,本质上,它是将错误和异常处理集中化,通常的软件设计实际上都是对数据进行处理和再加工,以及展现,很大一部分的错误都是由于不正确的数据设置导致的,那么我们可以把数据的错误处理专门用一层来处理以使得内部的逻辑可以不用对数据进行检测,见下图:
上图很清晰的说明了这一过程,简而言之,就是专门增加了一层来专门处理数据,以解放内部逻辑,这样结构更加清晰。
5 总结
我始终认为软件的好坏与其健壮性有很大的联系,所有的软件开发人员都要对它有足够的重视,从一点一滴开始做起,不要忽视任何的细节,不能盲目依赖测试去发现bug,而是以测试驱动编程,不断地思考可能发生的问题以进行预防,这才是防御式编程,在这里做个记录,与诸君共勉,马上又要重构代码去了:)。
参考文献