tryCatch还是runCatch,这是一个问题

在编写 Kotlin 代码时,你最终会遇到处理异常的不同方法,或者更笼统地说,处理可能会失败的函数的不同方法。

一种常见的方法是使用 try-catch 块,就像在许多其他编程语言(例如 Java)那样。

然而,Kotlin 引入了一些其他便捷方法,这些方法能让异常处理更加灵活和简洁。其中一种就是 runCatching

在本文中,我们将详细探讨 KotlinrunCatching。我们会分析它与传统 try-catch 块有何不同,以及它是否真的带来了实际好处,还是说仅仅是新瓶装旧酒。通过这次探讨,应能帮助你判断 runCatching 是否适合你的编码风格或项目需求。

try-catch:传统的错误处理

在讨论 runCatching 之前,我们看下传统的 Kotlin 错误处理方式。

传统上,错误处理通常围绕代码中出现的异常展开,在 Kotlin 中处理异常的方式与在 Java 中相同:

kotlin 复制代码
try {
    // 可能会抛出异常的代码
} catch (e: Exception) {
    // 处理异常
} finally {
    // 无论是否抛出异常都应该运行的代码
}

try-catch 是怎么用的呢?

  • try 块:将你认为可能会抛出异常的代码放在 try 块中。
  • catch 块:如果抛出了某种类型的异常,程序流程会跳转到这里来处理错误。你可以选择记录异常、重新抛出异常,或者以其他方式处理它。
  • finally 块:这个块是可选的。无论 try 块或 catch 块中发生了什么(除非完全终止应用程序),finally 块中的代码都会运行。通常,这用于清理资源,比如关闭数据库连接。

如果你熟悉 IO 操作的话,那么你一定知道 try-catch 的用法,尤其不要忘了在 finally 块中关闭流。

runCatching:新颖的错误处理

Kotlin 提供了一些高阶函数来处理可能会失败的操作,其中就有 runCatching。从这个名字我们就能略知一二:"run" 指的是执行某些代码,"catching" 指的是捕获这些代码可能抛出的异常。

所以从根本上说,runCatching 是一个执行给定代码块并封装结果的函数。如果你的代码块运行失败,runCatching 会捕获异常;如果运行成功,它则会捕获成功的结果。

你可以将 runCatching 视为 Kotlin 采用的一种更具函数式风格的错误处理方式。它返回一个 Result<T>,在很多 Kotlin 版本中,Result<T> 本质上是一个密封类,它既可以保存成功的结果(Success),也可以保存失败的结果(Failure)。

下面是一个 runCatching 的简单示例:

kotlin 复制代码
val result: Result<Int> = runCatching {
    // 一些可能会抛出异常的代码
    10 / 2
}

在这段代码中,如果表达式 10 / 2 执行成功,result 将保存 Success(5)。如果执行过程中抛出异常,result 将保存 Failure(e),其中 e 代表具体的异常。

这个方法妙就妙在返回的 result 始终是 Result<Int> 类型,因此在紧接着的代码中你不需要使用不同的代码块来处理不同情况。

接下来,你可以以一种更加声明式的风格来使用 result 处理成功或失败的情况。我们给出复杂一点的例子,让我们尝试一些更贴近文件读取场景的操作:

kotlin 复制代码
import java.io.File
import java.io.IOException

fun readFileUsingRunCatching(filePath: String): Result<String> {
    return runCatching {
        val file = File(filePath)
        file.readText()
    }
}

fun main() {
    val result = readFileUsingRunCatching("/path/to/file.txt")
    result
        .onSuccess { content -> println("文件内容: $content") }
        .onFailure { exception -> println("读取文件失败: ${exception.message}") }
}

上述代码中,readFileUsingRunCatching 函数返回一个 Result<String>

  • 如果文件读取成功,结果将是包含文件内容的 Success
  • 如果读取失败,结果将是包含异常的 Failure

然后,在 main 函数中,我们可以通过链式调用来处理成功或失败的情况:

  • onSuccess {...} 在操作成功时调用。
  • onFailure {...} 在发生异常时调用 。

注意,在读取文件的函数中,我们避免编写显式的 try-catch 块。我们仅依靠 runCatching 来捕获任何抛出的异常。

这种风格可以让你的代码看起来更加简洁流畅,尤其当你喜欢链式函数调用或者采用更具函数式风格的编程时。

新老的碰撞

现在我们对 runCatching 的工作原理有了大致的了解,让我们来探讨一下:runCatching 仅仅是新瓶装旧酒,还是说它真的能带来额外的好处?

相似之处:

  • 两者都能捕获异常:无论你使用 try-catch 还是 runCatching,它们都能在出现问题时,捕获异常。
  • 两者都不能消除异常的可能性:即使 runCatching 返回一个 Result 对象,你仍然需要处理代码某些部分可能失败的情况。
  • 两者都能处理清理工作:你可以通过对 Result 对象进行链式调用来重现一些原本放在 finally 块中的逻辑(比如使用 onFailure 来释放资源)。

差异

  • 返回类型:try-catch 本身不会生成一个可以存储和传递的显式类型。当然开发者可以手动返回不同的值(比如失败时返回 null,成功时返回实际值),但这需要你自己去实现;runCatching 始终返回一个 Result<T>,这意味着你得到了一个标准的包装对象,你可以传递它、对其进行链式调用,或者使用 maprecover 等扩展函数对其进行转换。

  • 代码可读性和函数式风格:使用 try-catch 时,如果存在多个可能的失败点,代码可能会变得更加嵌套或杂乱。最终你可能会编写多个 try-catch 块,或者将逻辑堆积在一个块中,编写多个 catch 块;使用 runCatching 时,代码流程通常更具线性。因为在包装操作之后,你可以在 Result 上链式调用方法。它更具声明性,能清楚地展示如何处理成功和失败情况,以及如何转换结果或从错误中恢复。

  • 链式调用和转换:try-catch 通常意味着 "停止正常流程,进入 catch 块,然后继续执行";runCatching 能很好地与转换操作集成。你可以这样做:

    kotlin 复制代码
    runCatching { /* 操作 */ }
      .map { /* 成功时进行转换 */ }
      .recover { /* 失败时进行回退 */ }
      .onSuccess { /* 执行某些操作 */ }
      .onFailure { /* 执行某些操作 */ }

    通过这种方法,你可以将操作以管道形式链式连接,而不是使用块来控制流程。

  • 冗长程度,如果只有一两行可能抛出异常的代码,try-catch 相当容易理解。然而,如果你要进行大量的转换操作,或者传递结果,最终可能会得到多个嵌套的 try-catch 块,或者一个带有复杂逻辑的单一代码块;而 runCatching 有助于减少样板代码,你不再需要将每一步都包装在 try 块中。相反,你只需将整个操作包装一次,并将所有情况作为一个 Result 来处理。

深入了解 Result<T>

runCatching 会返回一个 Result<T>

Kotlin 中,这个 Result 类既可以存储一个值(表示成功),也可以存储一个异常(表示失败)。它旨在帮助你以一种统一的方式处理这些结果,而不是让逻辑分散。

Result<T> 的常用方法:

  • isSuccess / isFailure:用于检查结果是成功还是失败的布尔值。
  • getOrNull():如果操作成功,返回对应的值;如果失败,则返回 null
  • exceptionOrNull():如果操作失败,返回抛出的异常;如果成功,则返回 null
  • onSuccess(action: (T) -> Unit): Result<T>:如果结果是成功,运行给定的 lambda 表达式。
  • onFailure(action: (Throwable) -> Unit): Result<T>:如果结果是失败,运行给定的 lambda 表达式。
  • map(transform: (value: T) -> R): Result<R>:如果操作成功,对值进行转换;如果失败,则传播失败结果。
  • recover(transform: (Throwable) -> T): Result<T>:如果操作失败,使用给定的 lambda 表达式提供一个回退值,从而有效地从错误中恢复。

你可以链式调用这些方法,创建一个操作流程,无论是处理数据(操作成功时),还是处理错误(操作失败时)。这与传统的 try-catch 有很大不同,传统的 try-catch 通常在捕获到异常或完成 try 块后就结束了。

我们给出几个链式调用的示例。

假设你有一个从服务器获取用户对象的函数。如果获取到的用户对象有效,我们希望对其进行处理;否则,记录错误并返回一个默认用户。即使在恢复过程中出现问题,getOrNull 也会返回 null

kotlin 复制代码
val userResult = runCatching { fetchUserFromServer() }
   .map { user ->
        // 转换用户对象,例如修剪字段或进行验证
        user.copy(name = user.name.trim())
    }
   .recover { exception ->
        // 如果出现问题,使用一个默认用户进行恢复
        println("Error fetching user: ${exception.message}")
        User(id = -1, name = "Guest")
    }

val user = userResult.getOrNull()
println("User: $user")

这种方式可以使你的逻辑保持在一个连贯的流程中,而无需使用多个嵌套块。

runCatching 之后,我们在成功时进行转换,失败时用默认值。

下面来看一下如何使用 onSuccessonFailure

kotlin 复制代码
val userResult = runCatching {
    fetchUserFromServer()
}.map { user ->
    user.copy(name = user.name.trim())
}

userResult
   .onSuccess { user ->
        // 如果一切成功,打印修剪后的用户
        println("User: $user")
    }
   .onFailure { exception ->
        // 如果出现问题,记录错误并打印默认用户
        println("Error fetching user: ${exception.message}")
        val fallback = User(id = -1, name = "Guest")
        println("User: $fallback")
    }

使用 try/catch/finally 块实现同样功能的代码如下:

kotlin 复制代码
fun main() {
    var user: User? = null
    try {
        val fetchedUser = fetchUserFromServer()
        val trimmedUser = fetchedUser.copy(name = fetchedUser.name.trim())
        user = trimmedUser  // 如果一切顺利,我们存储修剪后的用户
    } catch (exception: Exception) {
        println("Error fetching user: ${exception.message}")
        user = User(id = -1, name = "Guest")  // 失败时返回默认用户
    } finally {
        println("User: $user")
    }
}

在使用 try-catch-finally 时,经常涉及资源管理的问题。例如,你在 try 块中打开了一个文件或建立了一个网络连接,无论发生什么,你可能都希望在 finally 块中关闭它。

这在 runCatching 中是如何实现的呢?

在很多情况下,Kotlin 有自己处理资源的模式,例如用于处理可关闭资源的 use 函数,它可以自动进行清理。但如果你确实需要一些自定义的 "清理" 逻辑,也有以下几种选择:

  1. 仅在失败时清理(在 onFailure 中),如果你只需要在出现问题时清理资源,可以在 onFailure 中进行操作。假设我们有一个文件流,只有在读取失败时才需要丢弃或进行特殊处理:

    kotlin 复制代码
    import java.io.FileInputStream
    import java.io.IOException
    
    fun readWithCleanupOnFailure(filePath: String) {
        val stream = FileInputStream(filePath)
    
        runCatching {
            // 有风险的操作
            val data = stream.readBytes()
            println("Read ${data.size} bytes")
        }.onFailure { exception ->
            println("Something went wrong: ${exception.message}")
            // 如果只在失败时关闭流
            try {
                stream.close()
            } catch (closeException: IOException) {
                println("Error closing stream on failure: ${closeException.message}")
            }
        }
        // 如果没有异常,函数执行完后流仍保持打开状态
        // (只有在你确实希望如此时才使用这种方法)
    }

    我们首先打开 FileInputStream。在 .onFailure 中处理读取过程中可能出现的任何异常。 在这个块中关闭流。 如果一切成功,runCatching 执行完后流仍保持打开状态(通常你会在其他地方或以其他方式处理它)。

    如果你的资源只需要在出错时进行清理时,这种方法很有用。不过,通常情况下,你无论如何都需要关闭资源。

  2. 无论如何都进行清理

    • 使用 Kotlinuse 扩展:Kotlinuse 函数是关闭实现了 Closeable 接口的资源的最常用模式。当 lambda 表达式结束时,它会自动关闭资源,无论操作成功与否:

      kotlin 复制代码
      import java.io.FileInputStream
      import java.io.IOException
      
      fun readWithUse(filePath: String) {
          runCatching {
              FileInputStream(filePath).use { stream ->
                  val data = stream.readBytes()
                  println("Read ${data.size} bytes")
                  // 流会自动关闭
              }
          }.onFailure { exception ->
              println("Something went wrong: ${exception.message}")
          }
      }

      use 确保在块结束后(包括抛出异常的情况)总会调用 stream.close()。 这就无需手动编写 finally 块或手动调用关闭方法。

    • 在整个结果链之后进行清理:你必须在 runCatching 之前打开资源,然后在之后无条件关闭它:

      kotlin 复制代码
      import java.io.FileInputStream
      import java.io.IOException
      
      fun readWithSeparateClose(filePath: String) {
          val stream = FileInputStream(filePath)
      
          val result = runCatching {
              val data = stream.readBytes()
              println("Read ${data.size} bytes")
          }
      
          // 执行无条件清理,此处依然可以使用 runCatching !
          try {
              stream.close()
          } catch (e: IOException) {
              println("Failed to close stream: ${e.message}")
          }
      
          // 现在处理成功或失败的情况
          result.onFailure {
              println("Operation failed: ${it.message}")
          }.onSuccess {
              println("Operation succeeded.")
          }
      }

      runCatching 之前打开流。 在 runCatching 中执行有风险的操作。 然后在普通的 try/catch 中无条件关闭流。 最后,检查 Result 来处理成功或失败的情况。

      这类似于 finally 块,但使用了两个不同的步骤:runCatching 用于捕获错误,随后的块用于确保资源被清理。

    • 使用 also {... } 进行清理:

      kotlin 复制代码
      import java.io.FileInputStream
      import java.io.IOException
      
      fun readWithAlso(filePath: String) {
          val stream = FileInputStream(filePath)
      
          val result = runCatching {
              // 有风险的操作
              val data = stream.readBytes()
              println("Read ${data.size} bytes")
              // 返回某些值或进行更多处理...
          }.also {
              // 无论上述操作成功还是失败,这个块都会运行。
              // 非常适合无条件清理或日志记录:
              try {
                  stream.close()
              } catch (e: IOException) {
                  println("Error closing stream: ${e.message}")
              }
              println("Cleanup or logging done here.")
          }
      
          result
             .onFailure { println("Operation failed: ${it.message}") }
             .onSuccess { println("Operation succeeded.") }
      }

      val result = runCatching {... }:我们执行一个可能成功(读取字节)或失败(抛出异常)的块。操作结果会包装在一个 Result 中。

      .also {... }also 函数接受一个 lambda 表达式,该表达式作用于 Result,但重要的是,无论操作成功与否,它总是会运行。它会以 Result 作为接收者被调用,所以我们可以在这里进行资源关闭或最终的日志记录。 这类似于传统 try-catch-finally 链中的 finally 块。 与只在一种情况下运行的 onFailureonSuccess 不同,also 是无条件运行的。

      result.onFailure {... }.onSuccess {... }:在 also 中执行完无条件逻辑后,如果我们还想进一步记录日志或转换结果,可以继续处理成功或失败的情况。

那么,何时使用 also 进行清理呢?

  • 无条件的最终操作:如果你有一个资源无论发生错误与否都必须清理,而且你不想在 runCatching 中嵌入 try-finally,那么使用 also {... } 是一种简洁的方式,可以在结果生成后立即将清理逻辑集中处理。

  • 日志记录:你可以使用 also {... } 来记录 "操作完成" 或者进行一些指标跟踪,因为它在成功和失败路径中都会被保证执行。

runCatching的最佳实践

保持代码可读性

runCatching 可以形成非常优雅的链式调用,但也可能导致极其冗长的调用链,从而难以理解。如果你的调用链超过了几步,考虑将其拆分为命名恰当的函数或扩展方法。这样,阅读你代码的人看到的将是一个逻辑清晰的流程,而不是一长串无休止的 lambda 表达式。

避免滥用链式调用

有时,直接使用 try-catch 会更易读。如果你只有一步操作可能失败,并且只想记录错误,那么简单的 try-catch 可能是最佳选择。当你想要存储结果或传递结果以进行进一步转换时,runCatching 的优势才会显现出来。

牢记资源管理

如果你在处理必须关闭的资源,如文件流或数据库连接,依靠 Kotlin 内置的结构,如 use 函数。如果你需要无条件清理,可以考虑编写一个小函数,在 runCatching 外部(或之后)进行清理,或者将清理逻辑放在 .also 块中。不要试图将 finally 的概念直接硬塞进 runCatching 中(本身并没有 finally 的概念),要接受 Kotlin 处理资源的方式。

总结

Kotlin 不断朝着更安全、更具表现力的习惯用法发展,以降低隐藏错误的风险。runCatching 就是这种演变的一个典型例子:它将传统的概念(可能抛出异常的操作)包装在一个现代的结构(Result<T>)中,使你能够在统一的流程中进行链式调用、转换和处理错误。

如果你曾发现自己的代码中充满了重复的 try-catch 块,或者通过返回 null 或特殊标志来表示错误,那么 runCatching 提供了一种更优雅的解决方案。

如果你是 Kotlin 的新手,可以通过一些 runCatching 的小例子来练习,看看它与 try-catch 有何不同。

如果你是 Kotlin 开发团队的负责人,制定一个编码标准,让每个人都知道何时使用 runCatching,何时使用传统的 try-catch。代码库的一致性对于可读性至关重要。

希望这篇深入的讨论不仅澄清了 runCatchingtry-catch 之间的区别,还阐明了 Result<T> 是如何构建一种函数式的错误处理风格,以及你在哪里仍然可以添加最终操作来模仿传统的 finally 块。

现在的你,应该已经完全了解 runCatching 了吧!

相关推荐
FunnySaltyFish11 小时前
什么?Compose 把 GapBuffer 换成了 LinkBuffer?
算法·kotlin·android jetpack
Kapaseker17 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
Kapaseker2 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
A0微声z4 天前
Kotlin Multiplatform (KMP) 中使用 Protobuf
kotlin
alexhilton4 天前
使用FunctionGemma进行设备端函数调用
android·kotlin·android jetpack
lhDream4 天前
Kotlin 开发者必看!JetBrains 开源 LLM 框架 Koog 快速上手指南(含示例)
kotlin
RdoZam4 天前
Android-封装基类Activity\Fragment,从0到1记录
android·kotlin
Kapaseker5 天前
研究表明,开发者对Kotlin集合的了解不到 20%
android·kotlin
糖猫猫cc5 天前
Kite:两种方式实现动态表名
java·kotlin·orm·kite
如此风景6 天前
kotlin协程学习小计
android·kotlin