Java異常(三) 《Java Puzzles》中關於異常的幾個謎題


 

概要

本章介紹《Java Puzzles》中關於異常的幾個謎題。這一章都是以代碼為例,相比上一章看起來更有意思。內容包括:
謎題1: 優柔寡斷
謎題2: 極端不可思議
謎題3: 不受歡迎的賓客
謎題4: 您好,再見!
謎題5: 不情願的構造器
謎題6: 域和流
謎題7: 異常為循環而拋

轉載請注明出處:http://www.cnblogs.com/skywang12345/p/3544353.html

 

謎題1: 優柔寡斷

看看下面的程序,它到底打印什么?

public class Indecisive {

    public static void main(String[] args) {
        System.out.println(decision());
    }

    private static boolean decision() {
        try {
            return true;
        } finally {
            return false;
        }
    }
}

運行結果

false

結果說明
  在一個 try-finally 語句中,finally 語句塊總是在控制權離開 try 語句塊時執行的。無論 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 語句塊可以正常結束。return、break 或 continue 語句把控制權傳遞到 finally 語句塊之外應該是被禁止的, 任何可以引發將被檢查異常傳播到 finally 語句塊之外的語句也同樣應該是被禁止的。

 

謎題2: 極端不可思議

下面的三個程序每一個都會打印些什么? 不要假設它們都可以通過編譯。
第一個程序

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();
    }
}

運行結果
(01) 第一個程序編譯出錯!

Arcane1.java:9: exception java.io.IOException is never thrown in body of corresponding try statement
        } catch(IOException e) {
          ^
1 error

(02) 第二個程序能正常編譯和運行。
(03) 第三個程序能正常編譯和運行。輸出結果是: Hello world

結果說明
(01) Arcane1展示了被檢查異常的一個基本原則。它看起來應該是可以編譯的:try 子句執行 I/O,並且 catch 子句捕獲 IOException 異常。但是這個程序不能編譯,因為 println 方法沒有聲明會拋出任何被檢查異常,而IOException 卻正是一個被檢查異常。語言規范中描述道:如果一個 catch 子句要捕獲一個類型為 E 的被檢查異常, 而其相對應的 try 子句不能拋出 E 的某種子類型的異常,那么這就是一個編譯期錯誤。

(02) 基於同樣的理由,第二個程序,Arcane2,看起來應該是不可以編譯的,但是它卻可以。它之所以可以編譯,是因為它唯一的 catch 子句檢查了 Exception。盡管在這一點上十分含混不清,但是捕獲 Exception 或 Throwble 的 catch 子句是合法的,不管與其相對應的 try 子句的內容為何。盡管 Arcane2 是一個合法的程序,但是 catch 子句的內容永遠的不會被執行,這個程序什么都不會打印。

(03) 第三個程序,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。

 

謎題3: 不受歡迎的賓客

下面的程序會打印出什么呢?

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 {
}

運行結果

UnwelcomeGuest.java:10: variable USER_ID might already have been assigned
            USER_ID = GUEST_USER_ID;
            ^
1 error

結果說明
  該程序看起來很直觀。對 getUserIdFromEnvironment 的調用將拋出一個異常, 從而使程序將 GUEST_USER_ID(-1L)賦值給 USER_ID, 並打印 Loggin in as guest。 然后 main 方法執行,使程序打印 User ID: -1。表象再次欺騙了我們,該程序並不能編譯。如果你嘗試着去編譯它, 你將看到和一條錯誤信息。

  問題出在哪里了?USER_ID 域是一個空 final(blank final),它是一個在聲明中沒有進行初始化操作的 final 域。很明顯,只有在對 USER_ID 賦值失敗時,才會在 try 語句塊中拋出異常,因此,在 catch 語句塊中賦值是相 當安全的。不管怎樣執行靜態初始化操作語句塊,只會對 USER_ID 賦值一次,這正是空 final 所要求的。為什么編譯器不知道這些呢? 要確定一個程序是否可以不止一次地對一個空 final 進行賦值是一個很困難的問題。事實上,這是不可能的。這等價於經典的停機問題,它通常被認為是不可能解決的。為了能夠編寫出一個編譯器,語言規范在這一點上采用了保守的方式。在程序中,一個空 final 域只有在它是明確未賦過值的地方才可以被賦值。規范長篇大論,對此術語提供了一個准確的但保守的定義。 因為它是保守的,所以編譯器必須拒絕某些可以證明是安全的程序。這個謎題就展示了這樣的一個程序。幸運的是, 你不必為了編寫 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;
        }
    }

    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 {
}

  程序的這個版本很顯然是正確的,而且比最初的版本根據可讀性,因為它為了域值的計算而增加了一個描述性的名字, 而最初的版本只有一個匿名的靜態初始化操作語句塊。將這樣的修改作用於程序,它就可以如我們的期望來運行了。總之,大多數程序員都不需要學習明確賦值規則的細節。該規則的作為通常都是正確的。如果你必須重構一個程序,以消除由明確賦值規則所引發的錯誤,那么你應該考慮添加一個新方法。這樣做除了可以解決明確賦值問題,還可以使程序的可讀性提高。

 

謎題4: 您好,再見!

下面的程序將會打印出什么呢?

public class HelloGoodbye {
    public static void main(String[] args) {
        try {
            System.out.println("Hello world");
            System.exit(0);
        } finally {
            System.out.println("Goodbye world");
        }
    }
}

運行結果:

Hello world

結果說明
  這個程序包含兩個 println 語句: 一個在 try 語句塊中, 另一個在相應的 finally語句塊中。try 語句塊執行它的 println 語句,並且通過調用 System.exit 來提前結束執行。在此時,你可能希望控制權會轉交給 finally 語句塊。然而,如果你運行該程序,就會發現它永遠不會說再見:它只打印了 Hello world。這是否違背了"Indecisive示例" 中所解釋的原則呢? 不論 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,但是這個方法很少使用。

 

謎題5: 不情願的構造器

下面的程序將打印出什么呢?

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");
        }
    }
}

運行結果

Exception in thread "main" java.lang.StackOverflowError
    at Reluctant.<init>(Reluctant.java:3)
    ...

結果說明
  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 異常。

  至於本謎題名義上的題目:聲明將拋出異常的構造器,你需要注意,構造器必須聲明其實例初始化操作會拋出的所有被檢查異常。

 

謎題6: 域和流

下面的方法將一個文件拷貝到另一個文件,並且被設計為要關閉它所創建的每一個流,即使它碰到 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 被調用,從而使輸出流仍保持在開放狀態。請注意,該程序違反了"優柔寡斷" 的建議:對 close 的調用可能會導致 finally 語句塊意外結束。遺憾的是,編譯器並不能幫助你發現此問題,因為 close 方法拋出的異常與 read 和 write 拋出的異常類型相同,而其外圍方法(copy)聲明將傳播該異常。解決方式是將每一個 close 都包裝在一個嵌套的 try 語句塊中。

下面的 finally 語句塊的版本可以保證在兩個流上都會調用 close:

try {
    // 和之前一樣
} 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
        }
    }
}

  總之,當你在 finally 語句塊中調用 close 方法時,要用一個嵌套的 try-catch 語句來保護它,以防止 IOException 的傳播。更一般地講,對於任何在 finally 語句塊中可能會拋出的被檢查異常都要進行處理,而不是任其傳播。

 

謎題7: 異常為循環而拋

下面的程序會打印出什么呢?

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;
    }
}

運行結果

0

結果說明
  該程序主要說明了兩個問題。

  第1個問題:不應該使用異常作為終止循環的手段!
  該程序用 thirdElementIsThree 方法測試了 tests 數組中的每一個元素。遍歷這個數組的循環顯然是非傳統的循環:它不是在循環變量等於數組長度的時候終止,而是在它試圖訪問一個並不在數組中的元素時終止。盡管它是非傳統的,但是這個循環應該可以工作。
  如果傳遞給 thirdElementIsThree 的參數具有 3 個或更多的元素,並且其第三個元素等於 3,那么該方法將返回 true。對於 tests中的 5 個元素來說,有 2 個將返回 true,因此看起來該程序應該打印 2。如果你運行它,就會發現它打印的時 0。肯定是哪里出了問題,你能確定嗎? 事實上,這個程序犯了兩個錯誤。第一個錯誤是該程序使用了一種可怕的循環慣用法,該慣用法依賴的是對數組的訪問會拋出異常。這種慣用法不僅難以閱讀, 而且運行速度還非常地慢。不要使用異常來進行循環控制;應該只為異常條件而使用異常。為了糾正這個錯誤,可以將整個 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++;

 

  第2個問題: 主要比較"&操作符" 和 "&&操作符"的區別。注意示例中的操作符是&,這是按位進行"與"操作。

 


更多內容

1. Java異常(一) 基礎和架構

2. Java異常(二) 《Effective Java》中關於異常處理的幾條建議 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM