通过异常处理错误(5):异常的限制、构造器

一、异常的限制

    当覆盖方法的时候,只能抛出在基类方法的异常说明里列出的那些异常。这个限制很有用,因为这意味着,当基类使用的代码应用到其派生类对象的时候,一样能够工作(当然,这是面向对象的基本概念),异常也不例外。

    下面例子演示了这种(在编译时)施加在异常上面的限制:

class BaseballException extends Exception {
}

class Foul extends BaseballException {
}

class Strike extends BaseballException {
}

abstract class Inning {
	public Inning() throws BaseballException {
	}

	public void event() throws BaseballException {
	}

	public abstract void atBat() throws Strike, Foul;

	public void walk() {
	}
}

class StormException extends Exception {
}

class RainedOut extends StormException {
}

class PopFoul extends Foul {
}

interface Storm {
	public void event() throws RainedOut;

	public void rainHard() throws RainedOut;
}

public class StormyInning extends Inning implements Storm {

	public StormyInning() throws RainedOut, BaseballException {
	}

	public StormyInning(String s) throws Foul, BaseballException {
	}

//	void walk() throws PopFoul {}

//	public void event() throws RainedOut{}

	@Override
	public void rainHard() throws RainedOut {

	}

	public void event() {
	}

	@Override
	public void atBat() throws PopFoul {
	}

	public static void main(String[] args) {
		try {
			StormyInning si = new StormyInning();
			si.atBat();
		} catch (PopFoul e) {
			System.out.println("Pop foul");
		} catch (RainedOut e) {
			System.out.println("Rained Out");
		} catch (BaseballException e) {
			System.out.println("Generic Baseball Exception");
		}

		try {
			Inning i = new StormyInning();
			i.atBat();
		} catch (Strike e) {
			System.out.println("Strike");
		} catch (Foul e) {
			System.out.println("Foul");
		} catch (RainedOut e) {
			System.out.println("Rained Out");
		} catch (BaseballException e) {
			System.out.println("Generic Baseball Exception");
		}
	}
}

    在Inning类中,可以看到构造器和event()方法都声明将抛出异常,但实际上没有抛出。这种方式使你能强制用户去捕获可能在覆盖后的event()版本中增加的异常,所以它们很合理。这对于抽象方法同样成立,比如atBat()。

    接口Storm值得注意,因为它包含了一个在Inning中定义的方法event()和一个不在Inning中定义的方法rainHard()。这两个方法都抛出新的异常RainedOut。如果StormyInning类在扩展Inning类的同时又实现了Storm接口,那么Storm里的event()方法就不能改变在Inning中event()方法的异常接口。否则的话,在使用基类的时候就不能判断是否捕获了正确的异常,所以这也很合理。当然,如果接口里定义的方法不是来自于基类,比如rainHard(),那么此方法抛出什么样的异常都没有问题。

    异常限制对构造器不起作用。你会发现StormyInning的构造器可以抛出任何异常,而不必理会基类构造器所抛出的异常。然而,因为基类构造器必须以这样或那样的方式被调用(这里默认构造器将自动被调用),派生类构造器的异常说明必须包含基类构造器的异常说明。

    派生类构造器不能捕获基类构造器抛出的异常。

    StormInning.walk()不能通过编译的原因是因为:它抛出了异常,而Inning.walk()并没有声明此异常。如果编译器允许这么做的话,就可以在调用Inning.walk()的时候不用做异常处理了,而且当把它替换成Inning的派生类的对象时,这个方法就有可能会抛出异常,于是程序就失灵了。通过强制派生类遵守基类方法的异常说明,对象的可替换性得到了保证。

    覆盖后的event()方法声明,派生类方法可以不抛出任何异常,即使它是基类所定义的异常。同样这是因为,假使基类的方法会抛出异常,这样做也不会破坏已有的程序,所以也没有问题。类似的情况出现在atBat()身上,它抛出的是PopFoul,这个异常是继承自“会被基类的atBat()抛出”的Foul。这样,如果你写的代码是同Inning打交道,并且调用了它的atBat()的话,那么肯定能捕获Foul。而PopFoul是由Foul派生出来的,因此异常处理程序也能捕获PopFoul。

    最后一个值得注意的地方是main()。这里可以看到,如果处理的刚好是StormyInning对象的话,编译器只会强制要求你捕获这个类所抛出的异常。但是如果将它向上转型成基类,那么编译器就会(正确的)要求你捕获基类的异常。所有这些限制都是为了能产生更为强壮的异常处理代码。

    尽管在继承过程中,编译器会对异常说明做强制要求,但异常说明本身并不属于方法类型的一部分,方法类型是由方法的名字与参数的类型组成的。因此,不能基于异常说明来重载方法。此外,一个出现在基类方法的异常说明中的异常,不一定会出现在派生类方法的异常说明里。这点同继承的规则明显不同,在继承中,基类的方法必须出现在派生类里,换句话说,在继承和覆盖的过程中,某个特定方法的“异常说明接口”不是变大了而是变小了--这恰好和类接口的在继承时的情形相反。

二、构造器

    有一点很重要,即你要时刻询问自己“如果异常发生了,所有的东西能被正确的清理吗?”尽管大多数情况下是安全的,但涉及构造器时,问题就出现了。构造器会把对象设置成安全的初始状态,但还会有别的动作,比如打开一个文件,这样的动作只有在对象使用完毕并且用户调用了特殊的清理方法之后才能得以清理。如果在构造器内抛出异常,这些清理行为也许就不能正常工作了。这意味着在编写构造器时要格外的细心。

    也许你会认为使用finally就可以解决问题。但问题并非如此简单,因为finally会每次都执行清理代码。如果构造器在执行过程中半途而废,也许该对象的某些部分还没有被成功创建,而这些部分在finally子句中却是要被清理的。

    在下面的例子中,建立了一个InputFile类,它能打开一个文件并且每次读取其中的一行。这里使用了java标准输入/输出库中的FileReader和BufferedReader类,这些类的基本用法很简单,应该很容易明白:

import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;

public class InputFile {
	private BufferedReader in;

	public InputFile(String fname) throws Exception {
		try {
			in = new BufferedReader(new FileReader(fname));
		} catch (FileNotFoundException e) {
			System.out.println("Could not open " + fname);
			throw e;
		} catch (Exception e) {
			try {
				in.close();
			} catch (IOException e1) {
				System.out.println("in.close() unsuccessful");
			}
			throw e;
		} finally {
		}
	}

	public String getLine() {
		String s;
		try {
			s = in.readLine();
		} catch (IOException e) {
			throw new RuntimeException("readLine() failed");
		}
		return s;
	}

	public void dispose() {
		try {
			in.close();
			System.out.println("dispose() successful");
		} catch (IOException e) {
			throw new RuntimeException("in.close() failed");
		}
	}
}

    InputFile的构造器接受字符串作为参数,该字符串表示所要打开的文件名。在try块中,会使用此文件名建立了FileReader对象。FileReader对象本身用处并不大,但可以用它来建立BufferedReader对象。注意,使用InputFile的好处就是能把两步操作合二为一。

    如果FileReader的构造器失败了,将抛出FileNotFoundException异常。对于这个异常,并不需要关闭文件,因为这个文件还没有被打开。而任何其他捕获异常的catch子句必须关闭文件,因为在它们捕获到异常之时,文件已经打开了(当然,如果还有其他方法能抛出FileNotFoundException,这个方法就显得有些投机取巧了。这时,通常必须把这些方法分别放到各自的try块里)。close()方法也可能会抛出异常,所以尽管它已经在另一个catch子句块里了,还是要再用一层try-catch--对java编译器而言,这只不过是多了一对花括号。在本地做完处理之后,异常被重新抛出,对于构造器而言这么做是很合适的,因为你总不希望去误导调用方,让他认为“这个对象已经创建完毕,可以使用了”。

    在本例中,由于finally会在每次完成构造器之后都执行一遍,因此它实在不该调用close()关闭文件的地方。我们希望文件在InputFile对象的整个生命周期内都处于打开状态。

    getLine()方法会返回表示文件下一行内容的字符串。它调用了能抛出异常的readLine(),但是这个异常已经在方法内得到处理,因此getLine()不会抛出任何异常。在设计异常时有一个问题:应该把异常全部放在这一层处理;还是先处理一部分,然后再向上层抛出相同的(或新的)异常;又或者是不做任何处理直接向上层抛出。如果用法恰当的话,直接向上层抛出的确能简化编程。在这里,getLine()方法将异常转换为RuntimeException,表示一个编程错误。

    用户在不需要InputFile对象时,就必须调用dispose()方法,这将释放BufferedReader和/或FileReader对象所占用的系统资源(比如文件句柄),在使用完InputFile对象之前是不会调用它的。可能你会考虑把上述功能放到finalize()里面,你不知道finalize()会不会被调用(即使能确定它将被调用,也不知道在什么时候调用)。这也是java缺陷:除了内存清理之外,所有的清理都不会自动发生。所以必须告诉客户端程序员,这是他们的责任。

    对于在构造阶段可能会抛出异常,并且要求清理的类,最安全的使用方式是使用嵌套的try子句:

public class Cleanup {
	public static void main(String[] args) {
		try {
			InputFile in = new InputFile("Cleanup.java");
			try {
				String s;
				int i = 1;
				while ((s = in.getLine()) != null)
					;
			} catch (Exception e) {
				System.out.println("Caught Exception in main");
				e.printStackTrace(System.out);
			} finally {
				in.dispose();
			}
		} catch (Exception e) {
			System.out.println("InputFile construction faild");
		}
	}
}

    清仔细观察这里的逻辑:对InputFile对象的构造在其自己的try语句块中有效,如果构造失败,将进入外部的catch子句,而dispose()方法不会被调用。但是,如果构造成功,我们肯定想确保对象能够被清理,因此在构造之后立即创建了一个新的try语句块。执行清理的finally与内部的try语句块相关联。在这中方式中,finally子句在构造失败时是不会执行的,而在构造成功时将总是执行。

    这种通用的清理惯用法在构造器不抛出任何异常时也应该运用,其基本规则是:在创建要清理的对象之后,立即进入一个try-finally语句块:

class NeedsCleanup {
	private static long counter = 1;
	private final long id = counter++;

	public void dispose() {
		System.out.println("NeedsCleanup " + id + "disposed");
	}
}

class ConstructionException extends Exception {
}

class NeedsCleanup2 extends NeedsCleanup {
	public NeedsCleanup2() throws ConstructionException {
	}
}

public class CleanupIdiom {
	public static void main(String[] args) {
		// Section 1:
		NeedsCleanup nc1 = new NeedsCleanup();
		try {
			// ...
		} finally {
			nc1.dispose();
		}
		// Section 2:
		NeedsCleanup nc2 = new NeedsCleanup();
		NeedsCleanup nc3 = new NeedsCleanup();
		try {
			// ...
		} finally {
			nc3.dispose();
			nc2.dispose();
		}
		// Section 3:
		try {
			NeedsCleanup2 nc4 = new NeedsCleanup2();
			try {
				NeedsCleanup2 nc5 = new NeedsCleanup2();
				try {
					// ...
				} finally {
					nc5.dispose();
				}
			} catch (ConstructionException e) {
				System.out.println(e);
			} finally {
				nc4.dispose();
			}
		} catch (ConstructionException e) {
			System.out.println(e);
		}
	}
}

    在main()中,Section1相当简单:遵循了在可去除对象之后紧跟try-finally的原则。如果对象构造不能失败,就不需要任何catch。在Section2中,为了构造和清理,可以看到具有不能失败的构造器的对象可以群组在一起。

    Section3展示了如何处理那些具有可以失败的构造器,且需要清理的对象。为了正确处理这种情况,事情变得很棘手,因为对于每一个构造,都必须包含在其自己的try-finally语句块中,并且每一个对象构造必须都跟随一个try-finally语句块以确保清理。

    本例中的异常处理的棘手程度,对于应该创建不能失败的构造器是一个有力的论据,尽管这么做并非总是可行。

    注意,如果dispose()可以抛出异常,那么你可能需要额外的try语句块。基本上,你应该仔细考虑所有的可能性,并确保正确处理每一种情况。

   如果本文对您有很大的帮助,还请点赞关注一下。

发布了112 篇原创文章 · 获赞 2 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_40298351/article/details/104511457