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

在合适的语言控制结构中,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"过程中,非常值得建立的一种思维方式。

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work4 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work4 天前
ch21 签名、校验与发版
kotlin
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui