原文:Magical Error Handling in Swift
作者: Gemma Barlow
译者:kmyhy
Swift 中的错误处理从 O-C 沿袭而来,但 Swift 1.0 之后逐渐发生了巨大改变。重要的改变发生在 Swift 2,它率先使用了“处理非异常的状态和条件”的做法,使你的 app 变得更加简单。
类似于其它编程语言,在 Swift 中,选择使用哪种错误处理技术,需要根据具体的错误类型和 app 整体架构而定。
本教程将演示一个“魔法”,在这个例子中,不但有男巫、女巫和蝙蝠,还有蟾蜍,以此来演示在常见错误处理过程中的最佳实践。你还可以看到,如何将使用 Swift 早期版本编写的错误处理进行升级,最终使用你的水晶球看到未来 Swift 的错误处理将是什么样子。
注:本教程假设你已经熟悉了 Swift 2 语法——尤其是枚举和可空。如果你不知道这些概念,请阅读 Greg Heo 的 What’s New in Swift 2 post。
好了,让我们开始领略 Swift2 的错误处理的迷人魅力吧!
开始
本教程有两个开始项目(playground)。一节一个,分别是:Avoiding-Errors-with-nil-Starter.playground 和 Avoiding-Errors-with-Custom-Handling-Starter.playground。
打开第一个 playground 文件。
阅读代码,你将发现几个类、结构和枚举。
注意如下代码:
protocol MagicalTutorialObject {
var avatar: String { get }
}
这个协议会被教程中所有类和结构所采用,并用于提供一个能够将每个对象打印到控制台的 String 对象。
enum MagicWords: String {
case Abracadbra = “abracadabra”
case Alakazam = “alakazam”
case HocusPocus = “hocus pocus”
case Prestochango = “presto chango”
}
这个枚举用于表示“咒语”,它将被“念”(spell)出来。
struct Spell: MagicalTutorialObject {
var magicWords: MagicWords = .Abracadbra
var avatar = "*"
}
这个结构用于将咒语“念”出来。默认情况下,其 magicWords 属性的初始值是 Abracadabra。
你已经了解在这个魔法世界的基本知识了,你可以开始练习咒语了。
为什么要进行错误处理?
良好的错误处理能增强用户体验,让软件维护者更容易发现问题,了解出错的原因以及错误的严重性。当代码中的错误的处理无所不在的时候,诊断问题就变得更加容易了。错误处理还会让系统以正确的方式终止执行,避免用户产生不必要的困扰。
当然并不是所有的错误都需要被处理。当不对错误进行处理时,语言特性也会进行某种级别的错误处理。一般,如果你能够避免错误的发生,则尽量避免。如果实在无法避免,则最好的做法就是错误处理。
避免 Swift 引用为空错误
由于 Swift 已经有了优雅的可空处理机制,类似这种错误:在你以为有值的地方却没有值——是可以完全避免的。作为一个聪明的程序员,你可以利用这种特性,在某种错误发生时故意返回一个 nil。如果你不想在错误发生时采取任何动作时,这种方式很好用,例如在事故发生时采取不作为措施。
避免 Swift 引用为空的两个典型例子就是:允许失败的初始化方法,以及 guard 语句。
允许失败的初始化方法
允许失败的初始化方法防止你创建出不完全满足创建条件的对象。在 Swift 2 之前(已经其它语言),这种方法通常在工厂方法设计模式中用到。
在 Swift 中的这种设计模式体现在 createWithMagicWords 中:
static func createWithMagicWords(words: String) -> Spell? {
if let incantation = MagicWords(rawValue: words) {
var spell = Spell()
spell.magicWords = incantation
return spell
}
else {
return nil
}
}
上述初始化方法企图用指定的咒语创建一个 Spell 对象,如果提供给它的 words 参数不是一个合法的咒语,则返回一个 nil 对象。
在本教程底部检查 Spell 对象的创建语句,你会看到:
第一个语句用“abracadabra”成功创建了一个 Spell 对象,但第二句使用”ascendio” 就不行了,返回了一个 nil 对象。(哈,巫师不是每次都能成功念出咒语的)
工厂方法是一种古旧的编程风格。其实在 Swift 中我们可以有更好的选择。你可以将 Spell 中的工厂方法修改为“允许失败的初始化方法”。
删除createWithMagicWords(_:) 并替换为:
init?(words: String) {
if let incantation = MagicWords(rawValue: words) {
self.magicWords = incantation
}
else {
return nil
}
}
这里,在这个方法声明中,我们没有显式地创建和返回一个 Spell 对象。
噢,这两句出现编译错误了:
let first = Spell.createWithMagicWords("abracadabra")
let second = Spell.createWithMagicWords("ascendio")
let first = Spell(words: "abracadabra")
let second = Spell(words: "ascendio")
这样,错误消失,playground 编译成功。这种改变让你的代码更整洁——但你还有更好的解决办法!
Guard 语句
guard 语句是一种更好的断言某些情况为 true 的方式:例如,判断一个值大于 0,或者判断某个值是否能够被解包的时候。如果这种情况都不满足,你可以执行语句块。
guard 语句在 Swift 2 才开始引入,通常用于在调用堆栈中进行冒泡法错误处理,这种方法中,错误将在最后才被处理。guard 语句能够尽早从方法/函数中退出,比起需要判断某个条件满足剩下的逻辑才会执行来说,显得更加简单。
将 Spell 的允许失败的初始化方法修改为 guard 语句:
init?(words: String) {
guard let incantation = MagicWords(rawValue: words) else {
return nil
}
self.magicWords = incantation
}
在这里,我们不需要将 else 放在单独的行上,而且对断言失败的处理变得显眼,因为它被更放在了方法的头部。同时,“黄金路径”缩进最少。“黄金路径”是指当每件事都如预期即没有错误发生时的执行路径。而缩进最少,则使它更易于被看到。
注,虽然 first 和 second 最终值不会有任何改变,但代码变得更加合理化。
对错误进行定制化处理
在完成 Spell 的初始化方法并利用 nil 避免某些错误之后,你将学习某些更高级的错误处理。
对于本教程的第二部分内容,请打开 Avoiding Errors with Custom Handling – Starter.playground。
看一下这些代码:
struct Spell: MagicalTutorialObject {
var magicWords: MagicWords = .Abracadbra
var avatar = "*"
init?(words: String) {
guard let incantation = MagicWords(rawValue: words) else {
return nil
}
self.magicWords = incantation
}
init?(magicWords: MagicWords) {
self.magicWords = magicWords
}
}
这是 Spell 的初始化方法,在第一部分内容的基础上修改而来。注意,MagicalTutorialObject 协议的使用,以及第二个允许失败的初始化方法,为了方便我们添加了它。
protocol Familiar: MagicalTutorialObject {
var noise: String { get }
var name: String? { get set }
init()
init(name: String?)
}
Familiar 协议会被使用到各种动物(比如蝙蝠和蟾蜍)。
注:Familiar 的意思是仆从,也就是男巫或女巫的动物精灵,拥有类人的特点。比如《哈利波特》中的猫头鹰(名为 Hedwig),或者《The Wizard of Oz》中的飞猴。
虽然它不是 Hewig,但仍然很漂亮,不是吗?
struct Witch: MagicalBeing {
var avatar = "*"
var name: String?
var familiar: Familiar?
var spells: [Spell] = []
var hat: Hat?
init(name: String?,familiar: Familiar?) {
self.name = name
self.familiar = familiar
if let s = Spell(magicWords: .Prestochango) {
self.spells = [s]
}
}
init(name: String?,familiar: Familiar?,hat: Hat?) {
self.init(name: name,familiar: familiar)
self.hat = hat
}
func turnFamiliarIntoToad() -> Toad {
if let hat = hat {
if hat.isMagical { // When have you ever seen a Witch perform a spell without her magical hat on ? :]
if let familiar = familiar { // Check if witch has a familiar
if let toad = familiar as? Toad { // Check if familiar is already a toad - no magic required
return toad
} else {
if hasSpellOfType(.Prestochango) {
if let name = familiar.name {
return Toad(name: name)
}
}
}
}
}
}
return Toad(name: "New Toad") // This is an entirely new Toad.
}
func hasSpellOfType(type: MagicWords) -> Bool { // Check if witch currently has appropriate spell in their spellbook
return spells.contains { $0.magicWords == type }
}
}
最后,是女巫。请看下面:
- 女巫的初始化需要一个名字和一只精灵,或者一个名字、一只精灵和一顶帽子。
- 女巫会念许多咒语,用一个 spells 保存,即一个 Spell 数组。
- 女巫有一个嗜好,当她一念到咒语:“Prestochango”,她的精灵就会被变成一只蟾蜍,这个动作用 turnFamiliarIntoToad() 方法 @H_59_404@
注意 turnFamiliarIntoToad() 方法中的缩进。在这个方法中,如果遇到任何错误,会返回一只全新的蟾蜍。这看起来有点不对劲(这是错误的!)。在下一部分,你将用自定义错误处理来解决这个问题。
用 Swift 错误进行重构
Swift 提供了运行时抛出、捕获、传递和操纵可恢复类型错误的支持。
-《The Swift Programming Language (Swift 2.2)》
与“死亡之庙”不同,在 Swift 或其它语言中,“厄运金字塔”是另外一种相反的模型。使用这种模型会在控制流中使用多级嵌套。例如上面的 turnFamiliarIntoToad() 方法,使用了 6 个 } 符号才能结束嵌套,基本构成了一条对角线。这样的代码阅读起来相当费劲。
厄运金字塔
使用先前提到的 guard 语句,以及可空绑定,能够避免出现“厄运金字塔”代码。do-catch 机制能够将错误处理从控制流中解耦出来,从减少“厄运金字塔”的出现。
do-catch 机制常用的关键字包括:
enum ChangoSpellError: ErrorType {
case HatMissingOrNotMagical
case NoFamiliar
case FamiliarAlreadyAToad
case SpellFailed(reason: String)
case SpellNotKnownToWitch
}
关于 ChangoSpellError 有两点需要注意:
- 它采用了 ErrorType 协议,这是必须的。在 Swift 中, ErrorType 表明了这是一种错误。
- 在 SpellFailed 的 case 分支,你可以指定一种自定义的原因,表示为什么咒语会念错。 @H_59_404@
注:ChangoSpellError 的名字来自于咒语“Presto Chango!”——女巫在将精灵变成蟾蜍时念的咒语。
func turnFamiliarIntoToad() throws -> Toad {
Update it as well on the MagicalBeing protocol:
protocol MagicalBeing: MagicalTutorialObject {
var name: String? { get set }
var spells: [Spell] { get set }
func turnFamiliarIntoToad() throws -> Toad
}
现在,你拥有了错误状态列表,接下来需要重新编写 turnFamiliarIntoToad() 方法,针对每个错误类型编写不同的处理语句。
处理帽子的错误
首先,修改下列语句,确保女巫已经佩戴了她永不离身的魔法师帽。
if let hat = hat {
guard let hat = hat else {
throw ChangoSpellError.HatMissingOrNotMagical
}
下一句是对一个布尔值进行检查,这也和魔法师帽有关:
if hat.isMagical {
你可以再用一个 guard 语句进行检查,也可以将两个检查合并到一个 guard 语句——这显然要清晰和简洁得多。因此将第一个 guard 语句修改为:
guard let hat = hat where hat.isMagical else {
throw ChangoSpellError.HatMissingOrNotMagical
}
然后将 if hat.isMagical { 删除。
在接下来的部分,你将继续破解“金字塔”问题。
处理精灵的错误
接着,判断巫师是否有一只精灵:
if let familiar = familiar {
将这句用抛出一个 .NoFamiliar 错误来替换:
guard let familiar = familiar else {
throw ChangoSpellError.NoFamiliar
}
处理蟾蜍的错误
接下来一句,如果女巫在试图用 turnFamiliarIntoToad() 方法时发现她的精灵其实已经是一只蟾蜍了,则返回已有的蟾蜍。但这里更好的做法是,用一个错误来表示这种情况。将下列代码:
if let toad = familiar as? Toad {
return toad
}
修改为:
if familiar is Toad {
throw ChangoSpellError.FamiliarAlreadyAToad
}
注意,我们将 as? 改为了 is。在需要检查某个对象是否能够转换为某个协议,但同时不需要使用转换结果时,这种写法更加简洁。is 关键字也可以更加泛型化的方式进行类型比较。如果你想了解更多内容,请阅读The Swift Programming Language“类型转换”一节。
将 else 之内的代码移到 else 之外,然后删除 else 语句,它没用了。
处理咒语的错误
最后,调用了 hasSpellOfType(type:) 方法,以检查女巫的魔法书中确实有相应的咒语。将下列代码:
if hasSpellOfType(.Prestochango) {
if let toad = f as? Toad {
return toad
}
}
修改为:
guard hasSpellOfType(.Prestochango) else {
throw ChangoSpellError.SpellNotKnownToWitch
}
guard let name = familiar.name else {
let reason = "Familiar doesn’t have a name."
throw ChangoSpellError.SpellFailed(reason: reason)
}
return Toad(name: name)
return Toad(name: "New Toad")
现在,你的方法变得更清晰和整洁,已经能够使用了。我在上述的代码添加了注释,以解释这个方法所做的工作:
func turnFamiliarIntoToad() throws -> Toad {
// When have you ever seen a Witch perform a spell without her magical hat on ? :]
guard let hat = hat where hat.isMagical else {
throw ChangoSpellError.HatMissingOrNotMagical
}
// Check if witch has a familiar
guard let familiar = familiar else {
throw ChangoSpellError.NoFamiliar
}
// Check if familiar is already a toad - if so,why are you casting the spell?
if familiar is Toad {
throw ChangoSpellError.FamiliarAlreadyAToad
}
guard hasSpellOfType(.Prestochango) else {
throw ChangoSpellError.SpellNotKnownToWitch
}
// Check if the familiar has a name
guard let name = familiar.name else {
let reason = "Familiar doesn’t have a name."
throw ChangoSpellError.SpellFailed(reason: reason)
}
// It all checks out! Return a toad with the same name as the witch's familiar return Toad(name: name) }
你曾经在 turnFamiliarIntoToad() 方法中返回一个可空来表示“在念咒语时出了差错”,但使用自定义错误能够更加清晰地表达错误的状态,以便你根据这些状态采取对应措施。
自定义错误还有什么好处?
现在,你有一个方法抛出了一个自定义 Swift 错误,你需要处理它们。接下来的标准动作是使用 do-catch 语句,这就好比 Java 等语言中的 try-catch 语句。
func exampleOne() {
print("") // Add an empty line in the debug area
// 1
let salem = Cat(name: "Salem Saberhagen")
salem.speak()
// 2
let witchOne = Witch(name: "Sabrina",familiar: salem)
do {
// 3
try witchOne.turnFamiliarIntoToad()
}
// 4
catch let error as ChangoSpellError {
handleSpellError(error)
}
// 5
catch {
print("Something went wrong,are you feeling OK?")
}
}
一下是对这个方法的解释:
- 创建女巫的精灵,它的名字叫 Salem。
- 创建女巫,名字叫 Sabrina。
- 试图将猫咪变成蟾蜍。
- 捕获 ChangoSpellError 并进行相应的处理。
- 捕获其它错误,打印友好信息。 @H_59_404@
func handleSpellError(error: ChangoSpellError) {
let prefix = "Spell Failed."
switch error {
case .HatMissingOrNotMagical:
print("\(prefix) Did you forget your hat,or does it need its batteries charged?")
case .FamiliarAlreadyAToad:
print("\(prefix) Why are you trying to change a Toad into a Toad?")
default:
print(prefix)
}
}
exampleOne()
点击 Xcode 工作空间左下角的上箭头,打开 Debug 控制台,你就会看到 playground 的输出了:
捕获错误
下面对上述代码中的每个语法特性进行简单讨论。
catch
你可以用 Swift 的模板匹配来处理某种错误,或者将错误类型进行分组处理。
前面的代码示范了 catch 的两个用法:一个是用于捕捉 ChangoSpell 错误,一种用于捕捉剩下的错误。
try
try 与 do-catch 语句配合使用,用于清晰定位是哪行语句或代码块将抛出错误。
try 语句有几种不同的用法,上面用到了其中之一:
- try: 标准用法,在简单的、立即的 do-catch 语句中使用。就是前面代码中的用法。
- try?: 处理错误,以忽略该错误的方式;如果有错误抛出,这个语句的结果是 nil。
- try!: 类似强制解包,这个前缀会创建期望的对象,理论上这个语句会抛出错误,但实际上这种错误永远不会发生。 try! 可以用于执行加载文件的动作,特别是当你明确知道文件是肯定存在的。就如前置解包一样,使用这种结构需要特别谨慎。 @H_59_404@
func exampleTwo() {
print("") // Add an empty line in the debug area
let toad = Toad(name: "Mr. Toad")
toad.speak()
let hat = Hat()
let witchTwo = Witch(name: "Elphaba",familiar: toad,hat: hat)
let newToad = try? witchTwo.turnFamiliarIntoToad()
if newToad != nil { // Same logic as: if let _ = newToad
print("Successfully changed familiar into toad.")
}
else {
print("Spell Failed.")
}
}
注意和 exampleOne 不同的地方。在这里我们不需要知道具体的错误输出了些什么,只是在它们抛出是捕获它们。Toad 对象最终会创建失败,因此 newToad 的值应当为 nil。
传递错误
throws
在 Swift 中,如果方法或函数代码中会抛出错误,则必须用到 throws 关键字。被抛出的错误会自动在调用堆栈中进行传递,但如果让错误从现场地向上冒泡太多并不是一个好主意。在代码库中充斥大量的错误传递会增加错误不被正确处理的可能性,因此 throws 是强制性的,以确保错误的传递被代码所记录——对于程序员来说是显而易见的。
rethrows
目前你所见到的例子都是关于 throws 的,而没有它的亲兄弟 rethrows 的吗?
rethrows 告诉编译器,这个函数会抛出一个错误,同时它的参数也会抛出一个错误。下面是一个例子(不需要将它加到你的 playground 里):
func doSomethingMagical(magicalOperation: () throws -> MagicalResult) rethrows -> MagicalResult {
return try magicalOperation()
}
这个方法只会抛出 magicalOperation 参数抛出的那个错误。如果成功,它返回一个 MagicalResult 对象。
操纵错误处理的行为
defer
尽管大部分情况下,我们让错误自动传播就可,但某些情况下,你可能想控制错误在调用堆栈中传递时 app 的行为。
defer 语句提供一种机制,让你在当前作用域结束时执行某些“清理”动作,比如方法或函数返回时。它可以清理某些资源,而无论动作是否执行成功或失败,尤其在错误处理上下文中有用。
要测试这种行为,请在 Witch 结构中加入如下方法:
func speak() {
defer {
print("*cackles*")
}
print("Hello my pretties.")
}
func exampleThree() {
print("") // Add an empty line in the debug area
let witchThree = Witch(name: "Hermione",familiar: nil,hat: nil)
witchThree.speak()
}
exampleThree()
在 Debug 控制台,你将看到女巫在每说一句话之后都会“咯咯笑”(cackles)。
有趣的是,defer 语句的执行顺序与书写顺序相反。
在 speak() 方法中添加另一个 defer 语句,这样当女巫说完一句话后,会先尖叫,然后再发出“咯咯”的笑声。
func speak() {
defer {
print("*cackles*")
}
defer {
print("*screeches*")
}
print("Hello my pretties.")
}
打印顺序是否如你所想?呵,神奇的 defer!
其它和错误有关的事
总而言之,Swift 已经和其他主流语言站到了同一起跑线上,同时 Swift 也不再采用 O-C 的基于 NSError 的错误处理机制。O-C 错误很多时候是被转换过的了,由编译器中的静态分析器帮你很好地完成了注入捕捉什么样的错误和错误何时发生的工作。
尽管 do-catch 和相关特性在其他语言中有不小的开销,但在 Swift 中,它们被视作和其它语句完全相同。这使得它们保持经济和高效。
虽然你可以创建自定义错误并随意抛出它们,但不意味着你就应该那样做。在每个项目中,当你需要抛出并捕捉错误时,你都应当遵循一定的开发指南。我建议:
- 不管在哪个代码库中,你的错误类型都需要命名清晰。
- 在只有一种错误状态时,使用可空就可以了。
- 在超过一种以上的错误状态是,才使用自定义错误。
- 不要让错误从错误现场传递到太远的地方。 @H_59_404@
- Swift Apprentice,Chapter 21 – Error Handling
- Failable Initializers
- Factory Method Pattern
- Pyramid of Doom @H_59_404@
Swift 将来的错误处理
在各大 Swift 论坛中,有很多关于未来的错误处理的想法。其中讨论得最多的一个是无类型传递。
“…我们觉得应该在当前的处理模型中增加对无类型传递的支持,以针对通用型的错误。做好这一点,尤其是在不增加代码尺寸和性能代价的前提下,需要有足够的决心和远见。因此,这被看成是 Swift 2.0 以后的任务。”
– from Swift 2.x Error Handling
无论你是否喜欢这种观点,Swift 3 中的错误处理必将有重大改变,或者你只关心眼前的一切,你也需要知道随着这门语言的演进,那种清晰的错误处理机制正在被激烈地讨论和改进当中。
结束
你可以下载本教程完整的 playgrounds。
本文的补充内容,我建议阅读下列文章,本教程也引用了其中一些内容:
如果你渴望了解 Swift 3 中将会有什么,我推荐你去看[Swift Language Proposals](Swift Language Proposals)中当前开放的提议,干嘛你不提交你自己的提议呢? 希望现在你已经体会到 Swift 错误处理的魅力。如果你有任何问题或建议,请在下面的讨论中留言!