黑马程序员——异常机制2:异常的应用

时间:2021-03-06 00:44:48

------- android培训java培训、期待与您交流! ----------

1. 异常的处理

那么异常发生了就需要去处理,这里我们还是以算数异常为例,当出现ArithmeticExcep­tion异常时,程序就停止运行了,并没有将最后的输出语句执行完毕。这是因为,Java虚拟机中有一套内置的异常处理机制,这个机制对异常的处理方式就是:将异常抛出,将异常信息打印在控制台,并结束程序的运行。这种方式实际上并没有真正意义上的处理问题,而我们的目的就是当发生类似问题时,通过定义异常处理代码,使程序恢复正常运行,将问题出现位置后面的代码继续执行完毕。

Java提供了一套专门用于处理异常的代码语句,完整格式如下:

try
{
	/*
		需要被检测的代码
		这部分代码就是可能会产生错误的代码
	*/
}
catch(异常类型 变量名)
{
	//处理异常的代码,也就是处理方式
}
finally
{
	//一定会执行的语句
}
上述三部分代码语句并不一定每次都要写全,只要能起到处理问题的作用即可。我们首先来讲仅有try-catch语句的情况,finally代码块放到后面讲解。

1) 异常处理演示

我们将代码1改写成try-catch处理模式,代码如下:

代码1:

class MathTools
{
	static int division(int x, int y)
	{
		return x/y;
	}
}
class ExcetpionDemo
{
	public static void main(String[] args)
	{
		try
		{
			int x = 5, y = 0;
			//向division方法中传入错误数字
			int result =MathTools.division(x, y);
			/*
				注意,应将下面的输出语句写进try代码块中
				因为,整型变量result是定义在try代码块中的局部变量
				该代码块以外的语句是不能访问该变量的。
			*/
			System.out.println(x+"/"+y+" = "+result);
		}
		catch (Exception e)
		{
			System.out.println("被除数不能为零");
		}
 
		System.out.println("over");
	}
}

运行结果为:

除数不能为零

over

从结果来看,通过try-catch方式处理了异常以后(简单讲异常抛出),继续执行了后面的输出语句。

2) 异常处理过程

try代码块:上面的代码从主函数开始执行,在try代码块内定义了两个局部整型变量,然后通过MathTools类名调用division方法,传入这两个变量,开始执行division方法的方法体进行除法运算。运算过程中发生了Java虚拟机识别的算术异常——ArithmeticExcetpion,并将这个异常封装为了一个对象(通过隐式的new关键字),随后将这个异常对象“抛”给了调用division方法的调用者——主函数。其中,try代码块的作用就是“监控器”一样,在检测被监控的代码中是否发生了异常。

假设没有定义try代码块,那么发生了同样的异常以后,division方法将异常对象“抛”给了主函数,而主函数没有检测该异常的方法(try代码块),就又将异常对象“抛”给了调用主函数的虚拟机,而虚拟机就会启动默认的异常处理机制——停止程序的运行,这就是在默认情况下,发生异常程序停止的原因。

另外,所谓“抛出”实际就是停止当前代码的执行。随着异常对象的创建,将程序跳转至能够接收该异常对象的catch代码块中,然后执行catch代码块。

catch代码块:那么我们接着说try代码块检测到了异常以后,就将该异常对象抛给了try代码块后面的catch代码块,catch代码块就捕获到了该异常对象(catch的意思就是捕获、抓住),那么catch代码块是如何捕获到异常对象呢?就是通过catch代码块的参数列表接收了异常对象,这与通常调用方法时传递参数的动作是类似的,而且代码2中还涉及到了多态。因为,try代码块实际抛出的异常对象是ArithmeticException类型的,而catch代码块是用它的父类引用Exception接收的,相当于Exceptione = new ArithmeticException。

catch代码块在接收到异常对象以后,就执行了代码块中的处理异常代码。代码2中的处理方式非常简单,而在实际开发过程中会更为具体和具有针对性,这个我们后面会与涉及。大家可能会发现,在调用division方法,抛出异常并执行catch代码块的过程中,并没有执行try代码块中的其余代码,这当然是因为,除法运算根本就没能算出有效结果,输出语句也就没有执行的意义了。执行完catch语句中处理代码以后,就表示该异常已经处理完毕,排出了故障就可以继续执行后面的代码了,所以最后的输出语句就被执行了。

啰嗦了这么多,可以做出这样的总结:try代码块的作用就是监控某段代码是否抛出异常;catch代码块的作用就是,接收异常对象,并做出预先定义好的异常处理动作。

3) 操作异常对象

在上述整个异常处理过程中,自始至终并没有操作由父类引用e指向的子类对象ArithmeticException。我们就举几个常用的异常对象方法为例进行说明。

返回值类型 方法名

StringgetMessage:获取简单的异常信息;

StringtoString:获取较为全面的异常信息,包括了异常对象的类名;

voidprintStackTrace:打印堆栈的跟踪信息——直接打印最为全面的异常信息,除了异常类名、异常信息还有发生异常的准确代码位置。

我们在代码1中添加上述三个方法调用代码,代码如下(仅catch部分):

代码2:

catch(Exception e)
{
	System.out.println("除数不能为零");
	System.out.println("getMessage():"+e.getMessage());
	System.out.println("toString():"+e.toString());
	System.out.print("printStackTrace:");
	e.printStackTrace();
}
运行结果为:

除数不能为零

getMessage()---/by zero

toString()---java.lang.ArithmeticException:/ by zero

printStackTrace---java.lang.ArithmeticException:/ by zero

        at MathTools.division(ExceptionDemo.java:7)

        at  ExceptionDemo.main(ExceptionDemo.java:18)

over

注意:其实Java虚拟机默认的异常处理方式就是调用异常对象的printStackTrace方法,因为该方法打印的结果与我们之前没有进行任何异常处理时的打印信息是相同的。

当然,如果try代码块中没有发生错误,也就不会抛出异常,catch代码块中的异常处理代码也就不会执行了。

此外通过上述try-catch代码块处理异常,除了能够增强代码的健壮性以外,又体现了异常机制的另一个优点:将异常处理代码和正常流程代码进行了分离,提高了代码的阅读性,否则,两者混在一起将难以理解程序员的意图。

2. 异常的声明——throws

我们通过上面的例子简单演示了异常的处理过程。但是try-catch代码块是需要程序员手动编入正常的执行代码中的,那么程序员该如何判断某段代码是否应该进行try-catch处理呢?

1) throws关键字的使用格式

在实际开发过程中,如果某个方法的运行可能会产生异常,通常就会在该方法参数列表后面添加关键字——throws,后接可能会抛出的异常类名,定义格式如下:

public void f() throws Exception

这里我们以所有异常类的父类Exception举例,一般会声明更为具体的异常类,以便程序员定义更有针对性的异常处理代码。

2) 进行throws声明的目的

通过throws关键进行异常声明会起到两个作用:一是告诉将要使用这个方法的程序员,该方法可能会抛出异常,并从语法角度强制要求程序员事先定义好处理该异常的方法;二是一旦真的发生异常,并且无法就地处理就将异常对象抛给更高一级的调用者。

那么用代码体现上述两个作用就是:要么try-catch处理,如果处理不了就接着通过throws关键字把异常抛给更高一级的调用者。第一种处理方式我们已经讲过,而后一种处理方式就像下面的代码:

代码3:

class MathTools
{
	//对外声明该方法可能会抛出异常,若真的发生异常,就抛给主函数
	public static int division(int x, int y)throws Exception
	{
		return x/y;
	}
}
class ExceptionDemo2
{
	//向更高一级的调用者——虚拟机——声明,主函数中可能会抛出异常
	public static void main(String[] args)throws Exception
	{
		int x = 5, y = 0;
		int result = MathTools.division(x,y);
		System.out.println(x+"/"+y+"= "+result);
 
		System.out.println("over");
       }
}
运行结果为:

Exceptionin thread "main" java.lang.ArithmeticException: / by zero

at MathTools.division(ExceptionDemo2.java:7)

at ExceptionDemo2.main(ExceptionDemo2.java:16)

通过再次抛出的方式处理异常,能够通过编译,但是在运行后就会出现“除零”异常。这是因为,主函数上已经声明了可能会抛出异常,那么在真的产生了异常对象的时候,就会把异常直接抛给主函数的调用者——虚拟机,而虚拟机就会启动默认的异常处理机制:将异常信息打印到控制台,并停止程序的继续执行。那么我们可以总结出Java异常处理总的一个流程,如下图:

黑马程序员——异常机制2:异常的应用

注意,通常我们不建议将异常继续抛出,尽量就地将异常处理掉是最好的方式。不过这两个选择也是要分情况的,后面会具体说明。

如果没有使用上述两种方式中的任意一种,那么就会在编译时报出与下面类似的提示:

ExceptionDemo11.java:16: 错误: 未报告的异常错误Exception; 必须对其进行捕获或声明以便抛出

看到这样的提示,表明编译失败,这样就杜绝了因程序员未能编写异常处理代码,或未能进行异常声明继续将异常对象抛给更高一级调用者而造成的不良后果。

3. finally代码块

在前面的内容中我们说到异常处理代码通常由三部分组成:try-catch-finally。而在finally代码块中定义的内容是一定要执行的代码。那么我们就来说说finally代码块的执行特点及其应用。

1) finally代码块的执行特点

还是以负除数异常为例,

代码4:

class MinusDivisorException extends Exception
{
	MinusDivisorException(String message)
	{
		super(message);
	}
}
class MathTools
{
	public static int division(int x, int y)throws MinusDivisorException
	{
		if(y< 0)
			throw new MinusDivisorException("除数不能为负!");
 
		returnx / y;
	}
}
class ExceptionDemo3
{
	publicstatic void main(String[] args)
	{
		try
		{
			int x = 5, y = -1;
			int result = MathTools.division(x, y);
			System.out.println(x+"/"+y+"= "+result);
		}
		catch(MinusDivisorException e)
		{
			System.out.println(e.toString());
			//return;
 
			//为演示finally代码块的执行特点,故意在此抛出运行时异常,终止代码执行
			//throw new RuntimeException("终止代码执行!");
		}
		finally
		{
			/*
				即便在catch代码块中定义有return语句
				也可以执行到finally中的代码
			*/
			System.out.println("finallyrun");
		}
		/*
			如果执行了catch代码块中的return语句
			那么下面的over输出语句将执行不到
		*/
		System.out.println("over");
	}
}
当return和抛出运行时异常语句都被注释时的运行结果为:

MinusDivisorException: 除数不能为负!

finally run

over

 

执行return语句,运行结果则是:

MinusDivisorException: 除数不能为负!

finally run

 

注释return语句,执行运行时异常抛出语句,运行结果为是:

MinusDivisorException: 除数不能为负!

finally run

Exception in thread "main"java.lang.RuntimeException: 终止代码执行!

       at ExceptionDemo3.main(ExceptionDemo3.java:35)

 

那么通过这段代码我们就可以看出: finally代码块前代码的执行无论以何种方式被终止,最终都会去执行finally中的代码。这也正是符合了finally的含义——最终。finally代码块的上述特性只有在一种情况会失效——遇到System.exit(0)语句时。该语句的作用是退出Java虚拟机,那么退出了虚拟机,无论是什么代码就都执行不了了。

2) finally关键字的意义

当我们连接诸如新浪等网站时,其实是在和新浪的服务器进行交互。我们的个人电脑连接到服务器后,获取到所需的数据,最终必须要断开连接。这是因为,服务器在一个时间段内能够处理的连接请求数量是有限的,如果先前连接的电脑没有断开连接,不仅不断地消耗服务器的资源,而且使得其他电脑无法连接服务器。

因此,当我们在编写一段服务器访问代码时,代码的最后总会添加一段断开连接代码,确保不占用服务器资源。然而实际在与服务器交互过程中,无法避免得会发生一些异常(比如SQLException,数据库异常),导致程序执行跳转,最终无法执行最后的断开连接代码。这时候,就必须将断开连接代码定义在finally代码块中,起到必须执行的作用的。我们用一段伪代码来说明问题:

代码5:

//当发生数据库异常而未能成功获取到数据时,向上抛出数据获取失败异常
class DataAcquisitionFailureException extends Exception
{
	DataAcquisitionFailureException(String message)
	{
		super(message);
	}
}
class ExceptionDemo4
{
	public static void main(String[] args)
	{
		try
		{
			method();
		}
		catch(DataAcquisitionFailureException e)
		{
			//定义针对数据获取失败的处理方式
		}
	}
	public static void method()throws DataAcquisitionFailureException
	{
		try
		{
			//连接数据库;
			//数据库操作,可能会抛出SQLException
		}
		catch(SQLException e)
		{
			//处理方式:
			//第一步:针对该数据库异常的处理;
			//第二步:向上抛出数据获取失败异常;
		}
		//定义最后一定要执行的代码
		finally
<span style="white-space:pre">		</span>{
			//关闭数据库;
		}
	}
}
上述与服务器断开连接的动作是一个最终必须执行的动作,那么这就是finally关键字在实际中的应用之一了。

总结:finally代码块中定义的是最终必须要执行的代码,通常这类代码用于关闭“资源”。这里的资源包括上述的服务器资源,也包括系统资源等等。

此外,在finally代码块中除了定义关闭资源的代码以外,也可以定义一些必须执行的代码,这就需要依具体需求而定了。

 

小知识点1:

我们来解释一下method方法中catch代码块内的第二步的用意。虽然在第一步中已经就数据库异常进行了处理,但是method的调用者不清楚数据的调取过程是如何运行的,也就是说调用者即使接收到了该异常也是无法处理的,所以不应该向上抛出数据库异常,而是就地处理,就像第一步做的那样。但是仅仅将问题处理是不够的,应该还要通过抛出异常的方式给调用者一个回复信息——数据获取失败。这里就又涉及到了问题转化的过程,或者成为分层思想

分层思想的含义是,当多人合作完成一项工作时,应按照分块负责的方式。对于上面的代码,method方法的调用者负责处理获取到的数据,而并不“关心”如何获取数据的;而method方法的职责就是数据的调取和返回,至于是谁调用,调用的目的是什么也与他无关。那么分层思想就是体现在调用者与被调用者之间的衔接关系。也就是说当被调用者出现异常时,要抛出调用者可以处理的异常,或者说调用者能够理解的异常,并回避掉涉及自己工作范围的内容,对问题进行封装,这就是分层思想。大家可能注意到,前述“老师”与“领导”之间的衔接也体现了分层思想。

3) 异常处理的其他格式

在前面的内容中我们说到处理异常的代码格式通常是try-catch-finally,不过这一格式不是一成不变的,可以是try-catch组合,也可以是try-finally的组合,至于使用哪种组合要看具体的需求。那么try-catch-finally和try-catch的组合的应用已经说过了,不再赘述,这里主要说说try-finally组合的应用。

比如我们定义一个方法f(),该方法在执行过程中可能会发生异常(这里以Exception举例),但是不在本方法内部处理(交给调用者处理),也就是说不定义catch代码块。那么只要没有定义catch代码块,就认为可能抛出的异常不会被处理,需要在方法上进行异常声明。另外,该方法的执行会调用系统的底层资源,需要在finally代码块内定义关闭资源的代码。

代码6:

public void f() throws Exception
{
	try
	{
		//throw new Exception;
	}
	finally
	{
		//关闭资源
	}
}
如上所述,在代码3这种情况下,就需要定义try-finally组合。

4. 多异常的处理

1) 针对性地声明异常

就像我们前面提到的那样,在方法上进行异常声明的时候我们应尽可能具体地声明异常的类型,诸如public void f() thorws Exception的声明方式通常是不可取的,因为Exception过于笼统没有针对性,程序员只通过异常类名无法判断该方法到底会发生怎样的问题,还是以前述除法算数异常为例,最好的声明方式应是:

public static int division(int x, int y)throws ArithmeticException

这样明确的告诉调用者该方法可能会发生算数异常,便于程序员做出针对性的处理动作。

2) 多异常的处理

在实际开发过程中,某个方法可能会发生多个异常,比如将division方法进行改写:

代码7:

classMathTools
{
	//声明两个异常,用逗号进行分隔
	public static int division(int x,int y)throws ArithmeticException, ArrayIndexOutOfBoundsException
	{
		/*
			为便于观察到异常现象
			故意在该位置定义了一个整型数组
			并为数组中的元素进行除法运算
		*/
		int[] arr = new int[x];
		arr[3] = x;
		arr[4] = y;
 
		return arr[3]/arr[4];
	}
}
class ExceptionDemo5
{
	public static void main(String[] args)
	{
		try
		{
			int x = 5, y = 1;
			int result =MathTools.division(x, y);
			System.out.println(x+"/"+y+"= "+result);
		}
		catch(ArithmeticException e)
		{
			System.out.println(e1.toString());
			System.out.println("除数不能为零!");
		}
		catch(ArrayIndexOutOfBoundsException e)
		{
			System.out.println(e2.toString());
			System.out.println("检测到角标越界!");
		}
 
		System.out.println("over");
	}
}
运行结果为:

5/1 = 5

over

若将变量x的值改为4,在运行时就会报出角标越界异常;若把变量y改为0,又会报出除零算数异常。根据上面的代码,我们可以总结:一个方法声明的异常个数和为其try-catch处理时catch代码块的个数是相同的。

注意:虽然我们为一个方法定义了两个异常,甚至可能会定义更多的异常,但是实际每次只会发生一种异常,因为,一旦异常发生,程序的执行顺序就会发生跳转,跳转至相对应的异常处理代码,而不会继续执行之前的代码的。

有朋友可能会想到一个一劳永逸的方法,就是把上述两个代码块合并为一个代码块,就像这样:

代码8:

catch(Exception e)
{
	System.out.println(e.toString());
}
实际测试上述代码从结果上看可能是行得通的,因为这个catch代码块多态地接收了异常对象,而在调用toString方法时打印的是子类特有的异常信息。然而这也仅限于打印异常信息,而在实际开发时catch代码块中定义的处理异常方法通常不会仅仅是打印异常信息,而是更为具体的处理方式,而上述“一刀切”的方式是缺乏针对性的。

说明:代码示例中,为了演示方便,我们仅以打印异常信息的方式处理了异常,但之所以一直强调进行具体的异常处理是因为,首先程序开发完成,并由普通用户使用时,并不一定有控制台(DOS命令行),没有控制台打印的异常信息就无法显示;其次,即使有控制台,普通用户也是无法理解上述的异常信息的。

具体异常处理方式举例:为了方便大家理解,举一个实际应用的例子进行说明。catch代码块中通常会定义一个方法,当接收到某个异常对象的时候,会通过该方法将该异常发生的时间、代码中的位置、原因等信息通过网络发送到软件开发商的服务器,并形成异常日志文件,软件的维护人员看到这个日志,就知道了程序发生的异常,并着手修改代码处理异常。

3) catch代码块的执行顺序

可能有朋友有这样的担忧:即使定义方法的时候已经尽可能全面地声明了可能会发生的异常,但还是有一定的几率造成其他问题,此时我们能否在多个catch代码块中添加上述代码6而保证完美呢?就像这样:

代码9:

catch(ArithmeticException e)
{
	System.out.println(e1.toString());
	System.out.println("除数不能为零!");
}
catch(ArrayIndexOutOfBoundsException e)
{
	System.out.println(e2.toString());
	System.out.println("检测到角标越界!");
}
catch(Exception e)
{
	System.out.println(e.toString());
}
这样做有三个问题:

(a) 我们还是要强调针对性的问题。假如真的发生了除已经声明的异常以外的问题,用代码7的方式处理,还是不具有针对性,实际上就是没有进行处理。

(b) 代码7的做法实际上是掩盖了潜在的异常,因为真有新的异常发生时,只要它是Exception异常类的子类,就能被第三个catch语句捕捉到,然后打印异常信息,而不做任何具体处理,而此时程序还在运行当中,这是很危险的。正确的做法应该是:将异常完全暴露出来,停止程序,找出异常发生的位置和原因,然后声明新的异常类型,定义相对应的具有针对性的异常处理代码。

(c) 如果不小心将代码7中的第三个catch代码块(参数列表为Exceptione)写到最前面,那么后面两个catch代码块就会毫无意义,无论发生什么异常都会被第一个catch代码块捕捉到。

因此,基于上述种种理由,代码7的做法是不可取的。

4) 总结

那么通过上述关于多异常处理的内容,我们可以做出如下总结:

(a) 尽可能有针对性的声明异常;

(b) 方法上声明的异常数量和处理异常的catch代码块的数量一致;

(c) 多个catch代码块中接收的异常类型之间有继承关系,必须将父类异常放到最后;

(d) 通过try-catch方式处理异常时,catch代码块中不要简单定义异常信息输出代码,而是要定义具体的处理方式。当然,也不要什么都不写,否则就是隐藏可能发生的异常。

5. 覆盖对异常机制的影响

说完了异常的处理、声明和多异常以后,有必要对继承体系中,覆盖方法的异常声明做出一些说明。继承体系中覆盖特性的出现,使得异常机制又有了一些新的特点。

1) 若父类方法声明抛出异常,那么子类的方法(覆盖父类的方法)只能声明抛出与父类方法相同的异常或该异常的子类,或者不声明抛出异常,而在方法内部就地进行处理。代码如下:

代码10:

//父类异常
class SuperException extends Exception
{
	SuperException(String message)
	{
		super(message);
	}
}
//子类异常
class SubException extends SuperException
{
	SubException(String message)
	{
		super(message);
	}
}
//父类
class Super
{
	voidf()throws SuperException{}
}
//子类
class Sub extends Super
{
	/*
		当子类方法覆盖父类方法时
		只能声明抛出与父类方法相同的异常或该异常的子类
		或者,不声明抛出任何异常
	*/
	voidf()throws SubException{}
}
我们利用上述子父类以及两个异常类举例说明做出上述规定的原因。

代码11:

//定义Demo类,其method方法需传入Super类对象或其子类对象
class Demo
{
	void method(Super sup)
	{
		try
		{
			sup.f();
		}
		catch(SuperException e)
		{
			//仅为演示,不定义处理方法
		}
	}
}
class ExceptionDemo6
{
	public static void main(String[] args)
	{
		Demo d = new Demo();
		//d.method(newSuper());
		//后期程序扩展时定义了Super类的子类Sub
		//向Demo对象的method方法传入子类对象
		d.method(newSub());
	}
}
就像代码2所演示的那样,程序开发早期定义了父类Super,并在Demo类method方法内调用Super对象的方法,并try-catch处理异常,catch代码块中接收父类异常SuperException对象。后期进行代码扩展时,定义了Super类的子类Sub,并向method方法传入Sub对象。此时,如果子类的f()方法也抛出异常,那么catch代码块中只能接收SuperException对象或者多态地接收其子类异常对象,如果传入其他异常对象就会编译失败。换句话说早期程序无法处理后期定义的新异常。

如果说子类f()方法内部真的会抛出父类异常及其子类以外的异常,那就只能在方法内部就地try-catch处理,而不能向上抛出。

除此以外,子类方法也可以不抛出任何异常。

2) 如果父类方法声明抛出多个异常,那么子类在覆盖该方法时,只能抛出父类方法声明的异常的子集(包括子类异常)。换句话说,子类方法不能声明抛出父类没有声明的异常。该规定的原因与1)是相同的——早期的catch代码块无法接收新定义的异常对象。同样如果必须抛出父类没有声明的异常,就必须在方法内就地try-catch处理,而不必在方法上声明。

3) 如果父类方法没有声明抛出任何异常,那么子类覆盖该方法时,也不能声明抛出任何异常,如果可能产生异常就只能在内部处理。原因也是一样的——早期的代码无法处理后期的异常。