系列中的上一篇
当前教程
未检查异常 - 争议
这是系列的结尾!

系列中的上一篇: 抛出异常

未检查异常 - 争议

 

未检查异常 - 争议

由于 Java 编程语言不要求方法捕获或指定未检查异常 (RuntimeExceptionError 及其子类),程序员可能会倾向于编写仅抛出未检查异常的代码,或使所有异常子类继承自 RuntimeException。这两种捷径都允许程序员编写代码而无需理会编译器错误,也无需理会指定或捕获任何异常。虽然这对于程序员来说似乎很方便,但它绕过了捕获或指定要求的意图,并可能给使用您的类的其他人带来问题。

为什么设计人员决定强制方法指定其作用域内可能抛出的所有未捕获的已检查异常?任何 Exception,如果方法可能抛出,都是方法公共编程接口的一部分。调用方法的人必须了解方法可能抛出的异常,以便他们可以决定如何处理这些异常。这些异常与方法的参数和返回值一样,都是该方法编程接口的一部分。

下一个问题可能是:“如果记录方法的 API(包括它可能抛出的异常)如此好,为什么不也指定运行时异常呢?”运行时异常表示编程问题导致的问题,因此,API 客户端代码无法合理地预期从这些问题中恢复或以任何方式处理这些问题。此类问题包括算术异常(例如除以零)、指针异常(例如尝试通过空引用访问对象)和索引异常(例如尝试通过过大或过小的索引访问数组元素)。

运行时异常可能发生在程序中的任何地方,在典型的程序中,它们可能非常多。如果必须在每个方法声明中添加运行时异常,会降低程序的清晰度。因此,编译器不要求您捕获或指定运行时异常(尽管您可以这样做)。

在抛出 RuntimeException 的常见做法中,有一种情况是用户错误地调用方法。例如,方法可以检查其参数之一是否错误地为 null。如果参数为 null,方法可能会抛出 NullPointerException,这是一个未检查异常。

一般来说,不要抛出 RuntimeException 或创建 RuntimeException 的子类,仅仅是因为您不想费心指定方法可能抛出的异常。

以下是底线准则:如果客户端可以合理地预期从异常中恢复,请将其设为已检查异常。如果客户端无法做任何事情来从异常中恢复,请将其设为未检查异常。

 

异常的优点

现在您已经了解了异常是什么以及如何使用它们,是时候学习在程序中使用异常的优点。

优点 1:将错误处理代码与“常规”代码分离

异常提供了将发生异常情况时要执行的操作的详细信息与程序的主要逻辑分离的方法。在传统编程中,错误检测、报告和处理通常会导致混乱的意大利面条代码。例如,考虑以下伪代码方法,该方法将整个文件读入内存。

readFile {
    open the file;
    determine its size;
    allocate that much memory;
    read the file into memory;
    close the file;
}

乍一看,此函数似乎很简单,但它忽略了以下所有潜在错误。

  • 如果无法打开文件会发生什么?
  • 如果无法确定文件长度会发生什么?
  • 如果无法分配足够的内存会发生什么?
  • 如果读取失败会发生什么?
  • 如果无法关闭文件会发生什么?

为了处理此类情况,readFile 函数必须有更多代码来执行错误检测、报告和处理。以下是一个示例,说明该函数可能是什么样子。

errorCodeType readFile {
    initialize errorCode = 0;
    
    open the file;
    if (theFileIsOpen) {
        determine the length of the file;
        if (gotTheFileLength) {
            allocate that much memory;
            if (gotEnoughMemory) {
                read the file into memory;
                if (readFailed) {
                    errorCode = -1;
                }
            } else {
                errorCode = -2;
            }
        } else {
            errorCode = -3;
        }
        close the file;
        if (theFileDidntClose && errorCode == 0) {
            errorCode = -4;
        } else {
            errorCode = errorCode and -4;
        }
    } else {
        errorCode = -5;
    }
    return errorCode;
}

这里有太多错误检测、报告和返回,以至于最初的七行代码都淹没在混乱中。更糟糕的是,代码的逻辑流程也丢失了,因此很难判断代码是否在做正确的事情:如果函数无法分配足够的内存,文件是否真的被关闭了?当您在编写代码三个月后修改方法时,更难确保代码继续做正确的事情。许多程序员通过简单地忽略这个问题来解决这个问题 - 当他们的程序崩溃时会报告错误。

异常使您能够编写代码的主要流程,并在其他地方处理异常情况。如果 readFile 函数使用异常而不是传统的错误管理技术,它看起来更像下面这样。

readFile {
    try {
        open the file;
        determine its size;
        allocate that much memory;
        read the file into memory;
        close the file;
    } catch (fileOpenFailed) {
       doSomething;
    } catch (sizeDeterminationFailed) {
        doSomething;
    } catch (memoryAllocationFailed) {
        doSomething;
    } catch (readFailed) {
        doSomething;
    } catch (fileCloseFailed) {
        doSomething;
    }
}

请注意,异常不会让您免于执行检测、报告和处理错误的工作,但它们确实可以帮助您更有效地组织工作。

优点 2:将错误向上传播到调用堆栈

异常的第二个优点是能够将错误报告向上传播到方法的调用堆栈。假设 readFile 方法是主程序进行的一系列嵌套方法调用中的第四个方法:method1 调用 method2method2 调用 method3,最后 method3 调用 readFile

method1 {
    call method2;
}

method2 {
    call method3;
}

method3 {
    call readFile;
}

还假设 method1 是唯一对 readFile 内可能发生的错误感兴趣的方法。传统的错误通知技术强制 method2method3readFile 返回的错误代码向上传播到调用堆栈,直到错误代码最终到达 method1 - 唯一对它们感兴趣的方法。

method1 {
    errorCodeType error;
    error = call method2;
    if (error)
        doErrorProcessing;
    else
        proceed;
}

errorCodeType method2 {
    errorCodeType error;
    error = call method3;
    if (error)
        return error;
    else
        proceed;
}

errorCodeType method3 {
    errorCodeType error;
    error = call readFile;
    if (error)
        return error;
    else
        proceed;
}

回想一下,Java 运行时环境向后搜索调用堆栈,以查找对处理特定异常感兴趣的任何方法。方法可以躲避它内部抛出的任何异常,从而允许调用堆栈中更远的方法捕获它。因此,只有关心错误的方法才需要担心检测错误。

method1 {
    try {
        call method2;
    } catch (exception e) {
        doErrorProcessing;
    }
}

method2 throws exception {
    call method3;
}

method3 throws exception {
    call readFile;
}

但是,如伪代码所示,躲避异常需要中间人方法付出一些努力。方法声明中必须指定可能在方法内部抛出的任何已检查异常。

优点 3:对错误类型进行分组和区分

由于程序中抛出的所有异常都是对象,因此异常的分组或分类是类层次结构的自然结果。Java 平台中一组相关的异常类的示例是在 java.io 中定义的 - IOException 及其后代。 IOException 是最通用的,表示执行 I/O 时可能发生的任何类型的错误。它的后代表示更具体的错误。例如,FileNotFoundException 表示无法在磁盘上找到文件。

方法可以编写可以处理非常具体的异常的特定处理程序。 FileNotFoundException 类没有后代,因此以下处理程序只能处理一种类型的异常。

catch (FileNotFoundException e) {
    ...
}

方法可以通过在 catch 语句中指定异常的任何超类来根据其组或一般类型捕获异常。例如,要捕获所有 I/O 异常(无论其具体类型如何),异常处理程序会指定 IOException 参数。

catch (IOException e) {
    ...
}

此处理程序将能够捕获所有 I/O 异常,包括 FileNotFoundExceptionEOFException 等等。您可以通过查询传递给异常处理程序的参数来查找发生的详细信息。例如,使用以下方法打印堆栈跟踪。

catch (IOException e) {
    // Output goes to System.err.
    e.printStackTrace();
    // Send trace to stdout.
    e.printStackTrace(System.out);
}

您甚至可以设置一个异常处理程序,该处理程序处理任何 Exception,处理程序如下所示。

// A (too) general exception handler
catch (Exception e) {
    ...
}

Exception 类接近 Throwable 类层次结构的顶部。因此,此处理程序将捕获除处理程序旨在捕获的异常之外的许多其他异常。如果您希望程序执行的操作是(例如)为用户打印错误消息,然后退出,您可能希望以这种方式处理异常。

但是,在大多数情况下,您希望异常处理程序尽可能具体。原因是处理程序必须首先确定发生了哪种类型的异常,然后才能决定最佳恢复策略。实际上,通过不捕获特定错误,处理程序必须适应任何可能性。过于通用的异常处理程序可能会使代码更容易出错,因为它会捕获和处理程序员没有预料到的异常,并且处理程序并非为此而设计。

如前所述,您可以创建异常组并以一般方式处理异常,或者您可以使用特定异常类型来区分异常并以精确方式处理异常。

 

总结

程序可以使用异常来指示发生了错误。要抛出异常,请使用 throw 语句并为其提供一个异常对象 - Throwable 的后代 - 来提供有关发生的特定错误的信息。抛出未捕获的已检查异常的方法必须在其声明中包含 throws 子句。

程序可以使用 trycatchfinally 块的组合来捕获异常。

  • try 块标识可能发生异常的代码块。
  • catch 块标识可以处理特定类型的异常的代码块,称为异常处理程序。
  • finally 块标识保证执行的代码块,它是关闭文件、恢复资源以及清理 try 块中包含的代码后的正确位置。

try 语句应至少包含一个 catch 块或一个 finally 块,并且可以包含多个 catch 块。

异常对象的类指示抛出的异常类型。异常对象可以包含有关错误的更多信息,包括错误消息。通过异常链,一个异常可以指向导致它的异常,该异常又可以指向导致它的异常,依此类推。


最后更新: 2021年9月14日


系列中的上一篇
当前教程
未检查异常 - 争议
这是系列的结尾!

系列中的上一篇: 抛出异常