异常处理
失败总是有可能的。
Kotlin在分析您的程序时会发现基本错误。在编译时无法检测到的错误必须在运行时处理。在异常一章中,您已经学习了如何抛出异常。在这篇文章中,我们将学习如何捕获异常。
从历史上看,失败常常是灾难性的。例如,在C语言中编写的程序会突然停止工作,丢失数据,并有可能导致操作系统崩溃。
改进的错误处理是增加代码可靠性的强大方式。在创建可重用的程序组件时,错误处理尤其重要。要创建一个健壮的系统,每个组件都必须是健壮的。通过一致的错误处理,组件可以可靠地将问题传达给客户端代码。
现代应用程序通常使用并发,而并发程序必须能够处理非关键异常。例如,服务器应该在会话被异常终止时进行恢复。
异常将三个活动混为一谈:
- 错误报告
- 恢复
- 资源清理
让我们来考虑每个活动。
报告
标准库中的异常通常足够。要进行更具体的异常处理,您可以从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
)。Exception1
和Exception2
都继承自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
,所
以如果Exception2
的catch
出现在处理程序的顺序中位于Exception3
的catch
之前,则会将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 内"
}
}
IllegalArgumentException
在testCode()
和库函数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
可以使用eq
与String
进行比较([5])。
通常情况下,您不会捕获Throwable
,而是会处理每个特定的异常类型。
指南
考虑到恢复最初是意图,异常处理恢复实际上是非常罕见的。Kotlin中异常的主要目的是发现程序错误,而不是恢复。因此,在普通的Kotlin代码中捕获异常是一种“代码异味”。
以下是在Kotlin中使用异常编程的准则:
-
逻辑错误:这些是您的代码中的错误。要么根本不捕获它们(并生成堆栈跟踪),要么在应用程序的顶层捕获它们并报告错误,可能会重新启动受影响的操作。
-
数据错误:这些是来自错误数据的错误,程序员无法控制。应用程序必须以某种方式处理该问题,而不会将其归咎于程序逻辑。例如,我们在本文中使用了
String.toInt()
,它会为不合适的String
抛出异常。它还具有伴生函数String.toIntOrNull()
,在失败时返回null
,因此您可以在表达式中使用它,例如val n = string.toIntOrNull() ?: default
。Kotlin库的设计围绕着通过返回null
来处理坏结果,而不是抛出异常。通常预计会偶尔失败的操作通常会有一个“OrNull”版本,您可以在其中使用异常版本。 -
检查指令:这些检查逻辑错误。当它们发现错误时,它们会抛出异常,但它们看起来像函数调用,因此您不需要在代码中明确抛出异常。
-
输入/输出错误:这些是无法控制且不能忽略的外部条件。然而,使用“OrNull”方法会迅速混淆代码的可读性。更重要的是,您通常可以从I/O错误中恢复,通常是通过重试操作。因此,Kotlin中的I/O操作会抛出异常,因此您的应用程序中会有处理这些异常并尝试从中恢复的代码。
练习和解决方案可在www.AtomicKotlin.com找到。