错误处理原则及其在Swift中的应用
四点2022-03-03

本文由两种内容组成,一部分是错误处理的思路,第二种是这种思路如何使用Swift来实现。

前言

错误处理是程序员最重要-也是最容易忽视的话题之一,程序员经常花费了大量的经历在打印大量日志,完善监控系统上面,但是往往对API返回的错误简单处理,甚至于直接忽略。比如对于OC中移除一个文件:

[[NSFileManager defaultManager] removeItemAtPath:path error:nil]

相信大家见过很多次类似的代码,程序员简简单单地将第二个参数的错误信息忽略,毕竟移除一个文件出错的概率可能很小,因此这种代码的行为具备统计意义上良好的正确性,这显然有损于真正的正确性。更重要的是,这里的错误处理非常丑陋:

NSError *removeError = nil;
[[NSFileManager defaultManager] removeItemAtPath:path error:&removeError];

if ( removeError ) {
    // 错误处理
}

仅仅一行代码的主逻辑,往往会变成10行以上,这些错误处理和主流程混在一起,导致代码的可读性、可维护性和正确性直线下降。

因此,我们来探讨一下应该如何进行错误处理。本文由两种内容组成,一部分是错误处理的思路,即应该如何进行错误处理,这是和具体的语言无关的,第二种是这种思路如何使用Swift来实现,这两种内容组合成了各小节的内容。

类型系统与程序的正确性

程序员编写错误处理的代码是为了提升程序的正确性,实际上,为了提升程序的正确性,我们还有另外三个方向的尝试,第一个方向是应用数理逻辑的框架理论(如Hoare逻辑、代数说明语言、指称语义等),这些非常抽象,笔者了解不多,不敢妄言;第二个方向是实时监测(如日志、crash、错误监控),这个非常容易理解,实际生产过程中也有很多应用;第三个方向就是类型系统,类型系统集成于编译器,它除了自动化地发现错误之外,还可以与错误处理配合,提升错误处理的可靠性和易用性。

因此,在正文的第一小节,笔者想先谈一谈类型系统。

按照Benjamin C. Pierce在《Types and Programming Languages》中的定义,类型系统是指一种对词语进行分类从而证明某些程序行为不会发生的语法手段。

事实上,这个定义仍然存在一些争议。读者可能会对此感到诧异。其实了解一点科学史就会明白,核心概念缺乏公认的定义是很常见的。作为大物理学家和大数学家的牛顿,对于力和无穷小的概念都没有很好的定义,这两者分别是万有引力定律和微积分的核心概念,生物科学不清楚如何在分子层面定义基因,心理学家一直在研究思维,对于思维也没有明确的定义。随着现象逐渐被理解,问题逐渐被回答,这些基本概念逐渐提炼清晰,学科也在这个过程中越来越成熟。

而且一个学科的基本概念常常要么没有公认的定义,要么看起来简单至极,人们往往从难易程度的直观角度出发忽视这些简单概念内涵的深刻性,因为一个简单而深刻的概念定义不仅仅需要解决技术性的问题,往往还需要非凡的想象力。狭义相对论的基本方程组是由洛伦兹先提出,爱因斯坦主要是重新定义了时空的概念,狭义相对论归功于爱因斯坦,因为新的时空观完全改变了人们对物理学的理解。就逻辑学而言,逻辑和函数是知识的两面,我们今天进行这个推导只需要几分钟,但实际上人类花了2000多年,从亚里士多德到弗雷格,弗雷格将谓词推广为关系,将函数引入逻辑学,看起来平平无奇,这两个概念却大大增强了逻辑的形式表达能力,并催生了计算机科学的诞生。

编程语言的类型系统通常根据强弱、静态动态划分为四个维度,静态动态的边界在于编译期还是运行期,强弱类型的定义现在还不够严格,不严谨的说,强弱类型系统可以通过是否允许隐式的类型转换来划分,这样Java、Swift、Haskell是静态强类型,C和C++则属于静态弱类型,Lisp、Python是动态强类型,js是动态弱类型。

对于类型系统的好恶因人而异,就我个人而言,我希望在生产环境的代码采用静态强类型系统以提升正确性,在脚本工具中采用动态弱类型系统以提升效率。

对于动态的类型系统而言,由于无法在编译期拒绝错误的程序,只能在运行期进行拒绝,这往往表现为崩溃。因此将动态的类型系统称为“运行期检查”也许更合适,下文中的类型系统统一指静态类型系统。

程序中的错误分为trapped error(不处理会crash)和untrapped error(不会crash但行为不确定,如缓冲区溢出),一个类型安全的类型系统能消除的错误常常称为forbidden behaviors,它必须包含所有的untrapped error,可能会包含一部分trapped error

类型系统能在编译期报错,比如向接受A类型参数的API传入B类型对象,调用一个对象没有的方法等。最明显的优点就是能更早、更精确的发现错误,在开始使用Swift的时候,其类型系统暴露的错误之多令我惊讶,而一旦运行起来,Runtime Error之少同样令我惊讶。因此类型系统是提升程序正确性的重要手段,某种意义上,类型系统也是一种错误处理,只不过它处理的错误不需要代码运行,而是代码语义的错误(除此之外,类型接口的设计还提供了抽象、自文档化的好处)。

类型系统需要类型信息来计算和推理,由于ML的成功经验,一个良好的类型推断系统能够省下绝大多数类型标注工作,从而降低程序员的工作量,新兴的高级语言几乎无一例外的吸收了这个特性。

显然,类型系统并非万能,像IO失败,以及业务逻辑定义的错误,类型系统无法在编译期报错,也无法形式化的处理,在程序员手动处理这些错误的过程中,我们希望能够得到类型系统的帮助来保证正确性,最好还能提升易用性。

错误处理的目标

一个好的错误处理模型,应该满足下面4个条件:

  1. 可靠性,可靠性是指应该保证所有应该处理的错误都得到正确的处理,而没有错误的地方都不需要添加额外的代码;
  2. 易用性,对程序员来说,错误的抛出、传递、处理过程应该方便、容易理解,并且尽可能地减少对代码可读性和可维护性的损害;
  1. 性能:对于没有发生错误的场景,应该尽可能减少对性能的影响,如果发生了错误,则应该是按需付费。
  2. 快速定位:错误应该尽可能多的包含诊断和定位信息。

相对于Joe Duffy的版本我没有考虑并发和协调,主要原因是并发应该是整个API设计需要考虑的时候,包括参数、内部状态、返回值和错误统一考虑的,而协调在我看来则是易用性的一部分,如果错误处理和代码的其他部分不协调,那一定也不好用。

错误处理的方案

首先,应该将错误分为不可恢复的错误和可恢复的错误,针对这两种错误,目前业界主要有四种错误处理的方案:

  1. 运行时错误
  2. 错误码
  1. 异常
  2. 代数数据类型

运行时错误

对于不可恢复的错误,比如不正确的类型转换、数组越界、零除错误、内存不足、栈溢出等等,这里即使通知到调用方,调用方也没有恢复的办法,因此业界一般使用快速失败Fail-Fast模型,这在Swift中是调用fatalError函数(或者abort\exit函数):

public func fatalError(_ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line) -> Never

这个函数的返回值类型为Never,这个类型在PLT中称为bottom-type,该类型的外延为空集,即不存在一个该类型的实例,常用来表示一个不终止的计算。

对于不可恢复的错误,Fail-Fast放在服务端是合适的,用在客户端则需要进一步的细分,尤其是手淘这样拥有众多业务模块的超级客户端,一个局部的错误影响面可能并不大,比如没有影响到核心交易链路的情况。因为服务端可以在进程崩溃后重启,App的崩溃严重影响用户体验,很大可能不会马上获得重启的机会。但是对于可恢复和不可恢复错误的分类仍然是重要的,对于不可恢复的错误在Debug包或者预发包中执行Fail-Fast方案,在生产环境中先上报到服务端,然后根据可能的影响范围选择是否让其崩溃,当然,这可以看成是一种弱化了的Fail-Fast

错误码和异常

关于错误码和异常的讨论从来没停止过,笔者建议看一下微软Midori操作系统架构师Joe Duffy在总结他对于十多年错误处理经验的博客http://joeduffyblog.com/2016/02/07/the-error-model/(中文版https://zhuanlan.zhihu.com/p/55835404 ),讨论深入且细致,核心论点如下:

  1. 错误码的优势是容易理解,可以显式处理,缺点是,性能可能会受到影响(分支预测失败)、可用性(封装数据结构,旁路返回,以及很多if,代码读写体验差)、最重要的是调用方经常会忘记检查错误码(比如本文开头的例子),从而使得程序的正确性降低。
  2. 异常分为Unchecked ExceptionsChecked ExceptionsUnchecked Exceptions最典型的就是Java的空指针,Unchecked Exceptions的问题在于任何函数都有可能抛出异常,没有任何标注,类型系统也无法分析,因此可靠性和易用性都很差。Checked Exceptions在API接口中必须声明,编译器要求调用方必须显式处理,可以选择处理或者继续上抛,这满足了可靠性,而异常基于宿主语言自身特性可以做的比错误码易用性更好(因为不受语言公开能力的限制,可以使用非公开的特性)。Joe Duffy重点分析了Java的异常机制,有兴趣的读者可以自行阅读。

让我们来看看Swift中使用异常的效果:

func getNum() throws -> Int {
    let a = Int.random(in: 0...10)
    if a < 9 {
        return a
    } else {
        throw MPError.RequestFail
    }
}

func work() throws {
    let num = try getNum()
    //do somework with num withoud if - condition code to check num or error
}

可以看到,对于调用方来说异常要么被传递,要么被显示处理,可靠性得到了保证。中间的调用方如果不需要处理异常,仅仅使用try就可以,避免了对主流程的影响,易用性非常好。总的来说,异常比错误码在可靠性和易用性都可以做的更好。

因此,对于可恢复的错误,应该使用静态受检查异常,需要注意的是,如果异常需要包含栈信息,则错误发生时性能就会比较差,但是带来了诊断和定位的方便,因此这里需要权衡。在Swift中,异常在Swift中体现为标记throws的函数和try的调用方式,事实上Swift中异常和错误共用Error协议类型,区别仅仅是return还是throw。

值得一提的是,Kotlin中不支持Checked Exception,官网给出的解释是:

Examination of small programs leads to the conclusion that requiring exception specifications could both enhance developer productivity and enhance code quality, but experience with large software projects suggests a different result – decreased productivity and little or no increase in code quality.

Kotlin开发团队的看法是,在小规模的程序中能提高生产力和代码质量,在大规模的程序中会降低生产力和代码质量,这是一个流传甚广的伪逻辑。我理解这个基本上和微软首席架构师、C#之父Anders Hejlsberg的2003访谈中的逻辑(https://www.artima.com/articles/the-trouble-with-checked-exceptions )类似,即在大规模的程序中每个模块都会抛出多个异常,导致处理异常的逻辑异常复杂。这个逻辑的漏洞在于,异常和错误的对比是和数量是无关的,即使不使用异常,每个模块都返回多个错误码,而处理这些错误码的逻辑绝不会比处理同样异常的逻辑更简单,用数量来论证异常的问题是偷换概念,这就是为什么这句话的前后逻辑会是如此的......分裂。

使用代数数据类型统一结果和错误

对于可恢复的错误,Haskell利用更强的抽象能力提供了另外一种方式:返回错误码,但是利用模式匹配和函数的组合实现了对错误码优雅的处理,即实现了易用性。对于单一错误,可以使用Maybe(对应Swift中的Optional),如果需要携带错误信息,可以使用Either(对应Swift中的Result),还可以通过Monad>>=函数的组合过程中处理(可以类比ResultflatMapFlatMapErrorpromisethencatchError)。

语言提供了这样的抽象能力,即一个数据结构既可以表示函数的结果,也可以表示错误,这样就实现了返回值类型的统一。由于结果和错误往往类型不同,这种抽象能力需要通过类型的加法提供(在Swift中是枚举类型,可以参考 https://ata.alibaba-inc.com/articles/214959 ),并且提供模式匹配和函数组合的能力,模式匹配提供可靠性,函数的组合提供易用性。

举个例子:

Just(1) >>= \a -> if success then Just(a + 1) else Nothing >>= \b -> Just(b + 2)

没用过Haskell的读者可能看不懂,这里涉及到Maybe类型,用来复合函数的>>=函数和匿名函数(写成一行是因为if...then...else在Haskell中是一个函数,而非控制流语句),这段代码翻译成Swift代码是这样的

Optional(1).flatMap { firstNum in
    if sucess {
        return .some(firstNum + 1)
    } else {
        return .none
    }
}.flatMap { secondNum in
    secondNum + 2
}

可以看到,尽管第一个闭包可能返回错误,仍然可以将第二步工作的闭包与第一个闭包进行组合,如果第一个闭包正常返回结果,则执行第二个闭包,如果第一个闭包返回错误,则不执行第二个闭包,这个处理过程通过
模式匹配隐藏在Optional的实现中,有兴趣的读者直接看Optional的源码(https://github.com/apple/swift/blob/main/stdlib/public/core/Optional.swift ),非常简单。

异步函数的错误处理

任何逻辑支持异步复杂度都会上升一个维度,对于不可恢复的失败,Fail-Fast同样适用于异步函数,对于可恢复的失败,我们希望采用异常来进行错误处理,但是在调用异步函数时,异常并不体现在函数的返回值,而是体现在作为参数的回调函数里,这时应该怎么办呢?

我看网上有一种方案是回调函数的参数不再是数据或者错误,而是一个支持返回数据或抛出异常的闭包,在回调函数中通过 try 这个闭包进行错误处理,这种方法实现了可靠性,但是易用性就很差了,读写都费劲,因此不予考虑。

async/await

如果你的语言支持continuation,大概率也会提供async/await或类似特性,这是目前支持异步编程最友好的方式,可以使用同步调用的形式来实现异步调用,从而使用与上面极其相似的方式来处理,唯一的区别就是在调用时将 tryawait 组合在一起。
对应的
Swift代码是这样的

// 错误传递
let (data, response) = try await URLSession.shared.data(from: url, delegate: nil)

// 错误处理
do {
    let url = URL(string: "https://www.taobao.com")!
    let (data, response) = try await URLSession.shared.data(from: url, delegate: nil)
} catch {
    // handle error 
}

完美符合前面得出的结论,唯一的问题是,async/await最低支持iOS13(XCode 13.2beta),哪里有从iOS13开始支持的App开发团队?

Optional/Result

对于采用回调函数作为参数的异步函数,对于错误处理可以采用Optional/Result将结果和错误统一起来,举例来说,Future表示一个将来的结果或错误,由于iOS13以下无法使用Combine,我自己实现了一个Future/Promise用法如下:

let f1 = Future<Int, MPError> { (promise) in
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        promise(.success(1))
    }
}

f1.thenF { (data, promise) in
    promise(.success(data+1))
}.thenF { (data, promise) in
    promise(.success(data+1))
}.catchError { e in
    // handle error
}

可以看到,多个thenF将主流程的多个任务串联起来,使得逻辑的热路径非常清晰,任意一步发生错误,都可以在catchError中处理,这样就做到了很好的易用性,而且实现了主流程和错误处理关注点的分离。而内部实现的通过模式匹配实现,保证了错误必须被处理,实现了可靠性:

switch self.result {
    case .success(let output): //成功
        return makeFuture(output)
    case .none: // 还没结果,造个假的Future返回
        return fakeFuture
    case .failure(let error): //失败
        return Future<NewOutput, Failure>{promise in promise(.failure(error))}
}

需要专门指出的是,Optional/Result 等代数数据类型在提升了易用性,可靠性主要是靠模式匹配必须覆盖所有情况来实现,以及在语法层面保证了成功和失败的互斥,但是在异步场景下调用方仍然可以简单的忽略回调参数而不执行模式匹配,目前的类型系统不会给出提示,因此这里的可靠性低于async/await,但仍然比成功和失败分开回调高。

流式接口的错误处理

Future提供了一个异步错误处理的良好示范,但是Future仅仅支持一次数据回调,如果是支持数据多次回调的流式接口(由于本地存在部分数据,消息的核心服务基本都是这样的接口),此时又该怎么处理呢?

在前面的场景里,结果中的数据既表示函数的返回值,又表示成功,而流式接口使得数据和成功出现了分离,数据处理逻辑级联之后会出现多个成功和失败的节点,需要对这些结果进行合并。对于错误处理来说,可以对合并后的结果采用和Future类似的机制。由于手淘需要支持到iOS9,集成OpenCombine又需要800多K的包大小,这里只能自己封装一个轻量的流式异步编程任务了。

结论

终于可以给出我们的结论了:

  1. 对于不可恢复的错误,使用特定的封装函数,这个函数在debug或预发下上报并调用faultError,在生产环境下上报,是否终止程序视具体情况而定(当然需要终止的情况可能需要经过分析和一段时间的验证才能稳定下来)。
  2. 对于可恢复的错误,同步的方法使用受检查的异常,异步的方法使用Optional/Result 配合Future/Promise或流式接口使用。如果iOS13以上,可以使用async/await将异步转为同步。
  1. 所有的错误都应该携带足够诊断和定位的信息量。

Swift标准库的行为

Swift标准库采用的规范跟我们上面的结论基本一致.

  1. 对于不可恢复的错误,直接产生运行时错误,且并不在接口上标记throws,无法catch
  2. 对于可恢复的错误,之前同步函数通过error参数旁路返回错误码的结构,Swift转换为了异常接口:
// OC
- (BOOL)removeItemAtPath:(NSString *)path error:(NSError **)error
// Swift
func removeItem(atPath path: String) throws
  1. 准库对于异步接口的错误处理有两种方式

从OC继承过来的接口,使用了有数据和错误分别作为回调函数参数的接口,这里主要是考虑兼容性

func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask

对于新的接口,比如Combine框架,对于一次回调数据或错误的Future,使用了Result,对于多次回调数据,最后回调成功或失败的流式接口,则采用了等价于Result<Void, Error> 的Completion统一了成功和错误。

// Future的结果回调
public typealias Promise = (Result<Output, Failure>) -> Void
// 流式结果回调
func receive(completion: Subscribers.Completion<Self.Failure>)
// Subscribers.Completion 是一个枚举
enum Completion<Failure> where Failure : Error {
        case finished
        case failure(Failure)
    }

参考

《Types and Programming Languages》, Benjamin C. Pierce
https://docs.swift.org/swift-book/LanguageGuide/ErrorHandling.html
https://nshipster.com/optional-throws-result-async-await/
https://link.zhihu.com/?target=http%3A//joeduffyblog.com/2016/02/07/the-error-model/
https://rwh.readthedocs.io/en/latest/chp/19.html