从 finally 理解程序的控制流:它为什么不是 catch 后面的代码?

从 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
                   │
                   ↓
                退出作用域

在合适的语言控制结构中,breakcontinue 等退出路径也可能需要经过 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 异常表、athrowireturn,以及 Kotlin 编译器如何处理 try/finally 的一个非常好的入口。

再往前一步,还可以把这个问题和 Kotlin 协程联系起来:

kotlin 复制代码
普通 Kotlin 函数
      ↓
控制流图
      ↓
try/finally
      ↓
JVM 字节码

suspend 函数
      ↓
控制流图
      ↓
状态机
      ↓
Continuation
      ↓
恢复与退出

表面上这是两个完全不同的话题。

但它们背后研究的其实都是同一个问题:

当一个程序存在多个可能的控制流路径时,编译器如何保证语言定义的语义在每一条路径上都成立?

这也是从"会写 Kotlin"走向"真正理解 Kotlin"过程中,非常值得建立的一种思维方式。

相关推荐
随遇丿而安1 小时前
第15周:Service 全功能 + 后台优化
android
YXL1111YXL1 小时前
续体和状态机 —— suspend 函数的 CPS 变换
android·kotlin
WAsbry1 小时前
Flow数据模型:冷流、状态、事件与共享策略
android
WAsbry1 小时前
深入理解 Kotlin suspend:挂起语义、状态机与恢复调度
android
WAsbry1 小时前
Flow在Android中的完整落地:异常、重试与生命周期
android
WAsbry1 小时前
协程共享状态:Atomic、Mutex、Semaphore与状态封闭
android
WAsbry1 小时前
Flow执行模型:上下文保持与生产消费节奏
android
WAsbry1 小时前
协程任务的组织方式:launch、async、withContext与结构化并发
android
WAsbry1 小时前
本文区分原子变量、复合操作、并发数量与状态所有权四类问题,比较Atomic、Mutex、Semaphore和状态封闭的适用边界,并结合计数、缓存和限流场景,建立
android