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 了吧!

相关推荐
悠哉清闲9 分钟前
Android Studio C++/JNI/Kotlin 示例 三
c++·kotlin·android studio
Kiri霧6 小时前
细谈kotlin中缀表达式
开发语言·微信·kotlin
Wy. Lsy2 天前
Kotlin基础学习记录
开发语言·学习·kotlin
Kiri霧2 天前
Noting
android·开发语言·kotlin
用户1982333188403 天前
让PAG动画在富文本中动起来
android·kotlin
hudawei9963 天前
kotlin中withContext,async,launch几种异步的区别
android·开发语言·kotlin
消失的旧时光-19433 天前
Kotlin 常用语法糖完整整理
android·开发语言·kotlin
每次的天空3 天前
Android-重学kotlin(协程源码第一阶段)新学习总结
开发语言·学习·kotlin
金銀銅鐵4 天前
[Kotlin] 单例对象是如何实现的?
java·kotlin