现在的位置: 首页 > 综合 > 正文

Java解惑4——异常谜题(易混淆10处)

2013年05月28日 ⁄ 综合 ⁄ 共 17130字 ⁄ 字号 评论关闭

Java解惑4——异常谜题(易混淆10处)

 

 转自http://blog.csdn.net/sunkun2013/article/details/16814977

谜题36:优柔寡断

    下面这个可怜的小程序并不能很好地做出其自己的决定。它的decision方法将返回true,但是它还返回了false。那么,它到底打印的是什么呢?甚至,它是合法的吗?

public class Indecisive {

    public static void main(String[] args) {

        System.out.println(decision());

    }

    static boolean decision() {

        try {

            return true;

        } finally {

            return false;

        }

    }

}

    你可能会认为这个程序是不合法的。毕竟,decision方法不能同时返回true和false。如果你尝试一下,就会发现它编译时没有任何错误,并且它所打印的是false。为什么呢?

    原因就是在一个try-finally语句中,finally语句块总是在控制权离开try语句块时执行的[JLS 14.20.2]。无论try语句块是正常结束的,还是意外结束的,情况都是如此。一条语句或一个语句块在它抛出了一个异常,或者对某个封闭型语句执行了一个break或continue,或是象这个程序一样在方法中执行了一个return时,将发生意外结束。它们之所以被称为意外结束,是因为它们阻止程序去按顺序执行下面的语句。

    当try语句块和finally语句块都意外结束时,在try语句块中引发意外结束的原因将被丢弃,而整个try-finally语句意外结束的原因将于finally语句块意外结束的原因相同。在这个程序中,在try语句块中的return语句所引发的意外结束将被丢弃,而try-finally语句意外结束是由finally语句块中的return造成的。简单地讲,程序尝试着(try)返回(return)true,但是它最终(finally)返回(return)的是false。

    丢弃意外结束的原因几乎永远都不是你想要的行为,因为意外结束的最初原因可能对程序的行为来说会显得更重要。对于那些在try语句块中执行break、continue或return语句,只是为了使其行为被finally语句块所否决掉的程序,要理解其行为是特别困难的。

    总之,每一个finally语句块都应该正常结束,除非抛出的是不受检查的异常。千万不要用一个return、break、continue或throw来退出一个finally语句块,并且千万不要允许将一个受检查的异常传播到一个finally语句块之外去。

    对于语言设计者,也许应该要求finally语句块在未出现不受检查的异常时必须正常结束。朝着这个目标,try-finally结构将要求finally语句块可以正常结束[JLS 14.21]。return、break或continue语句把控制权传递到finally语句块之外应该是被禁止的,任何可以引发将被检查异常传播到finally语句块之外的语句也同样应该是被禁止的。

谜题37:极端不可思议

    本谜题测试的是你对某些规则的掌握程度,这些规则用于声明从方法中抛出并被catch语句块所捕获的异常。下面的三个程序每一个都会打印些什么?不要假设它们都可以通过编译:

import java.io.IOException;

public class Arcane1 {

    public static void main(String[] args) {

        try {

            System.out.println("Hello world");

        } catch(IOException e) {

            System.out.println("I've never seen

               println fail!");

        }

    }

}

public class Arcane2 {

    public static void main(String[] args) {

        try {

            // If you have nothing nice to say, say nothing

        } catch(Exception e) {

            System.out.println("This can't

                happen");

        }

    }

}

interface Type1 {

    void f() throws CloneNotSupportedException;

}

interface Type2 {

    void f() throws InterruptedException;

}

interface Type3 extends Type1, Type2 {

}

public class Arcane3 implements Type3 {

    public void f() {

        System.out.println("Hello world");

    }

    public static void main(String[] args) {

        Type3 t3 = new Arcane3();

        t3.f();

    }

}

    第一个程序,Arcane1,展示了被检查异常的一个基本原则。它看起来应该是可以编译的:try子句执行I/O,并且catch子句捕获IOException异常。但是这个程序不能编译,因为println方法没有声明会抛出任何被检查异常,而IOException却正是一个被检查异常。语言规范中描述道:如果一个catch子句要捕获一个类型为E的被检查异常,而其相对应的try子句不能抛出E的某种子类型的异常,那么这就是一个编译期错误[JLS
11.2.3]。

    基于同样的理由,第二个程序,Arcane2,看起来应该是不可以编译的,但是它却可以。它之所以可以编译,是因为它唯一的catch子句检查了Exception。尽管JLS在这一点上十分含混不清,但是捕获Exception或Throwble的catch子句是合法的,不管与其相对应的try子句的内容为何。尽管Arcane2是一个合法的程序,但是catch子句的内容永远的不会被执行,这个程序什么都不会打印。

    第三个程序,Arcane3,看起来它也不能编译。方法f在Type1接口中声明要抛出被检查异常CloneNotSupportedException,并且在Type2接口中声明要抛出被检查异常InterruptedException。Type3接口继承了Type1和Type2,因此,看起来在静态类型为Type3的对象上调用方法f时,有潜在可能会抛出这些异常。一个方法必须要么捕获其方法体可以抛出的所有被检查异常,要么声明它将抛出这些异常。Arcane3的main方法在静态类型为Type3的对象上调用了方法f,但它对CloneNotSupportedException和InterruptedExceptioin并没有作这些处理。那么,为什么这个程序可以编译呢?

    上述分析的缺陷在于对“Type3.f可以抛出在Type1.f上声明的异常和在Type2.f上声明的异常”所做的假设。这并不正确,因为每一个接口都限制了方法f可以抛出的被检查异常集合。一个方法可以抛出的被检查异常集合是它所适用的所有类型声明要抛出的被检查异常集合的交集,而不是合集。因此,静态类型为Type3的对象上的f方法根本就不能抛出任何被检查异常。因此,Arcane3可以毫无错误地通过编译,并且打印Hello
world。

    总之,第一个程序说明了一项基本要求,即对于捕获被检查异常的catch子句,只有在相应的try子句可以抛出这些异常时才被允许。第二个程序说明了这项要求不会应用到的冷僻案例。第三个程序说明了多个继承而来的throws子句的交集,将减少而不是增加方法允许抛出的异常数量。本谜题所说明的行为一般不会引发难以捉摸的bug,但是你第一次看到它们时,可能会有点吃惊。

谜题38:不受欢迎的宾客

    本谜题中的程序所建模的系统,将尝试着从其环境中读取一个用户ID,如果这种尝试失败了,则缺省地认为它是一个来宾用户。该程序的作者将面对有一个静态域的初始化表达式可能会抛出异常的情况。因为Java不允许静态初始化操作抛出被检查异常,所以初始化必须包装在try-finally语句块中。那么,下面的程序会打印出什么呢?

public class UnwelcomeGuest {

    public static final long GUEST_USER_ID = -1;

    private static final long USER_ID;

    static {

        try {

            USER_ID = getUserIdFromEnvironment();

        } catch (IdUnavailableException e) {

            USER_ID = GUEST_USER_ID;

            System.out.println("Logging in as guest");

        }

    }

    private static long getUserIdFromEnvironment()

            throws IdUnavailableException {

        throw new IdUnavailableException();

    }

    public static void main(String[] args) {

        System.out.println("User ID: " + USER_ID);

    }

}

class IdUnavailableException extends Exception {

}

    该程序看起来很直观。对getUserIdFromEnvironment的调用将抛出一个异常,从而使程序将GUEST_USER_ID(-1L)赋值给USER_ID,并打印Loggin in as guest。然后main方法执行,使程序打印User ID: -1。表象再次欺骗了我们,该程序并不能编译。如果你尝试着去编译它,你将看到和下面内容类似的一条错误信息:

UnwelcomeGuest.java:10:

variable USER_ID might already have been assigned

            USER_ID = GUEST_USER_ID;

            ^

    题出在哪里了?USER_ID域是一个空final(blank final),它是一个在声明中没有进行初始化操作的final域[JLS 4.12.4]。很明显,只有在对USER_ID赋值失败时,才会在try语句块中抛出异常,因此,在catch语句块中赋值是相当安全的。不管怎样执行静态初始化操作语句块,只会对USER_ID赋值一次,这正是空final所要求的。为什么编译器不知道这些呢?

    要确定一个程序是否可以不止一次地对一个空final进行赋值是一个很困难的问题。事实上,这是不可能的。这等价于经典的停机问题,它通常被认为是不可能解决的[Turing 36]。为了能够编写出一个编译器,语言规范在这一点上采用了保守的方式。在程序中,一个空final域只有在它是明确未赋过值的地方才可以被赋值。规范长篇大论,对此术语提供了一个准确的但保守的定义[JLS 16]。因为它是保守的,所以编译器必须拒绝某些可以证明是安全的程序。这个谜题就展示了这样的一个程序。

    幸运的是,你不必为了编写Java程序而去学习那些骇人的用于明确赋值的细节。通常明确赋值规则不会有任何妨碍。如果碰巧你编写了一个真的可能会对一个空final赋值超过一次的程序,编译器会帮你指出的。只有在极少的情况下,就像本谜题一样,你才会编写出一个安全的程序,但是它并不满足规范的形式化要求。编译器的抱怨就好像是你编写了一个不安全的程序一样,而且你必须修改你的程序以满足它。

    解决这类问题的最好方式就是将这个烦人的域从空final类型改变为普通的final类型,用一个静态域的初始化操作替换掉静态的初始化语句块。实现这一点的最佳方式是重构静态语句块中的代码为一个助手方法:

public class UnwelcomeGuest {

    public static final long GUEST_USER_ID = -1;

    private static final long USER_ID = getUserIdOrGuest;

    private static long getUserIdOrGuest {

        try {

            return getUserIdFromEnvironment();

        } catch (IdUnavailableException e) {

            System.out.println("Logging in as guest");

            return GUEST_USER_ID;

        }

    }

    ...// The rest of the program is unchanged

}

    程序的这个版本很显然是正确的,而且比最初的版本根据可读性,因为它为了域值的计算而增加了一个描述性的名字,而最初的版本只有一个匿名的静态初始化操作语句块。将这样的修改作用于程序,它就可以如我们的期望来运行了。

    总之,大多数程序员都不需要学习明确赋值规则的细节。该规则的作为通常都是正确的。如果你必须重构一个程序,以消除由明确赋值规则所引发的错误,那么你应该考虑添加一个新方法。这样做除了可以解决明确赋值问题,还可以使程序的可读性提高。

谜题39:您好,再见!

    下面的程序在寻常的Hello world程序中添加了一段不寻常的曲折操作。那么,它将会打印出什么呢?

public class HelloGoodbye {

    public static void main(String[] args) {

        try {

            System.out.println("Hello world");

            System.exit(0);

        } finally {

            System.out.println("Goodbye world");

        }

    }

}

    这个程序包含两个println语句:一个在try语句块中,另一个在相应的finally语句块中。try语句块执行它的println语句,并且通过调用System.exit来提前结束执行。在此时,你可能希望控制权会转交给finally语句块。然而,如果你运行该程序,就会发现它永远不会说再见:它只打印了Hello world。这是否违背了谜题36中所解释的原则呢?

    不论try语句块的执行是正常地还是意外地结束,finally语句块确实都会执行。然而在这个程序中,try语句块根本就没有结束其执行过程。System.exit方法将停止当前线程和所有其他当场死亡的线程。finally子句的出现并不能给予线程继续去执行的特殊权限。

    当System.exit被调用时,虚拟机在关闭前要执行两项清理工作。首先,它执行所有的关闭挂钩操作,这些挂钩已经注册到了Runtime.addShutdownHook上。这对于释放VM之外的资源将很有帮助。务必要为那些必须在VM退出之前发生的行为关闭挂钩。下面的程序版本示范了这种技术,它可以如我们所期望地打印出Hello world和Goodbye world:

public class HelloGoodbye1 {

    public static void main(String[] args) {

       System.out.println("Hello world");

       Runtime.getRuntime().addShutdownHook(

             new Thread() {

                public void run() {

                       System.out.println("Goodbye world");

                }

            });

       System.exit(0);

    }

}

    VM执行在System.exit被调用时执行的第二个清理任务与终结器有关。如果System.runFinalizerOnExit或它的魔鬼双胞胎Runtime.runFinalizersOnExit被调用了,那么VM将在所有还未终结的对象上面调用终结器。这些方法很久以前就已经过时了,而且其原因也很合理。无论什么原因,永远不要调用System.runFinalizersOnExit和Runtime.runFinalizersOnExit:它们属于Java类库中最危险的方法之一[ThreadStop]。调用这些方法导致的结果是,终结器会在那些其他线程正在并发操作的对象上面运行,从而导致不确定的行为或导致死锁。

    总之,System.exit将立即停止所有的程序线程,它并不会使finally语句块得到调用,但是它在停止VM之前会执行关闭挂钩操作。当VM被关闭时,请使用关闭挂钩来终止外部资源。通过调用System.halt可以在不执行关闭挂钩的情况下停止VM,但是这个方法很少使用。

谜题40:不情愿的构造器

    尽管在一个方法声明中看到一个throws子句是很常见的,但是在构造器的声明中看到一个throws子句就很少见了。下面的程序就有这样的一个声明。那么,它将打印出什么呢?

public class Reluctant {

    private Reluctant internalInstance = new Reluctant();

    public Reluctant() throws Exception {

        throw new Exception("I'm not coming out");

    }

    public static void main(String[] args) {

        try {

            Reluctant b = new Reluctant();

            System.out.println("Surprise!");

        } catch (Exception ex) {

            System.out.println("I told you so");

        }

    }

}

    main方法调用了Reluctant构造器,它将抛出一个异常。你可能期望catch子句能够捕获这个异常,并且打印I told you so。凑近仔细看看这个程序就会发现,Reluctant实例还包含第二个内部实例,它的构造器也会抛出一个异常。无论抛出哪一个异常,看起来main中的catch子句都应该捕获它,因此预测该程序将打印I told you应该是一个安全的赌注。但是当你尝试着去运行它时,就会发现它压根没有去做这类的事情:它抛出了StackOverflowError异常,为什么呢?

    与大多数抛出StackOverflowError异常的程序一样,本程序也包含了一个无限递归。当你调用一个构造器时,实例变量的初始化操作将先于构造器的程序体而运行[JLS 12.5]。在本谜题中, internalInstance变量的初始化操作递归调用了构造器,而该构造器通过再次调用Reluctant构造器而初始化该变量自己的internalInstance域,如此无限递归下去。这些递归调用在构造器程序体获得执行机会之前就会抛出StackOverflowError异常,因为StackOverflowError是Error的子类型而不是Exception的子类型,所以catch子句无法捕获它。

    对于一个对象包含与它自己类型相同的实例的情况,并不少见。例如,链接列表节点、树节点和图节点都属于这种情况。你必须非常小心地初始化这样的包含实例,以避免StackOverflowError异常。

    至于本谜题名义上的题目:声明将抛出异常的构造器,你需要注意,构造器必须声明其实例初始化操作会抛出的所有被检查异常。下面这个展示了常见的“服务提供商”模式的程序,将不能编译,因为它违反了这条规则:

public class Car {

     private static Class engineClass = ...;

     private Engine engine =

             (Engine)enginClass.newInstance();

     public Car(){ }

}

    尽管其构造器没有任何程序体,但是它将抛出两个被检查异常,InstantiationException和IllegalAccessException。它们是Class.Instance抛出的,该方法是在初始化engine域的时候被调用的。订正该程序的最好方式是创建一个私有的、静态的助手方法,它负责计算域的初始值,并恰当地处理异常。在本案中,我们假设选择engineClass所引用的Class对象,保证它是可访问的并且是可实例化的。

    下面的Car版本将可以毫无错误地通过编译:

//Fixed - instance initializers don’t throw checked exceptions

public class Car {

     private static Class engineClass = ...;

     private Engine engine = newEngine;

     private static Engine newEngine() {

          try {

                  return (Engine)engineClass.newInstance();

          } catch (IllegalAccessException e) {

                 throw new AssertionError(e);

         } catch (InstantiationException e) {

                 throw new AssertionError(e);

         }

     }

     public Car(){ }

}

    总之,实例初始化操作是先于构造器的程序体而运行的。实例初始化操作抛出的任何异常都会传播给构造器。如果初始化操作抛出的是被检查异常,那么构造器必须声明也会抛出这些异常,但是应该避免这样做,因为它会造成混乱。最后,对于我们所设计的类,如果其实例包含同样属于这个类的其他实例,那么对这种无限递归要格外当心。

谜题41:域和流

    下面的方法将一个文件拷贝到另一个文件,并且被设计为要关闭它所创建的每一个流,即使它碰到I/O错误也要如此。遗憾的是,它并非总是能够做到这一点。为什么不能呢,你如何才能订正它呢?

static void copy(String src, String dest) throws IOException {

        InputStream in = null;

        OutputStream out = null;

        try {

            in = new FileInputStream(src);

            out = new FileOutputStream(dest);

            byte[] buf = new byte[1024];

            int n;

            while ((n = in.read(buf)) > 0)

                out.write(buf, 0, n);

        } finally {

            if (in != null) in.close();

            if (out != null) out.close();

        }

}

    这个程序看起来已经面面俱到了。其流域(in和out)被初始化为null,并且新的流一旦被创建,它们马上就被设置为这些流域的新值。对于这些域所引用的流,如果不为空,则finally语句块会将其关闭。即便在拷贝操作引发了一个IOException的情况下,finally语句块也会在方法返回之前执行。出什么错了呢?

    问题在finally语句块自身中。close方法也可能会抛出IOException异常。如果这正好发生在in.close被调用之时,那么这个异常就会阻止out.close被调用,从而使输出流仍保持在开放状态。

    请注意,该程序违反了谜题36的建议:对close的调用可能会导致finally语句块意外结束。遗憾的是,编译器并不能帮助你发现此问题,因为close方法抛出的异常与read和write抛出的异常类型相同,而其外围方法(copy)声明将传播该异常。

    解决方式是将每一个close都包装在一个嵌套的try语句块中。下面的finally语句块的版本可以保证在两个流上都会调用close:

} finally {

     if (in != null) {

          try {

              in.close();

          } catch (IOException ex) {

              // There is nothing we can do if close fails

          }

     if (out != null)

          try {

              out.close();

          } catch (IOException ex) {

              // There is nothing we can do if close fails

          }

    }

}

    从5.0版本开始,你可以对代码进行重构,以利用Closeable接口:

} finally {

     closeIgnoringException(in);

     closeIgnoringEcception(out);

}

private static void closeIgnoringException(Closeable c) {

     if (c != null) {

           try {

             c.close();

           } catch (IOException ex) {

             // There is nothing we can do if close  fails

           }

     }

}

    总之,当你在finally语句块中调用close方法时,要用一个嵌套的try-catch语句来保护它,以防止IOException的传播。更一般地讲,对于任何在finally语句块中可能会抛出的被检查异常都要进行处理,而不是任其传播。这是谜题36中的教训的一种特例,而对语言设计着的教训情况也相同。

谜题42:异常为循环而抛

    下面的程序循环遍历了一个int类型的数组序列,并且记录了满足某个特定属性的数组个数。那么,该程序会打印出什么呢?

public class Loop {

    public static void main(String[] args) {

        int[][] tests = { { 6, 5, 4, 3, 2, 1 }, { 1, 2 },

                      { 1, 2, 3 }, { 1, 2, 3, 4 }, { 1 } };

        int successCount = 0;

        try {

            int i = 0;

            while (true) {

                if (thirdElementIsThree(tests[i++]))

                    successCount ++;

            }

        } catch(ArrayIndexOutOfBoundsException e) {

            // No more tests to process

        }

        System.out.println(successCount);

    }   

    private static boolean thirdElementIsThree(int[] a) {

        return a.length >= 3 & a[2] == 3;

    }

}

    该程序用thirdElementIsThree方法测试了tests数组中的每一个元素。遍历这个数组的循环显然是非传统的循环:它不是在循环变量等于数组长度的时候终止,而是在它试图访问一个并不在数组中的元素时终止。尽管它是非传统的,但是这个循环应该可以工作。如果传递给thirdElementIsThree的参数具有3个或更多的元素,并且其第三个元素等于3,那么该方法将返回true。对于tests中的5个元素来说,有2个将返回true,因此看起来该程序应该打印2。如果你运行它,就会发现它打印的时0。肯定是哪里出了问题,你能确定吗?

    事实上,这个程序犯了两个错误。第一个错误是该程序使用了一种可怕的循环惯用法,该惯用法依赖的是对数组的访问会抛出异常。这种惯用法不仅难以阅读,而且运行速度还非常地慢。不要使用异常来进行循环控制;应该只为异常条件而使用异常[EJ Item 39]。为了纠正这个错误,可以将整个try-finally语句块替换为循环遍历数组的标准惯用法:

for (int i = 0; i < test.length; i++)

    if (thirdElementIsThree(tests[i]))

        successCount++;

    如果你使用的是5.0或者是更新的版本,那么你可以用for循环结构来代替:

for (int[] test : tests)

    if(thirdElementIsThree(test))

        successCount++;

    就第一个错误的糟糕情况来说,只有它自己还不足以产生我们所观察到的行为。然而,订正该错误可以帮助我们找到真正的bug,它更加深奥:

Exception in thread "main"

java.lang.ArrayIndexOutOfBoundsException: 2

        at Loop1.thirdElementIsThree(Loop1.java:19)

        at Loop1.main(Loop1.java:13)

    很明显,在thirdElementIsThree方法中有一个bug:它抛出了一个ArrayIndexOutOfBoundsException异常。这个异常先前伪装成了那个可怕的基于异常的循环的终止条件。

    如果传递给thirdElementIsThree的参数具有3个或更多的元素,并且其第三个元素等于3,那么该方法将返回true。问题是在这些条件不满足时它会做些什么呢。如果你仔细观察其值将会被返回的那个布尔表达式,你就会发现它与大多数布尔AND操作有一点不一样。这个表达式是a.length >= 3 & a[2] == 3。通常,你在这种情况下看到的是 && 操作符,而这个表达式使用的是 & 操作符。那是一个位AND操作符吗?

    事实证明 & 操作符有其他的含义。除了常见的被当作整型操作数的位AND操作符之外,当被用于布尔操作数时,它的功能被重载为逻辑AND操作符[JLS 15.22.2]。这个操作符与更经常被使用的条件AND操作符有所不同,& 操作符总是要计算它的两个操作数,而 && 操作符在其左边的操作数被计算为false时,就不再计算右边的操作数了[JLS 15.23]。因此,thirdElementIsThree方法总是要试图访问其数组参数的第三个元素,即使该数组参数的元素不足3个也是如此。订正这个方法只需将
& 操作符替换为 && 操作符即可。通过这样的修改,这个程序就可以打印出我们所期望的2了:

private static boolean thirdElementIsThree(int[] a) {

        return a.length >= 3 && a[2] == 3;

    }

    正像有一个逻辑AND操作符伴随着更经常被使用的条件AND操作符一样,还有一个逻辑OR操作符(|)也伴随着条件OR操作符(||)[JLS 15.22.2,15.24]。| 操作符总是要计算它的两个操作数,而 || 操作符在其左边的操作数被计算为true时,就不再计算右边的操作数了。我们一不注意,就很容易使用了逻辑操作符而不是条件操作符。遗憾的是,编译器并不能帮助你发现这种错误。有意识地使用逻辑操作符的情形非常少见,少到了我们对所有这样使用的程序都应该持怀疑态度的地步。如果你真的想使用这样的操作符,为了是你的意图清楚起见,请加上注释。

    总之,不要去用那些可怕的使用异常而不是使用显式的终止测试的循环惯用法,因为这种惯用法非常不清晰,而且会掩盖bug。要意识到逻辑AND和OR操作符的存在,并且不要因无意识的误用而受害。对语言设计者来说,这又是一个操作符重载会导致混乱的明证。对于在条件AND和OR操作符之外还要提供逻辑AND和OR操作符这一点,并没有很明显的理由。如果这些操作符确实要得到支持的话,它们应该与其相对应的条件操作符存在着视觉上的明显差异。

谜题43:异常地危险

    在JDK1.2中,Thread.stop、Thread.suspend以及其他许多线程相关的方法都因为它们不安全而不推荐使用了[ThreadStop]。下面的方法展示了你用Thread.stop可以实现的可怕事情之一。

    // Don’t do this - circumvents exception checking!

    public static void sneakyThrow(Throwable t) {

        Thread.currentThread().stop(t); // Deprecated!!

    }

    这个讨厌的小方法所做的事情正是throw语句要做的事情,但是它绕过了编译器的所有异常检查操作。你可以(卑鄙地)在你的代码的任意一点上抛出任何受检查的或不受检查的异常,而编译器对此连眉头都不会皱一下。

    不使用任何不推荐的方法,你也可以编写出在功能上等价于sneakyThrow的方法。事实上,至少有两种方式可以这么实现这一点,其中一种只能在5.0或更新的版本中运行。你能够编写出这样的方法吗?它必须是用Java而不是用JVM字节码编写的,你不能在其客户对它编译完之后再去修改它。你的方法不必是完美无瑕的:如果它不能抛出一两个Exception的子类,也是可以接受的。

    本谜题的一种解决之道是利用Class.newInstance方法中的设计缺陷,该方法通过反射来对一个类进行实例化。引用有关该方法的文档中的话[Java-API]:“请注意,该方法将传播从空的[换句话说,就是无参数的]构造器所抛出的任何异常,包括受检查的异常。使用这个方法可以有效地绕开在其他情况下都会执行的编译期异常检查。”一旦你了解了这一点,编写一个sneakyThrow的等价方法就不是太难了。

public class Thrower {

    private static Throwable t;

    private Thrower() throws Throwable {

         throw t;

    }

   

public static synchronized void sneakyThrow(Throwable t) {

         Thrower.t = t;

         try {

              Thrower.class.newInstance();

         } catch (InstantiationException e) {

              throw new IllegalArgumentException();

         } catch (IllegalAccessException e) {

              throw new IllegalArgumentException();

         } finally {

              Thrower.t = null; // Avoid memory leak

         }

    }

}

    在这个解决方案中将会发生许多微妙的事情。我们想要在构造器执行期间所抛出的异常不能作为一个参数传递给该构造器,因为Class.newInstance调用的是一个类的无参数构造器。因此,sneakyThrow方法将这个异常藏匿于一个静态变量中。为了使该方法是线程安全的,它必须被同步,这使得对其的并发调用将顺序地使用静态域t。

    要注意的是,t这个域在从finally语句块中出来时是被赋为空的:这只是因为该方法虽然是卑鄙的,但这并不意味着它还应该是内存泄漏的。如果这个域不是被赋为空出来的,那么它阻止该异常被垃圾回收。最后,请注意,如果你让该方法抛出一个InstantiationException或是一个IllegalAccessException异常,它将以抛出一个IllegalArgumentException而失败。这是这项技术的一个内在限制。

    Class.newInstance的文档继续描述道“Constructor.newInstance方法通过将构造器抛出的任何异常都包装在一个(受检查的)InvocationTargetException异常中而避免了这个问题。”很明显,Class.newInstance应该是做了相同的处理,但是纠正这个缺陷已经为时过晚,因为这么做将引入源代码级别的不兼容性,这将使许多依赖于Class.newInstance的程序崩溃。而弃用这个方法也不切实际,因为它太常用了。当你在使用它时,一定要意识到Class.newInstance可以抛出它没有声明过的受检查异常。

    被添加到5.0版本中的“通用类型(generics)”可以为本谜题提供一个完全不同的解决方案。为了实现最大的兼容性,通用类型是通过类型擦除(type erasure)来实现的:通用类型信息是在编译期而非运行期检查的[JLS 4.7]。

    下面的解决方案就利用了这项技术:

// Don't do this either - circumvents exception checking!

class TigerThrower<T extends Throwable> {

    public static void sneakyThrow(Throwable t) {

        new TigerThrower<Error>().sneakyThrow2(t);

    }

    private void sneakyThrow2(Throwable t) throws T {

        throw (T) t;

    }

}

这个程序在编译时将产生一条警告信息:

TigerThrower.java:7:warning: [unchecked] unchecked cast

found    :  java.lang.Throwable, required: T

           throw (T) t;

                       ^

    警告信息是编译器所采用的一种手段,用来告诉你:你可能正在搬起石头砸自己的脚,而且事实也正是如此。“不受检查的转型”警告告诉你这个有问题的转型将不会在运行时刻受到检查。当你获得了一个不受检查的转型警告时,你应该修改你的程序以消除它,或者你可以确信这个转型不会失败。如果你不这么做,那么某个其他的转型可能会在未来不确定的某个时刻失败,而你也就很难跟踪此错误到其源头了。对于本谜题所示的情况,其情况更糟糕:在运行期抛出的异常可能与方法的签名不一致。sneakyThrow2方法正是利用了这一点。

    对平台设计者来说,有好几条教训。在设计诸如反射类库之类在语言之外实现的类库时, 要保留语言所作的所有承诺。当从头设计一个支持通用类型的平台时,要考虑强制要求其在运行期的正确性。Java通用类型工具的设计者可没有这么做,因为他们受制于通用类库必须能够与现有客户进行互操作的要求。对于违反方法签名的异常,为了消除其产生的可能性,应该考虑强制在运行期进行异常检查。

    总之,Java的异常检查机制并不是虚拟机强制执行的。它只是一个编译期工具,被设计用来帮助我们更加容易地编写正确的程序,但是在运行期可以绕过它。要想减少你因为这类问题而被曝光的次数,就不要忽视编译器给出的警告信息。

谜题44:切掉类

    请考虑下面的两个类:

public class Strange1 {

    public static void main(String[] args) {

        try {

            Missing m = new Missing();

        } catch (java.lang.NoClassDefFoundError ex) {

            System.out.println("Got it!");

        }

    }

}

public class Strange2 {

    public static void main(String[] args) {

        Missing m;

【上篇】
【下篇】

抱歉!评论已关闭.