ホームページ  >  記事  >  类库下载  >  高品質のコードを書く: Java プログラムを改善するためのヒント: 例外

高品質のコードを書く: Java プログラムを改善するためのヒント: 例外

高洛峰
高洛峰オリジナル
2016-10-14 15:53:071590ブラウズ

Java 例外には 3 つのメカニズムがあります:

Error クラスとそのサブクラスは、VirtualMachineError 仮想マシン エラー、ThreadDeath スレッド ゾンビなど、プログラマが処理する必要がなく、処理できない例外です。

RunTimeException クラスとそのサブクラスは、プログラマによってスローされる可能性がある例外、および境界外の IndexOutOfBoundsException 例外を表します。

Exception クラスとそのサブクラス (未チェック例外を除く) は、プログラマが処理する必要がある例外です。たとえば、IOException は、データベース アクセスを表します。 SQLException で表される例外。

オブジェクトの作成プロセスでは、メモリ割り当て、静的コードの初期化、コンストラクターの実行などが行われることがわかっています。オブジェクト生成の重要なステップはコンストラクターです。コンストラクターで例外をスローすることもできますか? Java 構文の観点からは、コンストラクターで例外をスローできます。ただし、システム設計および開発の観点からは、3 つの異なるタイプの例外を使用しないようにしてください。それを説明するための例外です。

(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 歳未満のユーザーは生成しません。インスタンスオブジェクト、オブジェクトがなければ、クラスの動作 doSomething メソッドは実行できません。このアイデアは良いのですが、これは、たとえば、次のような予期せぬ結果をもたらします。オブジェクトは RunTimeException であるため作成できません。開発者はそれをキャプチャできますが、コードは論理的に正しく、欠陥がないように見えますが、実際には、このプログラムは例外をスローし、実行できません。このコードでは 2 つの警告が表示されます:

上位レベルのコード作成者の負担が増加します: この RuntimeException 例外をキャッチしてください。その後、誰がこの例外について教えてくれるでしょうか?ドキュメントの制約を介してのみ、 Person クラスのコンストラクターがリファクタリングされ、他の未チェックの例外がスローされると、main メソッドは変更せずにテストに合格できますが、ここには隠れた欠陥がある可能性があり、欠陥を記述するのは依然として非常に困難です。再現するのが難しい。これは、この RuntimeException をキャッチしないという通常の考え方です。これは未チェックの例外として記述されているため、メイン メソッドのコーダーはこの例外をまったく処理できず、最悪の場合、Person メソッドは実行されません。これは非常に危険です。例外が発生すると、スレッド全体が実行を継続できなくなるか、リンクが閉じられないか、データがデータベースに書き込まれないか、メモリ例外が発生し、システムに影響を及ぼします。システム全体。

後続のコードは実行されません。main メソッドの実装者は当初、コード ロジックの一部として p オブジェクトの確立を使用したいと考えていましたが、doSomething メソッドの実行後、他のロジックを完了する必要がありましたが、チェックされていない例外が発生したためです。例外は最終的に JVM にスローされ、スレッド全体の実行が終了した後、後続のすべてのコードが実行を継続できなくなり、ビジネス ロジックに致命的な影響を及ぼします。

(3). コンストラクターはチェック例外をできるだけスローしないようにする必要があります

次の例を見てみましょう。コードは次のとおりです:

public static void main(String[] args) {
        Person p =  new Person(17);
        p.doSomething();        /*其它的业务逻辑*/
    }

これは、スローする方法を示す非常に単純なコードです。コンストラクター内のチェック例外 3 つの欠点:

サブクラスの拡張が発生する: この例では、サブクラスのパラメーターなしコンストラクターは省略できません。その理由は、親クラスのパラメーターなしコンストラクターが IOException をスローし、そのクラスのパラメーターなしコンストラクターが IOException をスローするためです。サブクラスのデフォルトは次のとおりです。親クラスのコンストラクターが呼び出されるため、サブクラスのパラメーターなしのコンストラクターも IOException またはその親クラスをスローする必要があります。

リスコフ置換原則に違反します: 「リスコフ置換原則」とは、親クラスが出現できる場所にはサブクラスも出現でき、親クラスをサブクラスで置き換えても例外は発生しないことを意味します。次に、Sub クラスが Base クラスを置き換えられるかどうかを確認します。たとえば、上位レベルのコードは次のように記述されています。

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

    }
}

次に、new Base() を new Sub() に置き換えることを期待します。コードは正常にコンパイルして実行できます。 Sub コンストラクターが例外をスローするため、コンパイルが失敗するのは残念です。これを解決するには、新しい 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毫秒左右),难道我们的系统连如此微小的性能消耗都不予许吗?


声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。