首頁 > 类库下载 > java类库 > 編寫高品質程式碼:改善Java程式的建議:異常

編寫高品質程式碼:改善Java程式的建議:異常

高洛峰
發布: 2016-10-14 15:53:07
原創
1633 人瀏覽過

  Java異常的機制有三種:

Error類別及其子類別表示的是錯誤,它是不需要程式設計師處理也不能處理的異常,例如VirtualMachineError虛擬機器錯誤,ThreadDeath執行緒僵死等。

RunTimeException類別及其子類別表示的是非受檢異常,是系統可能會拋出的異常,程式設計師可以去處理,也可以不處理,最經典的就是Nul​​lPointException空指標異常和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異常,開發人員可以捕捉也可以不捕捉,程式碼看起來邏輯很正確,沒有任何瑕疵,但是事實上,這段程式會拋出異常,無法執行。這段程式碼給了我們兩個警示:

加重了上層程式碼編寫者的負擔:捕捉這個RuntimeException異常吧,那誰來告訴我有這個異常呢?只有透過文件約束了,一旦Person類別的建構函式經過重構後再拋出其它非受檢異常,那main方法不用修改也是可以測試通過的,但是這裡就可能會產生隱藏的缺陷,而寫還是很難重現的缺陷。不捕捉這個RuntimeException異常,這個是我們通常的想法,既然已經寫成了非受檢異常,main方法的編碼者完全可以不處理這個異常嘛,大不了不執行Person的方法!這是非常危險的,一旦產生異常,整個線程就不再繼續執行,或者連結沒有關閉,或者資料沒有寫入資料庫,或者產生記憶體異常,這些都是會對整個系統產生影響。

後續程式碼不會執行:main方法的實作者原本是想把p物件的建立作為其程式碼邏輯的一部分,執行完doSomething方法後還需要完成其它邏輯,但是因為沒有對非受檢異常進行捕捉,異常最終會被拋出到JVM中,這會導致整個執行緒執行結束後,後面所有的程式碼都不會繼續執行了,這就對業務邏輯產生了致命的影響。

(3)、建構子盡可能不要拋出受檢異常

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

//父类
class Base {
    // 父类抛出IOException
    public Base() throws IOException {
        throw new IOException();
    }
}
//子类
class Sub extends Base {
    // 子类抛出Exception异常
    public Sub() throws Exception {

    }
}
登入後複製

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

導致子類膨脹:在我們的例子中子類的無參構造函數不能省略,原因是父類的無參構造函數拋出了IOException異常,子類的無參構造函數默認呼叫的是父類別的建構函數,所以子類別無參構造函數也必須拋出IOException或其父類別。

違背了里氏替換原則:"里氏替換原則" 是說父類能出現的地方子類就可以出現,而且將父類替換為子類也不會產生任何異常。那我們回頭看看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类,两个方法methodA和methodB都调用了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异常),则认为是不包含,若不抛出异常则可以认为包含该枚举项,看上去这段代码很正常,但是其中有是哪个错误:

异常判断降低了系统的性能

降低了代码的可读性,只有详细了解valueOf方法的人才能读懂这样的代码,因为valueOf抛出的是一个非受检异常

隐藏了运行期可能产生的错误,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毫秒左右),难道我们的系统连如此微小的性能消耗都不予许吗?


來源:php.cn
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板