Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

异常处理

失败总是有可能的。

Kotlin在分析您的程序时会发现基本错误。在编译时无法检测到的错误必须在运行时处理。在异常一章中,您已经学习了如何抛出异常。在这篇文章中,我们将学习如何捕获异常。

从历史上看,失败常常是灾难性的。例如,在C语言中编写的程序会突然停止工作,丢失数据,并有可能导致操作系统崩溃。

改进的错误处理是增加代码可靠性的强大方式。在创建可重用的程序组件时,错误处理尤其重要。要创建一个健壮的系统,每个组件都必须是健壮的。通过一致的错误处理,组件可以可靠地将问题传达给客户端代码。

现代应用程序通常使用并发,而并发程序必须能够处理非关键异常。例如,服务器应该在会话被异常终止时进行恢复。

异常将三个活动混为一谈:

  1. 错误报告
  2. 恢复
  3. 资源清理

让我们来考虑每个活动。

报告

标准库中的异常通常足够。要进行更具体的异常处理,您可以从Exception或其子类型继承新的异常类型:

// ExceptionHandling/DefiningExceptions.kt
package exceptionhandling
import atomictest.*

class Exception1(
  val value: Int
): Exception("错误的值: $value")

open class Exception2(
  description: String
): Exception(description)

class Exception3(
  description: String
): Exception2(description)

fun main() {
  capture {
    throw Exception1(13)
  } eq "Exception1: 错误的值: 13"
  capture {
    throw Exception3("错误")
  } eq "Exception3: 错误"
}

main()中所示,throw表达式需要一个Throwable子类型的实例。要定义新的异常类型,继承Exception(它继承自Throwable)。Exception1Exception2都继承自Exception,而Exception3继承自Exception2

恢复

异常处理的目标是恢复。这意味着您要修复问题,将程序恢复到稳定状态,并继续执行。恢复通常包括记录有关错误的信息。

很多情况下,恢复是不可能的。异常可能代表无法恢复的程序故障,无论是编码错误还是环境中无法控制的问题。

当抛出异常时,异常处理机制会寻找一个适当的位置继续执行。异常会向外移动到更高的级别,从抛出异常的function1(),到调用function1()function2(),再到调用function2()function3(),以此类推,直到达到main()。匹配的处理程序会捕获异常。这将停止搜索并运行该处理程序。如果程序从未找到匹配的处理程序,将会终止并生成一个控制台堆栈跟踪。

// ExceptionHandling/Stacktrace.kt
package stacktrace
import exceptionhandling.Exception1

fun function1(): Int =
  throw Exception1(-52)

fun function2() = function1()

fun function3() = function2()

fun main() {
//  function3()
}

取消注释对function3()的调用会产生以下堆栈跟踪:

Exception in thread "main" exceptionhandling.Exception1: 错误的值: -\
52
  at stacktrace.StacktraceKt.function1(Stacktrace.kt:6)
  at stacktrace.StacktraceKt.function2(Stacktrace.kt:8)
  at stacktrace.StacktraceKt.function3(Stacktrace.kt:10)
  at stacktrace.StacktraceKt.main(Stacktrace.kt:13)
  at stacktrace.StacktraceKt.main(Stacktrace.kt)

function1()function2()function3()中的任何一个都可以catch异常并处理它,阻止异常终止程序。

异常处理程序是以catch关键字开头,后面跟着一个参数列表,其中包含您正在处理的异常。然后是一个实现恢复的代码块。

在下面的示例中,函数toss()为参数1-3产生不同的异常,否则返回“OK”。test()包含了throws()函数的完整一组处理程序:

// ExceptionHandling/Handlers.kt
package exceptionhandling
import atomictest.eq

fun toss(which: Int) = when (which) {
  1 -> throw Exception1(1)
  2 -> throw Exception2("异常2")
  3 -> throw Exception3("异常3")
  else -> "OK"
}

fun test(which: Int): Any? =
  try {
    toss(which)
  } catch (e: Exception1) {
    e.value
  } catch (e: Exception3) {
    e.message
  } catch (e: Exception2) {
    e.message
  }

fun main() {
  test(0) eq "OK"
  test(1) eq 1
  test(2) eq "异常2"
  test(3) eq "异常3"
}

当调用toss()时,您必须catch所有相关的toss()异常,以便非相关的异常“冒泡”并在其他地方被捕获。

test()中的整个try-catch是单个表达式:它返回try主体的最后一个表达式或与异常匹配的catch子句的最后一个表达式。如果没有catch处理异常,该异常会向上传递给堆栈。如果未捕获,它会生成一个堆栈跟踪。

因为Exception3扩展自Exception2,所

以如果Exception2catch出现在处理程序的顺序中位于Exception3catch之前,则会将Exception3处理为Exception2

// ExceptionHandling/Hierarchy.kt
package exceptionhandling
import atomictest.eq

fun testCatchOrder(which: Int) =
  try {
    toss(which)
  } catch (e: Exception2) {    // [1]
    "处理 Exception2 得到 ${e.message}"
  } catch (e: Exception3) {    // [2]
    "处理 Exception3 得到 ${e.message}"
  }

fun main() {
  testCatchOrder(2) eq
    "处理 Exception2 得到 异常2"
  testCatchOrder(3) eq
    "处理 Exception2 得到 异常3"
}

catch子句的顺序意味着Exception3会被行 [1] 捕获,尽管在行 [2] 中具有更具体的异常处理程序。

异常子类型

testCode()中,不正确的code参数会抛出一个IllegalArgumentException

// ExceptionHandling/LibraryException.kt
package exceptionhandling
import atomictest.*

fun testCode(code: Int) {
  if (code <= 1000) {
    throw IllegalArgumentException(
      "'code' 必须大于 1000: $code")
  }
}

fun main() {
  try {
    // A1在16进制表示法中是161:
    testCode("A1".toInt(16))
  } catch (e: IllegalArgumentException) {
    e.message eq "'code' 必须大于 1000: 161"
  }
  try {
    testCode("0".toInt(1))
  } catch (e: IllegalArgumentException) {
    e.message eq "radix 1 不在有效范围 2..36 内"
  }
}

IllegalArgumentExceptiontestCode()和库函数toInt(radix)中都会被抛出。这导致了在main()中的有些令人困惑的错误消息。问题在于我们正在使用相同的异常来表示两个不同的问题。我们通过为我们的错误引入一个名为IncorrectInputException的新异常类型来解决这个问题:

// ExceptionHandling/NewException.kt
package exceptionhandling
import atomictest.eq

class IncorrectInputException(
  message: String
): Exception(message)

fun checkCode(code: Int) {
  if (code <= 1000) {
    throw IncorrectInputException(
      "代码必须大于 1000: $code")
  }
}

fun main() {
  try {
    checkCode("A1".toInt(16))
  } catch (e: IncorrectInputException) {
    e.message eq "代码必须大于 1000: 161"
  } catch (e: IllegalArgumentException) {
    "产生错误" eq "如果执行到这里"
  }
  try {
    checkCode("1".toInt(1))
  } catch (e: IncorrectInputException) {
    "产生错误" eq "如果执行到这里"
  } catch (e: IllegalArgumentException) {
    e.message eq "radix 1 不在有效范围 2..36 内"
  }
}

现在,每个问题都有自己的处理程序。

不要创建过多的异常类型。作为一个经验法则,使用不同的异常类型来区分不同的处理方案,使用不同的构造函数参数为特定的处理方案提供详细信息。

资源清理

当失败不可避免时,自动资源清理有助于使程序的其他部分继续安全运行。

finally关键字确保在异常处理期间进行资源清理。无论您是否正常离开try块,finally子句始终都会运行,不管是正常还是异常情况:

// ExceptionHandling/TryFinally.kt
package exceptionhandling
import atomictest.*

fun checkValue(value: Int) {
  try {
    trace(value)
    if (value <= 0)
      throw IllegalArgumentException(
        "值必须为正数: $value")
  } finally {
    trace("在 finally 子句中,用于 $value")
  }
}

fun main() {
  listOf(10, -10).forEach {
    try {
      checkValue(it)
    } catch (e: IllegalArgumentException) {
      trace("在 main() 的 catch 子句中")
      trace(e.message)
    }
  }
  trace eq """
    10
    在 finally 子句中,用于 10
    -10
    在 finally 子句中,用于 -10
    在 main() 的 catch 子句中
    值必须为正数: -10
  """
}

finally甚至可以与中间的catch子句一起使用。例如,假设在使用完开关后必须将其关闭:

// ExceptionHandling/GuaranteedCleanup.kt
package exceptionhandling
import atomictest.eq

data class Switch(
  var on: Boolean = false,
  var result: String = "OK"
)

fun testFinally(i: Int): Switch {
  val sw = Switch()
  try {
    sw.on = true
    when (i) {
      0 -> throw IllegalStateException()
      1 -> return sw                 // [1]
    }
  } catch (e: IllegalStateException) {
    sw.result = "exception"
  } finally {
    sw.on = false
  }
  return sw
}

fun main() {
  testFinally(0) eq
    "Switch(on=false, result=exception)"
  testFinally(1) eq
    "Switch(on=false, result=OK)"    // [2]
  testFinally(2) eq
    "Switch(on=false, result=OK)"
}

即使在try块中使用return[1]),finally子句仍然会运行([2])。无论testFinally()是正常完成还是异常完成,finally子句始终会执行。

AtomicTest中的异常处理

本书使用AtomicTest的capture()来确保预期的异常被抛出。capture()接受一个函数参数,并返回一个包含异常类和错误消息的CapturedException对象:

// ExceptionHandling/CaptureImplementation.kt
package exceptionhandling
import atomictest.CapturedException

fun capture(f:

() -> Unit): CapturedException =
  try {                                 // [1]
    f()
    CapturedException(null,
      "<Error>: 预期异常")              // [2]
  } catch (e: Throwable) {              // [3]
    CapturedException(e::class,         // [4]
      if (e.message != null) ": ${e.message}"
      else "")
  }

fun main() {
  capture {
    throw Exception("!!!")
  } eq "Exception: !!!"                 // [5]
  capture {
    1
  } eq "<Error>: 预期异常"
}

capture()try块内部调用其函数参数f[1]),通过捕获Throwable[3])处理所有可能的异常。如果没有抛出异常,CapturedException消息表示预期异常([2])。如果捕获了异常,返回的CapturedException将包含异常类和消息([4])。CapturedException可以使用eqString进行比较([5])。

通常情况下,您不会捕获Throwable,而是会处理每个特定的异常类型。

指南

考虑到恢复最初是意图,异常处理恢复实际上是非常罕见的。Kotlin中异常的主要目的是发现程序错误,而不是恢复。因此,在普通的Kotlin代码中捕获异常是一种“代码异味”。

以下是在Kotlin中使用异常编程的准则:

  1. 逻辑错误:这些是您的代码中的错误。要么根本不捕获它们(并生成堆栈跟踪),要么在应用程序的顶层捕获它们并报告错误,可能会重新启动受影响的操作。

  2. 数据错误:这些是来自错误数据的错误,程序员无法控制。应用程序必须以某种方式处理该问题,而不会将其归咎于程序逻辑。例如,我们在本文中使用了String.toInt(),它会为不合适的String抛出异常。它还具有伴生函数String.toIntOrNull(),在失败时返回null,因此您可以在表达式中使用它,例如val n = string.toIntOrNull() ?: default。Kotlin库的设计围绕着通过返回null来处理坏结果,而不是抛出异常。通常预计会偶尔失败的操作通常会有一个“OrNull”版本,您可以在其中使用异常版本。

  3. 检查指令:这些检查逻辑错误。当它们发现错误时,它们会抛出异常,但它们看起来像函数调用,因此您不需要在代码中明确抛出异常。

  4. 输入/输出错误:这些是无法控制且不能忽略的外部条件。然而,使用“OrNull”方法会迅速混淆代码的可读性。更重要的是,您通常可以从I/O错误中恢复,通常是通过重试操作。因此,Kotlin中的I/O操作会抛出异常,因此您的应用程序中会有处理这些异常并尝试从中恢复的代码。

练习和解决方案可在www.AtomicKotlin.com找到。