前面介绍的几种异常(不包含错误),编码的时候没认真看还发现不了,直到程序运行到特定的代码跑不下去了,程序员才会恍然大悟:原来这里的代码逻辑有问题。像这些在运行的时候才暴露出来的异常,又被称作“运行时异常”,与之相对的另一类异常叫做“非运行时异常”。所谓非运行时异常,指的是在编码阶段就被编译器发现这里存在潜在的风险,需要开发者关注并加以处理。比如把某个字符串转换成日期类型,用到了SimpleDateFormat实例的parse方法,倘若按照常规方式编码,则编译器会在parse这行提示代码错误,并给出如下图所示的处理建议小窗。
可见编译器提供了两种解决办法,第一种是“Add throws declaration”,表示要添加throws声明;第二种是“Surround with try/catch”,表示要用try/catch语句把parse行包围起来。为了消除编译错误,姑且先采用第一种解决方式,给parse行所在的方法添加“ throws ParseException”,下面是修改后的演示代码:
// 解析异常:指定日期不是真实的日子
// ParseException属于编译时异常,在编码时就要处理,否则无法编译通过。
// 处理方式有两种:一种是往外丢异常,另一种是通过try...catch...语句捕捉异常
private static void getDateFromFormat() throws ParseException {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
String strDate = "2021/02/28";
Date date = sdf.parse(strDate);
}
然而不光是上面的getDateFromFormat方法需要添加throws声明,连该方法所在的main方法也要添加throws声明才行。好不容易把该加的throws语句全都加了,接着故意填个格式错误的日期字符串,运行这个格式转换代码,果然程序输出了异常信息“java.text.ParseException: Unparseable date: "2021/02/28"”。
不过手工添加throws实在麻烦,得从调用parse的地方开始一层一层往上加过去,改动量太大。那么再试试编译器提供的第二种解决方式,也就是parse这行增加try/catch语句块,具体代码示例如下:
// 通过try...catch...语句捕捉日期的解析异常
private static void getDateWithCatch() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
String strDate = "2021/02/28";
try { // 开始小心翼翼地尝试,随时准备捕捉异常
Date date = sdf.parse(strDate);
} catch (ParseException e) { // 捕捉到了解析异常
e.printStackTrace(); // 打印出错时的栈轨迹信息
}
}
运行包含try/catch的以上代码,程序依然打印ParseException的相关异常日志,只是此时的打印动作由catch内部的“e.printStackTrace();”触发。但这不是重点,重点在于try与catch两个代码块之间的关系。从示例代码可知,try后面放的是普通代码,而catch后面放的是异常信息打印语句,它们对应着两个分支:一个try正常分支,一个catch异常分支。如果try的内部代码完全正常运行,则异常分支的内部代码根本不会执行;如果try的内部代码运行出错,则程序略过try的剩余代码,直接跳到异常分支处理。照这么看,try/catch的处理逻辑类似于if/else,都存在“如果……就……,否则……”的分支操作。不同之处在于,try语句并不指定满足进入的条件,而是由程序在运行时根据是否发生异常来决定继续处理还是跳到异常分支。况且也不是所有的异常都能跳进catch分支,只有符合catch语句指定的异常种类,才能跳的进去,否则还是往上一层一层扔出异常了。
有了try和catch这对好搭档,程序运行时不管是正常分支还是异常分支均可妥善处理了。不过有的业务需要在操作开始前分配资源,在操作结束后释放资源,例如访问数据库就得先建立数据库连接,再进行记录的增删改查等操作,最后处理完了再释放数据库连接。对于这种业务,无论是正常流程还是异常流程,最终都得执行资源释放操作。或许有人说,在try/catch整块代码后面补充释放资源不就得了?要是针对if/else的业务场景,倒是可以这么干;但现在业务场景变成try/catch,就不能如此蛮干了。因为在try/catch整块后面添加代码,新代码本质上仍走正常流程,即try/catch两个分支并流之后的正常流程。同时catch语句只能捕捉到某种类型的异常,并不能捕捉到所有异常,也就是说,一旦try内部遇到了未知异常,这个未知异常不会跳到现有的catch分支(因catch分支无法识别未知异常),而是当场一层一层往外扔出未知异常。这样一来,跟在try/catch后面的资源释放代码根本没机会执行,故而该方式将在遇到未知异常时失效。
为了保证在所有情况下(没有异常,或者遇到任何一种异常包括未知异常)都能执行某段代码,Java给try/catch机制增加了finally语句,该语句要求程序不管发生任何情况都得进来到此一游,像资源释放这种代码就适合放在finally内部,管你没异常还是有异常还是什么未知异常,最终统统拉到finally语句里面走一遭。仍以日期转换为例,要求给某个字符串形式的日期加上若干天,如果字符串日期解析失败,则自动用当前日期代替,并且无论遇到什么异常,务必返回一个正常的日期字符串。据此联合运用try/catch/finally,编写出来的处理代码如下所示:
// 给指定日期加上若干天。如果日期解析失败,则自动用当前日期代替
private static String addSomeDays(String strDate, int number) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Date date = null;
try { // 开始小心翼翼地尝试,随时准备捕捉异常
date = sdf.parse(strDate);
} catch (ParseException e) { // 捕捉到了解析异常
date = new Date();
} finally { // 无论是否发生异常,都要执行最终的代码块
if (date == null) {
date = new Date();
}
long time = date.getTime() + number*24*60*60*1000;
date.setTime(time);
}
return sdf.format(date);
}
这下总算实现了任意情况均可正常运行的需求,try/catch/finally三兄弟联手,正应了那句老话“三个臭皮匠,赛过诸葛亮”。
除了系统自带的各种异常,程序员也可自己定义新的异常,自定义异常很简单,只需从Exception派生出子类,并编写该类的构造方法即可。下面便是两个自定义异常的代码例子,第一个是数组为空异常,定义代码如下所示:
//定义一个数组为空异常。异常类必须由Exception派生而来
public class ArrayIsNullException extends Exception {
private static final long serialVersionUID = -1L; public ArrayIsNullException(String message) {
super(message);
}
}
第二个是数组越界异常,定义代码如下所示:
//定义一个数组越界异常。异常类必须由Exception派生而来
public class ArrayOutOfException extends Exception {
private static final long serialVersionUID = -1L; public ArrayOutOfException(String message) {
super(message);
}
}
由于这两个是自定义的异常,不会被系统自动丢出来,因此需要由程序员在代码中手工扔出自定义的异常。扔出异常的代码格式为“throw 某异常的实例;”,异常扔出之后,倘若当前方法没有捕捉异常,则该方法还得在入参列表之后添加语句“throws 以逗号分隔的异常列表”,表示本方法处理不了这些异常,请求上级方法帮忙处理。举个根据下标获取数组元素的例子,正常获取指定下标的元素有两个前提:其一数组不能为空,其二下标不能超出数组范围。如果发现目标数组为空,就令代码扔出数组为空异常ArrayIsNullException;如果发现下标不在合法的位置,就令代码扔出数组越界异常ArrayOutOfException。按此思路编写的方法代码示例如下:
// 根据下标获取指定数组对应位置的元素
private static int getItemByIndex(int[] array, int index)
throws ArrayIsNullException, ArrayOutOfException { // 同时扔出了多个异常
if (array == null) { // 如果数组为空
// 就扔出数组为空异常
throw new ArrayIsNullException("这是个空数组");
} else if (index<0 || index>=array.length) { // 如果下标超出了数组范围
// 就扔出数组越界异常
throw new ArrayOutOfException("下标超出了数组范围");
}
return array[index];
}
特别注意上面的异常扔出操作用到了两个关键字,一个是没带s的throw,另一个是带s尾巴的throws,它们之间的区别不仅仅是调用位置不同,而且一次扔出的异常数量也不同,throw每次只能扔出一个异常,而throws允许一次性扔出多个异常。
另外,刚才的getItemByIndex方法扔出了两个异常,留待它的上级方法接手烂摊子。上级方法固然可以沿用try/catch语句捕捉异常,不过这次面对的是两个异常不是单个异常,这也好办,既然有两个异常就写上两个异常分支呗,两个catch分支分别捕捉数组为空异常和数组越界异常。如此一来,上级方法的异常捕捉代码就变成下面这般:
// 进行数组的下标访问测试(数组为空)
private static void testArrayByIndexWithNull() {
int[] array = null;
try { // 开始小心翼翼地尝试,随时准备捕捉异常
// 根据下标获取指定数组对应位置的元素
int item = getItemByIndex(array, 3);
System.out.println("item="+item);
} catch (ArrayIsNullException e) { // 捕捉到了数组为空异常
e.printStackTrace(); // 打印出错时的栈轨迹信息
} catch (ArrayOutOfException e) { // 捕捉到了下标越界异常
e.printStackTrace(); // 打印出错时的栈轨迹信息
}
}
看起来,catch分支仿佛if/else语句里的else分支,都支持有多路的条件分支。当多个else分支的处理代码保持一致时,则允许通过或操作将它们合并为一个else分支;同理,假如多个catch分支的异常处理没有差别,也支持引入或操作将它们合并为一个catch分支,具体写法形如“catch (异常A | 异常B e)”。合并异常分支之后的异常处理代码如下所示:
// 进行数组的下标访问测试(下标越界)
private static void testArrayByIndexWithOut() {
int[] array = {1, 2, 3};
try { // 开始小心翼翼地尝试,随时准备捕捉异常
// 根据下标获取指定数组对应位置的元素
int item = getItemByIndex(array, 3);
System.out.println("item="+item);
} catch (ArrayIsNullException | ArrayOutOfException e) { // 捕捉到了数组为空异常或下标越界异常
e.printStackTrace(); // 打印出错时的栈轨迹信息
}
}
因为ArrayIsNullException和ArrayOutOfException都是Exception的子类,所以“ArrayIsNullException | ArrayOutOfException”可以被“Exception”所取代,进一步简化后的方法代码如下:
// 进行数组的下标访问测试(捕获所有异常)
private static void testArrayByIndexWithAny() {
int[] array = null;
try { // 开始小心翼翼地尝试,随时准备捕捉异常
// 根据下标获取指定数组对应位置的元素
int item = getItemByIndex(array, 3);
System.out.println("item="+item);
} catch (Exception e) { // 捕捉到了任何一种异常
e.printStackTrace(); // 打印出错时的栈轨迹信息
}
}
上述代码里的异常分支“catch (Exception e)”表示将捕捉任何属于Exception类型的异常,这些异常包括Exception自身及其派生出来的所有子类,当然也包含前面自定义的ArrayIsNullException和ArrayOutOfException了。
更多Java技术文章参见《Java开发笔记(序)章节目录》