Java编程的逻辑 (25) - 异常 (下)
上节我们介绍了异常的基本概念和异常类,本节我们进一步介绍对异常的处理,我们先来看Java语言对异常处理的支持,然后探讨在实际中到底应该如何处理异常。
异常处理
catch匹配
上节简单介绍了使用try/catch捕获异常,其中catch只有一条,其实,catch还可以有多条,每条对应一个异常类型,比如说:
try{ //可能触发异常的代码 }catch(NumberFormatException e){ System.out.println("not valid number"); }catch(RuntimeException e){ System.out.println("runtime exception "+e.getMessage()); }catch(Exception e){ e.printStackTrace(); }
异常处理机制将根据抛出的异常类型找第一个匹配的catch块,找到后,执行catch块内的代码,其他catch块就不执行了,如果没有找到,会继续到上层方法中查找。需要注意的是,抛出的异常类型是catch中声明异常的子类也算匹配,所以需要将最具体的子类放在前面,如果基类Exception放在前面,则其他更具体的catch代码将得不到执行。
示例也演示了对异常信息的利用,e.getMessage()获取异常消息,e.printStackTrace()打印异常栈到标准错误输出流。通过这些信息有助于理解为什么会出异常,这是解决编程错误的常用方法。示例是直接将信息输出到标准流上,实际系统中更常用的做法是输出到专门的日志中。
重新throw
在catch块内处理完后,可以重新抛出异常,异常可以是原来的,也可以是新建的,如下所示:
try{ //可能触发异常的代码 }catch(NumberFormatException e){ System.out.println("not valid number"); throw new AppException("输入格式不正确", e); }catch(Exception e){ e.printStackTrace(); throw e; }
对于Exception,在打印出异常栈后,就通过throw e重新抛出了。
而对于NumberFormatException,我们重新抛出了一个AppException,当前Exception作为cause传递给了AppException,这样就形成了一个异常链,捕获到AppException的代码可以通过getCause()得到NumberFormatException。
为什么要重新抛出呢?因为当前代码不能够完全处理该异常,需要调用者进一步处理。
为什么要抛出一个新的异常呢?当然是当前异常不太合适,不合适可能是信息不够,需要补充一些新信息,还可能是过于细节,不便于调用者理解和使用,如果调用者对细节感兴趣,还可以继续通过getCause()获取到原始异常。
finally
异常机制中还有一个重要的部分,就是finally, catch后面可以跟finally语句,语法如下所示:
try{ //可能抛出异常 }catch(Exception e){ //捕获异常 }finally{ //不管有无异常都执行 }
finally内的代码不管有无异常发生,都会执行。具体来说:
- 如果没有异常发生,在try内的代码执行结束后执行。
- 如果有异常发生且被catch捕获,在catch内的代码执行结束后执行
- 如果有异常发生但没被捕获,则在异常被抛给上层之前执行。
由于finally的这个特点,它一般用于释放资源,如数据库连接、文件流等。
try/catch/finally语法中,catch不是必需的,也就是可以只有try/finally,表示不捕获异常,异常自动向上传递,但finally中的代码在异常发生后也执行。
finally语句有一个执行细节,如果在try或者catch语句内有return语句,则return语句在finally语句执行结束后才执行,但finally并不能改变返回值,我们来看下代码:
public static int test(){ int ret = 0; try{ return ret; }finally{ ret = 2; } }
这个函数的返回值是0,而不是2,实际执行过程是,在执行到try内的return ret;语句前,会先将返回值ret保存在一个临时变量中,然后才执行finally语句,最后try再返回那个临时变量,finally中对ret的修改不会被返回。
如果在finally中也有return语句呢?try和catch内的return会丢失,实际会返回finally中的返回值。finally中有return不仅会覆盖try和catch内的返回值,还会掩盖try和catch内的异常,就像异常没有发生一样,比如说:
public static int test(){ int ret = 0; try{ int a = 5/0; return ret; }finally{ return 2; } }
以上代码中,5/0会触发ArithmeticException,但是finally中有return语句,这个方法就会返回2,而不再向上传递异常了。
finally中不仅return语句会掩盖异常,如果finally中抛出了异常,则原异常就会被掩盖,看下面代码:
public static void test(){ try{ int a = 5/0; }finally{ throw new RuntimeException("hello"); } }
finally中抛出了RuntimeException,则原异常ArithmeticException就丢失了。
所以,一般而言,为避免混淆,应该避免在finally中使用return语句或者抛出异常,如果调用的其他代码可能抛出异常,则应该捕获异常并进行处理。
throws
异常机制中,还有一个和throw很像的关键字throws,用于声明一个方法可能抛出的异常,语法如下所示:
public void test() throws AppException, SQLException, NumberFormatException { //.... }
throws跟在方法的括号后面,可以声明多个异常,以逗号分隔。这个声明的含义是说,我这个方法内可能抛出这些异常,我没有进行处理,至少没有处理完,调用者必须进行处理。这个声明没有说明,具体什么情况会抛出什么异常,作为一个良好的实践,应该将这些信息用注释的方式进行说明,这样调用者才能更好的处理异常。
对于RuntimeException(unchecked exception),是不要求使用throws进行声明的,但对于checked exception,则必须进行声明,换句话说,如果没有声明,则不能抛出。
对于checked exception,不可以抛出而不声明,但可以声明抛出但实际不抛出,不抛出声明它干嘛?主要用于在父类方法中声明,父类方法内可能没有抛出,但子类重写方法后可能就抛出了,子类不能抛出父类方法中没有声明的checked exception,所以就将所有可能抛出的异常都写到父类上了。
如果一个方法内调用了另一个声明抛出checked exception的方法,则必须处理这些checked exception,不过,处理的方式既可以是catch,也可以是继续使用throws,如下代码所示:
public void tester() throws AppException { try { test(); } catch (SQLException e) { e.printStackTrace(); } }
对于test抛出的SQLException,这里使用了catch,而对于AppException,则将其添加到了自己方法的throws语句中,表示当前方法也处理不了,还是由上层处理吧。
Checked对比Unchecked Exception
以上,可以看出RuntimeException(unchecked exception)和checked exception的区别,checked exception必须出现在throws语句中,调用者必须处理,Java编译器会强制这一点,而RuntimeException则没有这个要求。
为什么要有这个区分呢?我们自己定义异常的时候应该使用checked还是unchecked exception啊?对于这个问题,业界有各种各样的观点和争论,没有特别一致的结论。
一种普遍的说法是,RuntimeException(unchecked)表示编程的逻辑错误,编程时应该检查以避免这些错误,比如说像空指针异常,如果真的出现了这些异常,程序退出也是正常的,程序员应该检查程序代码的bug而不是想办法处理这种异常。Checked exception表示程序本身没问题,但由于I/O、网络、数据库等其他不可预测的错误导致的异常,调用者应该进行适当处理。
但其实编程错误也是应该进行处理的,尤其是,Java被广泛应用于服务器程序中,不能因为一个逻辑错误就使程序退出。所以,目前一种更被认同的观点是,Java中的这个区分是没有太大意义的,可以统一使用RuntimeException即unchcked exception来代替。
这个观点的基本理由是,无论是checked还是unchecked异常,无论是否出现在throws声明中,我们都应该在合适的地方以适当的方式进行处理,而不是只为了满足编译器的要求,盲目处理异常,既然都要进行处理异常,checked exception的强制声明和处理就显得啰嗦,尤其是在调用层次比较深的情况下。
其实观点本身并不太重要,更重要的是一致性,一个项目中,应该对如何使用异常达成一致,按照约定使用即可。Java中已有的异常和类库也已经在哪里,我们还是要按照他们的要求进行使用。
如何使用异常
针对异常,我们介绍了try/catch/finally, catch匹配、重新抛出、throws、checked/unchecked exception,那到底该如何使用异常呢?
异常应该且仅用于异常情况
这个含义是说,异常不能代替正常的条件判断。比如说,循环处理数组元素的时候,你应该先检查索引是否有效再进行处理,而不是等着抛出索引异常再结束循环。对于一个引用变量,如果正常情况下它的值也可能为null,那就应该先检查是不是null,不为null的情况下再进行调用。
另一方面,真正出现异常的时候,应该抛出异常,而不是返回特殊值,比如说,我们看String的substring方法,它返回一个子字符串,它的代码如下:
public String substring(int beginIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } int subLen = value.length - beginIndex; if (subLen < 0) { throw new StringIndexOutOfBoundsException(subLen); } return (beginIndex == 0) ? this : new String(value, beginIndex, subLen); }
代码会检查beginIndex的有效性,如果无效,会抛出StringIndexOutOfBoundsException。纯技术上一种可能的替代方法是不抛异常而返回特殊值null,但beginIndex无效是异常情况,异常不能假装当正常处理。
异常处理的目标
异常大概可以分为三个来源:用户、程序员、第三方。用户是指用户的输入有问题,程序员是指编程错误,第三方泛指其他情况如I/O错误、网络、数据库、第三方服务等。每种异常都应该进行适当的处理。
处理的目标可以分为报告和恢复。恢复是指通过程序自动解决问题。报告的最终对象可能是用户,即程序使用者,也可能是系统运维人员或程序员。报告的目的也是为了恢复,但这个恢复经常需要人的参与。
对用户,如果用户输入不对,可能提示用户具体哪里输入不对,如果是编程错误,可能提示用户系统错误、建议联系客服,如果是第三方连接问题,可能提示用户稍后重试。
对系统运维人员或程序员,他们一般不关心用户输入错误,而关注编程错误或第三方错误,对于这些错误,需要报告尽量完整的细节,包括异常链、异常栈等,以便尽快定位和解决问题。
对于用户输入或编程错误,一般都是难以通过程序自动解决的,第三方错误则可能可以,甚至很多时候,程序都不应该假定第三方是可靠的,应该有容错机制。比如说,某个第三方服务连接不上(比如发短信),可能的容错机制是,换另一个提供同样功能的第三方试试,还可能是,间隔一段时间进行重试,在多次失败之后再报告错误。
异常处理的一般逻辑
如果自己知道怎么处理异常,就进行处理,如果可以通过程序自动解决,就自动解决,如果异常可以被自己解决,就不需要再向上报告。
如果自己不能完全解决,就应该向上报告。如果自己有额外信息可以提供,有助于分析和解决问题,就应该提供,可以以原异常为cause重新抛出一个异常。
总有一层代码需要为异常负责,可能是知道如何处理该异常的代码,可能是面对用户的代码,也可能是主程序。如果异常不能自动解决,对于用户,应该根据异常信息提供用户能理解和对用户有帮助的信息,对运维和程序员,则应该输出详细的异常链和异常栈到日志。
这个逻辑与在公司中处理问题的逻辑是类似的,每个级别都有自己应该解决的问题,自己能处理的自己处理,不能处理的就应该报告上级,把下级告诉他的,和他自己知道的,一并告诉上级,最终,公司老板必须要为所有问题负责。每个级别既不应该掩盖问题,也不应该逃避责任。
小结
上节和本节介绍了Java中的异常机制。在没有异常机制的情况下,唯一的退出机制是return,判断是否异常的方法就是返回值。
方法根据是否异常返回不同的返回值,调用者根据不同返回值进行判断,并进行相应处理。每一层方法都需要对调用的方法的每个不同返回值进行检查和处理,程序的正常逻辑和异常逻辑混杂在一起,代码往往难以阅读理解和维护。
另外,因为异常毕竟是少数情况,程序员经常偷懒,假定异常不会发生,而忽略对异常返回值的检查,降低了程序的可靠性。
在有了异常机制后,程序的正常逻辑与异常逻辑可以相分离,异常情况可以集中进行处理,异常还可以自动向上传递,不再需要每层方法都进行处理,异常也不再可能被自动忽略,从而,处理异常情况的代码可以大大减少,代码的可读性、可靠性、可维护性也都可以得到提高。
至此,关于Java语言本身的主要概念我们就介绍的差不多了,接下来的几节中,我们介绍Java中一些常用的类及其操作,从包装类开始。