喵神王巍:Swift 2 throws 错误处理全解析——从原理到实践

分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友 微信微信
查看查看290 回复回复1 收藏收藏 分享淘帖 转播转播 分享分享 微信
查看: 290|回复: 1
收起左侧

喵神王巍:Swift 2 throws 错误处理全解析——从原理到实践

[复制链接]
swifter 发表于 2016-6-20 12:01:48 | 显示全部楼层 |阅读模式
快来登录
获取优质的苹果资讯内容
收藏热门的iOS等技术干货
拷贝下载Swift Demo源代码
订阅梳理好了的知识点专辑

本文从 Swift 2 中新加入的 throws 关键字的用法入手,通过在 SIL 和汇编层面的分析,深入剖析了 Swift 中异常机制这一新特性的背后机理。在此基础上,本文进一步对异常处理相关的 ErrorType 接口进行了一些研究。结合 Swift 2 中错误处理方式的特点,作者对 throws 这一关键字在实际使用时的场景和适用范围进行了示例和说明,并给出了一些建议。希望本文能帮到 敏捷大拇指 Swifthumb.com上的诸位Swift开发者。




1、Swift 2 错误处理简介

throws 关键字和异常处理机制是 Swift 2 中新加入的重要特性。Apple 希望通过在语言层面对异常处理的流程进行规范和统一,来让代码更加安全,同时让开发者可以更加及时可靠地处理这些错误。Swift 2 中所有的同步 Cocoa API 的 NSError 都已经被 throw 关键字取代,举个例子:

清单 1. 在文件操作中复制文件的 API 在 Swift 1 中使用的是和 Objective-C 类似的 NSError 指针方式。

[Swift] 纯文本查看 复制代码
func copyItemAtPath(_ srcPath: String, toPath dstPath: String, error: NSErrorPointer)


清单 2. 而在 Swift 2 中,变为了 throws。

[Swift] 纯文本查看 复制代码
func copyItemAtPath(_ srcPath: String, toPath dstPath: String) throws


清单 3. 使用时,Swift 1 中我们需要创建并传入 NSError 的指针,在方法调用后检查指针的内容,来判断是否成功。

[Swift] 纯文本查看 复制代码
let fileManager = NSFileManager.defaultManager()
var error: NSError?
fileManager.copyItemAtPath(srcPath, toPath: dstPath, error: &error)
if error != nil {
 // 发生了错误
} else {
 // 复制成功
}


在实践中,因为这个 API 仅会在极其特定的条件下(比如磁盘空间不足)会出错,所以开发者为了方便,有时会直接传入 nil 来忽视掉这个错误。

清单 4. 忽略错误

[Swift] 纯文本查看 复制代码
let fileManager = NSFileManager.defaultManager()
// 不关心是否发生错误
fileManager.copyItemAtPath(srcPath, toPath: dstPath, error: nil)


这种做法无形中降低了应用的可靠性以及从错误中恢复的能力。为了解决这个问题,Swift 2 中在编译器层级就对 throws 进行了限定。被标记为 throws 的 API,我们需要完整的 try catch 来捕获可能的异常,否则无法编译通过。

清单 5. 编译失败

[Swift] 纯文本查看 复制代码
let fileManager = NSFileManager.defaultManager()
do {
 try fileManager.copyItemAtPath(srcPath, toPath: dstPath)
} catch let error as NSError {
 // 发生了错误
 print(error.localizedDescription)
}


对于非 Cocoa 框架的 API,我们也可以通过声明 ErrorType 并在出错时进行 throw 操作。这为错误处理提供了统一的处理出口,有益于提高应用质量。




2、throws 技术内幕

throws 关键字究竟做了些什么,在 Swift 开源之前,我们可以用稍微底层一点的手法来进行一些探索。



2.1、Swift 编译器,SIL 及汇编

所有的 Swift 源文件都要经过 Swift 编译器编译后才能执行。Swift 编译过程遵循非常经典的 LLVM 编译架构:编译器前端首先对 Swift 源码进行词法分析和语法分析,生成 Swift 抽象语法树(AST),然后从 AST 生成 Swift 中间语言(Swift Intermediate Language,SIL),接下来 SIL 被翻译成通用的 LLVM 中间表述(LLVM Intermediate Representation, LLVM IR),最后通过编译器后端的优化,得到汇编语言。整个过程可以用下面的框图来表示:

图 1. CompilerFlow

喵神王巍:Swift 2 throws 全解析——从原理到实践

喵神王巍:Swift 2 throws 错误处理全解析——从原理到实践 - 敏捷大拇指 - 喵神王巍:Swift 2 throws 全解析——从原理到实践


Swift 编译器提供了非常灵活的命令行工具:swiftc,这个命令行工具可以运行在不同模式下,我们通过控制命令行参数能获取到 Swift 源码编译到各个阶段的结果。使用 swiftc --help 我们能得知各个模式的使用方法,这篇文章会用到下面几个模式,它们分别将 Swift 源代码编译为 SIL,LLVM IR 和汇编语言。

清单 6. sh

[Swift] 纯文本查看 复制代码
> swiftc --help
...
MODES:
 -emit-sil Emit canonical SIL file(s)
 -emit-ir Emit LLVM IR file(s)
 -emit-assembly Emit assembly file(s) (-S)
 ...


在 Swift 开源之前,将源码编译到各个阶段是探索 Swift 原理和实现方式的重要方式。我们接下来将会分析一段简单的 throw 代码,来看看 Swift 的异常机制到底是如何运作的。



2.2、throw,try,catch 深层解析

为了保持问题的简单,我们定义一个最简单的 ErrorType 并用一个方法来将其抛出,源代码如下。

清单 7. 定义 ErrorType

[Swift] 纯文本查看 复制代码
// throw.swift
enum MyError: ErrorType {
 case SampleError
}
func throwMe(shouldThrow: Bool) throws -> Bool {
 if shouldThrow {
 throw MyError.SampleError 
 }
 return true
}


清单 8. 使用 swiftc 将其编译为 SIL。

[Swift] 纯文本查看 复制代码
swiftc -emit-sil -O -o ./throw.sil ./throw.swift


清单 9. 在输出文件中,可以找到 throwMe 的对应 Swift 中间语言表述。

[Swift] 纯文本查看 复制代码
// throw.throwMe (Swift.Bool) throws -> Swift.Bool
sil hidden @_TF5throw7throwMeFzSbSb :
					$@convention(thin) (Bool) -> (Bool, @error ErrorType) {
bb0(%0 : $Bool):
 debug_value %0 : $Bool // let shouldThrow // id: %1
 %2 = struct_extract %0 : $Bool, #Bool.value // user: %3
 cond_br %2, bb1, bb2 // id: %3

bb1: // Preds: bb0
 ...
 throw %4#0 : $ErrorType // id: %7

bb2: // Preds: bb0
 ...
 return %9 : $Bool // id: %10
}


_TF5throw7throwMeFzSbSb 是 throwMe 方法 Mangling 以后的名字。在去掉一些噪音后,我们可以将这个方法的签名等效看作。

清单 10. throwMe 方法

[Swift] 纯文本查看 复制代码
throwMe(shouldThrow: Bool) -> (Bool, ErrorType)


它其实是返回的是一个 (Bool, ErrorType) 的多元组。和一般的多元组不同的是,第二个元素 ErrorType 被一个 @error 修饰了。这个修饰让多元组具有了“排他性”,也就是只要多元组的第一个元素被返回即可:在条件分支 bb2(也即没有抛出异常的正常分支)中,仅只有 Bool 值被返回了。而对于发生错误需要抛出的处理,SIL 层面还并没有具体实现,只是生成了对应的错误枚举对象,然后对其调用了 throw 命令。

这就是说,我们想要探索 throw 的话,还需要更深入一层。

清单 11. 用 swiftc 将源代码编译为 LLVM IR

[Swift] 纯文本查看 复制代码
swiftc -emit-ir -O -o ./throw.ir ./throw.swift


清单 12. 结果中 throwMe 的关键部分

[Swift] 纯文本查看 复制代码
define hidden i1 @_TF5throw7throwMeFzSbSb(i1,
	%swift.refcounted* nocapture readnone, %swift.error** nocapture) #0 {

}


这是我们非常熟悉的形式,参数中的 swift.error**和 Swift 1 以及 Objective-C 中使用 NSError 指针来获取和存储错误的做法是一致的。在示例的这种情况下,LLVM 后端针对 swift.error 进行了额外处理。

清单 13. 最终得到的汇编码伪码 (在未启用 -O 优化的条件下)。

[Swift] 纯文本查看 复制代码
int __TF5throw7throwMeFzSbSb(int arg0) {
 rax = arg0;
 var_8 = rdx;
 if ((rax & 0x1) == 0x0) {
 rax = 0x1;
 }
 else {
 rax = swift_allocError(0x1000011c8, __TWPO5throw7MyErrorSs9ErrorTypeS_);
 var_18 = rax;
 swift_willThrow(rax);
 rax = var_8;
 *rax = var_18;
 }
 return rax;
}


函数最终的返回是一个 int,它有可能是一个实际的整数值,也有可能是一个指向错误地址的指针。这和 Swift 1 中传入 NSErrorPointer 来存储错误指针地址有明显不同:首先直接使用返回值我们就可以判断调用是否出现错误,而不必使用额外的空间进行存储;其次整个过程中没有使用到 NSError 或者 Objective-C Runtime 的任何内容,在性能上要优于传统的错误处理方式。

我们在了解了 throw 的底层机理后,对于 try catch 代码块的理解自然也就水到渠成了。

清单 14. 加入一个 try catch 后的 SIL 相关部分

[Swift] 纯文本查看 复制代码
try_apply %15(%16) : $@convention(thin) (Bool) -> (Bool, @error ErrorType), normal bb1, error bb9 // id: %17

bb1(%18 : $Bool):
...
bb9(%80 : $ErrorType):
...


其他层级的实现也与此类似,都是对返回值进行类型判断,然后进入不同的条件分支进行处理。




3、ErrorType 和 NSError

throw 语句的作用对象是一个实现了 ErrorType 接口的值,本节将探讨 ErrorType 背后的内容,以及 NSError 与它的关系。

清单 15. 在 Swift 公开的标准库中,ErrorType 接口并没有公开的方法

[Swift] 纯文本查看 复制代码
public protocol ErrorType {
}


清单 16. 这个接口有一个 extension,但是也没有公开的内容:

[Swift] 纯文本查看 复制代码
extension ErrorType {
}


清单 17. 我们可以通过使用 LLDB 的类型检索来获取关于这个接口的更多信息。在调试器中运行 type lookup ErrorType:

[Swift] 纯文本查看 复制代码
 (lldb) type lookup ErrorType
protocol ErrorType {
 var _domain: Swift.String { get }
 var _code: Swift.Int { get }
}
extension ErrorType {
 var _domain: Swift.String {
 get {}
 }
}


可以看到这个接口实际上需要实现两个属性:domain 描述错误的所属域,code 标记具体的错误号,这和传统的 NSError 中定义一个错误所需要的内容是一致的。事实上 NSError 在 Swift 2 中也实现了 ErrorType 接口,它简单地返回错误的域和错误代码信息,这是 Swift 1 到 2 的错误处理相关 API 转换的兼容性的保证。

虽然 Cocoa/CocoaTouch 框架中的 throw API 抛出的都是 NSError,但是应用开发者更为常用的表述错误的类型应该是 enum,这也是 Apple 对于 throw 的推荐用法。对于实现了 ErrorType 的 enum 类型,其错误代码将根据 enum 中 case 声明的顺序从 0 开始编号,而错误域的名字就是它的类型全名 (Module 名 + 类型名):

清单 18. ErrorType

[Swift] 纯文本查看 复制代码
MyError.InvalidUser._code: 0
MyError.InvalidUser._domain: ModuleName.MyError

MyError.InvalidPassword._code: 1
MyError.InvalidPassword._domain: ModuleName.MyError


这虽然为按照错误号来处理错误提供了可能性,但是我们在实践中应当尽量依赖 enum case 而非错误号来对错误进行辨别,这可以提高稳定性,同时降低维护的压力。除了 enum 以外,struct 和 class 也是可以实现 ErrorType 接口,并作为被 throw 的对象的。在使用非 enum 值来表示错误的时候,我们可能需要显式地指定 _code 和 _domain,以区分不同的错误。




4、throws 的一些实践



4.1、异步操作中的异常处理

带有 throw 的方法现在只能工作在同步 API 中,这受限于异常抛出方法的基本思想。一个可以抛出的方法实际上做的事情是执行一个闭包,接着选择返回一个值或者是抛出一个异常。直接使用一个 throw 方法,我们无法在返回或抛出之前异步地执行操作并根据操作的结果来决定方法行为。要改变这一点,理论上我们可以通过将闭包的执行和对结果的操作进行分离,来达到“异步抛出”的效果。

清单 19. 假设有一个同步方法可以抛出异常:

[Swift] 纯文本查看 复制代码
func syncFunc<A, R>(arg: A) throws -> R


清单 20. 通过为其添加一次调用,可以将闭包执行部分和结果判断及返回部分分离:

[Swift] 纯文本查看 复制代码
func syncFunc<A, R>(arg: A)() throws -> R


清单 21. 这相当于将原来的方法改写为:

[Swift] 纯文本查看 复制代码
func syncFunc<A, R>(arg: A) -> (Void throws -> R)


这样,单次对 syncFunc 的调用将返回一个 Void throws -> R 类型的方法,这使我们有机会执行代码而不是直接返回或抛出。在执行 syncFunc 返回后,我们还需要对其结果用 try 来进行判断是否抛出异常。

清单 22. 利用这个特点,我们就可以将这个同步的抛出方法改写为异步形式:

[Swift] 纯文本查看 复制代码
func asyncFunc<A, R>(arg: A, callback: (Void throws -> R) -> Void) {
// 处理操作
	let result: () throws -> R = {
// 根据结果抛出异常或者正常返回
	}
	return callback(result)
}

// 调用
asyncFunc(arg: someArg) { (result) -> Void in
 do {
 let r = try result()
 // 正常返回
 } catch _ {
 // 出现异常
 }
}


绕了一大个圈子,我们最后发现这么做本质上其实和简单地使用 Result<T, E> 来表示异步方法的结果并没有本质区别,反而增加了代码阅读和理解的难度,也破坏了 Swift 异常机制原本的设计意图,其实并不是可取的选项。除开某些非常特殊的用例外,对于异步 API 现在并不适合使用 throw 来进行错误判断。



4.2、异常处理的测试

在 XCTest 中暂时还没有直接对 Swift 2 异常处理进行测试的方法,如果想要测试某个调用应当/不应当抛出某个异常的话,我们可以对 XCTest 框架的方法进行一些额外但很简单包装,传入 block 并运行,然后在 try 块或是 catch 块内进行 XCTAssert 的断言检测。在 Apple 开发者论坛有关于这个问题的更详细的讨论,完整的示例代码和使用例子可以在这里找到。



4.3、类型安全的异常抛出

Swift 2 中异常另一个严重的不足是类型不安全。throw 语句可以作用于任意满足 ErrorType 的类型,你可以 throw 任意域的错误。而在 catch 块中我们也同样可以匹配任意的错误类型,这一切都没有编译器保证。由于这个原因,现在的异常处理机制并不好用,需要处理异常的开发者往往需要通读文档才能知道可能会有哪些异常,而文档的维护又是额外的工作。缺少强制机制来保证异常抛出和捕获的类型的正确性,这为程序中 bug 的出现埋下了隐患。

事实上从我们之前对 throw 底层实现的分析来看,在语言层面上实现只抛出某一特定类型的错误并不是很困难的事情。但是考虑到与 `NSError` 和传统错误处理 API 兼容问题,Swift 2 中并没有这样实现,也许我们在之后的 Swift 版本中能看到限定类型的异常机制。



4.4、异常的调试和断点

Swift 的异常抛出并不是传统意义的 exception,在调试时抛出异常并不会触发 Exception 断点。另外,throw 本身是语言的关键字,而不是一个 symbol,它也不能触发 Symbolic 类型的断点。如果我们希望在所有 throw 语句执行的时候让程序停住的话,需要一些额外的技巧。在之前 throw 的汇编实现中,可以看到所有 throw 语句在返回前都会进行一次 swift_willThrow 的调用,这就是一个有效的 Symbolic 语句,我们设置一个 swift_willThrow 的 Symbolic 断点,就可以让程序在 throw 的时候停住,并使用调用栈信息来获知程序在哪里抛出了异常。




5、结束语

本文通过分析 Swift 2 中 throw 的底层实现方式,从深层次阐述了这一新加关键字的使用方法和高级特性。通过在项目中使用新的异常处理机制,可以保证更多的错误处理逻辑覆盖和更全面的恢复机制。通过列举实践中的一些使用例子,读者可以迅速在项目中开始使用新的错误处理机制,这将帮助有助于提高所构建应用的运行稳定性。




6、参考资料




都看到这里了,就把这篇资料推荐给您的好朋友吧,让他们也感受一下。

回帖是一种美德,也是对楼主发帖的尊重和支持。

*声明:敏捷大拇指是全球最大的Swift开发者社区、苹果粉丝家园、智能移动门户,所载内容仅限于传递更多最新信息,并不意味赞同其观点或证实其描述;内容仅供参考,并非绝对正确的建议。本站不对上述信息的真实性、合法性、完整性做出保证;转载请注明来源并加上本站链接,敏捷大拇指将保留所有法律权益。如有疑问或建议,邮件至marketing@swifthumb.com

*联系:微信公众平台:“swifthumb” / 腾讯微博:@swifthumb / 新浪微博:@swifthumb / 官方QQ一群:343549891(满) / 官方QQ二群:245285613 ,需要报上用户名才会被同意进群,请先注册敏捷大拇指

嗯,不错!期待更多好内容,支持一把:
支持敏捷大拇指,用支付宝支付10.24元 支持敏捷大拇指,用微信支付10.24元

评分

参与人数 1金钱 +10 贡献 +10 专家分 +10 收起 理由
Anewczs + 10 + 10 + 10 32个赞!专家给力!

查看全部评分

买定离手 发表于 2016-6-22 12:11:53 | 显示全部楼层
记得大拇指上去年就有介绍了吧
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

做任务,领红包。
我要发帖

分享扩散

都看到这里了,就把这资料推荐给您的好朋友吧,让他们也感受一下。
您的每一位朋友访问此永久链接后,您都将获得相应的金钱积分奖励
关闭

站长推荐 上一条 /3 下一条

热门推荐