5.6 异常与错误处理
12 分钟阅读
5.6 异常与错误处理
了解 Kotlin 如何使用异常来处理运行时错误。
即使出现可能中断程序执行的运行时错误,异常也能让你的代码运行得更加可预测。Kotlin 默认把所有异常都视为非受检(unchecked)异常。非受检异常简化了异常处理流程:你可以捕获异常,但不必显式处理或声明它们。
提示: 想进一步了解 Kotlin 在与 Java、Swift 和 Objective-C 交互时如何处理异常,请参阅与 Java、Swift 和 Objective-C 的异常互操作一节。
处理异常主要包括两个操作:
- 抛出异常: 在出现问题时加以表明。
- 捕获异常: 通过解决问题,或通知开发者/应用用户,来手动处理意外异常。
异常由 Exception 类的子类表示,而 Exception 又是 Throwable 类的子类。关于这一层级的更多信息,请参阅异常层级一节。由于 Exception 是 open class,你可以创建自定义异常来满足应用的特定需求。
抛出异常
你可以用 throw 关键字手动抛出异常。抛出异常表示代码中出现了意外的运行时错误。异常是对象,抛出一个异常就是创建一个异常类的实例。
可以不携带任何参数抛出异常:
| |
为了更好地了解问题的来源,可以附加更多信息,例如自定义消息和原始原因:
| |
在这个例子中,当用户输入负值时抛出 IllegalArgumentException。你可以创建自定义的错误消息,并保留异常的原始原因(cause),它会包含在堆栈轨迹中。
使用前置条件函数抛出异常
Kotlin 还提供了使用前置条件函数自动抛出异常的其他方式。前置条件函数包括:
| 前置条件函数 | 用途 | 抛出的异常 |
| require() | 校验用户输入的有效性 | IllegalArgumentException |
| check() | 校验对象或变量状态的有效性 | IllegalStateException |
| error() | 表明非法的状态或条件 | IllegalStateException |
当特定条件不满足时程序流程就无法继续,这几种函数正适合这样的场景。它能让代码更简洁,也让这类检查更高效。
require() 函数
当输入参数对函数的运行至关重要、参数无效时函数就无法继续,此时可以用 require() 函数来校验它们。
如果 require() 中的条件不满足,它会抛出 IllegalArgumentException:
| |
注意:
require()函数允许编译器执行智能转换。检查成功后,变量会自动转换为非空类型。这类函数常用于空值检查,以确保变量在继续执行前不为 null。例如:kotlin fun printNonNullString(str: String?) { // 空值检查 require(str != null) // 检查成功后,可以保证 'str' // 不为 null,并会自动智能转换为非空 String println(str.length) }
check() 函数
用 check() 函数校验对象或变量的状态。如果检查失败,说明存在需要解决的逻辑错误。
如果 check() 函数中指定的条件为 false,它会抛出 IllegalStateException:
| |
注意:
check()函数允许编译器执行智能转换。检查成功后,变量会自动转换为非空类型。这类函数常用于空值检查,以确保变量在继续执行前不为 null。例如:kotlin fun printNonNullString(str: String?) { // 空值检查 check(str != null) // 检查成功后,可以保证 'str' // 不为 null,并会自动智能转换为非空 String println(str.length) }
error() 函数
error() 函数用于表明代码中出现了非法的状态,或逻辑上不应该发生的情况。当你希望在代码中故意抛出异常时(例如代码遇到了意外状态),它就非常合适。这个函数在 when 表达式中特别有用,可以清晰地处理那些逻辑上不应发生的情况。
在下面的例子中,error() 函数用于处理未定义的用户角色。如果角色不在预定义范围内,就会抛出 IllegalStateException:
| |
使用 try-catch 块处理异常
异常抛出后会中断程序的正常执行。你可以用 try 和 catch 关键字优雅地处理异常,让程序保持稳定。try 块中包含可能抛出异常的代码,而 catch 块则在异常发生时捕获并处理它。异常会由第一个与其具体类型或其超类匹配的 catch 块捕获。
下面是把 try 和 catch 关键字一起使用的写法:
| |
常见做法是把 try-catch 用作表达式,这样它可以从 try 块或 catch 块返回一个值:
| |
你可以在不使用异常实例的情况下处理异常。例如,可以在 catch 块中提供回退值或通用的错误消息。
用下划线(_)代替异常参数名,表示有意忽略该异常实例:
| |
同一个 try 块可以使用多个 catch 处理器。你可以按需要添加任意多个 catch 块,以分别处理不同的异常。当有多个 catch 块时,按代码从上到下的顺序,把最具体的异常放在前面、最不具体的放在后面,这一点很重要。这样的顺序与程序的执行流程一致。
来看一个使用自定义异常的例子:
| |
处理 WithdrawalException 的通用 catch 块会捕获该类型的所有异常,包括 InsufficientFundsException 这类具体异常,除非它们已被更具体的 catch 块提前捕获。
finally 块
finally 块中的代码无论 try 块是成功完成还是抛出异常,都一定会执行。借助 finally 块,你可以在 try 和 catch 块执行之后进行清理。这在处理文件、网络连接等资源时尤为重要,因为 finally 能保证它们被正确关闭或释放。
下面是 try-catch-finally 三个块一起使用的典型写法:
| |
try 表达式的返回值由 try 块或 catch 块中最后执行的表达式决定。如果没有发生异常,结果来自 try 块;如果异常被处理,结果来自 catch 块。finally 块总会被执行,但它不会改变 try-catch 块的结果。
我们用一个例子来演示:
| |
注意: 在 Kotlin 中,管理实现了
AutoClosable接口的资源(例如FileInputStream或FileOutputStream这类文件流)的惯用做法是使用.use()函数。无论是否抛出异常,该函数都会在代码块执行完毕时自动关闭资源,因此不需要finally块。正因如此,Kotlin 不需要像 Java 的 try-with-resources 那样的特殊语法来管理资源。kotlin FileWriter("test.txt").use { writer -> writer.write("some text") // 该代码块结束后,.use 函数会自动调用 writer.close(),类似 finally 块 }
如果你的代码需要清理资源但不需要处理异常,也可以只使用 try 和 finally 而不写 catch 块:
| |
可以看到,无论是否发生异常,finally 块都能保证资源被关闭。
在 Kotlin 中,你可以按需只使用 catch 块、只使用 finally 块,或两者都用,但 try 块必须至少伴随一个 catch 块或一个 finally 块。
创建自定义异常
在 Kotlin 中,你可以通过创建继承内置 Exception 类的类来定义自定义异常。这样可以针对应用的需求创建更具体的错误类型。
要创建自定义异常,可以定义一个继承 Exception 的类:
| |
在这个例子中有一个默认的错误消息 “My message”,如果你愿意,也可以把它留空。
提示: Kotlin 中的异常是有状态的对象,携带与创建上下文相关的信息,即堆栈轨迹。避免使用对象声明来创建异常,而应在每次需要时创建新的异常实例。这样可以确保异常的状态准确反映具体的上下文。
自定义异常也可以继承任何已有的异常子类,例如 ArithmeticException 子类:
| |
注意: 如果你想创建自定义异常的子类,必须把父类声明为
open,因为类默认是 final 的,否则无法被继承。例如:kotlin // 把自定义异常声明为 open 类,使其可以被继承 open class MyCustomException(message: String): Exception(message) // 创建该自定义异常类的子类 class SpecificCustomException: MyCustomException("Specific error message")
自定义异常的行为与内置异常完全一样。你可以用 throw 关键字抛出它们,并用 try-catch-finally 块处理它们。来看一个演示例子:
| |
在错误场景多样的应用中,构建异常层级有助于让代码更清晰、更具体。你可以用抽象类或密封类作为承载公共异常特征的基类,再为具体的异常类型创建子类。此外,带默认值参数的自定义异常更加灵活,可以用不同的消息初始化,从而实现更细粒度的错误处理。
下面这个例子以密封类 AccountException 作为异常层级的基类,子类 APIKeyExpiredException 则展示了如何使用带默认值的参数来提供更详细的异常信息:
| |
Nothing 类型
在 Kotlin 中,每个表达式都有类型。表达式 throw IllegalArgumentException() 的类型是 Nothing,这是一个内置类型,是所有其他类型的子类型,也被称为底类型。这意味着在期望其他任何类型的地方,都可以使用 Nothing 作为返回类型或泛型类型,而不会引发类型错误。
Nothing 是 Kotlin 中的一种特殊类型,用于表示永远不会成功完成的函数或表达式——它们要么总是抛出异常,要么进入像无限循环这样永不结束的执行路径。你可以用 Nothing 标记尚未实现或设计为总是抛出异常的函数,从而向编译器和代码阅读者清楚表明你的意图。如果编译器在函数签名中推断出 Nothing 类型,它会给出警告。显式地把 Nothing 声明为返回类型可以消除这个警告。
下面这段 Kotlin 代码演示了 Nothing 类型的用法,编译器会把函数调用之后的代码标记为不可达:
| |
Kotlin 的 TODO() 函数同样使用 Nothing 类型,它作为占位符,用于标出代码中将来需要实现的部分:
| |
可以看到,TODO() 函数总是抛出 NotImplementedError 异常。
异常类
我们来看看 Kotlin 中一些常见的异常类型,它们都是 RuntimeException 类的子类:
ArithmeticException:当无法执行某个算术运算(例如除以零)时会发生此异常。
| |
IndexOutOfBoundsException:当数组或字符串等某种索引超出范围时抛出此异常。
| |
注意: 要避免这个异常,请改用更安全的方式,例如
getOrNull()函数:kotlin val myList = listOf(1, 2, 3) // 返回 null,而不是抛出 IndexOutOfBoundsException val element = myList.getOrNull(3) println("Element at index 3: $element")
NoSuchElementException:当访问某个集合中不存在的元素时抛出此异常。在使用first()或last()这类期望存在特定元素的方法时会发生。
| |
注意: 要避免这个异常,请改用更安全的方式,例如
firstOrNull()函数:kotlin val emptyList = listOf<Int>() // 返回 null,而不是抛出 NoSuchElementException val firstElement = emptyList.firstOrNull() println("First element in empty list: $firstElement")
NumberFormatException:当尝试把字符串转换为数值类型,但字符串的格式不合适时会发生此异常。
| |
注意: 要避免这个异常,请改用更安全的方式,例如
toIntOrNull()函数:kotlin val nonNumericString = "not a number" // 返回 null,而不是抛出 NumberFormatException val number = nonNumericString.toIntOrNull() println("Converted number: $number")
NullPointerException:当应用尝试使用值为null的对象引用时抛出此异常。尽管 Kotlin 的空安全特性显著降低了 NullPointerException 的风险,但仍然可能因故意使用!!运算符,或与缺少 Kotlin 空安全特性的 Java 交互而发生。
| |
虽然 Kotlin 中所有异常都是非受检的,你不必显式捕获它们,但如果需要,你仍然可以灵活地捕获。
异常层级
Kotlin 异常层级的根是 Throwable 类。它有两个直接子类:Error 和 Exception:
Error子类表示应用可能无法自行恢复的严重基础性问题。这类问题通常不需要你去处理,例如OutOfMemoryError或StackOverflowError。Exception子类用于表示你可能希望处理的情况。Exception类型的子类型,例如RuntimeException和IOException(输入/输出异常),用于处理应用中的异常事件。
RuntimeException 通常由程序代码中检查不充分引起,可以通过编程手段避免。Kotlin 帮助避免 NullPointerException 这类常见的 RuntimeException,并针对除零等潜在的运行时错误提供编译期警告。下图展示了派生自 RuntimeException 的子类型层级:
堆栈轨迹
堆栈轨迹(stack trace)是运行时环境生成的报告,用于调试。它展示了导致程序到达某个特定位置(尤其是发生错误或异常的位置)的函数调用序列。
来看一个在 JVM 环境中因异常而自动打印堆栈轨迹的例子:
| |
在 JVM 环境中运行这段代码会得到如下输出:
| |
第一行是异常描述,包含:
- 异常类型:
java.lang.ArithmeticException - 线程:
main - 异常消息:
"This is an arithmetic exception!"
异常描述之后,每一以 at 开头的行都是堆栈轨迹的一部分。其中一行称为一个堆栈轨迹元素或栈帧:
at MainKt.main (Main.kt:3):显示了方法名(MainKt.main)以及调用该方法所在的源文件和行号(Main.kt:3)。at MainKt.main (Main.kt):表明异常发生在Main.kt文件的main()函数中。
与 Java、Swift 和 Objective-C 的异常互操作
由于 Kotlin 把所有异常都视为非受检异常,当这些异常被区分受检与非受检异常的语言调用时,可能会带来麻烦。为解决 Kotlin 与 Java、Swift、Objective-C 等语言在异常处理上的这种差异,你可以使用 @Throws 注解。该注解会提醒调用方可能抛出的异常。更多信息请参阅从 Java 调用 Kotlin和与 Swift/Objective-C 的互操作。