Java开发中不要使用受检异常

简介

Java是唯一(主流)实现了受检异常概念的编程语言。一开始,受检异常就是争议的焦点。在当时被视为一种创新概念(Java于1996年推出),如今却被视不良实践。

本文要讨论Java中非受检异常和受检异常的动机以及它们优缺点。与大多数关注这个主题的人不同,我希望提供一个平衡的观点,而不仅仅是对受检异常概念的批评。

我们先深入探讨Java中受检异常和非受检异常的动机。Java之父詹姆斯·高斯林对这个话题有何看法?接下来,我们要看一下Java中异常的工作原理以及受检异常存在的问题。我们还将讨论在何时应该使用哪种类型的异常。最后,我们将提供一些常见的解决方法,例如使用Lombok的@SneakyThrows注解。

Java和其他编程语言中异常的历史

在软件开发中,异常处理可以追溯到20世纪60年代LISP的引入。通过异常,我们可以解决在程序错误处理过程中可能遇到的几个问题。

异常的主要思想是将正常的控制流与错误处理分离。让我们看一个不使用异常的例子:

public void handleBookingWithoutExceptions(String customer, String hotel) {

 if (isValidHotel(hotel)) {

  int hotelId = getHotelId(hotel);

  if (sendBookingToHotel(customer, hotelId)) {

   int bookingId = updateDatabase(customer, hotel);

   if (bookingId > 0) {

    if (sendConfirmationMail(customer, hotel, bookingId)) {

     logger.log(Level.INFO, "Booking confirmed");

    } else {

     logger.log(Level.INFO, "Mail failed");

    }

   } else {

    logger.log(Level.INFO, "Database couldn't be updated");

   }

  } else {

   logger.log(Level.INFO, "Request to hotel failed");

  }

 } else {

  logger.log(Level.INFO, "Invalid data");

 }

}

程序的逻辑只占据了大约5行代码,其余的代码则是用于错误处理。这样,代码不再关注主要的流程,而是被错误检查所淹没。

如果我们的编程语言没有异常机制,我们只能依赖函数的返回值。让我们使用异常来重写我们的函数:

public void handleBookingWithExceptions(String customer, String hotel) {

 try {

  validateHotel(hotel);

  sendBookingToHotel(customer, getHotelId(hotel));

  int bookingId = updateDatabase(customer, hotel);

  sendConfirmationMail(customer, hotel, bookingId);

  logger.log(Level.INFO, "Booking confirmed");

 } catch(Exception e) {

  logger.log(Level.INFO, e.getMessage());

 }

}

采用这种方法,我们不需要检查返回值,而是将控制流转移到catch块中。这样的代码更易读。我们有两个独立的流程: 正常流程和错误处理流程。

除了可读性之外,异常还解决了"半谓词问题"(semipredicate problem)。简而言之,半谓词问题发生在表示错误(或不存在值)的返回值成为有效返回值的情况下。让我们看几个示例来说明这个问题:

示例:

int index = "Hello World".indexOf("World");

int value = Integer.parseInt("123");

int freeSeats = getNumberOfAvailableSeatsOfFlight();

indexOf() 方法如果未找到子字符串,将返回 -1。当然,-1 绝对不可能是一个有效的索引,所以这里没有问题。然而,parseInt() 方法的所有可能返回值都是有效的整数。这意味着我们没有一个特殊的返回值来表示错误。最后一个方法 getNumberOfAvailableSeatsOfFlight() 可能会导致隐藏的问题。我们可以将 -1 定义为错误或没有可用信息的返回值。乍看起来这似乎是合理的。然而,后来可能发现负数表示等待名单上的人数。异常机制能更优雅地解决这个问题。

Java中异常的工作方式

在讨论是否使用受检异常之前,让我们简要回顾一下Java中异常的工作方式。下图显示了异常的类层次结构:

java-exception

RuntimeException继承自Exception,而Error继承自Throwable。RuntimeException和Error被称为非受检异常,意味着它们不需要由调用代码处理(即它们不需要被“检查”)。所有其他继承自Throwable(通常通过Exception)的类都是受检异常,这意味着编译器期望调用代码处理它们(即它们必须被“检查”)。

所有继承自Throwable的异常,无论是受检的还是非受检的,都可以在catch块中捕获。

最后,值得注意的是,受检异常和非受检异常的概念是Java编译器的特性。JVM本身并不知道这个区别,所有的异常都是非受检的。这就是为什么其他JVM语言不需要实现这个特性的原因。

在我们开始讨论是否使用受检异常之前,让我们简要回顾一下这两种异常类型之间的区别。

受检异常

受检异常需要被try-catch块包围,或者调用方法需要在其签名中声明异常。由于Scanner类的构造函数抛出一个FileNotFoundException异常,这是一个受检异常,所以下面的代码无法编译:

public void readFile(String filename) {

 Scanner scanner = new Scanner(new File(filename));

}

我们会得到一个编译错误:

Unhandled exception: java.io.FileNotFoundException

我们有两种选项来解决这个问题。我们可以将异常添加到方法的签名中:

public void readFile(String filename) throws FileNotFoundException {

 Scanner scanner = new Scanner(new File(filename));

}

或者我们可以使用try-catch块在现场处理异常:

public void readFile(String filename) {

 try {

  Scanner scanner = new Scanner(new File(filename));

 } catch (FileNotFoundException e) {

  // handle exception

 }

}

非受检异常

对于非受检异常,我们不需要做任何处理。由Integer.parseInt引发的NumberFormatException是一个运行时异常,所以下面的代码可以编译通过:

public int readNumber(String number) {

 return Integer.parseInt(callEndpoint(number));

}

然而,我们仍然可以选择处理异常,因此以下代码也可以编译通过:

public int readNumber(String number) {

 try {

  return Integer.parseInt(callEndpoint(number));

 } catch (NumberFormatException e) {

  // handle exception

  return 0;

 }

}

为什么我们要使用受检异常?

如果我们想了解受检异常背后的动机,我们需要看一下Java的历史。该语言的创建是以强调健壮性和网络功能为重点的。

最好用Java创始人詹姆斯·高斯林(James Gosling)自己的一句话来表达:“你不能无意地说,‘我不在乎。’你必须明确地说,‘我不在乎。’”这句话摘自一篇与詹姆斯·高斯林进行的有趣的采访,在采访中他详细讨论了受检异常。

在《编程之父》这本书中,詹姆斯也谈到了异常。他说:“人们往往忽略了检查返回代码。”

这再次强调了受检异常的动机。通常情况下,当错误是由于编程错误或错误的输入时,应该使用非受检异常。如果在编写代码时程序员无法做任何处理,应该使用受检异常。后一种情况的一个很好的例子是网络问题。开发人员无法解决这个问题,但程序应该适当地处理这种情况,可以是终止程序、重试操作或简单地显示错误消息。

受检异常存在的问题

了解了受检异常和非受检异常背后的动机,我们再来看看受异常在代码库中可能引入的一些问题。

受检异常不适应规模化

一个主要反对受异常的观点是代码的可扩展性和可维护性。当一个方法的异常列表发生变化时,会打破调用链中从调用方法开始一直到最终实现try-catch来处理异常的方法的所有方法调用。举个例子,假设我们调用一个名为libraryMethod()的方法,它是外部库的一部分:

public void retrieveContent() throws IOException {

 libraryMethod();

}

在这里,方法libraryMethod()本身来自一个依赖项,例如,一个处理对外部系统进行REST调用的库。其实现可能如下所示:

public void libraryMethod() throws IOException {

 // some code

}

在将来,我们决定使用库的新版本,甚至用另一个库替换它。尽管功能相似,但新库中的方法会抛出两个异常:

public void otherSdkCall() throws IOException, MalformedURLException {

 // call method from SDK

}

由于有两个受检异常,我们的方法声明也需要更改:

public void retrieveContent() throws IOException, MalformedURLException {

 sdkCall();

}

对于小型代码库来说,这可能不是一个大问题,但对于大型代码库来说,这将需要进行相当多的重构。当然,我们也可以直接在方法内部处理异常:

public void retrieveContent() throws IOException {

 try {

  otherSdkCall();

 } catch (MalformedURLException e) {

  // do something with the exception

 }

}

使用这种方法,我们在代码库中引入了一种不一致性,因为我们立即处理了一个异常,而推迟了另一个异常的处理。

异常传播

一个与可扩展性非常相似的论点是受检异常如何在调用堆栈中传播。如果我们遵循“尽早抛出,尽晚捕获”的原则,我们需要在每个调用方法上添加一个throws子句(a):

异常传播

相反,非受检异常(b)只需要在实际发生异常的地方声明一次,并在我们希望处理异常的地方再次声明。它们会在调用堆栈中自动传播,直到达到实际处理异常的位置。

不必要的依赖关系

受检异常还会引入与非受检异常不必要的依赖关系。让我们再次看看在场景(a)中我们在三个不同的位置添加了IOException。如果methodA()、methodB()和methodC()位于不同的类中,那么所有相关类都将对异常类有一个依赖关系。如果我们使用了非受检异常,我们只需要在methodA()和methodC()中有这个依赖关系。甚至methodB()所在的类或模块都不需要知道异常的存在。

让我们用一个例子来说明这个想法。假设你从度假回家。你在酒店前台退房,乘坐公共汽车去火车站,然后换乘一次火车,在回到家乡后,你又乘坐另一辆公共汽车从车站回家。回到家后,你意识到你把手机忘在了酒店里。在你开始整理行李之前,你进入了“异常”流程,乘坐公共汽车和火车回到酒店取手机。在这种情况下,你按照之前相反的顺序做了所有的事情(就像在Java中发生异常时向上移动堆栈跟踪一样),直到你到达酒店。显然,公共汽车司机和火车操作员不需要知道“异常”,他们只需要按照他们的工作进行。只有在前台,也就是“回家”流程的起点,我们需要询问是否有人找到了手机。

糟糕的编码实践

当然,作为专业的软件开发人员,我们绝不能在良好的编码实践上选择方便。然而,当涉及到受检异常时,往往会诱使我们快速引入以下三种模式。通常的想法是以后再处理。我们都知道这样的结果。另一个常见的说法是“我想为正常流程编写代码,不想被异常打扰”。我经常见到以下三种模式。

第一种模式是捕获所有异常(catch-all exception):

public void retrieveInteger(String endpoint) {

 try {

  URL url = new URL(endpoint);

  int result = Integer.parseInt(callEndpoint(endpoint));

 } catch (Exception e) {

  // do something with the exception

 }

}

我们只是捕获所有可能的异常,而不是单独处理不同的异常:

public void retrieveInteger(String endpoint) {

 try {

  URL url = new URL(endpoint);

  int result = Integer.parseInt(callEndpoint(endpoint));

 } catch (MalformedURLException e) {

  // do something with the exception

 } catch (NumberFormatException e) {

  // do something with the exception

 }

}

当然,在一般情况下,这并不一定是一种糟糕的实践。如果我们只想记录异常,或者在Spring Boot的@ExceptionHandler中作为最后的安全机制,这是一种适当的做法。

第二种模式是空的catch块:

public void myMethod() {

 try {

  URL url = new URL("malformed url");

 } catch (MalformedURLException e) {}

}

这种方法显然绕过了受检异常的整个概念。它完全隐藏了异常,使我们的程序在没有提供任何关于发生了什么的信息的情况下继续执行。

第三种模式是简单地打印堆栈跟踪并继续执行,就好像什么都没有发生一样:

public void consumeAndForgetAllExceptions(){

 try {

  // some code that can throw an exception

 } catch (Exception ex){

  ex.printStacktrace();

 }

}

为了满足方法签名而添加额外的代码

有时我们可以确定除非出现编程错误,否则不会抛出异常。让我们考虑以下示例:

public void readFromUrl(String endpoint) {

 try {

  URL url = new URL(endpoint);

 } catch (MalformedURLException e) {

  // do something with the exception

 }

}

MalformedURLException是一个受检异常,当给定的字符串不符合有效的URL格式时,会抛出该异常。需要注意的重要事项是,如果URL格式不正确,就会抛出异常,这并不意味着URL实际上存在并且可以访问。

即使我们在之前验证了格式:

public void readFromUrl(@ValidUrl String endpoint)

或者我们已经将其硬编码:

public static final String endpoint = "http://www.example.com";

编译器仍然强制我们处理异常。我们需要写两行“无用”的代码,只是因为有一个受检异常。

如果我们无法编写代码来触发某个异常的抛出,就无法对其进行测试,因此测试覆盖率将会降低。

有趣的是,当我们想将字符串解析为整数时,并不强制我们处理异常:

Integer.parseInt("123");

parseInt方法在提供的字符串不是有效整数时会抛出NumberFormatException,这是一个非受检异常。

Lambda表达式和异常

受检异常并不总是与Lambda表达式很好地配合使用。让我们来看一个例子:

public class CheckedExceptions {

 public static String readFirstLine(String filename) throws FileNotFoundException {

  Scanner scanner = new Scanner(new File(filename));

  return scanner.next();

 }

 public void readFile() {

  List<String> fileNames = new ArrayList<>();

  List<String> lines = fileNames.stream().map(CheckedExceptions::readFirstLine).toList();

 }

}

由于我们的readFirstLine()方法抛出了一个受检异常,所以会导致编译错误:

Unhandled exception: java.io.FileNotFoundException in line 8.

如果我们尝试使用try-catch块来修正代码:

public void readFile() {

 List<String> fileNames = new ArrayList<>();

 try {

  List<String> lines = fileNames.stream()

    .map(CheckedExceptions::readFirstLine)

    .toList();

 } catch (FileNotFoundException e) {

   // handle exception

 }

}

我们仍然会得到一个编译错误,因为我们无法在lambda内部将受检异常传播到外部。我们必须在lambda表达式内部处理异常并抛出一个运行时异常:

public void readFile() {

 List<String> lines = fileNames.stream()

  .map(filename -> {

   try{

    return readFirstLine(filename);

   } catch(FileNotFoundException e) {

    throw new RuntimeException("File not found", e);

   }

  }).toList();

}

不幸的是,如果静态方法引用抛出受检异常,这种方式将变得不可行。或者,我们可以让lambda表达式返回一个错误消息,然后将其添加到结果中:

public void readFile() {

 List<String> lines = fileNames.stream()

  .map(filename -> {

   try{

    return readFirstLine(filename);

   } catch(FileNotFoundException e) {

    return "default value";

   }

  }).toList();

}

然而,代码看起来仍然有些杂乱。

我们可以在lambda内部传递一个非受检异常,并在调用方法中捕获它:

public class UncheckedExceptions {

 public static int parseValue(String input) throws NumberFormatException {

  return Integer.parseInt(input);

 }

 

 public void readNumber() {

  try {

   List<String> values = new ArrayList<>();

   List<Integers> numbers = values.stream()

       .map(UncheckedExceptions::parseValue)
    
       .toList();

  } catch(NumberFormatException e) {

   // handle exception

  }

 }

}

在这里,我们需要注意之前使用受检异常和使用非受检异常的例子之间的一个关键区别。对于非受检异常,流的处理将继续到下一个元素,而对于受检异常,处理将结束,并且不会处理更多的元素。显然,我们想要哪种行为取决于我们的用例。

处理受检异常的替代方法

将受检异常包装为非受检异常

我们可以通过将受检异常包装为非受检异常来避免在调用堆栈中的所有方法中添加throws子句。而不是让我们的方法抛出一个受检异常:

public void myMethod() throws IOException{}

我们可以将其包装在一个非受检异常中:

public void myMethod(){

 try {

  // some logic

 } catch(IOException e) {

  throw new MyUnchckedException("A problem occurred", e);

 }

}

理想情况下,我们应用异常链。这样可以确保原始异常不会被隐藏。我们可以在第5行看到异常链的应用,原始异常作为参数传递给新的异常。这种技术在早期版本的Java中几乎适用于所有核心Java异常。

异常链是许多流行框架(如Spring或Hibernate)中常见的一种方法。这两个框架从受检异常转向非受检异常,并将不属于框架的受检异常包装在自己的运行时异常中。一个很好的例子是Spring的JDBC模板,它将所有与JDBC相关的异常转换为Spring框架的非受检异常。

Lombok @SneakyThrows

Project Lombok为我们提供了一个注解,可以消除异常链的需要。而不是在我们的方法中添加throws子句:

public void beSneaky() throws MalformedURLException {

 URL url = new URL("http://test.example.org");

}

我们可以添加@SneakyThrows 注解,这样我们的代码就可以编译通过:

@SneakyThrows

public void beSneaky() {

 URL url = new URL("http://test.example.org");

}

然而,重要的是要理解,@SneakyThrows并不会使MalformedURLException的行为完全像运行时异常一样。我们将无法再捕获它,并且以下代码将无法编译:

public void callSneaky() {

 try {

  beSneaky();

 } catch (MalformedURLException e) {

  // handle exception

 }

}

由于@SneakyThrows移除了异常,而MalformedURLException仍然被视为受检异常,因此我们将在第4行得到编译器错误:

Exception 'java.net.MalformedURLException' is never thrown in the corresponding try block

性能

在我的研究过程中,我遇到了一些关于异常性能的讨论。在受检异常和非受检异常之间是否存在性能差异?实际上,它们之间没有性能差异。这是一个在编译时决定的特性。

然而,是否在异常中包含完整的堆栈跟踪会导致显着的性能差异:

public class MyException extends RuntimeException {

 public MyException(String message, boolean includeStacktrace) {

  super(message, null, !includeStacktrace, includeStacktrace);

 }

}

在这里,我们在自定义异常的构造函数中添加了一个标志。该标志指定是否要包含完整的堆栈跟踪。在抛出异常的情况下,构建堆栈跟踪会导致程序变慢。因此,如果性能至关重要,则应排除堆栈跟踪。

一些指南

如何处理软件中的异常是我们工作的一部分,它高度依赖于具体的用例。在我们结束讨论之前,这里有三个高级指南,我相信它们(几乎)总是正确的。

  • 如果不是编程错误,或者程序可以执行一些有用的恢复操作,请使用受检异常。
  • 如果是编程错误,或者程序无法进行任何恢复操作,请使用运行时异常。
  • 避免空的catch块。

结论

本文深入探讨了Java中的异常。我们讲了为什么要引入异常到语言中,何时应该使用受检异常和非受检异常。我们还讨论了受检异常的缺点以及为什么它们现在被认为是不良实践 - 尽管也有一些例外情况。


【注】本文译自: Don’t Use Checked Exceptions (reflectoring.io)