編寫高質量程式碼:改善Java程式的151個建議(第8章:異常___建議114~117)

阿赫瓦里發表於2016-10-14

建議114:不要在建構函式中丟擲異常

  Java異常的機制有三種:

  • Error類及其子類表示的是錯誤,它是不需要程式設計師處理也不能處理的異常,比如VirtualMachineError虛擬機器錯誤,ThreadDeath執行緒僵死等。
  • RunTimeException類及其子類表示的是非受檢異常,是系統可能會丟擲的異常,程式設計師可以去處理,也可以不處理,最經典的就是NullPointException空指標異常和IndexOutOfBoundsException越界異常。
  • Exception類及其子類(不包含非受檢異常),表示的是受檢異常,這是程式設計師必須處理的異常,不處理則程式不能通過編譯,比如IOException表示的是I/O異常,SQLException表示的資料庫訪問異常。  

我們知道,一個物件的建立過程經過記憶體分配,靜態程式碼初始化、建構函式執行等過程,物件生成的關鍵步驟是建構函式,那是不是也允許在建構函式中丟擲異常呢?從Java語法上來說,完全可以在建構函式中丟擲異常,三類異常都可以,但是從系統設計和開發的角度來分析,則儘量不要在建構函式中丟擲異常,我們以三種不同型別的異常來說明之。

(1)、建構函式中丟擲錯誤是程式設計師無法處理的

  在建構函式執行時,若發生了VirtualMachineError虛擬機器錯誤,那就沒招了,只能丟擲,程式設計師不能預知此類錯誤的發生,也就不能捕捉處理。

(2)、建構函式不應該丟擲非受檢異常

  我們來看這樣一個例子,程式碼如下:

class Person {
    public Person(int _age) {
        // 不滿18歲的使用者物件不能建立
        if (_age < 18) {
            throw new RuntimeException("年齡必須大於18歲.");
        }
    }

    public void doSomething() {
        System.out.println("doSomething......");
    }
}

  這段程式碼的意圖很明顯,年齡不滿18歲的使用者不會生成一個Person例項物件,沒有物件,類行為doSomething方法就不可執行,想法很好,但這會導致不可預測的結果,比如我們這樣引用Person類: 

public static void main(String[] args) {
        Person p =  new Person(17);
        p.doSomething();
        /*其它的業務邏輯*/
    }

  很顯然,p物件不能建立,因為是一個RunTimeException異常,開發人員可以捕捉也可以不捕捉,程式碼看上去邏輯很正確,沒有任何瑕疵,但是事實上,這段程式會丟擲異常,無法執行。這段程式碼給了我們兩個警示:

  1. 加重了上層程式碼編寫者的負擔:捕捉這個RuntimeException異常吧,那誰來告訴我有這個異常呢?只有通過文件約束了,一旦Person類的建構函式經過重構後再丟擲其它非受檢異常,那main方法不用修改也是可以測試通過的,但是這裡就可能會產生隱藏的缺陷,而寫還是很難重現的缺陷。不捕捉這個RuntimeException異常,這個是我們通常的想法,既然已經寫成了非受檢異常,main方法的編碼者完全可以不處理這個異常嘛,大不了不執行Person的方法!這是非常危險的,一旦產生異常,整個執行緒都不再繼續執行,或者連結沒有關閉,或者資料沒有寫入資料庫,或者產生記憶體異常,這些都是會對整個系統產生影響。
  2. 後續程式碼不會執行:main方法的實現者原本是想把p物件的建立作為其程式碼邏輯的一部分,執行完doSomething方法後還需要完成其它邏輯,但是因為沒有對非受檢異常進行捕捉,異常最終會丟擲到JVM中,這會導致整個執行緒執行結束後,後面所有的程式碼都不會繼續執行了,這就對業務邏輯產生了致命的影響。

(3)、建構函式儘可能不要丟擲受檢異常

  我們來看下面的例子,程式碼如下:

//父類
class Base {
    // 父類丟擲IOException
    public Base() throws IOException {
        throw new IOException();
    }
}
//子類
class Sub extends Base {
    // 子類丟擲Exception異常
    public Sub() throws Exception {

    }
}

  就這麼一段簡單的程式碼,展示了在建構函式中丟擲受檢異常的三個不利方面:

  1. 導致子類膨脹:在我們的例子中子類的無參建構函式不能省略,原因是父類的無參建構函式丟擲了IOException異常,子類的無參建構函式預設呼叫的是父類的建構函式,所以子類無參建構函式也必須丟擲IOException或其父類。
  2. 違背了里氏替換原則"里氏替換原則" 是說父類能出現的地方子類就可以出現,而且將父類替換為子類也不會產生任何異常。那我們回頭看看Sub類是否可以替換Base類,比如我們的上層程式碼是這樣寫的:
public static void main(String[] args) {
        try {
            Base base = new Base();
        } catch (Exception e) {    
            e.printStackTrace();
        }
    }

  然後,我們期望把new Base()替換成new Sub(),而且程式碼能夠正常編譯和執行。非常可惜,編譯不通過,原因是Sub的建構函式丟擲了Exception異常,它比父類的建構函式丟擲更多的異常範圍要寬,必須增加新的catch塊才能解決。  

  可能大家要問了,為什麼Java的建構函式允許子類的建構函式丟擲更廣泛的異常類呢?這正好與類方法的異常機制相反,類方法的異常是這樣要求的:

// 父類
class Base {
    // 父類方法丟擲Exception
    public void testMethod() throws Exception {

    }
}

// 子類
class Sub extends Base {
    // 父類方法丟擲Exception
    @Override
    public void testMethod() throws IOException {

    }
}

  子類的方法可以丟擲多個異常,但都必須是覆寫方法的子型別,對我們的例子來說,Sub類的testMethod方法丟擲的異常必須是Exception的子類或Exception類,這是Java覆寫的要求。建構函式之所以於此相反,是因為建構函式沒有覆寫的概念,只是建構函式間的引用呼叫而已,所以在建構函式中丟擲受檢異常會違背里氏替換原則原則,使我們的程式缺乏靈活性。

  3.子類建構函式擴充套件受限:子類存在的原因就是期望實現擴充套件父類的邏輯,但父類建構函式丟擲異常卻會讓子類建構函式的靈活性大大降低,例如我們期望這樣的建構函式。

// 父類
class Base {
    public Base() throws IOException{
        
    }
}
// 子類
class Sub extends Base {
    public Sub() throws Exception{
        try{
            super();
        }catch(IOException e){
            //異常處理後再丟擲
            throw e;
        }finally{
            //收尾處理
        }
    }
}

  很不幸,這段程式碼編譯不通過,原因是建構函式Sub沒有把super()放在第一句話中,想把父類的異常重新包裝再丟擲是不可行的(當然,這裡有很多種 “曲線” 的實現手段,比如重新定義一個方法,然後父子類的建構函式都呼叫該方法,那麼子類建構函式就可以自由處理異常了),這是Java語法機制。

  將以上三種異常型別彙總起來,對於建構函式,錯誤只能丟擲,這是程式人員無能為力的事情;非受檢異常不要丟擲,丟擲了 " 對己對人 " 都是有害的;受檢異常儘量不丟擲,能用曲線的方式實現就用曲線方式實現,總之一句話:在建構函式中儘可能不出現異常。

  注意 :在建構函式中不要丟擲異常,儘量曲線實現。

建議115:使用Throwable獲得棧資訊

  AOP程式設計可以很輕鬆的控制一個方法呼叫哪些類,也能夠控制哪些方法允許被呼叫,一般來說切面程式設計(比如AspectJ),只能控制到方法級別,不能實現程式碼級別的植入(Weave),比如一個方法被類A的m1方法呼叫時返回1,在類B的m2方法呼叫時返回0(同引數情況下),這就要求被呼叫者具有識別呼叫者的能力。在這種情況下,可以使用Throwable獲得棧資訊,然後鑑別呼叫者並分別輸出,程式碼如下: 

class Foo {
    public static boolean method() {
        // 取得當前棧資訊
        StackTraceElement[] sts = new Throwable().getStackTrace();
        // 檢查是否是methodA方法呼叫
        for (StackTraceElement st : sts) {
            if (st.getMethodName().equals("methodA")) {
                return true;
            }
        }
        return false;
    }
}
//呼叫者
class Invoker{
    //該方法列印出true
    public static void methodA(){
        System.out.println(Foo.method());
    }
    //該方法列印出false
    public static void methodB(){
        System.out.println(Foo.method());
    }
}

  注意看Invoker類,兩個方法methodAmethodB都呼叫了Foo的method方法,都是無參呼叫,返回值卻不同,這是我們的Throwable類發揮效能了。JVM在建立一本Throwable類及其子類時會把當前執行緒的棧資訊記錄下來,以便在輸出異常時準確定位異常原因,我們來看Throwable原始碼。

public class Throwable implements Serializable {
    private static final StackTraceElement[] UNASSIGNED_STACK = new StackTraceElement[0];
    //出現異常記錄的棧幀
    private StackTraceElement[] stackTrace = UNASSIGNED_STACK;
    //預設建構函式
    public Throwable() {
//記錄棧幀 fillInStackTrace(); } //本地方法,抓取執行時的棧資訊
private native Throwable fillInStackTrace(int dummy); public synchronized Throwable fillInStackTrace() { if (stackTrace != null || backtrace != null /* Out of protocol state */) { fillInStackTrace(0); stackTrace = UNASSIGNED_STACK; } return this; } }

  在出現異常時(或主動宣告一個Throwable物件時),JVM會通過fillInStackTrace方法記錄下棧幀資訊,然後生成一個Throwable物件,這樣我們就可以知道類間的呼叫順序,方法名稱及當前行號等了。

  獲得棧資訊可以對呼叫者進行判斷,然後決定不同的輸出,比如我們的methodA和methodB方法,同樣地輸入引數,同樣的呼叫方法,但是輸出卻不同,這看起來很想是一個bug:方法methodA呼叫method方法正常顯示,而方法methodB呼叫卻會返回錯誤資料,因此我們雖然可以根據呼叫者的不同產生不同的邏輯,但這僅侷限在對此方法的廣泛認知上,更多的時候我們使用method方法的變形體,程式碼如下:  

class Foo {
    public static boolean method() {
        // 取得當前棧資訊
        StackTraceElement[] sts = new Throwable().getStackTrace();
        // 檢查是否是methodA方法呼叫
        for (StackTraceElement st : sts) {
            if (st.getMethodName().equals("methodA")) {
                return true;
            }
        }
        throw new RuntimeException("除了methodA方法外,該方法不允許其它方法呼叫");
    }
}

  只是把“return false” 替換成了一個執行期異常,除了methodA方法外,其它方法呼叫都會產生異常,該方法常用作離線註冊碼校驗,讓破解者檢視暴力破解時,由於執行者不是期望的值,因此會返回一個經過包裝和混淆的異常資訊,大大增加了破解難度。

建議116:異常只為異常服務

  異常只為異常服務,這是何解?難道異常還能為其它服務不成?確實能,異常原本是正常邏輯的一個補充,但是有時候會被當做主邏輯使用,看如下程式碼:

//判斷一個列舉是否包含String列舉項
    public static <T extends Enum<T>> boolean Contain(Class<T> clz,String name){
        boolean result = false;
        try{
            Enum.valueOf(clz, name);
            result = true;
        }catch(RuntimeException e){
            //只要是丟擲異常,則認為不包含
        }
        return result;
    }

  判斷一個列舉是否包含指定的列舉項,這裡會根據valueOf方法是否丟擲異常來進行判斷,如果丟擲異常(一般是IllegalArgumentException異常),則認為是不包含,若不丟擲異常則可以認為包含該列舉項,看上去這段程式碼很正常,但是其中有是哪個錯誤:

  1. 異常判斷降低了系統的效能
  2. 降低了程式碼的可讀性,只有詳細瞭解valueOf方法的人才能讀懂這樣的程式碼,因為valueOf丟擲的是一個非受檢異常
  3. 隱藏了執行期可能產生的錯誤,catch到異常,但沒有做任何處理。

  我們這段程式碼是用一段異常實現了一個正常的業務邏輯,這導致程式碼產生了壞味道。要解決從問題也很容易,即不在主邏輯中實使用異常,程式碼如下:  

    // 判斷一個列舉是否包含String列舉項
    public static <T extends Enum<T>> boolean Contain(Class<T> clz, String name) {
        // 遍歷列舉項
        for (T t : clz.getEnumConstants()) {
            // 列舉項名稱是否相等
            if (t.name().equals(name)) {
                return true;
            }
        }
        return false;
    }

  異常只能用在非正常的情況下,不能成為正常情況下的主邏輯,也就是說,異常是是主邏輯的輔助場景,不能喧賓奪主。

  而且,異常雖然是描述例外事件的,但能避免則避免之,除非是確實無法避免的異常,例如: 

public static void main(String[] args) {
        File file = new File("a.txt");
        try {
            FileInputStream fis = new FileInputStream(file);
            // 其它業務處理
        } catch (FileNotFoundException e) {
            e.printStackTrace();
            // 異常處理
        }
    }

  這樣一段程式碼經常在我們的專案中出現,但經常寫並不代表不可優化,這裡的異常類FileNotFoundException完全可以在它誕生前就消除掉:先判斷檔案是否存在,然後再生成FileInputStream物件,這也是專案中常見的程式碼:

    public static void main(String[] args) {
        File file = new File("a.txt");
        // 經常出現的異常,可以先做判斷
        if (file.exists() && !file.isDirectory()) {
            try {
                FileInputStream fis = new FileInputStream(file);
                // 其它業務處理
            } catch (FileNotFoundException e) {
                e.printStackTrace();
                // 異常處理
            }
        }
    }

  雖然增加了if判斷語句,增加了程式碼量,但是卻減少了FileNotFoundException異常出現的機率,提高了程式的效能和穩定性。

建議117:多使用異常,把效能問題放一邊

  我們知道異常是主邏輯的例外邏輯,舉個簡單的例子來說,比如我在馬路上走(這是主邏輯),突然開過一輛車,我要避讓(這是受檢異常,必須處理),繼續走著,突然一架飛機從我頭頂飛過(非受檢異常),我們可以選在繼續行走(不捕捉),也可以選擇指責其噪音汙染(捕捉,主邏輯的補充處理),再繼續走著,突然一顆流星砸下來,這沒有選擇,屬於錯誤,不能做任何處理。這樣具備完整例外場景的邏輯就具備了OO的味道,任何一個事務的處理都可能產生非預期的效果,問題是需要以何種手段來處理,如果不使用異常就需要依靠返回值的不同來進行處理了,這嚴重失去了物件導向的風格。

  我們在編寫用例文件(User case Specification)時,其中有一項叫做 " 例外事件 ",是用來描述主場景外的例外場景的,例如使用者登入的用例,就會在" 例外事件 "中說明" 連續3此登入失敗即鎖定使用者賬號 ",這就是登入事件的一個異常處理,具體到我們的程式中就是:  

public void login(){
        try{
            //正常登陸
        }catch(InvalidLoginException lie){
            //    使用者名稱無效
        }catch(InvalidPasswordException pe){
            //密碼錯誤的異常
        }catch(TooMuchLoginException){
            //多次登陸失敗的異常
        }
    }

  如此設計則可以讓我們的login方法更符合實際的處理邏輯,同時使主邏輯(正常登入,try程式碼塊)更加清晰。當然了,使用異常還有很多優點,可以讓正常程式碼和異常程式碼分離、能快速查詢問題(棧資訊快照)等,但是異常有一個缺點:效能比較慢。

  Java的異常機制確實比較慢,這個"比較慢"是相對於諸如String、Integer等物件來說的,單單從物件的建立上來說,new一個IOException會比String慢5倍,這從異常的處理機制上也可以解釋:因為它要執行fillInStackTrace方法,要記錄當前棧的快照,而String類則是直接申請一個記憶體建立物件,異常類慢一籌也就在所難免了。

  而且,異常類是不能快取的,期望先建立大量的異常物件以提高異常效能也是不現實的。

  難道異常的效能問題就沒有任何可以提高的辦法了?確實沒有,但是我們不能因為效能問題而放棄使用異常,而且經過測試,在JDK1.6下,一個異常物件的建立時間只需1.4毫秒左右(注意是毫秒,通常一個交易是在100毫秒左右),難道我們的系統連如此微小的效能消耗都不予許嗎?

 注意:效能問題不是拒絕異常的藉口。

相關文章