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 同样会在我们的插件中产生异常时进行相应的处理,所以在单纯编写游戏逻辑相关的代码时,我们几乎从不会遇到 try 和 catch。
那么,什么时候会需要捕获异常呢?这取决于我们插件的代码是不是『应该』处理这个错误。例如,当玩家输入不合格式的命令而触发异常时,我们可以捕获这个异常,并给玩家发送一条错误消息。要是不这么做,玩家就只会收到『试图执行该命令时出现意外错误』,这可不是我们想要的。除此之外,由于异常会打断代码的执行,所以如果有些代码是『无论如何都要执行』的话,那也需要捕获异常,我们会在下一节中看到一些具体的例子。