从 finally 理解程序的控制流:它为什么不是 catch 后面的代码?
很多编程语言都有 try / catch / finally 这样的异常处理结构。
第一次接触它时,很容易产生一个疑问:
scss
try {
doSomething()
} catch (e: Exception) {
handleException(e)
} finally {
cleanup()
}
为什么一定需要 finally?
如果只是想让 cleanup() 在异常处理之后执行,完全可以写成:
scss
try {
doSomething()
} catch (e: Exception) {
handleException(e)
}
cleanup()
难道 finally 的存在,只是为了更加明确地表达"这段代码必须执行"吗?
答案是:
"必须执行"确实是 finally 非常重要的语义,但这还不是最本质的原因。
真正理解 finally,需要把视角从"异常处理"提升到"控制流"。
catch 处理的是异常,而 finally 处理的是作用域退出。
一、先看一个看起来等价的例子
假设我们有:
scss
fun foo() {
try {
doSomething()
} catch (e: Exception) {
handleException(e)
}
cleanup()
}
以及:
kotlin
fun foo() {
try {
doSomething()
} catch (e: Exception) {
handleException(e)
} finally {
cleanup()
}
}
如果 doSomething() 正常执行,或者抛出异常并且 handleException() 正常返回,那么两者看起来确实一样。
因此很容易产生一个错觉:
finally不就是把代码放到了catch后面吗?
但这种理解很快就会遇到问题。
二、catch 自己也可能抛异常
假设:
scss
fun foo() {
try {
doSomething()
} catch (e: Exception) {
handleException(e)
}
cleanup()
}
而:
kotlin
fun handleException(e: Exception) {
throw RuntimeException("handler failed")
}
那么执行过程是:
scss
try
│
│ doSomething()
│
↓
发生异常
│
↓
catch
│
│ handleException()
│
↓
再次抛出异常
│
↓
函数退出
这时候:
scss
cleanup()
根本不会执行。
因为控制流已经离开函数了。
但是:
kotlin
fun foo() {
try {
doSomething()
} catch (e: Exception) {
handleException(e)
} finally {
cleanup()
}
}
则不同。
执行过程变成:
scss
try
│
↓
发生异常
│
↓
catch
│
│ handleException()
│
│
│ 再次抛出异常
↓
finally
│
↓
cleanup()
│
↓
异常继续向外传播
这里第一次暴露出了 finally 的真正特征:
finally并不是"catch 后面的普通代码"。
它是:
在真正离开 try/catch 作用域之前,必须经过的控制流节点。
三、甚至没有异常,也可能需要 finally
考虑另一个例子:
kotlin
fun foo(): User {
try {
return getUser()
}
cleanup()
}
这里的 cleanup() 永远不会执行。
因为:
kotlin
return getUser()
已经让函数准备退出。
如果我们希望无论如何都执行清理工作:
kotlin
fun foo(): User {
try {
return getUser()
} finally {
cleanup()
}
}
那么 cleanup() 就会执行。
这里甚至根本没有 catch。
这说明:
finally的语义其实和"异常"没有必然关系。
它真正关心的是:
控制流是否正在离开这个作用域。
四、finally 真正关注的是"作用域退出"
我们可以把两者的职责分开。
catch:异常发生之后怎么办?
scss
try {
readFile()
} catch (e: IOException) {
showError()
}
catch 关心的是:
发生异常
↓
这个异常我能处理吗?
↓
如果能,我应该怎么处理?
所以 catch 是异常处理机制。
finally:离开这个作用域之前必须做什么?
scss
try {
doSomething()
} finally {
cleanup()
}
它关心的是:
csharp
进入作用域
↓
执行 try
↓
无论发生什么
↓
finally
↓
离开作用域
因此:
finally 更接近资源生命周期管理和控制流管理,而不仅仅是异常处理。
五、finally 处理的是所有退出路径
如果把程序的控制流画出来,会更加直观。
kotlin
try
│
┌──────────┼──────────┐
│ │ │
正常 exception return
│ │ │
│ catch │
│ │ │
└──────────┼──────────┘
│
finally
│
↓
真正退出
实际上还可以更多:
kotlin
try
│
┌──────────┼───────────┐
│ │ │
正常 throw return
│ │ │
│ catch │
│ │ │
└──────────┼───────────┘
│
finally
│
↓
退出作用域
在合适的语言控制结构中,break、continue 等退出路径也可能需要经过 finally。
因此可以把 finally 理解为:
所有从这个作用域出去的控制流,在真正出去之前必须经过的一道关卡。
六、这也是为什么"把代码放在 catch 后面"不够
现在再回头看:
scss
try {
...
} catch (...) {
...
}
cleanup()
它表达的是:
try/catch 结构正常执行到这里之后,执行 cleanup。
而:
csharp
try {
...
} catch (...) {
...
} finally {
cleanup()
}
表达的是:
无论 try/catch 结构以什么方式退出,都在真正退出之前执行 cleanup。
两者的区别不是代码位置,而是控制流语义。
普通代码:
scss
cleanup()
只有当控制流真的走到这里时才会执行。
而 finally 是语言级别告诉编译器:
所有退出路径都必须经过这里。
七、这也是 finally 最典型的用途:资源清理
例如:
java
val connection = pool.acquire()
try {
connection.execute()
} finally {
pool.release(connection)
}
这里真正想表达的并不是:
如果
execute()抛异常怎么办?
而是:
我获得了一个资源,因此无论后面的操作成功还是失败,在离开这个作用域之前都必须释放它。
控制流可能是:
scss
acquire()
↓
execute()
│
├── 成功 ────────┐
│ │
├── 异常 ────────┤
│ │
└── return ──────┤
↓
finally
↓
release()
这也是为什么很多语言把资源管理和 finally 紧密联系起来。
八、Kotlin 的 use 就是这种思想的进一步抽象
Kotlin 中经常写:
lua
FileInputStream(file).use { input ->
input.read()
}
它背后的思想可以近似理解成:
lua
val input = FileInputStream(file)
try {
input.read()
} finally {
input.close()
}
use 并不是简单地"帮你写少几行代码"。
它实际上把:
资源获取 → 使用 → 无论如何释放
这种生命周期模式抽象了出来。
因此:
arduino
acquire resource
↓
use resource
↓
release resource
是一个非常普遍的程序设计模式。
而 finally 正是这个模式的重要底层机制。
九、真正有意思的地方:return 遇到 finally 会发生什么?
理解了 finally 是控制流结构之后,一个非常有意思的问题出现了:
kotlin
fun foo(): Int {
try {
return 1
} finally {
println("cleanup")
}
}
这里:
kotlin
return 1
按直觉似乎意味着:
"马上返回 1。"
但实际上并不是。
更准确地说:
kotlin
return 1
↓
准备返回值 1
↓
执行 finally
↓
finally 执行完毕
↓
真正 return 1
也就是说:
return 请求发生以后,真正离开函数之前,还存在一个 finally 阶段。
从概念上可以近似转换成:
kotlin
fun foo(): Int {
val result = 1
println("cleanup")
return result
}
这里的 result 表示:
返回值已经确定,但函数还没有真正退出。
十、这解释了一个容易让人困惑的例子
看下面的代码:
kotlin
fun foo(): Int {
var x = 1
try {
return x
} finally {
x = 2
}
}
最终返回:
1
为什么不是 2?
因为:
kotlin
return x
产生返回值时,x 已经是 1。
可以近似理解成:
kotlin
fun foo(): Int {
var x = 1
val result = x
x = 2
return result
}
所以:
kotlin
return x
↓
得到返回值 1
↓
finally
↓
x = 2
↓
return 已经确定的 1
这进一步说明:
finally 是发生在"确定退出结果"和"真正退出"之间的。
十一、但是 finally 也可以改变退出结果
现在看一个更加极端的例子:
kotlin
fun foo(): Int {
try {
return 1
} finally {
return 2
}
}
最终返回:
2
为什么?
因为控制流是:
kotlin
try
│
│ return 1
│
│ "我要返回 1"
↓
finally
│
│ return 2
│
│ "不,我现在要返回 2"
↓
真正返回 2
所以:
finally 中的 return 可以覆盖之前的 return。
这就是为什么通常应该避免在 finally 中使用 return。
十二、甚至 finally 可以"吞掉"异常
更危险的情况是:
kotlin
fun foo(): Int {
try {
throw Exception("error")
} finally {
return 2
}
}
直觉上我们会认为:
php
throw Exception
应该让函数异常退出。
但实际控制流是:
kotlin
try
│
│ throw Exception
↓
finally
│
│ return 2
↓
正常返回 2
原来的异常被覆盖了。
这说明:
finally 并不是简单的"最后执行一段代码"。
它实际上位于:
csharp
所有退出路径
↓
finally
↓
真正退出
这个位置。
因此它具有改变退出结果的能力。
十三、这也解释了 Kotlin 编译器为什么会"优化掉" return
例如:
kotlin
fun foo(): Int {
try {
return 1
} finally {
return 2
}
}
反编译后可能看到:
csharp
public static final int foo() {
try {
return 2;
} finally {
;
}
}
看起来很奇怪:
原来的
return 1去哪里了?
实际上,从语义上来看:
kotlin
try 中:
return 1
↓
finally:
return 2
↓
最终结果一定是 2
因此前面的 return 1 已经没有任何可能影响最终结果。
编译器完全可以进行优化。
从结果上看,整个函数最终就是:
kotlin
return 2
反编译器显示出来的:
kotlin
try {
return 2;
} finally {
;
}
也不应该被简单理解成 Kotlin 编译器真正生成的 Java 源代码。
这里需要特别注意:
反编译得到的 Java 代码,是根据字节码重新构造出来的"近似源码",而不是 Kotlin 编译器内部真正经过的中间表示。
所以研究编译器行为时,更可靠的对象其实是:
markdown
Kotlin 源代码
↓
Kotlin 编译器
↓
JVM 字节码
↓
反编译器
↓
Java 形式的近似代码
真正发生事情的是中间的:
Kotlin → JVM bytecode
而不是:
Kotlin → Java → JVM
十四、从这里可以看到编译器真正做的一件事
我们平时写:
kotlin
try {
return 1
} finally {
cleanup()
}
很容易把它理解成:
kotlin
return 1
cleanup()
但编译器看到的其实更接近:
csharp
准备退出
↓
保存退出结果
↓
执行 finally
↓
根据 finally 的执行结果决定最终如何退出
也就是说,编译器需要对控制流进行重新组织。
这其实是一个非常重要的思想:
程序并不只是"从上往下执行的一串语句",而是一个具有多个控制流路径的图。
十五、从控制流图的角度重新理解 finally
假设:
kotlin
fun foo(): Int {
try {
return 1
} catch (e: Exception) {
return 2
} finally {
cleanup()
}
}
我们可以想象成:
kotlin
try
│
┌──────────┴──────────┐
│ │
正常执行 抛出异常
│ │
return 1 catch
│ │
│ return 2
│ │
└──────────┬──────────┘
│
finally
│
cleanup()
│
↓
真正退出
finally 就像一个汇合点。
所有可能的退出路径都需要先经过它。
这就是为什么:
csharp
try {
...
} finally {
...
}
是一种控制流结构,而不是简单的语法糖。
十六、这件事和 Kotlin 协程也有很深的联系
理解 finally 后,再看 Kotlin 协程,会发现一个非常有趣的相似之处。
例如:
kotlin
suspend fun foo() {
try {
bar()
} finally {
cleanup()
}
}
这里 bar() 可能:
- 正常返回
- 抛异常
- 挂起
- 恢复
- 被取消
但无论最终以哪种方式退出这个作用域,都需要遵守 finally 的语义。
因此,当我们研究 Kotlin 协程状态机时,经常会遇到一个核心问题:
控制流在不同状态之间如何转移?
而 finally 恰好也是一个控制流问题。
这与我们理解协程编译器生成的状态机,其实是同一类思维方式:
不要只看代码"写成了什么"
而要看:
这个代码可能产生哪些控制流路径?
这些路径在哪里汇合?
离开作用域之前需要经过什么?
十七、所以 finally 最准确的定义是什么?
如果让我用一句话重新定义 finally,我不会说:
"finally 是异常发生后执行的代码。"
甚至也不会简单说:
"finally 是无论如何都会执行的代码。"
我更愿意这样描述:
finally 是绑定在一个作用域上的退出处理机制:无论控制流因为正常完成、异常、return 等原因离开这个作用域,都必须先执行 finally 中的代码。
其中最重要的关键词是:
离开作用域。
而不是:
发生异常。
十八、重新回答最开始的问题
现在回到最初的问题:
为什么很多语言都给 try 配置 finally?
明明可以把想执行的代码放在 catch 后面。
难道只是为了体现"必须执行"的语意吗?
答案可以分成三个层次。
第一层:语义表达
是的。
finally 明确表达:
这段代码是退出这个作用域之前必须执行的代码。
这比普通代码更准确。
第二层:控制流
但更重要的是:
catch 后面的普通代码无法覆盖所有退出路径。
例如:
kotlin
return
或者:
arduino
throw
都会导致后面的普通代码无法执行。
而 finally 可以插入到这些退出路径上。
第三层:语言机制
更深一层:
finally 是语言提供的一种控制流结构,它要求编译器在所有退出路径上建立一个共同的退出处理阶段。
因此:
csharp
普通代码:
某个路径
↓
代码
finally:
多个退出路径
↓
finally
↓
真正退出
这才是 finally 真正存在的原因。
十九、一个值得记住的心智模型
以后看到:
csharp
try {
...
} finally {
...
}
不要在脑子里把它读成:
try 做完以后,执行 finally。
而应该读成:
我要进入一个受保护的作用域。
无论将来从这个作用域以什么方式退出,都必须经过 finally。
于是:
kotlin
进入作用域
│
↓
try
│
┌──────────────┼──────────────┐
│ │ │
正常 异常 return
│ │ │
│ catch │
│ │ │
└──────────────┼──────────────┘
│
finally
│
↓
真正退出
这张图比"try-catch-finally 是异常处理语法"更接近 finally 的本质。
二十、最后的一个延伸
理解 finally 后,其实会自然产生一个更深的问题:
kotlin
fun foo(): Int {
try {
return 1
} finally {
println("cleanup")
}
}
编译器到底是怎么把:
kotlin
return
finally
真正退出
变成 JVM 字节码的?
尤其是:
kotlin
return
throw
finally
这些本来完全不同的控制流,如何最终被编译器组织到一起?
而这恰好是理解 JVM 异常表、athrow、ireturn,以及 Kotlin 编译器如何处理 try/finally 的一个非常好的入口。
再往前一步,还可以把这个问题和 Kotlin 协程联系起来:
kotlin
普通 Kotlin 函数
↓
控制流图
↓
try/finally
↓
JVM 字节码
suspend 函数
↓
控制流图
↓
状态机
↓
Continuation
↓
恢复与退出
表面上这是两个完全不同的话题。
但它们背后研究的其实都是同一个问题:
当一个程序存在多个可能的控制流路径时,编译器如何保证语言定义的语义在每一条路径上都成立?
这也是从"会写 Kotlin"走向"真正理解 Kotlin"过程中,非常值得建立的一种思维方式。