1. 处理错误

如果出现错误而导致一个操作无法完成,程序应该返回到安全状态,并允许用户执行其他的命令或保存所有工作,并妥善地终止程序。

Java 允许方法有一个备选的退出路径,如果方法没有正常运行,则选择该路径。方法将 抛出(throw) 一个封装了错误信息的对象(而不是正常情况下的返回一个值),然后异常处理机制开始搜索能够处理这种异常状况的 异常处理器(exception handler)。

1) 异常分类

异常对象都是扩展自 Throwable 类的类的实例,其下一级有 Error 类和 Exception 类两个分支,前者描述 Java 运行时系统的内部错误和资源耗尽问题,后者的下一层有 IOException 类和 RuntimeException 类两个分支,前者由输入输出等错误导致,后者由编程错误导致。

如果出现 RuntimeException,那么一定是程序员的问题。

RuntimeException 包括的部分问题:

  • 错误的强制类型转换。
  • 数组访问越界。
  • 访问 null 指针。

RuntimeException 不包括的部分问题:

  • 试图越过文件末尾继续读取数据。
  • 试图打开一个不存在的文件。
  • 试图查找一个不存在的类。

非检查型(unchecked) 异常:扩展自 ErrorRuntimeException 的异常。

检查型(checked) 异常:所有其他异常。

2) 声明检查型异常

public FileInputStream(String name) throws FileNotFoundException

以上声明表示此构造器方法将要根据给定参数构造一个对象,但也可能出错并抛出 FileNotFoundException。如果真的出错,构造器将不会构造一个对象,而是抛出一个 FileNotFoundException 类对象,此时系统将搜索知道如何处理它的异常处理器。

以下 4 种情况将导致抛出异常:

  • 调用抛出检查型异常的方法,例如,上文的 FileInputStream 构造器。
  • 检测到错误,并用 throw 语句抛出一个检查型异常。
  • 程序出错,例如,调用 a[-1] = 0 将抛出一个非检查型异常。
  • JVM 或运行时库出现内部错误。

在前两种情况下,要在方法的首部声明异常。

如果一个方法可能抛出多种检查型异常,则需要在其首部列出所有异常(用逗号分隔)。

不要声明非检查型异常,因为 RuntimeException 可以避免,Error 则在控制以外。

异常不仅可以声明,还可以 捕获(catch),如果选择捕获,异常将不会被方法抛出,也就不需要在其首部声明。

总之,要在方法首部声明所有可能抛出的检查型异常。

3) 如何抛出异常

readData() 读取一个文件,文件首部承诺其长度为 1024 个字符,然而读到 733 个字符后文件就结束了,此时希望抛出一个异常。阅读 Java API 文档后发现,EOFException 的描述匹配此情况。

throw new EOFException();
String readData(Scanner in) throws EOFException {
    // ...
    while () {
        if (!in.hasNext()) {
            if (n < len) throw new EOFException();
        }
        // ...
    } return s;
}

EOFException 有一个带字符串参数的构造器,可以用它详细地描述异常:

String gripe = "Content-length: " + len + ", Received: " + n;
throw new EOFException(gripe);

如果方法抛出了异常,它将不会返回到调用者,所以无需设定一个默认的返回值或错误码。

4) 创建异常类

如果找不到适用的异常类,可以自定义异常类。该类需属于 Exception 分支。

class FileFormatException extends IOException {
    public FileFormatException() {
        // ...
    }
    
    public FileFormatException(String gripe) {
        super(gripe);
    }
}

2. 捕获异常

1) 捕获异常概述

try {
    // 可能产生异常的代码
}
catch(*ExceptionType e) {
    // 对此类异常的处理
}

如果 try 语句块中的代码抛出了符合在 catch 子句中指定类型的异常,那么程序将:

  1. 跳过 try 的其余代码。
  2. 执行 catch 中的处理器代码。

如果方法抛出了不符合在 catch 中指定类型的异常,那么它将直接退出。

public void read(String filename) {
    try {
        var in = new FileInputStream(filename);
        int b;
        while ((b = in.read()) != -1)
        // 处理输入
    }
    catch (IOException e) {
        e.printStackTrace();
    }
}

情况简单时,可以如上处理。不过最好的选择是什么也不做,将异常传递给调用者,交给他处理。如果选择传递,则必须声明这个方法可能抛出的异常。

public void read(String filename) throws IOException {
    var in = new FileInputStream(filename);
    int b;
    while ((b = in.read()) != -1)
        // 处理输入
}

捕获那些你知道如何处理的异常,传递那些你不知道如何处理的异常。

子类方法的 throws 列表中不允许出现同名超类方法未列出的异常,如果超类中的方法没有抛出异常,则子类方法必须捕获异常。

2) 捕获多个异常

一个 try 可以捕获多种异常并作出不同处理,为每种异常使用一个单独的 catch

try {
    // ...
}
catch (FileNotFoundException e) {
    // ...
}
catch (UnknownHostException e) {
    // ...
}
catch (IOException e) {
    // ...
}

异常对象可能包含关于异常的信息,调用 e.getMessage() 获取详细的错误消息,调用 e.getClass().getName() 获取异常对象的类型。

一个 catch 可以捕获多种异常,多个异常类型用分隔符(|)间隔(这样一来,对于这些异常都采取相同的处理)。

try {
    // ...
}
catch (FileNotFoundException | UnknownHostException e) {
    // ...
}
catch (IOException e) {
    // ...
}

3) 再次抛出异常与异常链

如果需要改变异常的类型,可以在 catch 中抛出一个异常。

P300-301

4) finally 子句

方法抛出异常时,其中的剩余代码就不再执行。但是有时它已经获得了一些资源,需要完成资源清理,finally 子句 可以解决此问题。

无论是否有异常抛出,finally 都将执行。

FileInputStream in = new FileInputStream()
try {
    // 1
    // 可能抛出异常的代码
    // 2
}
catch (IOException e) {
    // 3
    // 显示错误信息
    // 4
}
finally {
    // 5
    in.close();
}
// 6

执行以上代码时 3 种可能的情况:

  • 没有抛出异常。程序首先执行 try 的全部,然后执行 finally,执行顺序 1、2、5、6。
  • 抛出一个异常,并被一个 catch 捕获。程序首先执行 try 直至异常抛出,然后执行对应 catch,最后执行 finally。如果 catch 没有再次抛出异常,将执行 finally 之后的第一条语句,执行顺序 1、3、4、5、6;如果 catch 又抛出一个异常,异常将被抛回方法调用者,执行顺序 1、3、5。
  • 抛出一个异常,但没有被任何 catch 捕获。程序首先执行 try 直至异常抛出,然后执行 finally,异常将被抛回方法调用者,执行顺序 1、5。

try 可以只有 finally 而没有 catch

不要把改变控制流程的语句(returnthrowbreakcontinue)放在 finally 中。

public static int parseInt(String s) {
    try {
        return Integer.parseInt(s);
    }
    finally {
        return 0;
    }
}

调用 parseInt("5") 似乎将返回 5,不过在这个方法真正返回之前,finally 将执行,导致返回 0。更糟的情况下,调用 parseInt("zero"),方法 Integer::parseInt 将抛出 NumberFormatException,然后执行 finally 返回 0return 语句直接吞掉了这个异常,导致调用者对此毫不知情(看上去正常地返回了 0)。

5) try-with-Resources 语句

// 打开资源
try {
    // 处理资源
}
finally {
    // 关闭资源
}

如果该资源属于实现了 AutoCloseable 接口的类,对于以上模式有一个简便写法。

void close() throws Exception

try-with-resources 语句的最简形式为:

try (*ResourceType res = ...) {
    // 处理资源
}

try 块退出时将自动调用 res.close()

例如,读取一个文件中的所有单词:

try (var in = new Scanner(Path.of("in.txt"), StandardCharsets.UTF_8)) {
    while (in.hasNext())
        System.out.println(in.next());
}

无论这段代码如何退出,都将调用 in.close(),就好像使用了 finally

可以指定多个资源:

try (var in = new Scanner(Path.of("in.txt"), StandardCharsets.UTF_8); var out = new PrintWriter("out.txt", StandardCharsets.UTF_8)) {
    while (in.hasNext()) out.println(in.next().toUpperCase());
}

无论这段代码如何退出,inout 都将关闭,如果用常规方式,需要两个嵌套的 try / finally

可以在 try 首部提供事先声明的事实最终变量:

public static void printAll(String[] lines, PrintWriter out) {
    try (out) {
        for (String line : lines) out.println(line);
    }
}

6) 分析栈轨迹元素

栈轨迹(stack trace):一个程序执行过程中某个特定点上所有挂起的方法调用的列表。

调用方法 Throwable::printStackTrace 访问栈轨迹的文本描述信息:

Throwable t = new Throwable();
StringWriter out = new StringWriter();
t.printStackTrace(new PrintWriter(out));
String description = out.toString();

StackWalker 类见 P305

3. 使用异常的技巧

  1. 异常处理不能代替简单的测试。

假设有一段代码尝试将一个空栈弹出 10000000 次,第一种做法是先检查是否为空:

if (!s.empty()) s.pop();

第二种做法是不论如何先执行弹出操作,再捕获 EmptyStackException 来提醒我们本不该这样做:

try {
    s.pop();
}
catch (EmptyStackException e) {
}

后者的时间开销为前者的数十倍,可见完成简单的测试比先运行再捕获异常效率高很多。

  1. 不要过分地细化异常。

很多程序员将每一条语句都分装在单独的 try 语句块中:

for (int i = 0; i < 100; i ++)
{
    try
    {
        n = s.pop();
    }
    catch (EmptyStackException e)
    {
    }
    try
    {
        out.writeInt(n);
    }
    catch (IOException e)
    {
    }
}

这种风格将导致代码量的急剧膨胀,合理的做法是将整个任务包在一个 try 语句块中:

try {
    for (int i = 0; i < 100; i ++) {
        n = s.pop();
        out.writeInt(n);
    }
}
catch (EmptyStackException e) {
}
catch (IOException e) {
}
  1. 合理利用异常层次结构。

不要只抛出 RuntimeException,而应寻找一个合适的子类或创建自己的异常类。

不要只捕获 Throwable 异常,否则代码可读性和可维护性将降低。

如果能将一种异常转换为另一种更合适的异常,那么不要犹豫。

  1. 不要压制异常。

  2. 在检测错误时,“苛刻”要比放任更好。

当栈为空时,使 Stack.pop() 抛出异常比使其返回 null 更好。

  1. 不要羞于传递异常。

很多情况下,传递异常给高层方法比自己捕获更好。

  1. 使用标准方法报告 null 指针和越界异常。

  2. Object 类包含的方法完成参数检验。

  3. 不要向最终用户显示栈轨迹。

栈轨迹可能包含实现细节,暴露给攻击者将带来风险,应将其记入日志以便之后获取,而只向用户显示一条总结消息。

4. 使用断言

1) 断言的概念

假设需要调用 Math.sqrt(x),首先检查 x 非负:

if (x < 0) throw new IllegalArgumentException("x < 0");

测试完成后这段代码仍然留在程序中,如果有大量的测试代码,程序运行速度将变慢。

断言(assertion) 机制允许在测试期间向代码插入一些检查,而在生产代码中自动将其删除。

关键字 assert 的两种格式:

assert *condition;
 
// 或
 
assert *condition : *expression;

两种语句都将计算 *condition,如果结果为 false,则抛出 AssertionError。第二种语句中,*expression 将传入 AssertionError 对象的构造器,并转换为一个消息字符串。

对上例,断言 x 非负写为:

assert x >= 0;

或将 x 的值传递给 AssertionError 对象,以便之后显示:

assert x >= 0 : x;

2) 启用和禁用断言

默认情况下断言是禁用的,可以在程序运行时用 -enableassertions-ea 选项启用断言。

可以为特定的类或包启用断言:

java -ea:MyClass -ea:com.mycompany.mylib MyApp

-disableassertions-da 选项禁用断言。

3) 使用断言完成参数检查

P313-314

4) 使用断言提供假设文档

P314-315

5. 日志

1) 基本日志

对于简单的日志记录,可以使用全局日志记录器(global logger)并调用其方法 info()

Logger.getGlobal().info("File->Open menu item selected");

在默认情况下将打印此记录:

May 10, 2013 10:12:15 PM LoggingImageViewer fileOpen
INFO: File->Open menu item selected

在适当位置(如 main() 最前端)调用 Logger.getGlobal().setLevel(Level.off) 将抑制所有日志。

2) 高级日志

定义自己的日志记录器,可以调用 getLogger() 创建或获取一个日志记录器:

private static final Logger myLogger = Logger.getLogger("com.company.app")

日志的 7 个级别:

  • SEVERE
  • WARNING
  • INFO
  • CONFIG
  • FINE
  • FINER
  • FINEST

默认只记录前 3 个级别,也可以手动设置记录级别,如 logger.setLevel(Level.FINE),现在将记录 FINE 及更高级别。Level.ALL 将开启全部级别,Level.OFF 将关闭全部级别。

每个级别都对应有日志记录方法,如 logger.info(message)logger.fine(message),也可以使用 log() 并指定级别,如 logger.log(Level.FINE, message)

6. 调试技巧