《Java 编程的逻辑》笔记——第6章 异常

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

开头语

之前我们介绍的基本类型、类、接口、枚举都是在表示和操作数据,操作的过程中可能有很多出错的情况,出错的原因可能是多方面的,有的是不可控的内部原因,比如内存不够了、磁盘满了,有的是不可控的外部原因,比如网络连接有问题,更多的可能是程序的编程错误,比如引用变量未初始化就直接调用实例方法。

这些非正常情况在 Java 中统一被认为是异常,Java 使用异常机制来统一处理,由于内容较多,我们分为两节来介绍,本节介绍异常的初步概念,以及异常类本身,下节主要介绍异常的处理。

6.1 初识异常

我们先来看两个具体的异常 NullPointerException (空指针异常) 和 NumberFormatException (数字格式异常)

6.1.1 NullPointerException (空指针异常)

我们来看段代码:

public class ExceptionTest {
    
    
    public static void main(String[] args) {
    
    
        String s = null;
        s.indexOf("a");
        System.out.println("end");
    }
}

变量 s 没有初始化就调用其实例方法 indexOf,运行,屏幕输出为:

Exception in thread "main" java.lang.NullPointerException
    at ExceptionTest.main(ExceptionTest.java:5)

输出是告诉我们:在 ExceptionTest 类的 main 函数中,代码第 5 行,出现了空指针异常(java.lang.NullPointerException)。

但,具体发生了什么呢?当执行 s.indexOf(“a”) 的时候,Java 系统发现 s 的值为 null,没有办法继续执行了,这时就启用异常处理机制,首先创建一个异常对象,这里是类 NullPointerException 的对象,然后查找看谁能处理这个异常,在示例代码中,没有代码能处理这个异常,Java 就启用默认处理机制,那就是打印异常栈信息到屏幕,并退出程序

在介绍函数调用原理的时候,我们介绍过栈,异常栈信息就包括了从异常发生点到最上层调用者的轨迹,还包括行号,可以说,这个栈信息是分析异常最为重要的信息。

Java 的默认异常处理机制是退出程序,异常发生点后的代码都不会执行,所以示例代码中最后一行 System.out.println(“end”) 不会执行。

6.1.2 NumberFormatException (数字格式异常)

我们再来看一个例子,代码如下:

public class ExceptionTest {
    
    
    public static void main(String[] args) {
    
    
        if(args.length<1){
    
    
            System.out.println("请输入数字");
            return;
        }
        int num = Integer.parseInt(args[0]);
        System.out.println(num);
    }
}

args 表示命令行参数,这段代码要求参数为一个数字,它通过 Integer.parseInt 将参数转换为一个整数,并输出这个整数。参数是用户输入的,我们没有办法强制用户输入什么,如果用户输的是数字,比如 123,屏幕会输出 123,但如果用户输的不是数字,比如 abc,屏幕会输出:

Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    at java.lang.Integer.parseInt(Integer.java:492)
    at java.lang.Integer.parseInt(Integer.java:527)
    at ExceptionTest.main(ExceptionTest.java:7)

出现了异常 NumberFormatException。这个异常是怎么产生的呢?根据异常栈信息,我们看相关代码:

这是 NumberFormatException 类 65 行附近代码:

64 static NumberFormatException forInputString(String s) {
    
    
65     return new NumberFormatException("For input string: \"" + s + "\"");
66 }

这是 Integer 类 492 行附近代码:

490 digit = Character.digit(s.charAt(i++),radix);
491 if (digit < 0) {
    
    
492     throw NumberFormatException.forInputString(s);
493 }
494 if (result < multmin) {
    
    
495     throw NumberFormatException.forInputString(s);
496 }

将这两处合为一行,主要代码就是:

throw new NumberFormatException(...)

new NumberFormatException(…) 是我们容易理解的,就是创建了一个类的对象,只是这个类是一个异常类。throw 是什么意思呢?就是抛出异常,它会触发 Java 的异常处理机制。在之前的空指针异常中,我们没有看到 throw 的代码,可以认为 throw 是由 Java 虚拟机自己实现的。

throw 关键字可以与 return 关键字进行对比,return 代表正常退出,throw 代表异常退出,return 的返回位置是确定的,就是上一级调用者,而 throw 后执行哪行代码则经常是不确定的,由异常处理机制动态确定。

异常处理机制会从当前函数开始查找看谁"捕获"了这个异常,当前函数没有就查看上一层,直到主函数,如果主函数也没有,就使用默认机制,即输出异常栈信息并退出,这正是我们在屏幕输出中看到的。

对于屏幕输出中的异常栈信息,程序员是可以理解的,但普通用户无法理解,也不知道该怎么办,我们需要给用户一个更为友好的信息,告诉用户,他应该输入的是数字,要做到这一点,我们需要自己"捕获"异常。

"捕获"是指使用 try/catch 关键字,我们看捕获异常后的示例代码:

public class ExceptionTest {
    
    
    public static void main(String[] args) {
    
    
        if(args.length<1){
    
    
            System.out.println("请输入数字");
            return;
        }
        try{
    
    
            int num = Integer.parseInt(args[0]);
            System.out.println(num);    
        }catch(NumberFormatException e){
    
    
            System.err.println("参数"+args[0]
                    +"不是有效的数字,请输入数字");
        }
    }
}

我们使用 try/catch 捕获并处理了异常,try 后面的大括号 {} 内包含可能抛出异常的代码,括号后的 catch 语句包含能捕获的异常和处理代码,catch 后面括号内是异常信息,包括异常类型和变量名,这里是 NumberFormatException e,通过它可以获取更多异常信息,大括号 {} 内是处理代码,这里输出了一个更为友好的提示信息。

捕获异常后,程序就不会异常退出了,但 try 语句内异常点之后的其他代码就不会执行了,执行完 catch 内的语句后,程序会继续执行 catch 大括号外的代码

这样,我们就对异常有了一个初步的了解,异常是相对于 return 的一种退出机制,可以由系统触发,也可以由程序通过 throw 语句触发,异常可以通过 try/catch 语句进行捕获并处理,如果没有捕获,则会导致程序退出并输出异常栈信息。异常有不同的类型,接下来,我们来认识一下。

6.2 异常类

NullPointerException 和 NumberFormatException 都是异常类,所有异常类都有一个共同的父类 Throwable,我们先来介绍这个父类,然后介绍 Java 中异常类的体系,最后介绍怎么自定义异常。

6.2.1 Throwable

NullPointerException 和 NumberFormatException 都是异常类,所有异常类都有一个共同的父类 Throwable,它有 4 个 public 构造方法:

  1. public Throwable()
  2. public Throwable(String message)
  3. public Throwable(String message, Throwable cause)
  4. public Throwable(Throwable cause)

有两个主要参数,一个是 message,表示异常消息,另一个是 cause,表示触发该异常的其他异常。异常可以形成一个异常链,上层的异常由底层异常触发,cause 表示底层异常。

Throwable 还有一个 public 方法用于设置 cause,Throwable 的某些子类没有带 cause 参数的构造方法,就可以通过这个方法来设置,这个方法最多只能被调用一次。

Throwable initCause(Throwable cause)

所有构造方法中都有一句重要的函数调用,它会将异常栈信息保存下来,这是我们能看到异常栈的关键。

fillInStackTrace();

Throwable 有一些常用方法用于获取异常信息

void printStackTrace() // 打印异常栈信息到标准错误输出流
// 打印栈信息到指定的流,关于 PrintStream 和 PrintWriter 我们后续文章介绍
void printStackTrace(PrintStream s)
void printStackTrace(PrintWriter s)
String getMessage() // 获取设置的异常 message
Throwable getCause() // 获取异常的 cause
// 获取异常栈每一层的信息,每个 StackTraceElement 包括文件名、类名、函数名、行号等信息
StackTraceElement[] getStackTrace()

6.2.2 异常类体系

以 Throwable 为根,Java API 中定义了非常多的异常类,表示各种类型的异常,部分类如图 6-1 所示。

在这里插入图片描述

Throwable 是所有异常的基类,它有两个子类 Error 和 Exception

Error 表示系统错误或资源耗尽,由 Java 系统自己使用,应用程序不应抛出和处理。比如图中列出的虚拟机错误(VirtualMacheError)及其子类内存溢出错误(OutOfMemoryError)和栈溢出错误(StackOverflowError)。

Exception 表示应用程序错误,它有很多子类,应用程序也可以通过继承 Exception 或其子类创建自定义异常。图中列出了三个直接子类:IOException(输入输出I/O异常),SQLException(数据库SQL异常),RuntimeException(运行时异常)。

RuntimeException(运行时异常) 比较特殊,它的名字有点误导,因为其他异常也是运行时产生的,它表示的实际含义是 unchecked exception (未受检异常),相对而言,Exception 的其他子类和 Exception 自身则是 checked exception (受检异常),Error 及其子类也是 unchecked exception。

checked 还是 unchecked,区别在于 Java 如何处理这两种异常,对于 checked 异常,Java 会强制要求程序员进行处理,否则会有编译错误,而对于 unchecked 异常则没有这个要求。下节我们会进一步解释。

RuntimeException 也有很多子类,表 6-1 列出了其中常见的一些。

在这里插入图片描述

这么多不同的异常类其实并没有比 Throwable 这个基类多多少属性和方法,大部分类在继承父类后只是定义了几个构造方法,这些构造方法也只是调用了父类的构造方法,并没有额外的操作。

那为什么定义这么多不同的类呢?主要是为了名字不同,异常类的名字本身就代表了异常的关键信息,无论是抛出还是捕获异常时,使用合适的名字都有助于代码的可读性和可维护性。

6.2.3 自定义异常

除了 Java API 中定义的异常类,我们也可以自己定义异常类,一般通过继承 Exception 或者它的某个子类。如果父类是 RuntimeException 或它的某个子类,则自定义异常也是 unchecked exception,如果是 Exception 或 Exception 的其他子类,则自定义异常是 checked exception。

我们通过继承 Exception 来定义一个异常,代码如下:

public class AppException extends Exception {
    
    
    public AppException() {
    
    
        super();
    }

    public AppException(String message,
            Throwable cause) {
    
    
        super(message, cause);
    }

    public AppException(String message) {
    
    
        super(message);
    }

    public AppException(Throwable cause) {
    
    
        super(cause);
    }
}

和很多其他异常类一样,我们没有定义额外的属性和代码,只是继承了 Exception,定义了构造方法并调用了父类的构造方法。

6.3 异常处理

在了解了异常的基本概念和异常类之后,我们来看 Java 语言对异常处理的支持,包括 catch、throw、finally、try-with-resources 和 throws,最后对比受检和未受检异常。

6.3.1 catch 匹配

上节简单介绍了使用 try/catch 捕获异常,其中 catch 只有一条,其实,catch 还可以有多条,每条对应一个异常类型,比如说:

try{
    
    
    //可能触发异常的代码
}catch(NumberFormatException e){
    
    
    System.out.println("not valid number");
}catch(RuntimeException e){
    
    
    System.out.println("runtime exception "+e.getMessage());
}catch(Exception e){
    
    
    e.printStackTrace();
}

异常处理机制将根据抛出的异常类型找第一个匹配的 catch 块,找到后,执行 catch 块内的代码,其他 catch 块就不执行了,如果没有找到,会继续到上层方法中查找。需要注意的是,抛出的异常类型是 catch 中声明异常的子类也算匹配,所以需要将最具体的子类放在前面,如果基类 Exception 放在前面,则其他更具体的 catch 代码将得不到执行。

示例也演示了对异常信息的利用,e.getMessage() 获取异常消息,e.printStackTrace() 打印异常栈到标准错误输出流。通过这些信息有助于理解为什么会出异常,这是解决编程错误的常用方法。示例是直接将信息输出到标准流上,实际系统中更常用的做法是输出到专门的日志中。

6.3.2 重新抛出异常

在 catch 块内处理完后,可以重新抛出异常,异常可以是原来的,也可以是新建的,如下所示:

try{
    
    
    //可能触发异常的代码
}catch(NumberFormatException e){
    
    
    System.out.println("not valid number");
    throw new AppException("输入格式不正确", e);
}catch(Exception e){
    
    
    e.printStackTrace();
    throw e;
}

对于 Exception,在打印出异常栈后,就通过 throw e 重新抛出了。

而对于 NumberFormatException,我们重新抛出了一个 AppException,当前 Exception 作为 cause 传递给了 AppException,这样就形成了一个异常链,捕获到 AppException 的代码可以通过 getCause() 得到 NumberFormatException。

为什么要重新抛出呢?因为当前代码不能够完全处理该异常,需要调用者进一步处理

为什么要抛出一个新的异常呢?当然是当前异常不太合适,不合适可能是信息不够,需要补充一些新信息,还可能是过于细节,不便于调用者理解和使用,如果调用者对细节感兴趣,还可以继续通过 getCause() 获取到原始异常。

6.3.3 finally

异常机制中还有一个重要的部分,就是 finally,catch 后面可以跟 finally 语句,语法如下所示:

try{
    
    
    //可能抛出异常
}catch(Exception e){
    
    
    //捕获异常
}finally{
    
    
    //不管有无异常都执行
}

finally 内的代码不管有无异常发生,都会执行。具体来说:

  • 如果没有异常发生,在 try 内的代码执行结束后执行。
  • 如果有异常发生且被 catch 捕获,在 catch 内的代码执行结束后执行
  • 如果有异常发生但没被捕获,则在异常被抛给上层之前执行。

由于 finally 的这个特点,它一般用于释放资源,如数据库连接、文件流等

try/catch/finally 语法中,catch 不是必需的,也就是可以只有 try/finally,表示不捕获异常,异常自动向上传递,但 finally 中的代码在异常发生后也执行。

finally 语句有一个执行细节,如果在 try 或者 catch 语句内有 return 语句,则 return 语句在 finally 语句执行结束后才执行,但 finally 并不能改变返回值,我们来看下代码:

public static int test(){
    
    
    int ret = 0;
    try{
    
    
        return ret;
    }finally{
    
    
        ret = 2;
    }
}

这个函数的返回值是 0,而不是 2,实际执行过程是,在执行到 try 内的 return ret; 语句前,会先将返回值 ret 保存在一个临时变量中,然后才执行 finally 语句,最后 try 再返回那个临时变量,finally 中对 ret 的修改不会被返回。

如果在 finally 中也有 return 语句呢?try 和 catch 内的 return 会丢失,实际会返回 finally 中的返回值。finally 中有 return 不仅会覆盖 try 和 catch 内的返回值,还会掩盖 try 和 catch 内的异常,就像异常没有发生一样,比如说:

public static int test(){
    
    
    int ret = 0;
    try{
    
    
        int a = 5/0;
        return ret;
    }finally{
    
    
        return 2;
    }
}

以上代码中,5/0 会触发 ArithmeticException,但是 finally 中有 return 语句,这个方法就会返回 2,而不再向上传递异常了。

finally 中不仅 return 语句会掩盖异常,如果 finally 中抛出了异常,则原异常就会被掩盖,看下面代码:

public static void test(){
    
    
    try{
    
    
        int a = 5/0;
    }finally{
    
    
        throw new RuntimeException("hello");
    }
}

finally 中抛出了 RuntimeException,则原异常 ArithmeticException 就丢失了。

所以,一般而言,为避免混淆,应该避免在 finally 中使用 return 语句或者抛出异常,如果调用的其他代码可能抛出异常,则应该捕获异常并进行处理。

6.3.4 throws

异常机制中,还有一个和 throw 很像的关键字 throws用于声明一个方法可能抛出的异常,语法如下所示:

public void test() throws AppException, SQLException, NumberFormatException {
    
    
    //....
}

throws 跟在方法的括号后面,可以声明多个异常,以逗号分隔。这个声明的含义是说,我这个方法内可能抛出这些异常,我没有进行处理,至少没有处理完,调用者必须进行处理。这个声明没有说明,具体什么情况会抛出什么异常,作为一个良好的实践,应该将这些信息用注释的方式进行说明,这样调用者才能更好的处理异常。

对于 RuntimeException(unchecked exception),是不要求使用 throws 进行声明的,但对于 checked exception,则必须进行声明,换句话说,如果没有声明,则不能抛出

对于 checked exception,不可以抛出而不声明,但可以声明抛出但实际不抛出,不抛出声明它干嘛?主要用于在父类方法中声明,父类方法内可能没有抛出,但子类重写方法后可能就抛出了,子类不能抛出父类方法中没有声明的 checked exception,所以就将所有可能抛出的异常都写到父类上了。

如果一个方法内调用了另一个声明抛出 checked exception 的方法,则必须处理这些 checked exception,不过,处理的方式既可以是 catch,也可以是继续使用 throws,如下代码所示:

public void tester() throws AppException {
    
    
    try {
    
    
        test();
    }  catch (SQLException e) {
    
    
        e.printStackTrace();
    }
}

对于 test 抛出的 SQLException,这里使用了 catch,而对于 AppException,则将其添加到了自己方法的 throws 语句中,表示当前方法也处理不了,还是由上层处理吧。

6.3.5 对比受检和未受检异常

通过以上介绍可以看出,RuntimeException(unchecked exception) 和 checked exception 的区别,checked exception 必须出现在 throws 语句中,调用者必须处理,Java 编译器会强制这一点,而 RuntimeException 则没有这个要求。

为什么要有这个区分呢?我们自己定义异常的时候应该使用 checked 还是 unchecked exception 啊?对于这个问题,业界有各种各样的观点和争论,没有特别一致的结论。

一种普遍的说法是,RuntimeException(unchecked) 表示编程的逻辑错误,编程时应该检查以避免这些错误,比如说像空指针异常,如果真的出现了这些异常,程序退出也是正常的,程序员应该检查程序代码的 bug 而不是想办法处理这种异常。Checked exception 表示程序本身没问题,但由于 I/O、网络、数据库等其他不可预测的错误导致的异常,调用者应该进行适当处理

但其实编程错误也是应该进行处理的,尤其是,Java 被广泛应用于服务器程序中,不能因为一个逻辑错误就使程序退出。所以,目前一种更被认同的观点是,Java 中的这个区分是没有太大意义的,可以统一使用 RuntimeException 即 unchcked exception 来代替

这个观点的基本理由是,无论是 checked 还是 unchecked 异常,无论是否出现在 throws 声明中,我们都应该在合适的地方以适当的方式进行处理,而不是只为了满足编译器的要求,盲目处理异常,既然都要进行处理异常,checked exception 的强制声明和处理就显得啰嗦,尤其是在调用层次比较深的情况下。

其实观点本身并不太重要,更重要的是一致性,一个项目中,应该对如何使用异常达成一致,按照约定使用即可。Java 中已有的异常和类库也已经在哪里,我们还是要按照他们的要求进行使用。

6.4 如何使用异常

针对异常,我们介绍了 try/catch/finally、catch 匹配、重新抛出、throws、checked/unchecked exception,那到底该如何使用异常呢?

6.4.1 异常应该且仅用于异常情况

异常应该且仅用于异常情况,是指异常不能代替正常的条件判断。比如说,循环处理数组元素的时候,你应该先检查索引是否有效再进行处理,而不是等着抛出索引异常再结束循环。对于一个引用变量,如果正常情况下它的值也可能为 null,那就应该先检查是不是 null,不为 null 的情况下再进行调用。

另一方面,真正出现异常的时候,应该抛出异常,而不是返回特殊值。比如说,我们看 String 的 substring 方法,它返回一个子字符串,它的代码如下:

public String substring(int beginIndex) {
    
    
    if (beginIndex < 0) {
    
    
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    int subLen = value.length - beginIndex;
    if (subLen < 0) {
    
    
        throw new StringIndexOutOfBoundsException(subLen);
    }
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

代码会检查 beginIndex 的有效性,如果无效,会抛出 StringIndexOutOfBoundsException。纯技术上一种可能的替代方法是不抛异常而返回特殊值 null,但 beginIndex 无效是异常情况,异常不能假装当正常处理

6.4.2 异常处理的目标

异常大概可以分为三个来源:用户、程序员、第三方。用户是指用户的输入有问题,程序员是指编程错误,第三方泛指其他情况如 I/O 错误、网络、数据库、第三方服务等。每种异常都应该进行适当的处理。

处理的目标可以分为报告和恢复。恢复是指通过程序自动解决问题。报告的最终对象可能是用户,即程序使用者,也可能是系统运维人员或程序员。报告的目的也是为了恢复,但这个恢复经常需要人的参与。

对用户,如果用户输入不对,可能提示用户具体哪里输入不对,如果是编程错误,可能提示用户系统错误、建议联系客服,如果是第三方连接问题,可能提示用户稍后重试。

对系统运维人员或程序员,他们一般不关心用户输入错误,而关注编程错误或第三方错误,对于这些错误,需要报告尽量完整的细节,包括异常链、异常栈等,以便尽快定位和解决问题。

对于用户输入或编程错误,一般都是难以通过程序自动解决的,第三方错误则可能可以,甚至很多时候,程序都不应该假定第三方是可靠的,应该有容错机制。比如说,某个第三方服务连接不上(比如发短信),可能的容错机制是,换另一个提供同样功能的第三方试试,还可能是,间隔一段时间进行重试,在多次失败之后再报告错误。

6.4.3 异常处理的一般逻辑

如果自己知道怎么处理异常,就进行处理,如果可以通过程序自动解决,就自动解决,如果异常可以被自己解决,就不需要再向上报告。

如果自己不能完全解决,就应该向上报告。如果自己有额外信息可以提供,有助于分析和解决问题,就应该提供,可以以原异常为 cause 重新抛出一个异常。

总有一层代码需要为异常负责,可能是知道如何处理该异常的代码,可能是面对用户的代码,也可能是主程序。如果异常不能自动解决,对于用户,应该根据异常信息提供用户能理解和对用户有帮助的信息,对运维和程序员,则应该输出详细的异常链和异常栈到日志。

这个逻辑与在公司中处理问题的逻辑是类似的,每个级别都有自己应该解决的问题,自己能处理的自己处理,不能处理的就应该报告上级,把下级告诉他的,和他自己知道的,一并告诉上级,最终,公司老板必须要为所有问题负责。每个级别既不应该掩盖问题,也不应该逃避责任。

6.4.4 小结

本章介绍了 Java 中的异常机制。在没有异常机制的情况下,唯一的退出机制是 return,判断是否异常的方法就是返回值。方法根据是否异常返回不同的返回值,调用者根据不同返回值进行判断,并进行相应处理。每一层方法都需要对调用的方法的每个不同返回值进行检查和处理,程序的正常逻辑和异常逻辑混杂在一起,代码往往难以阅读理解和维护。

另外,因为异常毕竟是少数情况,程序员经常偷懒,假定异常不会发生,而忽略对异常返回值的检查,降低了程序的可靠性。

在有了异常机制后,程序的正常逻辑与异常逻辑可以相分离,异常情况可以集中进行处理,异常还可以自动向上传递,不再需要每层方法都进行处理,异常也不再可能被自动忽略,从而,处理异常情况的代码可以大大减少,代码的可读性、可靠性、可维护性也都可以得到提高

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/107838181