Kotlin中的异常处理
学习如何使用异常来处理代码中的运行时错误
异常是大多数高级编程语言(如 Kotlin、Java、Swift 或 C++)中表示错误的标准方式。因此,你可以在许多其他语言中应用相同的错误报告和处理概念。
异常不仅提供了一种表示错误的方式,还允许你处理这些错误。这种方式使你能够编写更健壮的代码。
在本课程中,你将学习 Kotlin 中异常处理的基础知识。
原则
异常允许你在代码中标识错误或意外行为。
更重要的是,它们允许你在与错误发生位置不同的地方处理该错误。我们说异常在代码中“向上冒泡”。这种异常冒泡机制使你能够在更高层次的抽象中处理异常,从而采取有意义的措施(例如从错误中恢复、重试操作等)。
例如,输入/输出(I/O)操作是异常的常见来源,无论你是访问文件系统、数据库还是网络资源。
I/O 是一种相对底层的操作。在执行 I/O 的代码段中,你可能并不知道如何从错误中恢复——是否通知用户、重试操作、跳过操作,或者尝试备用资源等。事实上,如果 I/O 代码本身实现了这些逻辑,有人可能会认为这违反了单一职责原则。
相比之下,应用程序的更高层部分应该能够从这类异常中恢复,例如:
- 如果网络服务不可达,应用程序将重试两次,否则通知用户
- 如果找不到文件,应用程序将对其进行初始化
实践:异常处理
Kotlin 有四个用于异常处理的关键字:
throw:创建(即“抛出”)一个新的异常(即:“嘿,这里发生了错误!”)try:包裹一段可能抛出异常的代码,以便对异常做出响应(即:“请运行这段代码,我知道它可能会抛出异常”)catch:定义当try块中抛出特定异常时要执行的代码(即:“啊,我预料到了这种错误,让我来处理它”)。可以有多个catch块来处理不同类型的异常。finally:定义在try块和catch块之后无论如何都会执行的代码(即:“我不知道之前发生了什么,但我必须做一些清理工作”)
异常的处理(catch)可以在与异常最初发生位置(throw)完全不同的代码部分进行。
代码示例
以下代码展示了如何抛出异常、异常冒泡的工作原理以及如何处理异常:
1var currentPressure = 80 2 3// 此方法可能抛出两种不同类型的异常 4fun reducePressureBy(bar: Int) { 5 if (bar < 0) { 6 throw IllegalArgumentException("请传入非负的 bar 值") 7 } 8 if (currentPressure - bar < 30) { 9 throw IllegalStateException("无法将压力减少 $bar bar,压力不能低于 30 bar") 10 } 11 12 println("正在减少 $bar bar 压力") 13 currentPressure -= bar 14 println("当前压力为 $currentPressure bar") 15} 16 17fun tryReducePressureBy(bar: Int) { 18 try { 19 reducePressureBy(bar) 20 } catch(e: IllegalArgumentException) { 21 // 在此处处理异常 22 // 例如:记录日志、修改状态以避免负值等 23 println(e.message) 24 } catch(e: IllegalStateException) { 25 // 你可以处理多种可能的异常 26 // 例如:什么都不做,因为你已知这意味着压力不能再降低 27 println(e.message) 28 } finally { 29 // 此代码将在块结束时始终执行 30 // 适用于清理工作(例如关闭打开的资源/连接) 31 } 32} 33 34// 让我们尝试几次调用(运行以查看输出) 35tryReducePressureBy(30) 36tryReducePressureBy(10) 37tryReducePressureBy(30) 38tryReducePressureBy(-10)
通过 throw SomeExceptionClass(...) 抛出新的异常。
注意: 与 Java、C++ 或 C# 不同,Kotlin 没有
new操作符。你仍然可以通过方法名以小写字母开头来判断是否调用了类构造函数或方法。
函数 reducePressureBy 可能抛出两种开发者应了解的异常类型:
IllegalArgumentException:通常用于验证函数参数。IllegalStateException:当当前状态不允许执行操作时使用(例如,会导致无效状态)。
换句话说,你应该谨慎调用 reducePressureBy 函数。如果异常被抛出但未被任何地方处理,你的应用程序将崩溃。为此,我们提供了 tryReducePressureBy,它会处理这两种可能的异常。
try 块包裹了可能“危险”的代码段。如果抛出异常,编译器会查找可以处理该异常的 catch 块:
- 如果存在这样的
catch块,它将被执行。 - 如果没有,异常将继续向上冒泡,即包含
try块的函数会将异常传递给其调用者。
提示: 你可能会好奇为什么有两个独立的函数。原因是清晰地划分职责(编写整洁代码)。
tryReducePressureBy的单一职责是异常处理,而reducePressureBy执行实际逻辑。这是一种有用的异常处理模式。
finally 块保证在 try-catch 块结束后无论是否发生异常都会执行。它常用于关闭打开的资源或连接,如文件句柄或数据库连接。
注意:
catch和finally都是可选的,但至少需要其中一个存在。
try-catch 是表达式
正如你所学,Kotlin 中大多数语言结构都是表达式(即具有值)。try-catch 也是如此。
与 if、when 等一致,try-catch 块的值由每个 try 块和 catch 块中的最后一行决定。
1val input: Int? = try { 2 randomInput.toInt() // 尝试将输入解析为整数 3} catch(e: NumberFormatException) { 4 println("无法将 '$randomInput' 解析为整数") 5 null // 如果无法解析,则返回 null 6} 7 8println("随机输入为 $input!")
在上述示例中,如果随机输入字符串可以被解析为整数,该整数值将存储在 input 中。
如果无法解析,toInt 将抛出 NumberFormatException,执行将跳转到 catch 块。因此,input 将根据 catch 块的最后一行设置为 null。
请注意,input 的类型是 Int?,因为 try 块返回 Int,而 catch 块返回 null。实际上,你应该尽量从 catch 块返回更有用的默认值,以避免 null。
提示: 我们建议为这种表达式的预期结果显式指定类型,如上面的
input: Int?。否则,你可能会意外更改返回类型,例如通过修改catch块的最后一行。结果,编译器可能会推断出非常通用的类型,如Any。
throw 也是表达式
Kotlin 更进一步:甚至 throw 也是一个表达式。但它的值是什么?毕竟,throw 意味着执行流程被中断,throw 之后的代码行不会被执行。
答案是:Kotlin 有一个表示非终止和不可达代码的类型。这个类型称为 Nothing。它代表 Kotlin 类型系统的底部,即 Nothing 是所有其他类型的子类型。例如,Nothing 是 Int、Unit、Any 以及你自己定义的任何类型的子类型。
Nothing 是一种特殊类型。不存在可以赋值给 Nothing 类型变量的有效值。相应地,其可空版本 Nothing? 的唯一有效值是 null。
由于 throw 是一个表达式,你可以将其作为 Elvis 操作符的第二个参数传递。 这是一个有用的模式。记住,你可以将 Elvis 操作符理解为“否则”。
1fun getUserOrNull(): User? = null // 尝试返回 `User()` 以表示成功情况 2 3val user = getUserOrNull() ?: throw UserNotFoundException("无法读取当前用户") 4user.authorize()
在上述示例中,我们尝试获取一个用户对象。如果找不到用户,我们将抛出自定义的 UserNotFoundException。
思考一下编译器会对 user 推断出什么类型。你可能期望推断为 User?,因为我们调用了返回 User? 的 getUserOrNull()。
然而,请注意,在第 5 行,我们无需进行空值处理即可调用 user.authorize()。这是因为 Kotlin 编译器足够聪明,能够推断出表达式 getUserOrNull() ?: throw UserNotFoundException(...) 的类型为 User。
为什么这是正确的?
- 如果
getUserOrNull()确实返回null,则会调用throw。在这种情况下,执行流程被中断;user不会被赋值,执行也不会到达第 5 行。 - 只有当
getUserOrNull()返回一个真实用户时,user才会被赋值,执行才会到达第 5 行。因此,user始终是User类型,永远不会为null。
这之所以可行,是因为 throw 表达式具有类型 Nothing。任何返回 Nothing 的表达式都不会返回值(即总是抛出异常)。正如你所见,编译器利用这一知识进行类型推断。
使用 Nothing
你可以利用这种行为,并从自己的函数中返回 Nothing。
例如,考虑某些测试框架中使用的 fail 函数:
1fun fail(message: String): Nothing { // “返回” Nothing => 永远不会返回 2 throw RuntimeException(message) 3} 4 5val user = getUserOrNull() ?: fail("无法读取当前用户") 6user.authorize()
测验
练习
注意: 这些练习也旨在检验你之前课程的知识。异常仅在完成前几步后才会发挥作用。
一段楼梯
实现一个程序,使用循环生成以下输出(不使用 Kotlin 的 String.repeat)。
1# 2## 3### 4#### 5##### 6###### 7#######
练习
解答
1 2fun main() { 3 for (count in 1..7) { 4 for (i in 1..count) { 5 print("#") 6 } 7 println() 8 } 9}
一棵圣诞树
基于上一题,实现一个程序,生成以下输出:
1 # 2 ### 3 ##### 4 ####### 5######### 6 ###
最后一行应始终包含三个井号,代表圣诞树的树干。
练习
解答
1 2// TODO
所有的圣诞树
注意: 此练习比前两个稍长一些。请花时间完成它,这确实是提升编程技能的最佳方式。
允许用户通过命令行参数运行你的程序,指定圣诞树的高度(即不包括树干的行数)。
圣诞树的高度必须至少为 3。如果不符合要求,或在解析命令行参数时发生其他异常,程序应打印一条提示信息,并回退到默认高度 5。
练习
解答
1 2fun main(args: Array<String>) { 3 4 // 使用局部辅助函数 5 fun readTreeHeightFromArgs(fallbackHeight: Int): Int { 6 return try { 7 args[0].toInt().also { require(it >= 3) } // 解析并验证命令行参数 8 } catch (e: NumberFormatException) { 9 System.err.println("命令行参数必须是整数。使用默认大小 $fallbackHeight...") 10 fallbackHeight // 异常情况下使用默认值 11 } catch (e: IllegalArgumentException) { 12 System.err.println("请使用至少 3 的树高。使用默认大小 $fallbackHeight...") 13 fallbackHeight 14 } catch (e: ArrayIndexOutOfBoundsException) { 15 System.err.println("请将树高作为命令行参数指定。使用默认大小 $fallbackHeight...") 16 fallbackHeight 17 } 18 } 19 20 // 提取相关数据 21 val treeHeight = readTreeHeightFromArgs(fallbackHeight = 5) 22 val treeWidth = (treeHeight * 2) - 1 23 val treeMiddle = treeWidth / 2 // 水平轴上的中心点 24 25 // 使用局部辅助函数 26 fun printTreeRow(row: Int) { 27 for (i in 1..treeMiddle - row) print(" ") 28 for (i in 1..(2 * row + 1)) print("#") 29 println() 30 } 31 32 fun printTreeLeaves() { // 注意这些辅助函数不需要参数,因为它们可以访问外部作用域的变量 33 for (row in 0 until treeHeight) { 34 printTreeRow(row) 35 } 36 } 37 38 fun printTreeTrunk() = printTreeRow(1) // 树干与树冠第二行相同 39 40 fun printTree() { 41 printTreeLeaves() 42 printTreeTrunk() 43 } 44 45 // 运行程序 46 printTree() 47}
提示:
- 你可以通过
main函数的args参数访问命令行参数,即fun main(args: Array<String>)。- 你可以使用
toInt()将给定的命令行参数从String转换为Int。- 使用 Kotlin 的
require函数验证给定高度至少为 3。- 你可能会遇到三种类型的异常:
- 当尝试访问
args的第一个元素但无此元素时,出现ArrayIndexOutOfBoundsException。require在条件不满足时抛出IllegalArgumentException。- 如果命令行参数无法解析为
Int,则抛出NumberFormatException。 注意:NumberFormatException是IllegalArgumentException的子类——这对捕获顺序意味着什么?(你可以尝试一下!)- 在 IntelliJ 中传递命令行参数的方法:进入 Run,Edit Configurations,并在 Program Arguments 中指定树的高度。
附加挑战: 将代码拆分为职责清晰、易于理解的函数。
在本课程中,你学习了 Kotlin 异常处理的基础知识,包括关键字 throw、try、catch 和 finally。你还了解了特殊的 Nothing 类型,它的含义以及如何使用它。
接下来,你将学习检查型异常与非检查型异常的区别,以及 Kotlin 团队为何选择不引入检查型异常。
Quiz
在 Kotlin 中,关于 try-catch 表达式、throw 表达式以及异常处理的语法,以下哪些说法是正确的?(多选)