为什么 'throws' 在 Swift 中不安全?

Why is 'throws' not type safe in Swift?

我对Swift最大的误解是throws关键字。考虑以下代码:

func myUsefulFunction() throws

我们无法真正理解它会抛出什么样的错误。我们唯一知道的是它可能会抛出一些错误。了解错误可能是什么的唯一方法是查看文档或在运行时检查错误。

但这不是违背Swift的天性吗? Swift 拥有强大的泛型和类型系统来使代码具有表现力,但感觉 throws 恰恰相反,因为你无法通过查看函数签名来了解任何有关错误的信息。

为什么会这样?还是我错过了一些重要的东西并且误解了这个概念?

这个选择是经过深思熟虑的设计决定。

他们不希望出现不需要像 Objective-C、C++ 和 C# 中那样声明异常抛出的情况,因为这使得调用者必须要么假设所有函数都抛出异常,要么包括样板来处理异常这可能不会发生,或者只是忽略例外的可能性。这些都不是理想的,第二个使异常不可用,除非你想终止程序,因为你不能保证调用堆栈中的每个函数在堆栈展开时都正确地释放了资源。

另一个极端是你提倡的,每一种抛出的异常都可以声明。不幸的是,人们似乎反对这样做的结果,即您有大量的 catch 块,因此您可以处理每种类型的异常。因此,例如,在 Java 中,他们将抛出 Exception 将情况减少到与我们在 Swift 中相同的情况,或者更糟的是,他们使用未经检查的异常,因此您可以完全忽略该问题。 GSON 库是后一种方法的一个例子。

We chose to use unchecked exceptions to indicate a parsing failure. This is primarily done because usually the client can not recover from bad input, and hence forcing them to catch a checked exception results in sloppy code in the catch() block.

https://github.com/google/gson/blob/master/GsonDesignDocument.md

这是一个非常糟糕的决定。 "Hi, you can't be trusted to do your own error handling, so your application should crash instead".

就我个人而言,我认为 Swift 取得了正确的平衡。您必须处理错误,但不必为此编写大量的 catch 语句。如果他们再进一步,人们会想办法破坏这个机制。

设计决定的完整理由在 https://github.com/apple/swift/blob/master/docs/ErrorHandlingRationale.rst

编辑

似乎有些人对我所说的某些事情有疑问。所以这里有一个解释。

程序可能抛出异常的原因有两大类。

  • 程序外部环境中的意外情况,例如文件 IO 错误或格式错误的数据。这些是应用程序通常可以处理的错误,例如通过向用户报告错误并允许他们选择不同的操作过程。
  • 编程中的错误,例如空指针或数组绑定错误。解决这些问题的正确方法是让程序员更改代码。

通常不应捕获第二种类型的错误,因为它们表明对环境的错误假设可能意味着程序的数据已损坏。我无法安全继续,所以你必须中止。

第一类错误通常可以恢复,但为了安全恢复,每个栈帧都必须正确展开,这意味着每个栈帧对应的函数必须知道它调用的函数可能会抛出异常异常并采取措施确保在抛出异常时始终如一地清理所有内容,例如,使用 finally 块或等效块。如果编译器不支持告诉程序员他们忘记了为异常计划,程序员将不会总是为异常计划,并且会编写泄漏资源或使数据处于不一致状态的代码。

之所以 gson 态度如此骇人听闻,是因为他们说你无法从解析错误中恢复(实际上,更糟糕的是,他们告诉你你缺乏从解析错误中恢复的技能)。这是一个荒谬的断言,人们总是试图解析无效的 JSON 文件。如果有人 select 错误地输入了 XML 文件,那么我的程序崩溃是好事吗?不,不是。它应该报告问题并要求他们 select 一个不同的文件。

顺便说一句,gson 只是一个例子,说明为什么对可以从中恢复的错误使用未经检查的异常是不好的。如果我确实想从某个 select 正在处理 XML 文件的人那里恢复,我需要捕获 Java 运行时异常,但是哪些异常呢?好吧,我可以查看 Gson 文档来找出答案,假设它们是正确的并且是最新的。如果他们使用已检查的异常,API 会告诉我期望哪些异常,如果我不处理它们,编译器会告诉我。

我是 Swift 中输入错误的早期支持者。这就是 Swift 团队说服我错了的方式。

强类型错误很脆弱,可能导致 API 进化不佳。如果 API 承诺只抛出恰好 3 个错误中的一个,那么当第四个错误条件在以后的版本中出现时,我有一个选择:我以某种方式将它埋在现有的 3 个中,或者我强制每个调用者重写他们的错误处理代码来处理它。由于它不在最初的 3 中,它可能不是一个很常见的情况,这给 API 施加了很大的压力,不要扩大他们的错误列表,特别是当一个框架已经长期广泛使用时时间(想想:基金会)。

当然,对于开放式枚举,我们可以避免这种情况,但是开放式枚举实现了 none 强类型错误的目标。这基本上又是一个未类型化的错误,因为您仍然需要 "default."

你可能仍然会说 "at least I know where the error comes from with an open enum," 但这往往会使事情变得更糟。假设我有一个日志系统,它尝试写入并收到 IO 错误。 return 应该是什么? Swift 没有代数数据类型(我不能说 () -> IOError | LoggingError),所以我可能不得不将 IOError 包装成 LoggingError.IO(IOError)(这会强制每一层明确地重新包装;你不能经常有 rethrows)。即使它确实有 ADT,你真的想要 IOError | MemoryError | LoggingError | UnexpectedError | ... 吗?一旦你有几层,我就会对一些底层 "root cause" 进行一层又一层的包装,这些底层 "root cause" 必须痛苦地解开才能处理。

你打算怎么处理?在绝大多数情况下,catch 块是什么样的?

} catch {
    logError(error)
    return
}

Cocoa 程序(即 "apps")深入挖掘错误的确切根源并根据每个精确案例执行不同操作的情况极为罕见。可能有那么一两个痊愈了,其他的都是你无论如何也无能为力的事情。 (这是 Java 中的一个常见问题,检查异常不仅仅是 Exception;这并不是说以前没有人走过这条路。我喜欢 Yegor Bugayenko's arguments for checked exceptions in Java,这基本上是他的争论点首选 Java 完全练习 Swift 解决方案。)

这并不是说在某些情况下强类型错误会非常有用。但是对此有两个答案:首先,您可以自由地使用枚举自行实现强类型错误,并获得很好的编译器强制执行。不完美(你仍然需要一个默认的 catch outside switch 语句,但不是 inside),但是如果你自己遵循一些约定就很好了.

其次,如果这个用例被证明很重要(而且它可能),那么在不破坏需要相当通用的错误处理的常见情况的情况下,稍后为这些情况添加强类型错误并不困难。他们只会添加语法:

func something() throws MyError { }

调用者必须将其视为强类型。

最后,要使强类型错误发挥作用,Foundation 需要抛出它们,因为它是系统中最大的错误产生者。 (与处理 Foundation 生成的文件相比,你真正从头开始创建 NSError 的频率是多少?)这将是对 Foundation 的大规模检修,并且很难与现有代码和 ObjC 保持兼容。因此,输入错误需要非常出色地解决非常常见的 Cocoa 问题,才值得考虑作为默认行为。再好不过了(更不用说有上述问题了)。

所以 none 这就是说,在所有情况下,非类型化错误都是 100% 完美的错误处理解决方案。但这些论点使我相信,今天进入 Swift 是正确的方法。