跳到主要内容

KT-8 Kotlin 异常处理

我们遇到了一点小麻烦

程序在运行的时候经常遇到各种无法处理的情况,例如,尝试将一个字符串转换为数字:

val str = "123"
val num = str.toInt()   // 123

如果把 123 换成 Ciallo~(∠・ω< )⌒☆,会发生什么?正常情况下,toInt 希望看到像是数字的东西,但现在它看到了不正常的东西,这是一种意料之外的情形,toInt 不知道该怎么做,因此程序出现了异常情况(Exception),或者可以称为错误(Error)

当异常情况出现时,程序仍然要继续运行(因为你不能让 CPU 停下来),所以它必须做点什么。一般来说,程序中的异常情况可以分为两种:

  • 不可恢复错误:这种错误发生时,程序不太可能有办法继续运行,例如内存空间不足。这种错误发生时,程序会立刻终止,也就是大家常说的崩溃(Panic)
  • 可恢复错误:这种错误发生时,函数无法完成要求的工作,但是调用者也许有办法继续运行(例如使用默认值)。这种错误可以通过 Kotlin 的异常(Exception) 来处理。平息异常情况带来的问题,让程序得以继续正常运行,就称作异常处理(Exception Handling)

在插件开发中,我们只会遇到可恢复错误,因为说到底插件只是服务端的一个附件,如果因为插件里面出了什么错就要立刻停止整个服务端,那就可能造成相当严重的后果,例如箱子里的 27 组下界合金锭都消失不见。因此,插件对错误的处理,其实也就是使用 Kotlin 的异常处理机制。

接受、增加、别提问

当函数正常执行时,它返回一个返回值(如果没有返回值则相当于返回一个 Unit),并在返回的时候将控制权交还给调用者,这是通过 return(Lambda 函数可以视作最后一个表达式之前有一个隐藏的 return)来完成的。这被称作正常返回

有正常返回,当然也就有异常返回,这正是 Kotlin 中处理异常的方式:

fun div(a: Double, b: Double): Double {
    if (b == 0.0) {
        throw IllegalArgumentException("不可以除以 0") 
    } else {
        return a / b
    }
}

throw 表达式的功能和 return 表达式几乎一致:终止函数的执行,将控制权交还给调用它的函数,同时把其后跟随的异常对象一并传递。与 return 不同的是,throw 可以异常返回任何类型的对象,不受函数本身的返回值类型限制。当然,也不是什么对象都能作为异常对象的,它必须实现 Throwable 接口,才可以被 throw 使用。

虽说 throw 叫做异常返回,但 Kotlin 并不允许我们像获取返回值一样获取异常,你不能做下面这样的事情:

val res = "NotANumber".toInt()
if (isOk(res)) {
    // 正常返回
} else {
    // 出现了错误
}

函数调用表达式的值仅在函数正常返回时才存在,并且被设为函数的返回值。当函数异常返回时,调用该函数的函数也会被传染,或者更严格来说:

如果函数 A 调用函数 B,而函数 B 异常返回,则函数 A 在收到这个异常返回时,也同样立即异常返回,使用相同的异常对象……

这也就是说,如果有下面这样的代码:

fun main() {
    foo()   // 异常返回到这里
}

fun foo() {
    div(10.0, 0.0)  // 在这里异常返回了
    println("You didn't see anything!") // 这不会执行
}

那么在 div 异常返回时,foo 也会立即异常返回到 main 中,下方的 println 就不会执行 —— 即使 foo 并没有使用 throw 或者 return!明明是写出来的代码,最后却没有执行,是不是看上去有点奇怪?其实这正是 Kotlin(和 Java)的异常设计,由于 Kotlin 本身是一门高度依赖函数式编程的语言,这种异常机制允许内部产生的错误被直接传送到最外层的调用者,而中间的函数可以完全当它们不存在,这就简化了函数的设计,同时也并不牺牲错误处理的能力。

在过去

在 Java 中,异常分为两类,一种是运行时异常(Runtime Exception),一种是可检测异常(Checked Exception)。运行时异常可以像上面所说的异常那样直接抛出,而可检测异常则需要像返回值一样添加在函数的签名中。设计 Java 的人最初是想把应该检测的异常(比如文件不存在)和即使检测了也没什么用的异常(比如内存不足)的处理区分开,然而事实上,大多数 Java 代码都只是简单地把可检测异常转换为运行时异常,令人啼笑皆非。

你是职业选手吗?

实际上 JVM 中异常处理的原理和函数的返回无关,它使用退栈(Stack Unwinding) 来完成异常返回,不过在逻辑上和上面的说法是一样的,因此为了方便理解,我们暂且不去谈论栈之类的概念。

聪明的读者应该已经发现,异常一旦产生,就会直接一路向上传递到最外层,那岂不是哪怕最微小的错误,都会让程序直接崩溃掉?但我们并没有见过这么不堪一击的程序,对不对?哈,上面的那条规则是以省略号结尾的,因为它其实还有下面半句话:

……除非 A 捕获并处理 B 产生的异常(或者 B 内部产生的异常)。

异常一旦产生,就会一路向上传递,而异常捕获(Exception Capturing) 正是打断这根链条的方法,要捕获异常,需要使用下面的语法:

try {
    // 可能产生异常的代码
} catch (ex: ExceptionType) {
    // 处理异常的代码
}

把一段代码用 try 包裹起来,再在后面添上一个 catch,那么在 try 块中产生的所有类型为 ExceptionType 或其派生类的异常,就可以被捕获。函数不会立即异常返回,而是会把控制权交给对应的 catch 块,由后者进行异常情况下的处理。catch 可以提供一个默认值,把错误信息打印到日志中,或者用 throw 重新异常返回,等等。当然,如果产生了其它异常,函数仍然会异常返回,因为 catch 并不能捕获它们。

一个 try 后面可以跟随多个 catch,用来捕获不同类型的异常:

try {
    // 可能产生异常的代码
} catch (ex: Type1) {
    // 对 Type1 类型的异常处理
} catch (ex: Type2) {
    // 对 Type2 类型的异常处理
}

在 Kotlin 中,所有的异常都实现了 Throwable 接口,所以如果指定 Throwable 作为异常类型,就可以捕获其中的全部异常。

异常的使用时机

在插件开发所涉及的 Kotlin 代码中,我们很少需要抛出或者捕获异常,大多数 Bukkit API 在正确使用时都不会产生异常,而 Bukkit 同样会在我们的插件中产生异常时进行相应的处理,所以在单纯编写游戏逻辑相关的代码时,我们几乎从不会遇到 trycatch

那么,什么时候会需要捕获异常呢?这取决于我们插件的代码是不是应该处理这个错误。例如,当玩家输入不合格式的命令而触发异常时,我们可以捕获这个异常,并给玩家发送一条错误消息。要是不这么做,玩家就只会收到试图执行该命令时出现意外错误,这可不是我们想要的。除此之外,由于异常会打断代码的执行,所以如果有些代码是无论如何都要执行的话,那也需要捕获异常,我们会在下一节中看到一些具体的例子。